diff --git a/flight-comparator/frontend/src/components/AirportChip.tsx b/flight-comparator/frontend/src/components/AirportChip.tsx new file mode 100644 index 0000000..13b6bd2 --- /dev/null +++ b/flight-comparator/frontend/src/components/AirportChip.tsx @@ -0,0 +1,27 @@ +import { PlaneTakeoff, X } from 'lucide-react'; + +interface AirportChipProps { + code: string; + onRemove: () => void; +} + +export default function AirportChip({ code, onRemove }: AirportChipProps) { + return ( + + + ); +} diff --git a/flight-comparator/frontend/src/components/Button.tsx b/flight-comparator/frontend/src/components/Button.tsx new file mode 100644 index 0000000..c511043 --- /dev/null +++ b/flight-comparator/frontend/src/components/Button.tsx @@ -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 { + 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, 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, 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 ( + + ); +} diff --git a/flight-comparator/frontend/src/components/EmptyState.tsx b/flight-comparator/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..447877b --- /dev/null +++ b/flight-comparator/frontend/src/components/EmptyState.tsx @@ -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 ( +
+
+
+

{title}

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ); +} diff --git a/flight-comparator/frontend/src/components/SegmentedButton.tsx b/flight-comparator/frontend/src/components/SegmentedButton.tsx new file mode 100644 index 0000000..cc39236 --- /dev/null +++ b/flight-comparator/frontend/src/components/SegmentedButton.tsx @@ -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 ( +
+ {options.map((option, i) => { + const active = option.value === value; + const Icon = option.icon; + return ( + + ); + })} +
+ ); +} diff --git a/flight-comparator/frontend/src/components/SkeletonCard.tsx b/flight-comparator/frontend/src/components/SkeletonCard.tsx new file mode 100644 index 0000000..ae84531 --- /dev/null +++ b/flight-comparator/frontend/src/components/SkeletonCard.tsx @@ -0,0 +1,61 @@ +/** Shimmer skeleton building blocks — import named exports as needed. */ + +function Bone({ className }: { className?: string }) { + return
; +} + +/** Matches StatCard dimensions */ +export function SkeletonStatCard() { + return ( +
+
+ + +
+ +
+ ); +} + +/** Matches a scan list-card row */ +export function SkeletonListItem() { + return ( +
+
+ + +
+ + +
+ ); +} + +/** Matches a data-table row */ +export function SkeletonTableRow() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/flight-comparator/frontend/src/components/StatCard.tsx b/flight-comparator/frontend/src/components/StatCard.tsx new file mode 100644 index 0000000..2ab3a8f --- /dev/null +++ b/flight-comparator/frontend/src/components/StatCard.tsx @@ -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 = { + 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 ( +
+
+
+
+ + {label} + +
+

+ {value} +

+ {trend && ( +

{trend}

+ )} +
+ ); +} diff --git a/flight-comparator/frontend/src/components/StatusChip.tsx b/flight-comparator/frontend/src/components/StatusChip.tsx new file mode 100644 index 0000000..d1c6f8f --- /dev/null +++ b/flight-comparator/frontend/src/components/StatusChip.tsx @@ -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 = { + 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 ( + + + ); +} diff --git a/flight-comparator/frontend/src/components/Toast.tsx b/flight-comparator/frontend/src/components/Toast.tsx index f082ca3..cd209c7 100644 --- a/flight-comparator/frontend/src/components/Toast.tsx +++ b/flight-comparator/frontend/src/components/Toast.tsx @@ -1,4 +1,6 @@ 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'; @@ -9,88 +11,61 @@ interface ToastProps { 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(() => { const timer = setTimeout(onClose, duration); return () => clearTimeout(timer); }, [duration, onClose]); - const getColors = () => { - 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 ( - - - - ); - case 'error': - return ( - - - - ); - case 'warning': - return ( - - - - ); - case 'info': - return ( - - - - ); - } - }; + const { icon: Icon, className, iconClass } = CONFIGS[type]; return (
-
{getIcon()}
-

{message}

+
); diff --git a/flight-comparator/frontend/src/pages/Dashboard.tsx b/flight-comparator/frontend/src/pages/Dashboard.tsx index 4c47ae2..3bba3eb 100644 --- a/flight-comparator/frontend/src/pages/Dashboard.tsx +++ b/flight-comparator/frontend/src/pages/Dashboard.tsx @@ -1,17 +1,52 @@ 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 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() { - const [scans, setScans] = useState([]); + const navigate = useNavigate(); + const [scans, setScans] = useState([]); const [loading, setLoading] = useState(true); - const [stats, setStats] = useState({ - total: 0, - pending: 0, - running: 0, - completed: 0, - failed: 0, + const [stats, setStats] = useState({ + total: 0, pending: 0, running: 0, completed: 0, failed: 0, }); useEffect(() => { @@ -24,14 +59,12 @@ export default function Dashboard() { const response = await scanApi.list(1, 10); const scanList = response.data.data; setScans(scanList); - - // Calculate stats setStats({ - total: response.data.pagination.total, - pending: scanList.filter(s => s.status === 'pending').length, - running: scanList.filter(s => s.status === 'running').length, + total: response.data.pagination.total, + pending: scanList.filter(s => s.status === 'pending').length, + running: scanList.filter(s => s.status === 'running').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) { 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 ( -
-
Loading...
-
- ); - } - return ( -
-
-

Dashboard

- - + New Scan - +
+ + {/* ── Stats grid ───────────────────────────────────────────── */} +
+ {loading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : ( + <> + + + + + + + )}
- {/* Stats Cards */} -
-
-
Total Scans
-
{stats.total}
+ {/* ── Recent scans ─────────────────────────────────────────── */} +
+
+

Recent Scans

+ + + New scan +
-
-
Pending
-
{stats.pending}
-
-
-
Running
-
{stats.running}
-
-
-
Completed
-
{stats.completed}
-
-
-
Failed
-
{stats.failed}
-
-
- {/* Recent Scans */} -
-
-

Recent Scans

-
-
- {scans.length === 0 ? ( -
- No scans yet. Create your first scan to get started! -
- ) : ( - scans.map((scan) => ( + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : scans.length === 0 ? ( +
+ navigate('/scans') }} + /> +
+ ) : ( +
+ {scans.map((scan) => ( -
-
-
- +
+
+ {/* Row 1: route + status */} +
+ {scan.origin} → {scan.country} - - {scan.status} - -
-
- {scan.start_date} to {scan.end_date} • {scan.adults} adult(s) • {scan.seat_class} +
+ + {/* Row 2: dates + config */} +

+ {scan.start_date} – {scan.end_date} + {' · '}{scan.adults} adult{scan.adults !== 1 ? 's' : ''} + {' · '}{scan.seat_class} +

+ + {/* Row 3: results (only when present) */} {scan.total_routes > 0 && ( -
- {scan.total_routes} routes • {scan.total_flights} flights found -
+

+ {scan.total_routes} routes · {scan.total_flights} flights found +

)}
-
- {formatDate(scan.created_at)} + + {/* Right: date + arrow */} +
+ + {formatRelativeDate(scan.created_at)} + +
- )) - )} -
+ ))} +
+ )}
+
); }