344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
}
|