website refactor
This commit is contained in:
@@ -1,343 +0,0 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { EmptyStateProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity, Lock, Search } from 'lucide-react';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
const Illustrations = {
|
||||
racing: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="35" cy="65" r="3" fill="currentColor"/>
|
||||
<circle cx="65" cy="65" r="3" fill="currentColor"/>
|
||||
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
league: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
team: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
sponsor: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
|
||||
</svg>
|
||||
),
|
||||
driver: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
<circle cx="40" cy="72" r="3" fill="currentColor"/>
|
||||
<circle cx="60" cy="72" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* EmptyState Component
|
||||
*
|
||||
* Provides consistent empty/placeholder states with 3 variants:
|
||||
* - default: Standard empty state with icon, title, description, and action
|
||||
* - minimal: Simple version without extra styling
|
||||
* - full-page: Full page empty state with centered layout
|
||||
*
|
||||
* Supports both icons and illustrations for visual appeal.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
illustration,
|
||||
ariaLabel = 'Empty state',
|
||||
}: EmptyStateProps) {
|
||||
// Render illustration if provided
|
||||
const IllustrationComponent = illustration ? Illustrations[illustration] : null;
|
||||
|
||||
// Common content
|
||||
const Content = () => (
|
||||
<Stack align="center" gap={4} mb={4}>
|
||||
{/* Visual - Icon or Illustration */}
|
||||
<Stack align="center" justify="center">
|
||||
{IllustrationComponent ? (
|
||||
<Stack color="text-gray-500">
|
||||
<IllustrationComponent />
|
||||
</Stack>
|
||||
) : Icon ? (
|
||||
<Stack h="16" w="16" align="center" justify="center" rounded="2xl" bg="iron-gray/60" border borderColor="charcoal-outline/50">
|
||||
<Icon className="w-8 h-8 text-gray-500" />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* Title */}
|
||||
<Heading level={3} weight="semibold" color="text-white" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<Text color="text-gray-400" textAlign="center" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{action && (
|
||||
<Stack align="center" pt={2}>
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{action.icon && (
|
||||
<action.icon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'default':
|
||||
return (
|
||||
<Stack
|
||||
py={12}
|
||||
align="center"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="md" fullWidth>
|
||||
<Content />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'minimal':
|
||||
return (
|
||||
<Stack
|
||||
py={8}
|
||||
align="center"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="sm" fullWidth gap={3}>
|
||||
{/* Minimal icon */}
|
||||
{Icon && (
|
||||
<Stack align="center">
|
||||
<Icon className="w-10 h-10 text-gray-600" />
|
||||
</Stack>
|
||||
)}
|
||||
<Heading level={3} weight="medium" color="text-gray-300">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className="text-primary-blue hover:text-blue-400 font-medium mt-2"
|
||||
icon={action.icon && <action.icon size={3} />}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'full-page':
|
||||
return (
|
||||
<Stack
|
||||
position="fixed"
|
||||
inset="0"
|
||||
bg="bg-deep-graphite"
|
||||
align="center"
|
||||
justify="center"
|
||||
p={6}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="lg" fullWidth align="center">
|
||||
<Stack mb={6} align="center">
|
||||
{IllustrationComponent ? (
|
||||
<Stack color="text-gray-500">
|
||||
<IllustrationComponent />
|
||||
</Stack>
|
||||
) : Icon ? (
|
||||
<Stack align="center">
|
||||
<Stack h="20" w="20" align="center" justify="center" rounded="2xl" bg="iron-gray/60" border borderColor="charcoal-outline/50">
|
||||
<Icon className="w-10 h-10 text-gray-500" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Heading level={2} weight="bold" color="text-white" mb={4}>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{description && (
|
||||
<Text color="text-gray-400" size="lg" mb={8} leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
className="min-w-[160px]"
|
||||
>
|
||||
{action.icon && (
|
||||
<action.icon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack mt={8}>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
Need help? Contact us at{' '}
|
||||
<Link
|
||||
href="mailto:support@gridpilot.com"
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
support@gridpilot.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for default empty state
|
||||
*/
|
||||
export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="default"
|
||||
className={className}
|
||||
illustration={illustration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for minimal empty state
|
||||
*/
|
||||
export function MinimalEmptyState({ icon, title, description, action, className }: Omit<EmptyStateProps, 'variant'>) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="minimal"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-page empty state
|
||||
*/
|
||||
export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="full-page"
|
||||
className={className}
|
||||
illustration={illustration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured empty states for common scenarios
|
||||
*/
|
||||
|
||||
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No data available"
|
||||
description="There is nothing to display here at the moment"
|
||||
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No results found"
|
||||
description="Try adjusting your search or filters"
|
||||
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Lock}
|
||||
title="Access denied"
|
||||
description="You don't have permission to view this content"
|
||||
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
|
||||
variant="full-page"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
|
||||
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, ArrowLeft, Box, Home, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
actions = [],
|
||||
showRetry = true,
|
||||
showNavigation = true,
|
||||
hideTechnicalDetails = false,
|
||||
className = '',
|
||||
}: ErrorDisplayProps) {
|
||||
const getErrorInfo = () => {
|
||||
const isApiError = error instanceof ApiError;
|
||||
|
||||
return {
|
||||
title: isApiError ? 'API Error' : 'Unexpected Error',
|
||||
message: error.message || 'Something went wrong',
|
||||
statusCode: isApiError ? error.context.statusCode : undefined,
|
||||
details: isApiError ? error.context.responseText : undefined,
|
||||
isApiError,
|
||||
};
|
||||
};
|
||||
|
||||
const errorInfo = getErrorInfo();
|
||||
|
||||
const defaultActions: ErrorDisplayAction[] = [
|
||||
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
|
||||
...(showNavigation ? [
|
||||
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
|
||||
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
|
||||
] : []),
|
||||
...actions,
|
||||
];
|
||||
|
||||
switch (variant) {
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={50}
|
||||
bg="bg-deep-graphite"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={6}
|
||||
className={className}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Box maxWidth="lg" fullWidth textAlign="center">
|
||||
<Box display="flex" justifyContent="center" mb={6}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="20"
|
||||
w="20"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="3xl"
|
||||
bg="bg-red-500/10"
|
||||
border={true}
|
||||
borderColor="border-red-500/30"
|
||||
>
|
||||
<Icon icon={AlertCircle} size={10} color="text-red-500" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Heading level={2} mb={3}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Box mb={6} display="inline-flex" alignItems="center" gap={2} px={4} py={2} bg="bg-iron-gray/40" rounded="lg">
|
||||
<Text size="sm" color="text-gray-300" font="mono">HTTP {errorInfo.statusCode}</Text>
|
||||
{errorInfo.details && !hideTechnicalDetails && (
|
||||
<Text size="sm" color="text-gray-500">- {errorInfo.details}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
icon={action.icon && <Icon icon={action.icon} size={4} />}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
|
||||
<Box mt={8} textAlign="left">
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-sm text-gray-500 hover:text-gray-400">
|
||||
Technical Details
|
||||
</summary>
|
||||
<Box as="pre" mt={2} p={4} bg="bg-black/50" rounded="lg" color="text-gray-400" style={{ fontSize: '0.75rem', overflowX: 'auto' }}>
|
||||
{error.stack}
|
||||
</Box>
|
||||
</details>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
border={true}
|
||||
borderColor="border-red-500/30"
|
||||
rounded="xl"
|
||||
p={6}
|
||||
className={className}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Stack direction="row" gap={4} align="start">
|
||||
<Icon icon={AlertCircle} size={6} color="text-red-500" />
|
||||
<Box flexGrow={1}>
|
||||
<Heading level={3} mb={1}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mb={3}>
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Text size="xs" font="mono" color="text-gray-500" block mb={3}>
|
||||
HTTP {errorInfo.statusCode}
|
||||
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Stack direction="row" gap={2}>
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-red-500/10"
|
||||
border={true}
|
||||
borderColor="border-red-500/30"
|
||||
rounded="lg"
|
||||
className={className}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Icon icon={AlertCircle} size={4} color="text-red-500" />
|
||||
<Text size="sm" color="text-red-400">{errorInfo.message}</Text>
|
||||
{onRetry && showRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
className="ml-2 text-xs text-red-300 hover:text-red-200 underline p-0 h-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
hideTechnicalDetails = false,
|
||||
}: {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
hideTechnicalDetails?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
hideTechnicalDetails={hideTechnicalDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkErrorDisplay({
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
}: {
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={new Error('Network connection failed. Please check your internet connection.')}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { LoadingWrapperProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* LoadingWrapper Component
|
||||
*
|
||||
* Provides consistent loading states with multiple variants:
|
||||
* - spinner: Traditional loading spinner (default)
|
||||
* - skeleton: Skeleton screens for better UX
|
||||
* - full-screen: Centered in viewport
|
||||
* - inline: Compact inline loading
|
||||
* - card: Loading card placeholders
|
||||
*
|
||||
* All variants are fully accessible with ARIA labels and keyboard support.
|
||||
*/
|
||||
export function LoadingWrapper({
|
||||
variant = 'spinner',
|
||||
message = 'Loading...',
|
||||
className = '',
|
||||
size = 'md',
|
||||
skeletonCount = 3,
|
||||
cardConfig,
|
||||
ariaLabel = 'Loading content',
|
||||
}: LoadingWrapperProps) {
|
||||
// Size mappings for different variants
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
spinner: 'w-4 h-4 border-2',
|
||||
inline: 'xs' as const,
|
||||
card: 'h-24',
|
||||
},
|
||||
md: {
|
||||
spinner: 'w-10 h-10 border-2',
|
||||
inline: 'sm' as const,
|
||||
card: 'h-32',
|
||||
},
|
||||
lg: {
|
||||
spinner: 'w-16 h-16 border-4',
|
||||
inline: 'base' as const,
|
||||
card: 'h-40',
|
||||
},
|
||||
};
|
||||
|
||||
const spinnerSize = sizeClasses[size].spinner;
|
||||
const inlineSize = sizeClasses[size].inline;
|
||||
const cardHeight = cardConfig?.height || sizeClasses[size].card;
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'spinner':
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="200px"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack align="center" gap={3}>
|
||||
<Box
|
||||
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
|
||||
/>
|
||||
<Text color="text-gray-400" size="sm">{message}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
return (
|
||||
<Stack
|
||||
gap={3}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
fullWidth
|
||||
bg="bg-iron-gray/40"
|
||||
rounded="lg"
|
||||
animate="pulse"
|
||||
style={{ height: cardHeight }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={50}
|
||||
bg="bg-deep-graphite/90"
|
||||
blur="sm"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={4}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Box textAlign="center" maxWidth="md">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<Text color="text-white" size="lg" weight="medium">{message}</Text>
|
||||
<Text color="text-gray-400" size="sm">This may take a moment...</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Box className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<Text color="text-gray-400" size={inlineSize}>{message}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
const cardCount = cardConfig?.count || 3;
|
||||
const cardClassName = cardConfig?.className || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gap={4}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{Array.from({ length: cardCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
bg="bg-iron-gray/40"
|
||||
rounded="xl"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={cardClassName}
|
||||
style={{ height: cardHeight }}
|
||||
>
|
||||
<Box h="full" w="full" display="flex" alignItems="center" justifyContent="center">
|
||||
<Box className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-screen loading
|
||||
*/
|
||||
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="full-screen"
|
||||
message={message}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for inline loading
|
||||
*/
|
||||
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="inline"
|
||||
message={message}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for skeleton loading
|
||||
*/
|
||||
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="skeleton"
|
||||
skeletonCount={skeletonCount}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for card loading
|
||||
*/
|
||||
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="card"
|
||||
cardConfig={cardConfig}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { ErrorDisplay } from '@/ui/ErrorDisplay';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Inbox, List, LucideIcon } from 'lucide-react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
@@ -61,27 +60,6 @@ export interface PageWrapperProps<TData> {
|
||||
* - Empty states (with icon, title, description, and action)
|
||||
* - Success state (renders Template component with data)
|
||||
* - Flexible children support for custom content
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* <PageWrapper
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={retry}
|
||||
* Template={MyTemplateComponent}
|
||||
* loading={{ variant: 'skeleton', message: 'Loading...' }}
|
||||
* error={{ variant: 'full-screen' }}
|
||||
* empty={{
|
||||
* icon: Trophy,
|
||||
* title: 'No data found',
|
||||
* description: 'Try refreshing the page',
|
||||
* action: { label: 'Refresh', onClick: retry }
|
||||
* }}
|
||||
* >
|
||||
* <AdditionalContent />
|
||||
* </PageWrapper>
|
||||
* ```
|
||||
*/
|
||||
export function PageWrapper<TData>({
|
||||
data,
|
||||
@@ -112,14 +90,14 @@ export function PageWrapper<TData>({
|
||||
|
||||
// Default to skeleton
|
||||
return (
|
||||
<Box>
|
||||
<React.Fragment>
|
||||
<LoadingWrapper
|
||||
variant="skeleton"
|
||||
message={loadingMessage}
|
||||
skeletonCount={3}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,14 +107,14 @@ export function PageWrapper<TData>({
|
||||
|
||||
if (errorVariant === 'card') {
|
||||
return (
|
||||
<Box>
|
||||
<React.Fragment>
|
||||
<ErrorDisplay
|
||||
error={error as ApiError}
|
||||
onRetry={retry}
|
||||
variant="card"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,7 +135,7 @@ export function PageWrapper<TData>({
|
||||
const hasAction = empty.action && retry;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<React.Fragment>
|
||||
<EmptyState
|
||||
icon={Icon || Inbox}
|
||||
title={empty.title || 'No data available'}
|
||||
@@ -169,24 +147,24 @@ export function PageWrapper<TData>({
|
||||
variant="default"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// If no empty config provided but data is empty, show nothing
|
||||
return (
|
||||
<Box>
|
||||
<React.Fragment>
|
||||
{children}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Success State - Render Template with data
|
||||
return (
|
||||
<Box>
|
||||
<React.Fragment>
|
||||
<Template data={data} />
|
||||
{children}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,4 +240,4 @@ export function DetailPageWrapper<TData>({
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, Box } from 'lucide-react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'primary';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} title={title}>
|
||||
<Box p={6}>
|
||||
<Stack direction="row" gap={4} align="start">
|
||||
{variant === 'danger' && (
|
||||
<Box p={2} rounded="full" bg="bg-racing-red/10">
|
||||
<AlertCircle className="w-6 h-6 text-racing-red" />
|
||||
</Box>
|
||||
)}
|
||||
<Box flexGrow={1}>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" mb={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text color="text-gray-400" size="sm" block mb={6}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" justifyContent="end" gap={3}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === 'danger' ? 'primary' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={variant === 'danger' ? 'bg-racing-red hover:bg-racing-red/90 border-racing-red' : ''}
|
||||
>
|
||||
{isLoading ? 'Processing...' : confirmLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, AlertTriangle, Box, CheckCircle, Info } from 'lucide-react';
|
||||
|
||||
interface InlineNoticeProps {
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
title?: string;
|
||||
message: string;
|
||||
mb?: number;
|
||||
}
|
||||
|
||||
export function InlineNotice({
|
||||
variant = 'info',
|
||||
title,
|
||||
message,
|
||||
mb,
|
||||
}: InlineNoticeProps) {
|
||||
const variants = {
|
||||
info: {
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
text: 'text-primary-blue',
|
||||
icon: Info,
|
||||
},
|
||||
success: {
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
text: 'text-performance-green',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
text: 'text-warning-amber',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-racing-red/10',
|
||||
border: 'border-racing-red/30',
|
||||
text: 'text-racing-red',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
rounded="lg"
|
||||
bg={config.bg}
|
||||
border
|
||||
borderColor={config.border}
|
||||
mb={mb}
|
||||
>
|
||||
<Stack direction="row" gap={3} align="start">
|
||||
<Icon className={`w-5 h-5 ${config.text} mt-0.5`} />
|
||||
<Box>
|
||||
{title && (
|
||||
<Text weight="semibold" color="text-white" block mb={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" color="text-gray-300" block>
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ProgressLineProps {
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressLine({ isLoading, className = '' }: ProgressLineProps) {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="full"
|
||||
h="0.5"
|
||||
bg="bg-iron-gray"
|
||||
overflow="hidden"
|
||||
className={`relative ${className}`}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-full bg-primary-blue"
|
||||
initial={{ width: '0%', left: '0%' }}
|
||||
animate={{
|
||||
width: ['20%', '50%', '20%'],
|
||||
left: ['-20%', '100%', '-20%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Toast as UIToast } from '@/ui/Toast';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertCircle, CheckCircle, Info, X } from 'lucide-react';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
interface Toast {
|
||||
@@ -33,23 +32,16 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<Stack
|
||||
position="fixed"
|
||||
bottom={6}
|
||||
right={6}
|
||||
zIndex={50}
|
||||
className="pointer-events-none space-y-3"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
<div style={{ position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 100, display: 'flex', flexDirection: 'column', gap: '0.75rem', pointerEvents: 'none' }}>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} style={{ pointerEvents: 'auto' }}>
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onClose={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -65,60 +57,32 @@ export function useToast() {
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
const variants = {
|
||||
success: {
|
||||
bg: 'bg-deep-graphite',
|
||||
border: 'border-performance-green/30',
|
||||
intent: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-performance-green',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-deep-graphite',
|
||||
border: 'border-racing-red/30',
|
||||
intent: 'critical' as const,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-racing-red',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-deep-graphite',
|
||||
border: 'border-primary-blue/30',
|
||||
intent: 'primary' as const,
|
||||
icon: Info,
|
||||
iconColor: 'text-primary-blue',
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[toast.variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="pointer-events-auto"
|
||||
<UIToast
|
||||
onClose={onClose}
|
||||
intent={config.intent}
|
||||
icon={<Icon icon={config.icon} size={5} intent={config.intent} />}
|
||||
isVisible={true}
|
||||
isExiting={false}
|
||||
>
|
||||
<Stack
|
||||
px={4}
|
||||
py={3}
|
||||
rounded="lg"
|
||||
bg={config.bg}
|
||||
border
|
||||
borderColor={config.border}
|
||||
shadow="xl"
|
||||
direction="row"
|
||||
align="center"
|
||||
gap={3}
|
||||
{...({ minWidth: "300px" } as any)}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${config.iconColor}`} />
|
||||
<Text size="sm" color="text-white" flexGrow={1}>
|
||||
{toast.message}
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={X}
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 hover:text-white transition-colors"
|
||||
/>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
<Text size="sm" variant="high">
|
||||
{toast.message}
|
||||
</Text>
|
||||
</UIToast>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user