website refactor
This commit is contained in:
191
apps/website/components/shared/CountrySelect.tsx
Normal file
191
apps/website/components/shared/CountrySelect.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
|
||||
|
||||
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CountryFlag } from '@/ui/CountryFlag';
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const COUNTRIES: Country[] = [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
];
|
||||
|
||||
interface CountrySelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function CountrySelect({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
errorMessage,
|
||||
disabled,
|
||||
placeholder = 'Select country',
|
||||
}: CountrySelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCountry = COUNTRIES.find(c => c.code === value);
|
||||
|
||||
const filteredCountries = COUNTRIES.filter(country =>
|
||||
country.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
country.code.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
onChange(code);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
|
||||
error
|
||||
? 'ring-warning-amber focus:ring-warning-amber'
|
||||
: 'ring-charcoal-outline focus:ring-primary-blue'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-charcoal-outline">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search countries..."
|
||||
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country List */}
|
||||
<div className="overflow-y-auto max-h-60">
|
||||
{filteredCountries.length > 0 ? (
|
||||
filteredCountries.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(country.code)}
|
||||
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
|
||||
value === country.code
|
||||
? 'bg-primary-blue/20 text-white'
|
||||
: 'text-gray-300 hover:bg-deep-graphite'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
|
||||
<span>{country.name}</span>
|
||||
</span>
|
||||
{value === country.code && (
|
||||
<Check className="w-4 h-4 text-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-gray-500 text-sm">
|
||||
No countries found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && errorMessage && (
|
||||
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
apps/website/components/shared/RangeField.tsx
Normal file
271
apps/website/components/shared/RangeField.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface RangeFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
helperText?: string;
|
||||
error?: string | undefined;
|
||||
disabled?: boolean;
|
||||
unitLabel?: string;
|
||||
rangeHint?: string;
|
||||
/** Show large value display above slider */
|
||||
showLargeValue?: boolean;
|
||||
/** Compact mode - single line */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function RangeField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
helperText,
|
||||
error,
|
||||
disabled,
|
||||
unitLabel = 'min',
|
||||
rangeHint,
|
||||
showLargeValue = false,
|
||||
compact = false,
|
||||
}: RangeFieldProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local value with prop when not dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isDragging]);
|
||||
|
||||
const clampedValue = Number.isFinite(localValue)
|
||||
? Math.min(Math.max(localValue, min), max)
|
||||
: min;
|
||||
|
||||
const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
|
||||
|
||||
const effectiveRangeHint =
|
||||
rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`);
|
||||
|
||||
const calculateValueFromPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!sliderRef.current) return clampedValue;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
||||
const rawValue = min + percent * (max - min);
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
return Math.min(Math.max(steppedValue, min), max);
|
||||
},
|
||||
[min, max, step, clampedValue]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging || disabled) return;
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[isDragging, disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
setLocalValue(min);
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const clamped = Math.min(Math.max(parsed, min), max);
|
||||
setLocalValue(clamped);
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Ensure value is synced on blur
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
// Quick preset buttons for common values
|
||||
const quickPresets = [
|
||||
Math.round(min + (max - min) * 0.25),
|
||||
Math.round(min + (max - min) * 0.5),
|
||||
Math.round(min + (max - min) * 0.75),
|
||||
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
|
||||
{/* Track fill */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
|
||||
bg-white border-2 border-primary-blue shadow-md
|
||||
transition-transform duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
|
||||
<span className="text-[10px] text-gray-500">{unitLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-gray-300">{label}</label>
|
||||
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
|
||||
</div>
|
||||
|
||||
{showLargeValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
|
||||
<span className="text-sm text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom slider */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
|
||||
|
||||
{/* Track fill with gradient */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
|
||||
{/* Tick marks */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
|
||||
{[0, 25, 50, 75, 100].map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className={`w-0.5 h-1 rounded-full transition-colors ${
|
||||
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
|
||||
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
|
||||
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
|
||||
transition-all duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value input and quick presets */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={clampedValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
|
||||
bg-iron-gray border border-charcoal-outline text-white
|
||||
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
|
||||
transition-colors
|
||||
${error ? 'border-warning-amber' : ''}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
|
||||
{quickPresets.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{quickPresets.slice(0, 3).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocalValue(preset);
|
||||
onChange(preset);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
|
||||
{error && <p className="text-xs text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
apps/website/components/shared/state/EmptyState.tsx
Normal file
329
apps/website/components/shared/state/EmptyState.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { EmptyStateProps } from '@/ui/state-types';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
const Illustrations = {
|
||||
racing: () => (
|
||||
<svg className="w-20 h-20" 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 className="w-20 h-20" 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 className="w-20 h-20" 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 className="w-20 h-20" 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 className="w-20 h-20" 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 = () => (
|
||||
<>
|
||||
{/* Visual - Icon or Illustration */}
|
||||
<div className="flex justify-center mb-4">
|
||||
{IllustrationComponent ? (
|
||||
<div className="text-gray-500">
|
||||
<IllustrationComponent />
|
||||
</div>
|
||||
) : Icon ? (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50">
|
||||
<Icon className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl font-semibold text-white mb-2 text-center">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-gray-400 mb-6 text-center leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{action && (
|
||||
<div className="flex justify-center">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'default':
|
||||
return (
|
||||
<div
|
||||
className={`text-center py-12 ${className}`}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-md mx-auto">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'minimal':
|
||||
return (
|
||||
<div
|
||||
className={`text-center py-8 ${className}`}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-sm mx-auto space-y-3">
|
||||
{/* Minimal icon */}
|
||||
{Icon && (
|
||||
<div className="flex justify-center">
|
||||
<Icon className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-300">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="text-sm text-primary-blue hover:text-blue-400 font-medium mt-2 inline-flex items-center gap-1"
|
||||
>
|
||||
{action.label}
|
||||
{action.icon && <action.icon className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'full-page':
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-lg w-full text-center">
|
||||
<div className="mb-6">
|
||||
{IllustrationComponent ? (
|
||||
<div className="text-gray-500 flex justify-center">
|
||||
<IllustrationComponent />
|
||||
</div>
|
||||
) : Icon ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-iron-gray/60 border border-charcoal-outline/50">
|
||||
<Icon className="w-10 h-10 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p className="text-gray-400 text-lg mb-8 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<div className="flex flex-col sm:flex-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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional helper text for full-page variant */}
|
||||
<div className="mt-8 text-sm text-gray-500">
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@gridpilot.com"
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
support@gridpilot.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
import { Activity, Lock, Search } from 'lucide-react';
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
248
apps/website/components/shared/state/ErrorDisplay.tsx
Normal file
248
apps/website/components/shared/state/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
|
||||
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
228
apps/website/components/shared/state/LoadingWrapper.tsx
Normal file
228
apps/website/components/shared/state/LoadingWrapper.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { LoadingWrapperProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
265
apps/website/components/shared/state/PageWrapper.tsx
Normal file
265
apps/website/components/shared/state/PageWrapper.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Inbox, List, LucideIcon } from 'lucide-react';
|
||||
|
||||
// ==================== PAGEWRAPPER TYPES ====================
|
||||
|
||||
export interface PageWrapperLoadingConfig {
|
||||
variant?: 'skeleton' | 'full-screen';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PageWrapperErrorConfig {
|
||||
variant?: 'full-screen' | 'card';
|
||||
card?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageWrapperEmptyConfig {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageWrapperProps<TData> {
|
||||
/** Data to be rendered */
|
||||
data: TData | undefined;
|
||||
/** Loading state (default: false) */
|
||||
isLoading?: boolean;
|
||||
/** Error state (default: null) */
|
||||
error?: Error | null;
|
||||
/** Retry function for errors */
|
||||
retry?: () => void;
|
||||
/** Template component that receives the data */
|
||||
Template: React.ComponentType<{ data: TData }>;
|
||||
/** Loading configuration */
|
||||
loading?: PageWrapperLoadingConfig;
|
||||
/** Error configuration */
|
||||
errorConfig?: PageWrapperErrorConfig;
|
||||
/** Empty configuration */
|
||||
empty?: PageWrapperEmptyConfig;
|
||||
/** Children for flexible content rendering */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageWrapper Component
|
||||
*
|
||||
* A comprehensive wrapper component that handles all page states:
|
||||
* - Loading states (skeleton or full-screen)
|
||||
* - Error states (full-screen or card)
|
||||
* - 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,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
// Priority order: Loading > Error > Empty > Success
|
||||
|
||||
// 1. Loading State
|
||||
if (isLoading) {
|
||||
const loadingVariant = loading?.variant || 'skeleton';
|
||||
const loadingMessage = loading?.message || 'Loading...';
|
||||
|
||||
if (loadingVariant === 'full-screen') {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="full-screen"
|
||||
message={loadingMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to skeleton
|
||||
return (
|
||||
<Box>
|
||||
<LoadingWrapper
|
||||
variant="skeleton"
|
||||
message={loadingMessage}
|
||||
skeletonCount={3}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Error State
|
||||
if (error) {
|
||||
const errorVariant = errorConfig?.variant || 'full-screen';
|
||||
|
||||
if (errorVariant === 'card') {
|
||||
return (
|
||||
<Box>
|
||||
<ErrorDisplay
|
||||
error={error as ApiError}
|
||||
onRetry={retry}
|
||||
variant="card"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to full-screen
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error as ApiError}
|
||||
onRetry={retry}
|
||||
variant="full-screen"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Empty State
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
if (empty) {
|
||||
const Icon = empty.icon;
|
||||
const hasAction = empty.action && retry;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={Icon || Inbox}
|
||||
title={empty.title || 'No data available'}
|
||||
description={empty.description}
|
||||
action={hasAction ? {
|
||||
label: empty.action!.label,
|
||||
onClick: empty.action!.onClick,
|
||||
} : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If no empty config provided but data is empty, show nothing
|
||||
return (
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Success State - Render Template with data
|
||||
return (
|
||||
<Box>
|
||||
<Template data={data} />
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for list data with automatic empty state handling
|
||||
*/
|
||||
export function ListPageWrapper<TData extends unknown[]>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
const listEmpty = empty || {
|
||||
icon: List,
|
||||
title: 'No items found',
|
||||
description: 'This list is currently empty',
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={errorConfig}
|
||||
empty={listEmpty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for detail pages with enhanced error handling
|
||||
*/
|
||||
export function DetailPageWrapper<TData>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData> & {
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
// Create enhanced error config with additional actions
|
||||
const enhancedErrorConfig: PageWrapperErrorConfig = {
|
||||
...errorConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={enhancedErrorConfig}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
391
apps/website/components/shared/state/StateContainer.tsx
Normal file
391
apps/website/components/shared/state/StateContainer.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { StateContainerProps, StateContainerConfig } from '@/ui/state-types';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Inbox, AlertCircle, Grid, List, LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* StateContainer Component
|
||||
*
|
||||
* Combined wrapper that automatically handles all states (loading, error, empty, success)
|
||||
* based on the provided data and state values.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic state detection and rendering
|
||||
* - Customizable configuration for each state
|
||||
* - Custom render functions for advanced use cases
|
||||
* - Consistent behavior across all pages
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* <StateContainer
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={retry}
|
||||
* config={{
|
||||
* 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 }
|
||||
* }
|
||||
* }}
|
||||
* >
|
||||
* {(content) => <MyContent data={content} />}
|
||||
* </StateContainer>
|
||||
* ```
|
||||
*/
|
||||
export function StateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
showEmpty = true,
|
||||
isEmpty,
|
||||
}: StateContainerProps<T>) {
|
||||
// Determine if data is empty
|
||||
const isDataEmpty = (data: T | null | undefined): boolean => {
|
||||
if (data === null || data === undefined) return true;
|
||||
if (isEmpty) return isEmpty(data);
|
||||
|
||||
// Default empty checks
|
||||
if (Array.isArray(data)) return data.length === 0;
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
return Object.keys(data).length === 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Priority order: Loading > Error > Empty > Success
|
||||
if (isLoading) {
|
||||
const loadingConfig = config?.loading || {};
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.loading) {
|
||||
return <>{config.customRender.loading()}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<LoadingWrapper
|
||||
variant={loadingConfig.variant || 'spinner'}
|
||||
message={loadingConfig.message || 'Loading...'}
|
||||
size={loadingConfig.size || 'md'}
|
||||
skeletonCount={loadingConfig.skeletonCount}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorConfig = config?.error || {};
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.error) {
|
||||
return <>{config.customRender.error(error)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={retry}
|
||||
variant={errorConfig.variant || 'full-screen'}
|
||||
actions={errorConfig.actions}
|
||||
showRetry={errorConfig.showRetry}
|
||||
showNavigation={errorConfig.showNavigation}
|
||||
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (showEmpty && isDataEmpty(data)) {
|
||||
const emptyConfig = config?.empty;
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.empty) {
|
||||
return <>{config.customRender.empty()}</>;
|
||||
}
|
||||
|
||||
// If no empty config provided, show nothing (or could show default empty state)
|
||||
if (!emptyConfig) {
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No data available"
|
||||
description="There is nothing to display here"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={emptyConfig.icon}
|
||||
title={emptyConfig.title || 'No data available'}
|
||||
description={emptyConfig.description}
|
||||
action={emptyConfig.action}
|
||||
variant="default"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state - render children with data
|
||||
if (data === null || data === undefined) {
|
||||
// This shouldn't happen if we've handled all cases above, but as a fallback
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Unexpected state"
|
||||
description="No data available but no error or loading state"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom success render
|
||||
if (config?.customRender?.success) {
|
||||
return <>{config.customRender.success(data as T)}</>;
|
||||
}
|
||||
|
||||
// At this point, data is guaranteed to be non-null and non-undefined
|
||||
return <>{children(data as T)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ListStateContainer - Specialized for list data
|
||||
* Automatically handles empty arrays with appropriate messaging
|
||||
*/
|
||||
export function ListStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
emptyConfig,
|
||||
}: StateContainerProps<T[]> & {
|
||||
emptyConfig?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const listConfig: StateContainerConfig<T[]> = {
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: List,
|
||||
title: 'No items found',
|
||||
description: 'This list is currently empty',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={listConfig}
|
||||
isEmpty={(arr) => !arr || arr.length === 0}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DetailStateContainer - Specialized for detail pages
|
||||
* Includes back/refresh functionality
|
||||
*/
|
||||
export function DetailStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
onBack,
|
||||
onRefresh,
|
||||
}: StateContainerProps<T> & {
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const detailConfig: StateContainerConfig<T> = {
|
||||
...config,
|
||||
error: {
|
||||
...config?.error,
|
||||
actions: [
|
||||
...(config?.error?.actions || []),
|
||||
...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
|
||||
...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
|
||||
],
|
||||
showNavigation: config?.error?.showNavigation ?? true,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={detailConfig}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PageStateContainer - Full page state management
|
||||
* Wraps content in proper page structure
|
||||
*/
|
||||
export function PageStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
title,
|
||||
description,
|
||||
}: StateContainerProps<T> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const pageConfig: StateContainerConfig<T> = {
|
||||
loading: {
|
||||
variant: 'full-screen',
|
||||
message: title ? `Loading ${title}...` : 'Loading...',
|
||||
...config?.loading,
|
||||
},
|
||||
error: {
|
||||
variant: 'full-screen',
|
||||
...config?.error,
|
||||
},
|
||||
empty: config?.empty,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>;
|
||||
}
|
||||
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
if (config?.empty) {
|
||||
return (
|
||||
<Box bg="bg-deep-graphite" py={12} minHeight="100vh">
|
||||
<Box maxWidth="4xl" mx="auto" px={4}>
|
||||
{title && (
|
||||
<Box mb={8}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400">{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="bg-deep-graphite" py={8} minHeight="100vh">
|
||||
<Box maxWidth="4xl" mx="auto" px={4}>
|
||||
{title && (
|
||||
<Box mb={6}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400">{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStateContainer - Specialized for grid layouts
|
||||
* Handles card-based empty states
|
||||
*/
|
||||
export function GridStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
emptyConfig,
|
||||
}: StateContainerProps<T[]> & {
|
||||
emptyConfig?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const gridConfig: StateContainerConfig<T[]> = {
|
||||
loading: {
|
||||
variant: 'card',
|
||||
...config?.loading,
|
||||
},
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: Grid,
|
||||
title: 'No items to display',
|
||||
description: 'Try adjusting your filters or search',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={gridConfig}
|
||||
isEmpty={(arr) => !arr || arr.length === 0}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
59
apps/website/components/shared/state/StatefulPageWrapper.tsx
Normal file
59
apps/website/components/shared/state/StatefulPageWrapper.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { PageWrapper, PageWrapperProps } from '@/components/shared/state/PageWrapper';
|
||||
|
||||
/**
|
||||
* Stateful Page Wrapper - CLIENT SIDE ONLY
|
||||
* Adds loading/error state management for client-side fetching
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* 'use client';
|
||||
*
|
||||
* export default function ProfilePage() {
|
||||
* const { data, isLoading, error, refetch } = usePageData(...);
|
||||
*
|
||||
* return (
|
||||
* <StatefulPageWrapper
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={refetch}
|
||||
* Template={ProfileTemplate}
|
||||
* loading={{ variant: 'skeleton', message: 'Loading profile...' }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function StatefulPageWrapper<TData>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
// Same implementation but with 'use client' for CSR-specific features
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={errorConfig}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from '@/components/shared/state/PageWrapper';
|
||||
Reference in New Issue
Block a user