feat: implement Phase 2 (component library) + Phase 3 (dashboard)

Phase 2 - New shared components:
- Button: filled/outlined/text/icon variants, loading state
- StatusChip: colored badge with icon per status (completed/running/pending/failed)
- StatCard: icon circle with tinted bg, big number display
- EmptyState: centered icon, title, description, optional action
- SkeletonCard: shimmer loading placeholders (stat, list item, table row)
- SegmentedButton: active shows Check icon, secondary-container colors
- AirportChip: PlaneTakeoff icon, error hover on remove
- Toast: updated to Lucide icons + design token colors

Phase 3 - Dashboard redesign:
- 5 stat cards with skeleton loading
- Status chip separated from destination text (fixes "BDS→DUScompleted" bug)
- Hover lift effect on scan cards
- Relative timestamps (Just now, Xm ago, Today, Yesterday, N days ago)
- EmptyState when no scans exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:53:16 +01:00
parent 7417d56578
commit 1021664253
9 changed files with 533 additions and 177 deletions

View File

@@ -0,0 +1,27 @@
import { PlaneTakeoff, X } from 'lucide-react';
interface AirportChipProps {
code: string;
onRemove: () => void;
}
export default function AirportChip({ code, onRemove }: AirportChipProps) {
return (
<span className="inline-flex items-center gap-1.5 h-8 pl-3 pr-1.5 border border-outline rounded-lg bg-surface-2 text-sm font-medium text-on-surface transition-shadow hover:shadow-level-1">
<PlaneTakeoff
size={14}
className="text-on-surface-variant shrink-0"
aria-hidden="true"
/>
<span>{code}</span>
<button
type="button"
onClick={onRemove}
className="ml-0.5 w-5 h-5 flex items-center justify-center rounded-full text-on-surface-variant hover:bg-error-container hover:text-error transition-colors"
aria-label={`Remove ${code}`}
>
<X size={12} aria-hidden="true" />
</button>
</span>
);
}

View File

@@ -0,0 +1,63 @@
import { Loader2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'filled' | 'outlined' | 'text' | 'icon';
size?: 'sm' | 'md';
loading?: boolean;
icon?: LucideIcon;
iconPosition?: 'left' | 'right';
}
export default function Button({
variant = 'filled',
size = 'md',
loading = false,
icon: Icon,
iconPosition = 'left',
disabled,
className,
children,
...props
}: ButtonProps) {
const isDisabled = disabled || loading;
const iconSize = size === 'sm' ? 14 : 16;
const base =
'inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-150 ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
'disabled:opacity-50 disabled:cursor-not-allowed select-none';
const variants: Record<NonNullable<ButtonProps['variant']>, string> = {
filled: 'h-9 px-4 rounded-full bg-primary text-on-primary text-sm hover:opacity-90 shadow-sm',
outlined: 'h-9 px-4 rounded-full border border-outline text-primary text-sm hover:bg-primary-container',
text: 'h-9 px-3 rounded-full text-primary text-sm hover:bg-surface-2',
icon: 'w-10 h-10 rounded-full text-on-surface-variant hover:bg-surface-2',
};
const smOverrides: Partial<Record<NonNullable<ButtonProps['variant']>, string>> = {
filled: 'h-8 px-3 text-xs',
outlined: 'h-8 px-3 text-xs',
text: 'h-8 px-2 text-xs',
icon: 'w-8 h-8',
};
return (
<button
disabled={isDisabled}
className={cn(base, variants[variant], size === 'sm' && smOverrides[variant], className)}
{...props}
>
{loading ? (
<Loader2 size={iconSize} className="animate-spin" aria-hidden="true" />
) : (
<>
{Icon && iconPosition === 'left' && <Icon size={iconSize} aria-hidden="true" />}
{children}
{Icon && iconPosition === 'right' && <Icon size={iconSize} aria-hidden="true" />}
</>
)}
</button>
);
}

