Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions app/(admin)/admin/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import {
Users,
Package,
DollarSign,
Truck,
ShieldCheck,
Lock,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import { useAdminDashboard } from '@/hooks/useAdminDashboard';

interface MetricCardProps {
label: string;
value: string | number;
icon: React.ElementType;
isLoading: boolean;
}

function MetricCard({ label, value, icon: Icon, isLoading }: MetricCardProps) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{label}</span>
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-900/30">
<Icon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
{isLoading ? (
<div className="h-8 w-24 animate-pulse rounded-md bg-slate-200 dark:bg-slate-700" />
) : (
<p className="text-2xl font-bold text-slate-900 dark:text-white">{value}</p>
)}
</div>
);
}

export default function AdminOverviewPage() {
const { stats, isLoading, isError, refetch } = useAdminDashboard();

return (
<div>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Admin Overview</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Platform-wide metrics and activity summary
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>

{isError && (
<div className="mb-6 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
<AlertCircle className="h-4 w-4 shrink-0" />
Failed to load stats. Please refresh or try again later.
</div>
)}

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<MetricCard
label="Total Users"
value={stats?.totalUsers ?? 0}
icon={Users}
isLoading={isLoading}
/>
<MetricCard
label="Active Deliveries"
value={stats?.activeDeliveries ?? 0}
icon={Package}
isLoading={isLoading}
/>
<MetricCard
label="Total Revenue (XLM)"
value={stats?.totalRevenue ?? 0}
icon={DollarSign}
isLoading={isLoading}
/>
<MetricCard
label="Active Drivers"
value={stats?.activeDrivers ?? 0}
icon={Truck}
isLoading={isLoading}
/>
<MetricCard
label="Pending KYC"
value={stats?.pendingKyc ?? 0}
icon={ShieldCheck}
isLoading={isLoading}
/>
<MetricCard
label="Escrow Locked (XLM)"
value={stats?.escrowLocked ?? 0}
icon={Lock}
isLoading={isLoading}
/>
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions app/(admin)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { AdminSidebar } from '@/components/admin/AdminSidebar';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';

export default function AdminLayout({ children }: { children: ReactNode }) {
return (
<ProtectedRoute requiredRole="Admin">
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
<AdminSidebar />
<main className="ml-64 flex-1 p-8">{children}</main>
</div>
</ProtectedRoute>
);
}
12 changes: 2 additions & 10 deletions app/(dashboard)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import { DisconnectButton } from '@/components/wallet/DisconnectButton';
import { redirect } from 'next/navigation';

export default function AdminDashboardPage() {
return (
<div>
<h1 data-tour="dashboard-title">Admin Dashboard</h1>
{/* Wallet session controls — see hooks/useWallet.ts for disconnect logic */}
<div data-tour="disconnect-wallet">
<DisconnectButton />
</div>
</div>
);
redirect('/admin/overview');
}
37 changes: 37 additions & 0 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';

interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}

export default function GlobalError({ error, reset }: ErrorProps) {
useEffect(() => {
console.error('[GlobalError]', error);
}, [error]);

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-slate-50 px-4 dark:bg-slate-950">
<div className="rounded-full bg-red-100 p-5 dark:bg-red-900/30">
<AlertTriangle className="h-10 w-10 text-red-500" />
</div>
<div className="text-center">
<h1 className="mb-2 text-2xl font-bold text-slate-900 dark:text-white">
Something went wrong
</h1>
<p className="max-w-md text-sm text-slate-500 dark:text-slate-400">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
</div>
<button
onClick={reset}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95"
>
Try again
</button>
</div>
);
}
81 changes: 81 additions & 0 deletions components/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
Users,
Package,
Shield,
Settings,
LogOut,
} from 'lucide-react';
import { useAuthStore } from '@/store/authStore';

interface AdminNavItem {
label: string;
href: string;
icon: React.ElementType;
}

const NAV_ITEMS: AdminNavItem[] = [
{ label: 'Overview', href: '/admin/overview', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Deliveries', href: '/admin/deliveries', icon: Package },
{ label: 'KYC Review', href: '/admin/kyc', icon: Shield },
{ label: 'Settings', href: '/admin/settings', icon: Settings },
];

export function AdminSidebar() {
const pathname = usePathname();
const router = useRouter();
const clearUser = useAuthStore((s) => s.clearUser);

function handleLogout() {
localStorage.removeItem('authToken');
clearUser();
router.replace('/login');
}

return (
<aside className="fixed left-0 top-0 flex h-screen w-64 flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
<span className="text-lg font-bold text-slate-900 dark:text-white">SwiftChain</span>
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Admin
</span>
</div>

<nav className="flex-1 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={`mb-1 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
{item.label}
</Link>
);
})}
</nav>

<div className="border-t border-slate-200 px-3 py-4 dark:border-slate-800">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</div>
</aside>
);
}
48 changes: 48 additions & 0 deletions components/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import type { UserRole } from '@/store/authStore';

interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: UserRole;
}

export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const router = useRouter();
const { isAuthenticated, isLoading, user } = useAuth();

useEffect(() => {
if (isLoading) return;

if (!isAuthenticated) {
router.replace('/login');
return;
}

if (requiredRole && user?.role !== requiredRole) {
router.replace('/');
}
}, [isAuthenticated, isLoading, requiredRole, user, router]);

if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
);
}

if (!isAuthenticated) {
return null;
}

if (requiredRole && user?.role !== requiredRole) {
return null;
}

return <>{children}</>;
}
35 changes: 35 additions & 0 deletions components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import type { LucideIcon } from 'lucide-react';

interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}

export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
<div className="mb-4 rounded-full bg-slate-100 p-4 dark:bg-slate-800">
<Icon className="h-8 w-8 text-slate-400 dark:text-slate-500" />
</div>
<h3 className="mb-2 text-base font-semibold text-slate-900 dark:text-white">{title}</h3>
{description && (
<p className="mb-6 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>
)}
{action && (
<button
onClick={action.onClick}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95"
>
{action.label}
</button>
)}
</div>
);
}
33 changes: 33 additions & 0 deletions hooks/useAdminDashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { adminService, type AdminStats } from '@/services/adminService';

interface UseAdminDashboardReturn {
stats: AdminStats | undefined;
isLoading: boolean;
isError: boolean;
refetch: () => void;
}

export function useAdminDashboard(): UseAdminDashboardReturn {
const {
data: stats,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ['admin', 'stats'],
queryFn: async () => {
const response = await adminService.getStats();
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to fetch admin stats');
}
return response.data;
},
staleTime: 30_000,
retry: 1,
});

return { stats, isLoading, isError, refetch };
}
Loading
Loading