diff --git a/app/(admin)/admin/overview/page.tsx b/app/(admin)/admin/overview/page.tsx new file mode 100644 index 0000000..9b50046 --- /dev/null +++ b/app/(admin)/admin/overview/page.tsx @@ -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 ( +
+
+ {label} +
+ +
+
+ {isLoading ? ( +
+ ) : ( +

{value}

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

Admin Overview

+

+ Platform-wide metrics and activity summary +

+
+ +
+ + {isError && ( +
+ + Failed to load stats. Please refresh or try again later. +
+ )} + +
+ + + + + + +
+
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 0000000..3d62782 --- /dev/null +++ b/app/(admin)/layout.tsx @@ -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 ( + +
+ +
{children}
+
+
+ ); +} diff --git a/app/(dashboard)/admin/page.tsx b/app/(dashboard)/admin/page.tsx index e21339d..792c3e9 100644 --- a/app/(dashboard)/admin/page.tsx +++ b/app/(dashboard)/admin/page.tsx @@ -1,13 +1,5 @@ -import { DisconnectButton } from '@/components/wallet/DisconnectButton'; +import { redirect } from 'next/navigation'; export default function AdminDashboardPage() { - return ( -
-

Admin Dashboard

- {/* Wallet session controls — see hooks/useWallet.ts for disconnect logic */} -
- -
-
- ); + redirect('/admin/overview'); } diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..7e31375 --- /dev/null +++ b/app/error.tsx @@ -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 ( +
+
+ +
+
+

+ Something went wrong +

+

+ {error.message || 'An unexpected error occurred. Please try again.'} +

+
+ +
+ ); +} diff --git a/components/admin/AdminSidebar.tsx b/components/admin/AdminSidebar.tsx new file mode 100644 index 0000000..715869d --- /dev/null +++ b/components/admin/AdminSidebar.tsx @@ -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 ( + + ); +} diff --git a/components/auth/ProtectedRoute.tsx b/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..20d2156 --- /dev/null +++ b/components/auth/ProtectedRoute.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + if (requiredRole && user?.role !== requiredRole) { + return null; + } + + return <>{children}; +} diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..301bac8 --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -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 ( +
+
+ +
+

{title}

+ {description && ( +

{description}

+ )} + {action && ( + + )} +
+ ); +} diff --git a/hooks/useAdminDashboard.ts b/hooks/useAdminDashboard.ts new file mode 100644 index 0000000..aacbd70 --- /dev/null +++ b/hooks/useAdminDashboard.ts @@ -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 }; +} diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts new file mode 100644 index 0000000..36927d3 --- /dev/null +++ b/hooks/useAuth.ts @@ -0,0 +1,51 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAuthStore, type AuthUser, type UserRole } from '@/store/authStore'; +import { authApiService } from '@/services/auth.service'; + +interface UseAuthReturn { + isAuthenticated: boolean; + isLoading: boolean; + user: AuthUser | null; +} + +const API_TO_STORE_ROLE: Record = { + customer: 'Customer', + driver: 'Driver', + admin: 'Admin', +}; + +export function useAuth(): UseAuthReturn { + const [isLoading, setIsLoading] = useState(true); + const { user, isAuthenticated, setUser, clearUser } = useAuthStore(); + + useEffect(() => { + const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null; + + if (!token) { + clearUser(); + setIsLoading(false); + return; + } + + authApiService + .getCurrentUser() + .then((res) => { + if (res.data) { + const role: UserRole = API_TO_STORE_ROLE[res.data.role] ?? 'Customer'; + setUser({ id: res.data.id, email: res.data.email, role }); + } else { + clearUser(); + } + }) + .catch(() => { + localStorage.removeItem('authToken'); + clearUser(); + }) + .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { isAuthenticated, isLoading, user }; +} diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..b9940af --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,41 @@ +import axios, { + type AxiosInstance, + type InternalAxiosRequestConfig, + type AxiosResponse, + type AxiosError, +} from 'axios'; + +const api: AxiosInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Attach JWT bearer token to every outgoing request +api.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; + }, + (error: AxiosError) => Promise.reject(error), +); + +// Handle 401 Unauthorized — clear stored token and redirect to login +api.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => { + if (error.response?.status === 401 && typeof window !== 'undefined') { + localStorage.removeItem('authToken'); + window.location.href = '/login'; + } + return Promise.reject(error); + }, +); + +export default api; diff --git a/middleware.ts b/middleware.ts index ae32628..227f743 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,7 +2,19 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { - // Placeholder middleware logic + const token = request.cookies.get('authToken')?.value; + const { pathname } = request.nextUrl; + + const isProtected = + pathname.startsWith('/dashboard') || + pathname.startsWith('/admin'); + + if (isProtected && !token) { + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = '/login'; + return NextResponse.redirect(loginUrl); + } + return NextResponse.next(); } diff --git a/services/adminService.ts b/services/adminService.ts new file mode 100644 index 0000000..8708caa --- /dev/null +++ b/services/adminService.ts @@ -0,0 +1,23 @@ +import api from '@/lib/api'; + +export interface AdminStats { + totalUsers: number; + activeDeliveries: number; + totalRevenue: number; + activeDrivers: number; + pendingKyc: number; + escrowLocked: number; +} + +export interface AdminApiResponse { + success: boolean; + message: string; + data?: T; +} + +export const adminService = { + async getStats(): Promise> { + const { data } = await api.get>('/admin/stats'); + return data; + }, +}; diff --git a/services/auth.service.ts b/services/auth.service.ts new file mode 100644 index 0000000..16145c1 --- /dev/null +++ b/services/auth.service.ts @@ -0,0 +1,22 @@ +import api from '@/lib/api'; +import type { ApiResponse, LoginResponse } from '@/services/authService'; + +export const authApiService = { + async login(email: string, password: string): Promise> { + const { data } = await api.post>('/auth/login', { + email, + password, + }); + return data; + }, + + async logout(): Promise> { + const { data } = await api.post>('/auth/logout'); + return data; + }, + + async getCurrentUser(): Promise> { + const { data } = await api.get>('/auth/me'); + return data; + }, +};