View File

@@ -0,0 +1,35 @@
import type { LucideIcon } from 'lucide-react';
import Button from './Button';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
}
export default function EmptyState({
icon: Icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center px-6">
<div className="w-24 h-24 rounded-full bg-surface-2 flex items-center justify-center mb-6">
<Icon size={40} className="text-on-surface-variant opacity-40" aria-hidden="true" />
</div>
<h3 className="text-xl font-medium text-on-surface mb-2">{title}</h3>
{description && (
<p className="text-sm text-on-surface-variant max-w-sm mb-6 leading-relaxed">
{description}
</p>
)}
{action && (
<Button variant="filled" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Check } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils';
interface Option {
value: string;
label: string;
icon?: LucideIcon;
}
interface SegmentedButtonProps {
options: Option[];
value: string;
onChange: (value: string) => void;
className?: string;
}
export default function SegmentedButton({
options,
value,
onChange,
className,
}: SegmentedButtonProps) {
return (
<div
className={cn(
'inline-flex border border-outline rounded-full overflow-hidden w-full',
className,
)}
role="group"
>
{options.map((option, i) => {
const active = option.value === value;
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
aria-pressed={active}
onClick={() => onChange(option.value)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 h-10 px-4 text-sm font-medium transition-colors duration-150',
i > 0 && 'border-l border-outline',
active
? 'bg-secondary-container text-on-secondary-container'
: 'bg-transparent text-on-surface hover:bg-surface-2',
)}
>
{active ? (
<Check size={16} aria-hidden="true" />
) : (
Icon && <Icon size={16} aria-hidden="true" />
)}
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,61 @@
/** Shimmer skeleton building blocks — import named exports as needed. */
function Bone({ className }: { className?: string }) {
return <div className={`skeleton rounded ${className ?? ''}`} />;
}
/** Matches StatCard dimensions */
export function SkeletonStatCard() {
return (
<div className="bg-surface rounded-lg p-5 shadow-level-1 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bone className="w-10 h-10 rounded-full shrink-0" />
<Bone className="h-4 w-24" />
</div>
<Bone className="h-9 w-16" />
</div>
);
}
/** Matches a scan list-card row */
export function SkeletonListItem() {
return (
<div className="bg-surface rounded-lg px-5 py-4 shadow-level-1 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Bone className="h-5 w-40" />
<Bone className="h-5 w-20 rounded-full" />
</div>
<Bone className="h-4 w-64" />
<Bone className="h-4 w-36" />
</div>
);
}
/** Matches a data-table row */
export function SkeletonTableRow() {
return (
<tr className="border-b border-outline-variant">
<td className="px-4 py-4">
<Bone className="h-4 w-20" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-32" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-12" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-24" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-16" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-16" />
</td>
<td className="px-4 py-4">
<Bone className="h-4 w-16" />
</td>
</tr>
);
}

View File

@@ -0,0 +1,54 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils';
type Variant = 'default' | 'primary' | 'secondary' | 'tertiary' | 'error';
const ICON_STYLES: Record<Variant, { bg: string; icon: string }> = {
default: { bg: 'bg-[#E8F0FE]', icon: 'text-primary' },
primary: { bg: 'bg-[#E8F0FE]', icon: 'text-primary' },
secondary: { bg: 'bg-[#E6F4EA]', icon: 'text-secondary' },
tertiary: { bg: 'bg-[#FEF7E0]', icon: 'text-tertiary' },
error: { bg: 'bg-[#FDECEA]', icon: 'text-error' },
};
interface StatCardProps {
label: string;
value: number | string;
icon: LucideIcon;
variant?: Variant;
trend?: string;
}
export default function StatCard({
label,
value,
icon: Icon,
variant = 'default',
trend,
}: StatCardProps) {
const styles = ICON_STYLES[variant];
return (
<div className="bg-surface rounded-lg p-5 shadow-level-1 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center shrink-0',
styles.bg,
)}
>
<Icon size={20} className={styles.icon} aria-hidden="true" />
</div>
<span className="text-sm text-on-surface-variant font-medium leading-tight">
{label}
</span>
</div>
<p className="text-3xl font-bold text-on-surface tabular-nums leading-none">
{value}
</p>
{trend && (
<p className="text-xs text-on-surface-variant">{trend}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { CheckCircle2, Loader2, Clock, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils';
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed';
interface StatusConfig {
icon: LucideIcon;
label: string;
chipClass: string;
iconClass: string;
spin?: boolean;
}
const CONFIGS: Record<ScanStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
label: 'completed',
chipClass: 'bg-[#E6F4EA] text-[#137333] border border-[#A8D5B5]',
iconClass: 'text-[#137333]',
},
running: {
icon: Loader2,
label: 'running',
chipClass: 'bg-[#E8F0FE] text-[#1557B0] border border-[#A8C7FA]',
iconClass: 'text-[#1557B0]',
spin: true,
},
pending: {
icon: Clock,
label: 'pending',
chipClass: 'bg-[#FEF7E0] text-[#7A5200] border border-[#F9D659]',
iconClass: 'text-[#7A5200]',
},
failed: {
icon: XCircle,
label: 'failed',
chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]',
iconClass: 'text-[#A50E0E]',
},
};
interface StatusChipProps {
status: ScanStatus;
}
export default function StatusChip({ status }: StatusChipProps) {
const cfg = CONFIGS[status] ?? CONFIGS.failed;
const { icon: Icon, label, chipClass, iconClass, spin } = cfg;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0',
chipClass,
)}
>
<Icon
size={12}
className={cn(iconClass, spin && 'animate-spin')}
aria-hidden="true"
/>
{label}
</span>
);
}

View File

@@ -1,4 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-react';
import { cn } from '../lib/utils';
export type ToastType = 'success' | 'error' | 'info' | 'warning'; export type ToastType = 'success' | 'error' | 'info' | 'warning';
@@ -9,88 +11,61 @@ interface ToastProps {
duration?: number; duration?: number;
} }
export default function Toast({ message, type, onClose, duration = 5000 }: ToastProps) { const CONFIGS = {
success: {
icon: CheckCircle2,
className: 'bg-[#E6F4EA] border border-[#A8D5B5] text-[#137333]',
iconClass: 'text-[#137333]',
},
error: {
icon: XCircle,
className: 'bg-[#FDECEA] border border-[#F5C6C6] text-[#A50E0E]',
iconClass: 'text-[#A50E0E]',
},
warning: {
icon: AlertTriangle,
className: 'bg-[#FEF7E0] border border-[#F9D659] text-[#7A5200]',
iconClass: 'text-[#7A5200]',
},
info: {
icon: Info,
className: 'bg-[#E8F0FE] border border-[#A8C7FA] text-[#1557B0]',
iconClass: 'text-[#1557B0]',
},
} as const;
export default function Toast({
message,
type,
onClose,
duration = 5000,
}: ToastProps) {
useEffect(() => { useEffect(() => {
const timer = setTimeout(onClose, duration); const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [duration, onClose]); }, [duration, onClose]);
const getColors = () => { const { icon: Icon, className, iconClass } = CONFIGS[type];
switch (type) {
case 'success':
return 'bg-green-50 border-green-200 text-green-800';
case 'error':
return 'bg-red-50 border-red-200 text-red-800';
case 'warning':
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
case 'info':
return 'bg-blue-50 border-blue-200 text-blue-800';
}
};
const getIcon = () => {
switch (type) {
case 'success':
return (
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
);
case 'error':
return (
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
);
case 'warning':
return (
<svg className="w-5 h-5 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
case 'info':
return (
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
);
}
};
return ( return (
<div <div
className={`fixed bottom-4 right-4 flex items-center p-4 border rounded-lg shadow-lg ${getColors()} animate-slide-up`} className={cn(
style={{ minWidth: '300px', maxWidth: '500px' }} 'fixed right-4 bottom-24 lg:bottom-6 z-50',
'flex items-start gap-3 p-4 rounded-xl shadow-level-3 animate-slide-up',
'w-[calc(100vw-2rem)] sm:w-auto sm:min-w-72 sm:max-w-sm',
className,
)}
role="alert"
aria-live="polite"
> >
<div className="flex-shrink-0">{getIcon()}</div> <Icon size={18} className={cn('shrink-0 mt-0.5', iconClass)} aria-hidden="true" />
<p className="ml-3 text-sm font-medium flex-1">{message}</p> <p className="flex-1 text-sm font-medium leading-snug">{message}</p>
<button <button
onClick={onClose} onClick={onClose}
className="ml-4 flex-shrink-0 text-gray-400 hover:text-gray-600" className="shrink-0 -mt-0.5 -mr-0.5 w-7 h-7 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
aria-label="Dismiss notification"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <X size={14} aria-hidden="true" />
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
); );

View File

@@ -1,17 +1,52 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import {
ScanSearch,
Clock,
Loader2,
CheckCircle2,
XCircle,
ArrowRight,
} from 'lucide-react';
import { scanApi } from '../api'; import { scanApi } from '../api';
import type { Scan } from '../api'; import type { Scan } from '../api';
import StatCard from '../components/StatCard';
import StatusChip from '../components/StatusChip';
import type { ScanStatus } from '../components/StatusChip';
import EmptyState from '../components/EmptyState';
import { SkeletonStatCard, SkeletonListItem } from '../components/SkeletonCard';
interface Stats {
total: number;
pending: number;
running: number;
completed: number;
failed: number;
}
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString('en-GB', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function Dashboard() { export default function Dashboard() {
const [scans, setScans] = useState<Scan[]>([]); const navigate = useNavigate();
const [scans, setScans] = useState<Scan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ const [stats, setStats] = useState<Stats>({
total: 0, total: 0, pending: 0, running: 0, completed: 0, failed: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
}); });
useEffect(() => { useEffect(() => {
@@ -24,14 +59,12 @@ export default function Dashboard() {
const response = await scanApi.list(1, 10); const response = await scanApi.list(1, 10);
const scanList = response.data.data; const scanList = response.data.data;
setScans(scanList); setScans(scanList);
// Calculate stats
setStats({ setStats({
total: response.data.pagination.total, total: response.data.pagination.total,
pending: scanList.filter(s => s.status === 'pending').length, pending: scanList.filter(s => s.status === 'pending').length,
running: scanList.filter(s => s.status === 'running').length, running: scanList.filter(s => s.status === 'running').length,
completed: scanList.filter(s => s.status === 'completed').length, completed: scanList.filter(s => s.status === 'completed').length,
failed: scanList.filter(s => s.status === 'failed').length, failed: scanList.filter(s => s.status === 'failed').length,
}); });
} catch (error) { } catch (error) {
console.error('Failed to load scans:', error); console.error('Failed to load scans:', error);
@@ -40,118 +73,100 @@ export default function Dashboard() {
} }
}; };
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'running':
return 'bg-blue-100 text-blue-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return ( return (
<div> <div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2> {/* ── Stats grid ───────────────────────────────────────────── */}
<Link <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
to="/scans" {loading ? (
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" Array.from({ length: 5 }).map((_, i) => <SkeletonStatCard key={i} />)
> ) : (
+ New Scan <>
</Link> <StatCard label="Total Scans" value={stats.total} icon={ScanSearch} variant="primary" />
<StatCard label="Pending" value={stats.pending} icon={Clock} variant="tertiary" />
<StatCard label="Running" value={stats.running} icon={Loader2} variant="primary" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} variant="secondary" />
<StatCard label="Failed" value={stats.failed} icon={XCircle} variant="error" />
</>
)}
</div> </div>
{/* Stats Cards */} {/* ── Recent scans ─────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8"> <div>
<div className="bg-white p-6 rounded-lg shadow"> <div className="flex items-center justify-between mb-3">
<div className="text-sm font-medium text-gray-500">Total Scans</div> <h2 className="text-base font-semibold text-on-surface">Recent Scans</h2>
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div> <Link
to="/scans"
className="text-sm text-primary font-medium hover:underline"
>
+ New scan
</Link>
</div> </div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Pending</div>
<div className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Running</div>
<div className="text-3xl font-bold text-blue-600 mt-2">{stats.running}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Completed</div>
<div className="text-3xl font-bold text-green-600 mt-2">{stats.completed}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Failed</div>
<div className="text-3xl font-bold text-red-600 mt-2">{stats.failed}</div>
</div>
</div>
{/* Recent Scans */} {loading ? (
<div className="bg-white rounded-lg shadow"> <div className="space-y-2">
<div className="px-6 py-4 border-b border-gray-200"> {Array.from({ length: 5 }).map((_, i) => <SkeletonListItem key={i} />)}
<h3 className="text-lg font-semibold text-gray-900">Recent Scans</h3> </div>
</div> ) : scans.length === 0 ? (
<div className="divide-y divide-gray-200"> <div className="bg-surface rounded-lg shadow-level-1">
{scans.length === 0 ? ( <EmptyState
<div className="px-6 py-12 text-center text-gray-500"> icon={ScanSearch}
No scans yet. Create your first scan to get started! title="No scans yet"
</div> description="Create your first scan to discover flight routes and prices."
) : ( action={{ label: '+ New Scan', onClick: () => navigate('/scans') }}
scans.map((scan) => ( />
</div>
) : (
<div className="space-y-2">
{scans.map((scan) => (
<Link <Link
key={scan.id} key={scan.id}
to={`/scans/${scan.id}`} to={`/scans/${scan.id}`}
className="block px-6 py-4 hover:bg-gray-50 cursor-pointer" className="group block bg-surface rounded-lg px-5 py-4 shadow-level-1 hover:-translate-y-0.5 hover:shadow-level-2 transition-all duration-150"
> >
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-3"> {/* Row 1: route + status */}
<span className="font-medium text-gray-900"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-on-surface">
{scan.origin} {scan.country} {scan.origin} {scan.country}
</span> </span>
<span <StatusChip status={scan.status as ScanStatus} />
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(
scan.status
)}`}
>
{scan.status}
</span>
</div>
<div className="mt-1 text-sm text-gray-500">
{scan.start_date} to {scan.end_date} {scan.adults} adult(s) {scan.seat_class}
</div> </div>
{/* Row 2: dates + config */}
<p className="mt-1 text-sm text-on-surface-variant">
{scan.start_date} {scan.end_date}
{' · '}{scan.adults} adult{scan.adults !== 1 ? 's' : ''}
{' · '}{scan.seat_class}
</p>
{/* Row 3: results (only when present) */}
{scan.total_routes > 0 && ( {scan.total_routes > 0 && (
<div className="mt-1 text-sm text-gray-500"> <p className="mt-0.5 text-sm text-on-surface-variant">
{scan.total_routes} routes {scan.total_flights} flights found {scan.total_routes} routes · {scan.total_flights} flights found
</div> </p>
)} )}
</div> </div>
<div className="text-sm text-gray-500">
{formatDate(scan.created_at)} {/* Right: date + arrow */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-on-surface-variant">
{formatRelativeDate(scan.created_at)}
</span>
<ArrowRight
size={16}
className="text-on-surface-variant group-hover:translate-x-0.5 transition-transform duration-150"
aria-hidden="true"
/>
</div> </div>
</div> </div>
</Link> </Link>
)) ))}
)} </div>
</div> )}
</div> </div>
</div> </div>
); );
} }