debug project
This commit is contained in:
parent
41879b49c7
commit
a7a23ce730
|
|
@ -0,0 +1,66 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function CreateAgentPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
router.push('/admin/agents');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader title="Add Agent" description="Create a new agent account" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">First Name *</label>
|
||||
<input type="text" className="form-input" required placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Last Name *</label>
|
||||
<input type="text" className="form-input" required placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Phone Number *</label>
|
||||
<input type="text" className="form-input" required placeholder="0912XXXXXXX" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" className="form-input" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Password *</label>
|
||||
<input type="password" className="form-input" required placeholder="Min 8 characters" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Role</label>
|
||||
<select className="form-select">
|
||||
<option value="agent">Agent</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer flex items-center justify-between">
|
||||
<button type="button" onClick={() => router.push('/admin/agents')} className="btn-outline">Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><span className="spinner-sm" /> Creating...</> : 'Create Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
|
||||
const mockAgents = [
|
||||
{ id: 1, firstName: 'Mehdi', lastName: 'Rezaei', phoneNumber: '09151234567', email: 'mehdi@example.com', role: 'agent', status: 'active', description: 'Delivery agent' },
|
||||
{ id: 2, firstName: 'Narges', lastName: 'Karimi', phoneNumber: '09161234567', email: 'narges@example.com', role: 'agent', status: 'active', description: 'Pickup agent' },
|
||||
];
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = mockAgents.filter((a) =>
|
||||
`${a.firstName} ${a.lastName} ${a.phoneNumber}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Agents"
|
||||
description="Manage system agents"
|
||||
action={{ label: 'Add Agent', href: '/admin/agents/create' }}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search agents..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="font-medium">#{a.id}</td>
|
||||
<td className="text-gray-900 dark:text-gray-100 font-medium">{a.firstName} {a.lastName}</td>
|
||||
<td className="font-mono text-sm">{a.phoneNumber}</td>
|
||||
<td className="text-gray-500 dark:text-gray-400">{a.email}</td>
|
||||
<td><span className="badge bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400 capitalize">{a.role}</span></td>
|
||||
<td><StatusBadge status={a.status} /></td>
|
||||
<td className="text-right">
|
||||
<Link href={`/admin/agents/${a.id}`} className="btn-secondary btn-sm">View</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="🤝" title="No agents found" action={{ label: 'Add Agent', href: '/admin/agents/create' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function EditBenefactorPage({ params }: { params: { id: string } }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
// Simulate API call
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader
|
||||
title={`Edit Benefactor #${params.id}`}
|
||||
description="Update benefactor information"
|
||||
/>
|
||||
|
||||
{saved && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-sm text-green-700 dark:text-green-300">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
Benefactor updated successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">First Name</label>
|
||||
<input type="text" className="form-input" defaultValue="Ali" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Last Name</label>
|
||||
<input type="text" className="form-input" defaultValue="Mohammadi" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Phone Number</label>
|
||||
<input type="text" className="form-input" defaultValue="09121234567" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" className="form-input" defaultValue="ali@example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Gender</label>
|
||||
<select className="form-select" defaultValue="male">
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-select" defaultValue="active">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Birth Date</label>
|
||||
<input type="date" className="form-input" defaultValue="1990-05-15" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Description</label>
|
||||
<textarea className="form-textarea" rows={3} placeholder="Additional notes..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer flex items-center justify-between">
|
||||
<Link href="/admin/benefactors" className="btn-outline">Cancel</Link>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? (
|
||||
<><span className="spinner-sm" /> Saving...</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function NewBenefactorPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
router.push('/admin/benefactors');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader title="Add Benefactor" description="Register a new benefactor in the system" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">First Name *</label>
|
||||
<input type="text" className="form-input" required placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Last Name *</label>
|
||||
<input type="text" className="form-input" required placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Phone Number *</label>
|
||||
<input type="text" className="form-input" required placeholder="0912XXXXXXX" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" className="form-input" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Gender</label>
|
||||
<select className="form-select">
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Birth Date</label>
|
||||
<input type="date" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Description</label>
|
||||
<textarea className="form-textarea" rows={3} placeholder="Additional notes..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer flex items-center justify-between">
|
||||
<button type="button" onClick={() => router.push('/admin/benefactors')} className="btn-outline">Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><span className="spinner-sm" /> Creating...</> : 'Create Benefactor'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
|
||||
// Mock data for display when API is not connected
|
||||
const mockBenefactors = [
|
||||
{ id: 1, firstName: 'Ali', lastName: 'Mohammadi', phoneNumber: '09121234567', email: 'ali@example.com', gender: 'male', status: 'active', birthDate: '1990-05-15', role: 'benefactor', description: '' },
|
||||
{ id: 2, firstName: 'Sara', lastName: 'Ahmadi', phoneNumber: '09131234567', email: 'sara@example.com', gender: 'female', status: 'active', birthDate: '1985-08-22', role: 'benefactor', description: '' },
|
||||
{ id: 3, firstName: 'Reza', lastName: 'Hosseini', phoneNumber: '09141234567', email: 'reza@example.com', gender: 'male', status: 'inactive', birthDate: '1992-11-03', role: 'benefactor', description: '' },
|
||||
];
|
||||
|
||||
export default function BenefactorsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [benefactors] = useState(mockBenefactors);
|
||||
|
||||
const filtered = benefactors.filter((b) =>
|
||||
`${b.firstName} ${b.lastName} ${b.phoneNumber}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Benefactors"
|
||||
description="Manage all benefactors in the system"
|
||||
action={{ label: 'Add Benefactor', href: '/admin/benefactors/new' }}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search benefactors by name or phone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="form-input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Gender</th>
|
||||
<th>Status</th>
|
||||
<th>Birth Date</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">#{b.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/benefactors/${b.id}/edit`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
{b.firstName} {b.lastName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="font-mono text-sm">{b.phoneNumber}</td>
|
||||
<td className="text-gray-500 dark:text-gray-400">{b.email || '—'}</td>
|
||||
<td>
|
||||
<span className={b.gender === 'male' ? 'text-blue-600' : 'text-pink-600'}>
|
||||
{b.gender === 'male' ? '♂ Male' : '♀ Female'}
|
||||
</span>
|
||||
</td>
|
||||
<td><StatusBadge status={b.status} /></td>
|
||||
<td className="text-gray-500 dark:text-gray-400">{formatDate(b.birthDate)}</td>
|
||||
<td className="text-right">
|
||||
<Link
|
||||
href={`/admin/benefactors/${b.id}/edit`}
|
||||
className="btn-secondary btn-sm"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? 'No benefactors match your search' : 'No benefactors yet'}
|
||||
description={search ? 'Try a different search term' : 'Benefactors will appear here once they register.'}
|
||||
action={!search ? { label: 'Add Benefactor', href: '/admin/benefactors/new' } : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total Benefactors', value: '—', href: '/admin/benefactors', color: 'from-blue-500 to-blue-600' },
|
||||
{ label: 'Kind Boxes', value: '—', href: '/admin/kind-boxes', color: 'from-purple-500 to-purple-600' },
|
||||
{ label: 'Requests', value: '—', href: '/admin/kind-box-requests', color: 'from-amber-500 to-orange-600' },
|
||||
{ label: 'Campaigns', value: '—', href: '/campaigns', color: 'from-emerald-500 to-emerald-600' },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ label: 'Create Benefactor', href: '/admin/benefactors', icon: '👤' },
|
||||
{ label: 'New Kind Box', href: '/admin/kind-boxes/create', icon: '📦' },
|
||||
{ label: 'New Request', href: '/admin/kind-box-requests/create', icon: '📋' },
|
||||
{ label: 'View Agents', href: '/admin/agents', icon: '🤝' },
|
||||
];
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Welcome to Niki management system</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<Link
|
||||
key={stat.label}
|
||||
href={stat.href}
|
||||
className="stat-card group cursor-pointer"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100">
|
||||
{stat.value}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
View details
|
||||
<span>→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.label}
|
||||
href={action.href}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-all duration-200 group"
|
||||
>
|
||||
<span className="text-2xl">{action.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{action.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">System Overview</h2>
|
||||
</div>
|
||||
<div className="card-body text-sm text-gray-600 dark:text-gray-400 space-y-3">
|
||||
<p>
|
||||
Niki is a kind box management system that connects benefactors with those in need.
|
||||
Use the sidebar to navigate between sections.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="badge bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">Benefactors</span>
|
||||
<span className="badge bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">Kind Boxes</span>
|
||||
<span className="badge bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Requests</span>
|
||||
<span className="badge bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">Campaigns</span>
|
||||
<span className="badge bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400">Wallet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backend Status */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Backend Status</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<span className="text-xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">API Not Connected</p>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-0.5">
|
||||
Set <code className="px-1 py-0.5 bg-yellow-100 dark:bg-yellow-900/40 rounded text-xs">NEXT_PUBLIC_API_URL</code> in your environment to connect to the backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const mockReq = {
|
||||
id: 1, benefactorId: 1, kindBoxType: 'on-table' as const,
|
||||
countRequested: 5, countAccepted: 0, description: 'Need boxes for charity event',
|
||||
status: 'pending' as const, deliverReferTimeId: 1,
|
||||
deliverReferDate: '2025-06-15', deliverAddressId: 1,
|
||||
senderAgentId: null, deliveredAt: '',
|
||||
};
|
||||
|
||||
export default function KindBoxRequestDetailPage({ params }: { params: { id: string } }) {
|
||||
const [status, setStatus] = useState<string>(mockReq.status);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleAction = async (action: 'accept' | 'reject') => {
|
||||
setLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setStatus(action === 'accept' ? 'accepted' : 'rejected');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/kind-box-requests" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="12" x2="5" y2="12" /><polyline points="12 19 5 12 12 5" /></svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Request #{params.id}</h1>
|
||||
<p className="text-sm text-gray-500">Benefactor #{mockReq.benefactorId}</p>
|
||||
</div>
|
||||
<div className="ml-auto"><StatusBadge status={status} size="md" /></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Request Details</h2></div>
|
||||
<div className="card-body space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Type</span><span className="capitalize font-medium">{mockReq.kindBoxType.replace(/-/g, ' ')}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Count Requested</span><span className="font-medium">{mockReq.countRequested}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Count Accepted</span><span className="font-medium">{mockReq.countAccepted}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Refer Date</span><span>{formatDate(mockReq.deliverReferDate)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Description</h2></div>
|
||||
<div className="card-body text-sm text-gray-600 dark:text-gray-400">
|
||||
{mockReq.description || 'No description provided.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === 'pending' && (
|
||||
<div className="card">
|
||||
<div className="card-body flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Process this request:</span>
|
||||
<button onClick={() => handleAction('accept')} disabled={loading} className="btn-success">
|
||||
{loading ? <span className="spinner-sm" /> : null} Accept
|
||||
</button>
|
||||
<button onClick={() => handleAction('reject')} disabled={loading} className="btn-danger">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/admin/kind-box-requests" className="btn-outline inline-flex">Back to Requests</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function CreateKindBoxRequestPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
router.push('/admin/kind-box-requests');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader title="New Request" description="Create a new kind box request for a benefactor" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div>
|
||||
<label className="form-label">Benefactor *</label>
|
||||
<select className="form-select" required>
|
||||
<option value="">Select benefactor...</option>
|
||||
<option value="1">Ali Mohammadi</option>
|
||||
<option value="2">Sara Ahmadi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Kind Box Type *</label>
|
||||
<select className="form-select" required>
|
||||
<option value="on-table">On Table</option>
|
||||
<option value="cylindrical">Cylindrical</option>
|
||||
<option value="stand-up">Stand Up</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Count Requested *</label>
|
||||
<input type="number" className="form-input" required min={1} placeholder="Number of items" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Deliver Refer Time</label>
|
||||
<select className="form-select">
|
||||
<option value="1">Morning (8-12)</option>
|
||||
<option value="2">Afternoon (12-16)</option>
|
||||
<option value="3">Evening (16-20)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Deliver Refer Date</label>
|
||||
<input type="date" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Delivery Address</label>
|
||||
<select className="form-select">
|
||||
<option value="1">Home - Tehran, Iran</option>
|
||||
<option value="2">Office - Isfahan, Iran</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Description</label>
|
||||
<textarea className="form-textarea" rows={3} placeholder="Reason for request..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer flex items-center justify-between">
|
||||
<button type="button" onClick={() => router.push('/admin/kind-box-requests')} className="btn-outline">Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><span className="spinner-sm" /> Creating...</> : 'Create Request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const mockRequests = [
|
||||
{ id: 1, benefactorId: 1, kindBoxType: 'on-table' as const, countRequested: 5, countAccepted: 3, status: 'pending' as const, deliverReferDate: '2025-06-15', description: 'Need boxes for charity event' },
|
||||
{ id: 2, benefactorId: 2, kindBoxType: 'cylindrical' as const, countRequested: 10, countAccepted: 8, status: 'accepted' as const, deliverReferDate: '2025-06-20', description: 'Monthly supply' },
|
||||
{ id: 3, benefactorId: 1, kindBoxType: 'stand-up' as const, countRequested: 3, countAccepted: 0, status: 'rejected' as const, deliverReferDate: '2025-05-01', description: '' },
|
||||
];
|
||||
|
||||
export default function KindBoxRequestsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockRequests.filter((r) =>
|
||||
`#${r.id} ${r.kindBoxType}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Kind Box Requests"
|
||||
description="Manage benefactor requests"
|
||||
action={{ label: 'New Request', href: '/admin/kind-box-requests/create' }}
|
||||
/>
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search requests..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Benefactor</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Accepted</th>
|
||||
<th>Status</th>
|
||||
<th>Refer Date</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="font-medium">#{r.id}</td>
|
||||
<td><Link href={`/admin/benefactors/${r.benefactorId}/edit`} className="text-blue-600 dark:text-blue-400 hover:underline">Benefactor #{r.benefactorId}</Link></td>
|
||||
<td className="capitalize">{r.kindBoxType.replace(/-/g, ' ')}</td>
|
||||
<td>{r.countRequested}</td>
|
||||
<td>{r.countAccepted}</td>
|
||||
<td><StatusBadge status={r.status} /></td>
|
||||
<td className="text-gray-500 dark:text-gray-400">{formatDate(r.deliverReferDate)}</td>
|
||||
<td className="text-right">
|
||||
<Link href={`/admin/kind-box-requests/${r.id}`} className="btn-secondary btn-sm">View</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📋" title="No requests found" action={{ label: 'New Request', href: '/admin/kind-box-requests/create' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import { formatDate, formatDateTime, getKindBoxTypeIcon } from '@/lib/utils';
|
||||
|
||||
const mockBox = {
|
||||
id: 1, kindBoxReqId: 1, benefactorId: 1, kindBoxType: 'on-table' as const,
|
||||
amount: 5, serialNumber: 'KB-001', status: 'delivered' as const,
|
||||
deliverReferTimeId: 1, deliverReferDate: '2025-01-10', deliverAddressId: 1,
|
||||
senderAgentId: 1, deliveredAt: '2025-01-15T10:30:00',
|
||||
returnReferTimeId: 2, returnReferDate: '2025-07-10', returnAddressId: 2,
|
||||
receiverAgentId: null, returnedAt: '',
|
||||
};
|
||||
|
||||
export default function KindBoxDetailPage({ params }: { params: { id: string } }) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/kind-boxes" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="12" x2="5" y2="12" /><polyline points="12 19 5 12 12 5" /></svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Kind Box #{params.id}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Serial: {mockBox.serialNumber}</p>
|
||||
</div>
|
||||
<div className="ml-auto"><StatusBadge status={mockBox.status} size="md" /></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Details</h2></div>
|
||||
<div className="card-body space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Type</span><span className="flex items-center gap-1.5">{getKindBoxTypeIcon(mockBox.kindBoxType)} {mockBox.kindBoxType.replace(/-/g, ' ')}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Amount</span><span className="font-medium">{mockBox.amount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Benefactor</span><Link href={`/admin/benefactors/${mockBox.benefactorId}/edit`} className="text-blue-600 dark:text-blue-400 hover:underline">#{mockBox.benefactorId}</Link></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Status</span><StatusBadge status={mockBox.status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Delivery</h2></div>
|
||||
<div className="card-body space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Delivered At</span><span>{formatDateTime(mockBox.deliveredAt)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Refer Date</span><span>{formatDate(mockBox.deliverReferDate)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Sender Agent</span><span>Agent #{mockBox.senderAgentId}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Return</h2></div>
|
||||
<div className="card-body space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Return Date</span><span>{formatDate(mockBox.returnReferDate)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Receiver Agent</span><span className="text-gray-400">{mockBox.receiverAgentId ? `Agent #${mockBox.receiverAgentId}` : '—'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Returned At</span><span className="text-gray-400">{mockBox.returnedAt || 'Not yet'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/admin/kind-boxes/${params.id}/edit`} className="btn-primary">Edit Kind Box</Link>
|
||||
<Link href="/admin/kind-boxes" className="btn-outline">Back to List</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function CreateKindBoxPage() {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
router.push('/admin/kind-boxes');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader title="New Kind Box" description="Create a new kind box" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Kind Box Type *</label>
|
||||
<select className="form-select" required>
|
||||
<option value="on-table">On Table</option>
|
||||
<option value="cylindrical">Cylindrical</option>
|
||||
<option value="stand-up">Stand Up</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Amount *</label>
|
||||
<input type="number" className="form-input" required min={1} placeholder="Number of items" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Benefactor *</label>
|
||||
<select className="form-select" required>
|
||||
<option value="">Select benefactor...</option>
|
||||
<option value="1">Ali Mohammadi</option>
|
||||
<option value="2">Sara Ahmadi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Deliver Refer Time</label>
|
||||
<select className="form-select">
|
||||
<option value="1">Morning (8-12)</option>
|
||||
<option value="2">Afternoon (12-16)</option>
|
||||
<option value="3">Evening (16-20)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Deliver Refer Date</label>
|
||||
<input type="date" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Delivery Address</label>
|
||||
<select className="form-select">
|
||||
<option value="1">Home - Tehran, Iran</option>
|
||||
<option value="2">Office - Isfahan, Iran</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer flex items-center justify-between">
|
||||
<button type="button" onClick={() => router.push('/admin/kind-boxes')} className="btn-outline">Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><span className="spinner-sm" /> Creating...</> : 'Create Kind Box'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { getKindBoxTypeIcon, formatDate } from '@/lib/utils';
|
||||
|
||||
// Type for mock data
|
||||
interface MockKindBox {
|
||||
id: number;
|
||||
kindBoxReqId: number;
|
||||
benefactorId: number;
|
||||
kindBoxType: string;
|
||||
amount: number;
|
||||
serialNumber: string;
|
||||
status: string;
|
||||
deliveredAt: string;
|
||||
deliverReferDate: string;
|
||||
returnReferDate: string;
|
||||
returnedAt: string;
|
||||
senderAgentId: number;
|
||||
receiverAgentId: number | null;
|
||||
}
|
||||
|
||||
// Mock benefactors for display
|
||||
const benefactors: Record<number, { name: string; phone: string }> = {
|
||||
1: { name: 'Ali Mohammadi', phone: '09121234567' },
|
||||
2: { name: 'Sara Ahmadi', phone: '09131234567' },
|
||||
3: { name: 'Reza Hosseini', phone: '09141234567' },
|
||||
};
|
||||
|
||||
// Comprehensive mock data covering all statuses
|
||||
const allBoxes = [
|
||||
{ id: 1, kindBoxReqId: 1, benefactorId: 1, kindBoxType: 'on-table' as const, amount: 5, serialNumber: 'KB-001', status: 'delivered' as const, deliveredAt: '2025-01-15', deliverReferDate: '2025-01-10', returnReferDate: '2025-07-10', returnedAt: '', senderAgentId: 1, receiverAgentId: null },
|
||||
{ id: 2, kindBoxReqId: 1, benefactorId: 2, kindBoxType: 'cylindrical' as const, amount: 3, serialNumber: 'KB-002', status: 'ready-to-return' as const, deliveredAt: '2025-02-01', deliverReferDate: '2025-01-28', returnReferDate: '2025-08-01', returnedAt: '', senderAgentId: 2, receiverAgentId: null },
|
||||
{ id: 3, kindBoxReqId: 2, benefactorId: 1, kindBoxType: 'stand-up' as const, amount: 8, serialNumber: 'KB-003', status: 'enumerated' as const, deliveredAt: '2025-03-10', deliverReferDate: '2025-03-05', returnReferDate: '2025-09-10', returnedAt: '2025-03-15', senderAgentId: 1, receiverAgentId: 3 },
|
||||
{ id: 4, kindBoxReqId: 2, benefactorId: 3, kindBoxType: 'on-table' as const, amount: 2, serialNumber: 'KB-004', status: 'assigned-receiver-agent' as const, deliveredAt: '2025-04-01', deliverReferDate: '2025-03-28', returnReferDate: '2025-10-01', returnedAt: '', senderAgentId: 2, receiverAgentId: 3 },
|
||||
{ id: 5, kindBoxReqId: 3, benefactorId: 2, kindBoxType: 'cylindrical' as const, amount: 10, serialNumber: 'KB-005', status: 'returned' as const, deliveredAt: '2024-12-01', deliverReferDate: '2024-11-28', returnReferDate: '2025-06-01', returnedAt: '2025-05-28', senderAgentId: 1, receiverAgentId: 3 },
|
||||
{ id: 6, kindBoxReqId: 3, benefactorId: 1, kindBoxType: 'stand-up' as const, amount: 4, serialNumber: 'KB-006', status: 'delivered' as const, deliveredAt: '2025-05-20', deliverReferDate: '2025-05-15', returnReferDate: '2025-11-20', returnedAt: '', senderAgentId: 2, receiverAgentId: null },
|
||||
];
|
||||
|
||||
const statusFilters = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Delivered', value: 'delivered' },
|
||||
{ label: 'Ready to Return', value: 'ready-to-return' },
|
||||
{ label: 'Assigned Receiver', value: 'assigned-receiver-agent' },
|
||||
{ label: 'Returned', value: 'returned' },
|
||||
{ label: 'Enumerated', value: 'enumerated' },
|
||||
] as const;
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
export default function KindBoxesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [confirmAction, setConfirmAction] = useState<{ id: number; action: string } | null>(null);
|
||||
const [boxes, setBoxes] = useState<MockKindBox[]>(allBoxes);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = boxes;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((b) =>
|
||||
`${b.serialNumber} ${b.kindBoxType} ${benefactors[b.benefactorId]?.name || ''}`
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
);
|
||||
}
|
||||
if (statusFilter) {
|
||||
result = result.filter((b) => b.status === statusFilter);
|
||||
}
|
||||
return result;
|
||||
}, [boxes, search, statusFilter]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
|
||||
|
||||
// Reset page when filters change
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleStatusFilter = (value: string) => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAction = (id: number, action: string) => {
|
||||
setConfirmAction({ id, action });
|
||||
};
|
||||
|
||||
const executeAction = () => {
|
||||
if (!confirmAction) return;
|
||||
const { id, action } = confirmAction;
|
||||
|
||||
setBoxes((prev) =>
|
||||
prev.map((b) => {
|
||||
if (b.id !== id) return b;
|
||||
switch (action) {
|
||||
case 'enumerate':
|
||||
return { ...b, status: 'enumerated' as const, returnedAt: new Date().toISOString().split('T')[0] };
|
||||
case 'assign-receiver':
|
||||
return { ...b, status: 'assigned-receiver-agent' as const, receiverAgentId: 3 };
|
||||
case 'mark-returned':
|
||||
return { ...b, status: 'returned' as const, returnedAt: new Date().toISOString().split('T')[0] };
|
||||
default:
|
||||
return b;
|
||||
}
|
||||
})
|
||||
);
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const getActionsForStatus = (status: string, id: number) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => handleAction(id, 'enumerate')} className="btn-success btn-sm">
|
||||
Enumerate
|
||||
</button>
|
||||
<button onClick={() => handleAction(id, 'assign-receiver')} className="btn-secondary btn-sm">
|
||||
Assign Agent
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
case 'ready-to-return':
|
||||
return (
|
||||
<button onClick={() => handleAction(id, 'mark-returned')} className="btn-warning btn-sm">
|
||||
Mark Returned
|
||||
</button>
|
||||
);
|
||||
case 'assigned-receiver-agent':
|
||||
return (
|
||||
<button onClick={() => handleAction(id, 'mark-returned')} className="btn-primary btn-sm">
|
||||
Confirm Return
|
||||
</button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Kind Boxes"
|
||||
description="Manage all kind boxes in the system"
|
||||
action={{ label: 'New Kind Box', href: '/admin/kind-boxes/create' }}
|
||||
/>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by serial, type, or benefactor..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="form-input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 w-fit">
|
||||
{statusFilters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleStatusFilter(f.value)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||
statusFilter === f.value
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{f.value && (
|
||||
<span className="ml-1.5 text-xs opacity-60">
|
||||
({boxes.filter((b) => b.status === f.value).length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{paginated.length > 0 ? (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Type</th>
|
||||
<th>Benefactor</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Delivered</th>
|
||||
<th>Return By</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={`/admin/kind-boxes/${b.id}`}
|
||||
className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{b.serialNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getKindBoxTypeIcon(b.kindBoxType)}
|
||||
<span className="capitalize text-gray-700 dark:text-gray-300">
|
||||
{b.kindBoxType.replace(/-/g, ' ')}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/admin/benefactors/${b.benefactorId}/edit`}
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<span className="font-medium">{benefactors[b.benefactorId]?.name || `Benefactor #${b.benefactorId}`}</span>
|
||||
<span className="block text-xs text-gray-400">{benefactors[b.benefactorId]?.phone}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="font-medium">{b.amount}</td>
|
||||
<td><StatusBadge status={b.status} /></td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(b.deliveredAt)}</td>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(b.returnReferDate)}</td>
|
||||
<td className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{getActionsForStatus(b.status, b.id)}
|
||||
<Link href={`/admin/kind-boxes/${b.id}`} className="btn-outline btn-sm">
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {(page - 1) * ITEMS_PER_PAGE + 1}–{Math.min(page * ITEMS_PER_PAGE, filtered.length)} of {filtered.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="btn-outline btn-sm disabled:opacity-30"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-8 h-8 rounded-lg text-sm font-medium transition-all ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="btn-outline btn-sm disabled:opacity-30"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="📦"
|
||||
title={search || statusFilter ? 'No kind boxes match your criteria' : 'No kind boxes yet'}
|
||||
description={search || statusFilter ? 'Try different search terms or filters' : 'Create your first kind box to get started.'}
|
||||
action={!search && !statusFilter ? { label: 'New Kind Box', href: '/admin/kind-boxes/create' } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmAction && (
|
||||
<div className="modal-overlay" onClick={() => setConfirmAction(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Confirm Action</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Are you sure you want to {confirmAction.action.replace(/-/g, ' ')} kind box #{confirmAction.id}?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => setConfirmAction(null)} className="btn-outline">Cancel</button>
|
||||
<button onClick={executeAction} className="btn-primary">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard requiredRoles={['super-admin', 'admin']}>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader title="Admin Profile" description="View and update your profile" />
|
||||
|
||||
{saved && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-sm text-green-700 dark:text-green-300">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
Profile updated successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-2xl font-bold">A</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Admin User</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">super-admin</p>
|
||||
</div>
|
||||
<div className="ml-auto"><StatusBadge status="active" /></div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">First Name</label>
|
||||
<input type="text" className="form-input" defaultValue="Admin" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Last Name</label>
|
||||
<input type="text" className="form-input" defaultValue="User" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Phone Number</label>
|
||||
<input type="text" className="form-input" defaultValue="09111111111" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" className="form-input" defaultValue="admin@niki.com" />
|
||||
</div>
|
||||
<div className="card-footer -mx-6 -mb-6 px-6">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><span className="spinner-sm" /> Saving...</> : 'Update Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
|
||||
const mockTimes = [
|
||||
{ id: 1, duration: '08:00 - 12:00', status: 'active' as const },
|
||||
{ id: 2, duration: '12:00 - 16:00', status: 'active' as const },
|
||||
{ id: 3, duration: '16:00 - 20:00', status: 'inactive' as const },
|
||||
];
|
||||
|
||||
export default function ReferTimesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockTimes.filter((t) => t.duration.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Refer Times" description="Manage delivery and return time slots" />
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search time slots..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Time Slot</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="font-medium">#{t.id}</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{t.duration}</td>
|
||||
<td><StatusBadge status={t.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="⏰" title="No refer times found" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AgentDashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Agent Dashboard</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Manage deliveries and returns</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/agent/kind-box-requests" className="stat-card group cursor-pointer">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Pending Deliveries</p>
|
||||
<p className="text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100">—</p>
|
||||
<div className="mt-3 flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity">View <span>→</span></div>
|
||||
</Link>
|
||||
<Link href="/agent/kind-boxes" className="stat-card group cursor-pointer">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Awaiting Returns</p>
|
||||
<p className="text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100">—</p>
|
||||
<div className="mt-3 flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity">View <span>→</span></div>
|
||||
</Link>
|
||||
<Link href="/agent/dashboard" className="stat-card group cursor-pointer">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Completed Today</p>
|
||||
<p className="text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100">—</p>
|
||||
<div className="mt-3 flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity">View <span>→</span></div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="text-lg font-semibold">Quick Actions</h2></div>
|
||||
<div className="card-body grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Link href="/agent/kind-box-requests" className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-all group">
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-blue-600">View Delivery Awaiting</span>
|
||||
</Link>
|
||||
<Link href="/agent/kind-boxes" className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-all group">
|
||||
<span className="text-2xl">📥</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-blue-600">View Return Awaiting</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const mockDeliveries = [
|
||||
{ id: 1, kindBoxType: 'on-table', countRequested: 5, benefactorName: 'Ali Mohammadi', status: 'assigned-sender-agent' as const, deliverReferDate: '2025-06-15', deliverAddress: '123 Main St, Tehran' },
|
||||
];
|
||||
|
||||
export default function AgentDeliveryAwaitingPage() {
|
||||
const [items] = useState(mockDeliveries);
|
||||
|
||||
const handleDeliver = async (id: number) => {
|
||||
// await kindBoxReqApi.deliverKindBoxReq(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Delivery Awaiting" description="Kind box requests pending delivery" />
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Count</th>
|
||||
<th>Benefactor</th>
|
||||
<th>Address</th>
|
||||
<th>Refer Date</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="font-medium">#{r.id}</td>
|
||||
<td className="capitalize">{r.kindBoxType.replace(/-/g, ' ')}</td>
|
||||
<td>{r.countRequested}</td>
|
||||
<td>{r.benefactorName}</td>
|
||||
<td className="max-w-[200px] truncate text-gray-500">{r.deliverAddress}</td>
|
||||
<td>{formatDate(r.deliverReferDate)}</td>
|
||||
<td className="text-right">
|
||||
<button onClick={() => handleDeliver(r.id)} className="btn-primary btn-sm">Mark Delivered</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📤" title="No deliveries pending" description="All requests have been delivered." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { getKindBoxTypeIcon } from '@/lib/utils';
|
||||
|
||||
const mockReturns = [
|
||||
{ id: 1, serialNumber: 'KB-002', kindBoxType: 'cylindrical', amount: 3, benefactorName: 'Sara Ahmadi', status: 'ready-to-return' },
|
||||
];
|
||||
|
||||
export default function AgentReturnAwaitingPage() {
|
||||
const [items] = useState(mockReturns);
|
||||
|
||||
const handleReturn = async (id: number) => {
|
||||
// await kindBoxApi.returnKindBox(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Return Awaiting" description="Kind boxes ready to be returned" />
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Benefactor</th>
|
||||
<th>Status</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="font-mono text-sm font-medium">{b.serialNumber}</td>
|
||||
<td><span className="flex items-center gap-1.5">{getKindBoxTypeIcon(b.kindBoxType)} {b.kindBoxType.replace(/-/g, ' ')}</span></td>
|
||||
<td>{b.amount}</td>
|
||||
<td>{b.benefactorName}</td>
|
||||
<td><StatusBadge status={b.status} /></td>
|
||||
<td className="text-right">
|
||||
<button onClick={() => handleReturn(b.id)} className="btn-primary btn-sm">Mark Returned</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📥" title="No returns awaiting" description="All kind boxes have been processed." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
|
||||
const mockAddresses = [
|
||||
{ id: 1, name: 'Home', address: '123 Main St, Tehran', postalCode: '1234567890', province: 'Tehran', city: 'Tehran' },
|
||||
{ id: 2, name: 'Office', address: '456 Business Ave, Isfahan', postalCode: '9876543210', province: 'Isfahan', city: 'Isfahan' },
|
||||
];
|
||||
|
||||
export default function AddressesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockAddresses.filter((a) =>
|
||||
`${a.name} ${a.address}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="My Addresses"
|
||||
description="Manage your delivery addresses"
|
||||
action={{ label: 'Add Address', href: '/benefactor/addresses/new' }}
|
||||
/>
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search addresses..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{filtered.map((a) => (
|
||||
<div key={a.id} className="card group">
|
||||
<div className="card-body">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{a.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{a.address}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{a.city}, {a.province} - {a.postalCode}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="btn-secondary btn-sm">Edit</button>
|
||||
<button className="btn-danger btn-sm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📍" title="No addresses yet" action={{ label: 'Add Address', href: '/benefactor/addresses/new' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const mockRequests = [
|
||||
{ id: 1, kindBoxType: 'on-table', countRequested: 5, status: 'pending', deliverReferDate: '2025-06-15' },
|
||||
{ id: 2, kindBoxType: 'cylindrical', countRequested: 10, status: 'accepted', deliverReferDate: '2025-06-20' },
|
||||
];
|
||||
|
||||
export default function BenefactorRequestsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="My Requests"
|
||||
description="Track your kind box requests"
|
||||
action={{ label: 'New Request', href: '/benefactor/kind-box-requests/new' }}
|
||||
/>
|
||||
|
||||
{mockRequests.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Count</th>
|
||||
<th>Status</th>
|
||||
<th>Refer Date</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockRequests.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="font-medium">#{r.id}</td>
|
||||
<td className="capitalize">{r.kindBoxType.replace(/-/g, ' ')}</td>
|
||||
<td>{r.countRequested}</td>
|
||||
<td><StatusBadge status={r.status} /></td>
|
||||
<td className="text-gray-500">{formatDate(r.deliverReferDate)}</td>
|
||||
<td className="text-right space-x-2">
|
||||
<Link href={`/benefactor/kind-box-requests/${r.id}`} className="btn-secondary btn-sm">View</Link>
|
||||
{r.status === 'pending' && (
|
||||
<button className="btn-danger btn-sm">Delete</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📋" title="No requests yet" action={{ label: 'New Request', href: '/benefactor/kind-box-requests/new' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDate, getKindBoxTypeIcon } from '@/lib/utils';
|
||||
|
||||
const mockBoxes = [
|
||||
{ id: 1, kindBoxType: 'on-table', serialNumber: 'KB-001', status: 'delivered', amount: 5, deliveredAt: '2025-01-15', deliverReferDate: '2025-01-10' },
|
||||
{ id: 2, kindBoxType: 'cylindrical', serialNumber: 'KB-002', status: 'ready-to-return', amount: 3, deliveredAt: '2025-02-01', deliverReferDate: '2025-01-28' },
|
||||
];
|
||||
|
||||
export default function BenefactorKindBoxesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="My Kind Boxes" description="View your registered kind boxes" />
|
||||
|
||||
{mockBoxes.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Delivered</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockBoxes.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">{b.serialNumber}</td>
|
||||
<td><span className="flex items-center gap-1.5">{getKindBoxTypeIcon(b.kindBoxType)} <span className="capitalize">{b.kindBoxType.replace(/-/g, ' ')}</span></span></td>
|
||||
<td>{b.amount}</td>
|
||||
<td><StatusBadge status={b.status} /></td>
|
||||
<td className="text-gray-500 dark:text-gray-400">{formatDate(b.deliveredAt)}</td>
|
||||
<td className="text-right">
|
||||
{b.status === 'ready-to-return' && (
|
||||
<button className="btn-primary btn-sm">Request Emptying</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📦" title="No kind boxes yet" description="Your registered kind boxes will appear here." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
|
||||
const mockTimes = [
|
||||
{ id: 1, duration: '08:00 - 12:00', status: 'active' as const },
|
||||
{ id: 2, duration: '12:00 - 16:00', status: 'active' as const },
|
||||
{ id: 3, duration: '16:00 - 20:00', status: 'inactive' as const },
|
||||
];
|
||||
|
||||
export default function BenefactorReferTimesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Refer Times" description="View available delivery and return time slots" />
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Time Slot</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockTimes.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="font-medium">#{t.id}</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{t.duration}</td>
|
||||
<td><StatusBadge status={t.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatCurrency, formatDate } from '@/lib/utils';
|
||||
|
||||
const mockCampaigns = [
|
||||
{ id: 1, title: 'Winter Clothing Drive', slogan: 'Keep everyone warm', description: 'Donate winter clothes to those in need', goalAmount: 10000000, raisedAmount: 6500000, status: 'active' as const, deadlineAt: '2025-12-01', createdAt: '2025-01-01' },
|
||||
{ id: 2, title: 'School Supplies Campaign', slogan: 'Education for all', description: 'Provide school supplies for children', goalAmount: 5000000, raisedAmount: 5000000, status: 'completed' as const, deadlineAt: '2025-05-01', createdAt: '2025-02-01' },
|
||||
];
|
||||
|
||||
export default function CampaignsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Campaigns" description="View and donate to campaigns" />
|
||||
|
||||
{mockCampaigns.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockCampaigns.map((c) => {
|
||||
const progress = c.goalAmount > 0 ? Math.round((c.raisedAmount / c.goalAmount) * 100) : 0;
|
||||
return (
|
||||
<div key={c.id} className="card hover:shadow-lg transition-all duration-200">
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{c.title}</h3>
|
||||
<p className="text-xs text-gray-500 italic mt-0.5">{c.slogan}</p>
|
||||
</div>
|
||||
<StatusBadge status={c.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{c.description}</p>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Raised: {formatCurrency(c.raisedAmount)}</span>
|
||||
<span className="text-gray-400">Goal: {formatCurrency(c.goalAmount)}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-blue-500 to-purple-600 transition-all" style={{ width: `${Math.min(progress, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{progress}% funded</p>
|
||||
</div>
|
||||
{c.deadlineAt && <p className="text-xs text-gray-400">Deadline: {formatDate(c.deadlineAt)}</p>}
|
||||
<button className="btn-primary w-full" disabled={c.status !== 'active'}>
|
||||
{c.status === 'active' ? 'Donate Now' : c.status === 'completed' ? 'Completed' : 'Closed'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="🎯" title="No campaigns yet" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
const mockItems = [
|
||||
{ id: 1, productId: 1, name: 'Basic Care Package', price: 250000, quantity: 2 },
|
||||
{ id: 2, productId: 2, name: 'Health Kit', price: 500000, quantity: 1 },
|
||||
];
|
||||
|
||||
export default function CartPage() {
|
||||
const [items, setItems] = useState(mockItems);
|
||||
|
||||
const removeItem = (id: number) => setItems(items.filter((i) => i.id !== id));
|
||||
const updateQty = (id: number, delta: number) => {
|
||||
setItems(items.map((i) => i.id === id ? { ...i, quantity: Math.max(1, i.quantity + delta) } : i));
|
||||
};
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Shopping Cart" />
|
||||
<EmptyState icon="🛒" title="Your cart is empty" description="Add some products to get started." action={{ label: 'Browse Products', href: '/products' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<PageHeader title="Shopping Cart" description={`${items.length} item(s) in your cart`} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="card">
|
||||
<div className="card-body flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-950/50 dark:to-purple-950/50 flex items-center justify-center text-2xl">🛍️</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">{item.name}</h3>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400 font-medium">{formatCurrency(item.price)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => updateQty(item.id, -1)} className="w-8 h-8 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">−</button>
|
||||
<span className="w-8 text-center font-medium text-gray-900 dark:text-gray-100">{item.quantity}</span>
|
||||
<button onClick={() => updateQty(item.id, 1)} className="w-8 h-8 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">+</button>
|
||||
</div>
|
||||
<p className="w-24 text-right font-semibold text-gray-900 dark:text-gray-100">{formatCurrency(item.price * item.quantity)}</p>
|
||||
<button onClick={() => removeItem(item.id)} className="text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{formatCurrency(total)}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/products" className="btn-outline">Continue Shopping</Link>
|
||||
<Link href="/orders" className="btn-primary">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /></svg>
|
||||
Proceed to Checkout
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
|
||||
const mockDrivers = [
|
||||
{ id: 1, firstName: 'Ehsan', lastName: 'Ghasemi', phoneNumber: '09191234567', nationalCode: '1234567890', licenseNumber: 'LIC-001', birthDate: '1988-04-18' },
|
||||
{ id: 2, firstName: 'Mohsen', lastName: 'Taghavi', phoneNumber: '09201234567', nationalCode: '0987654321', licenseNumber: 'LIC-002', birthDate: '1992-11-30' },
|
||||
];
|
||||
|
||||
export default function DriversPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockDrivers.filter((d) => `${d.firstName} ${d.lastName} ${d.phoneNumber}`.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Drivers" description="Manage drivers" />
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search drivers..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>National Code</th>
|
||||
<th>License</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="font-medium">#{d.id}</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{d.firstName} {d.lastName}</td>
|
||||
<td className="font-mono text-sm">{d.phoneNumber}</td>
|
||||
<td className="font-mono text-sm">{d.nationalCode}</td>
|
||||
<td className="text-sm">{d.licenseNumber}</td>
|
||||
<td className="text-right"><button className="btn-secondary btn-sm">View</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="🚗" title="No drivers found" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
const mockLeaderboard = [
|
||||
{ rank: 1, name: 'Ali Mohammadi', points: 2500, badges: 5, level: 'Gold' },
|
||||
{ rank: 2, name: 'Sara Ahmadi', points: 1800, badges: 3, level: 'Silver' },
|
||||
{ rank: 3, name: 'Reza Hosseini', points: 1200, badges: 2, level: 'Silver' },
|
||||
{ rank: 4, name: 'Mehdi Rezaei', points: 800, badges: 1, level: 'Bronze' },
|
||||
{ rank: 5, name: 'Narges Karimi', points: 500, badges: 1, level: 'Bronze' },
|
||||
];
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
Gold: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
Silver: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
Bronze: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
};
|
||||
|
||||
const badges = [
|
||||
{ name: 'First Donation', icon: '🎖️', description: 'Made your first donation' },
|
||||
{ name: 'Helping Hand', icon: '🤲', description: 'Helped 10 benefactors' },
|
||||
{ name: 'Consistent Giver', icon: '📅', description: 'Active for 30 days' },
|
||||
{ name: 'Top Donor', icon: '👑', description: 'Top 10 donor this month' },
|
||||
{ name: 'Community Star', icon: '⭐', description: 'Referral master' },
|
||||
];
|
||||
|
||||
export default function GamificationPage() {
|
||||
const [tab, setTab] = useState<'leaderboard' | 'badges'>('leaderboard');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Gamification" description="Leaderboards and achievements" />
|
||||
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 w-fit">
|
||||
<button onClick={() => setTab('leaderboard')} className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${tab === 'leaderboard' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>🏆 Leaderboard</button>
|
||||
<button onClick={() => setTab('badges')} className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${tab === 'badges' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>🎖️ Badges</button>
|
||||
</div>
|
||||
|
||||
{tab === 'leaderboard' && (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Name</th>
|
||||
<th>Points</th>
|
||||
<th>Badges</th>
|
||||
<th>Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockLeaderboard.map((p) => (
|
||||
<tr key={p.rank}>
|
||||
<td>
|
||||
<span className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold ${
|
||||
p.rank === 1 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
|
||||
p.rank === 2 ? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' :
|
||||
p.rank === 3 ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' :
|
||||
'text-gray-500'
|
||||
}`}>{p.rank}</span>
|
||||
</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{p.name}</td>
|
||||
<td className="font-bold">{p.points.toLocaleString()}</td>
|
||||
<td>{p.badges}</td>
|
||||
<td><span className={`badge ${levelColors[p.level]}`}>{p.level}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'badges' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{badges.map((b) => (
|
||||
<div key={b.name} className="card hover:shadow-lg transition-all">
|
||||
<div className="card-body flex items-center gap-4">
|
||||
<span className="text-3xl">{b.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{b.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{b.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,63 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 64px;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
/* Make dark mode class-based (use .dark class on html element) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
.dark {
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
/* Theme definitions - light mode defaults */
|
||||
@theme {
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 64px;
|
||||
|
||||
--color-primary: hsl(222.2 47.4% 11.2%);
|
||||
--color-primary-foreground: hsl(210 40% 98%);
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(222.2 84% 4.9%);
|
||||
--color-muted: hsl(210 40% 96.1%);
|
||||
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--color-border: hsl(214.3 31.8% 91.4%);
|
||||
--color-ring: hsl(222.2 84% 4.9%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark {
|
||||
--color-primary: hsl(210 40% 98%);
|
||||
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--color-background: hsl(222.2 84% 4.9%);
|
||||
--color-foreground: hsl(210 40% 98%);
|
||||
--color-muted: hsl(217.2 32.6% 17.5%);
|
||||
--color-muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--color-border: hsl(217.2 32.6% 17.5%);
|
||||
--color-ring: hsl(212.7 26.8% 83.9%);
|
||||
}
|
||||
|
||||
/* Keep original CSS variable names for backward compatibility with direct var() usage */
|
||||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 64px;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
|
||||
/* Base layer styles */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-700;
|
||||
|
|
@ -119,7 +150,7 @@
|
|||
|
||||
/* Stats card */
|
||||
.stat-card {
|
||||
@apply bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-6 card-hover;
|
||||
@apply bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
|
|
@ -128,27 +159,27 @@
|
|||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-yellow-500 text-white hover:bg-yellow-600 focus:ring-yellow-500;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-yellow-500 text-white hover:bg-yellow-600 focus:ring-yellow-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
|
|
@ -169,11 +200,11 @@
|
|||
}
|
||||
|
||||
.form-select {
|
||||
@apply form-input appearance-none cursor-pointer;
|
||||
@apply w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2.5 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 appearance-none cursor-pointer;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply form-input resize-none min-h-[100px];
|
||||
@apply w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2.5 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-none min-h-[100px];
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { AuthProvider } from "@/lib/AuthContext";
|
||||
import { SidebarProvider } from "@/lib/SidebarContext";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import MainContent from "@/components/MainContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Niki - Kind Box Management",
|
||||
description: "Niki kind box management system",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -23,11 +16,15 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className="h-full antialiased">
|
||||
<body className="min-h-full flex bg-gray-50 dark:bg-gray-950 font-sans">
|
||||
<AuthProvider>
|
||||
<SidebarProvider>
|
||||
<Sidebar />
|
||||
<MainContent>{children}</MainContent>
|
||||
</SidebarProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, loginBenefactor, isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<'admin' | 'benefactor'>('admin');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [step, setStep] = useState<'credentials' | 'otp'>('credentials');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Redirect if already authenticated — use replace to not pollute history
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
router.replace('/admin/dashboard');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<span className="spinner-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAdminLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const result = await login(phone, password);
|
||||
setLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
router.replace('/admin/dashboard');
|
||||
} else {
|
||||
setError(result.error || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Simulate OTP send
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setLoading(false);
|
||||
setStep('otp');
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const result = await loginBenefactor(phone, otp);
|
||||
setLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
router.replace('/benefactor/kind-boxes');
|
||||
} else {
|
||||
setError(result.error || 'Verification failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 mb-4 shadow-lg shadow-blue-500/25">
|
||||
<span className="text-2xl font-bold text-white">N</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome to Niki</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 mb-6">
|
||||
<button
|
||||
onClick={() => { setMode('admin'); setStep('credentials'); setError(''); }}
|
||||
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
mode === 'admin'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Admin Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMode('benefactor'); setStep('credentials'); setError(''); }}
|
||||
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
mode === 'benefactor'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Benefactor Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admin Login Form */}
|
||||
{mode === 'admin' && (
|
||||
<form onSubmit={handleAdminLogin} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div>
|
||||
<label className="form-label">Phone Number</label>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z" />
|
||||
</svg>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-input pl-10"
|
||||
placeholder="09111111111"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Password</label>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input pl-10"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-600 dark:text-red-400">
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? <><span className="spinner-sm" /> Signing in...</> : 'Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<p className="text-xs text-center text-gray-400 dark:text-gray-500">
|
||||
Demo: Use <strong className="text-gray-600 dark:text-gray-300">09111111111</strong> / <strong className="text-gray-600 dark:text-gray-300">admin123</strong>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Benefactor Login Form */}
|
||||
{mode === 'benefactor' && step === 'credentials' && (
|
||||
<form onSubmit={handleSendOtp} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div>
|
||||
<label className="form-label">Phone Number</label>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z" />
|
||||
</svg>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-input pl-10"
|
||||
placeholder="0912XXXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-600 dark:text-red-400">
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? <><span className="spinner-sm" /> Sending code...</> : 'Send OTP Code'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<p className="text-xs text-center text-gray-400 dark:text-gray-500">
|
||||
Demo: Enter any phone, use code <strong className="text-gray-600 dark:text-gray-300">123456</strong>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP Verification Form */}
|
||||
{mode === 'benefactor' && step === 'otp' && (
|
||||
<form onSubmit={handleVerifyOtp} className="card">
|
||||
<div className="card-body space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter the code sent to <strong className="text-gray-700 dark:text-gray-300">{phone}</strong>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('credentials')}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1"
|
||||
>
|
||||
Change phone number
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Verification Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input text-center text-2xl tracking-[0.5em]"
|
||||
placeholder="------"
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-600 dark:text-red-400">
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading || otp.length < 6}>
|
||||
{loading ? <><span className="spinner-sm" /> Verifying...</> : 'Verify & Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<p className="text-center mt-6">
|
||||
<a href="/" className="text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
← Back to home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
|
||||
const mockOrders = [
|
||||
{ id: 1, totalAmount: 750000, processStatus: 'processing' as const, paymentStatus: 'paid' as const, createdAt: '2025-06-01T10:30:00', items: 3 },
|
||||
{ id: 2, totalAmount: 350000, processStatus: 'delivered' as const, paymentStatus: 'paid' as const, createdAt: '2025-05-15T14:00:00', items: 1 },
|
||||
{ id: 3, totalAmount: 500000, processStatus: 'waiting-to-pay' as const, paymentStatus: 'unpaid' as const, createdAt: '2025-06-10T09:00:00', items: 2 },
|
||||
];
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="My Orders" description="Track your orders" />
|
||||
|
||||
{mockOrders.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order #</th>
|
||||
<th>Date</th>
|
||||
<th>Items</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th>Payment</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockOrders.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">#{o.id}</td>
|
||||
<td className="text-sm text-gray-500">{formatDateTime(o.createdAt)}</td>
|
||||
<td>{o.items}</td>
|
||||
<td className="font-medium">{formatCurrency(o.totalAmount)}</td>
|
||||
<td><StatusBadge status={o.processStatus} /></td>
|
||||
<td><StatusBadge status={o.paymentStatus} /></td>
|
||||
<td className="text-right">
|
||||
<button className="btn-secondary btn-sm">View Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="📦" title="No orders yet" action={{ label: 'Browse Products', href: '/products' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
src/app/page.tsx
402
src/app/page.tsx
|
|
@ -1,65 +1,357 @@
|
|||
import Image from "next/image";
|
||||
import Link from 'next/link';
|
||||
import LandingNavbar from '@/components/LandingNavbar';
|
||||
import LandingFooter from '@/components/LandingFooter';
|
||||
|
||||
export default function Home() {
|
||||
const stats = [
|
||||
{ label: 'Active Benefactors', value: '2,500+' },
|
||||
{ label: 'Kind Boxes Delivered', value: '12,000+' },
|
||||
{ label: 'Partner Agents', value: '350+' },
|
||||
{ label: 'Cities Covered', value: '85+' },
|
||||
];
|
||||
|
||||
const howItWorks = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Register',
|
||||
description: 'Create your account as a benefactor, agent, or administrator in minutes.',
|
||||
icon: (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="8.5" cy="7" r="4" /><line x1="20" y1="8" x2="20" y2="14" /><line x1="23" y1="11" x2="17" y2="11" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Request a Kind Box',
|
||||
description: 'Benefactors submit requests specifying the type, quantity, and delivery details.',
|
||||
icon: (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 11 12 14 22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Process & Assign',
|
||||
description: 'Admins review requests, assign agents for delivery and pickup coordination.',
|
||||
icon: (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
{
|
||||
step: '04',
|
||||
title: 'Deliver & Return',
|
||||
description: 'Agents complete deliveries and pickups. Kind boxes are enumerated and tracked.',
|
||||
icon: (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
},
|
||||
];
|
||||
|
||||
const portals = [
|
||||
{
|
||||
title: 'Admin Dashboard',
|
||||
description: 'Full management of benefactors, agents, kind boxes, requests, and system settings.',
|
||||
href: '/admin/dashboard',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-blue-500 to-indigo-600',
|
||||
roles: 'super-admin, admin',
|
||||
},
|
||||
{
|
||||
title: 'Benefactor Portal',
|
||||
description: 'Manage your kind boxes, requests, addresses, and refer times.',
|
||||
href: '/benefactor/kind-boxes',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-emerald-500 to-teal-600',
|
||||
roles: 'benefactor',
|
||||
},
|
||||
{
|
||||
title: 'Agent Panel',
|
||||
description: 'Handle delivery and return of kind boxes, manage assignments.',
|
||||
href: '/agent/dashboard',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-amber-500 to-orange-600',
|
||||
roles: 'agent',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace',
|
||||
description: 'Browse products, manage cart, place orders, and track deliveries.',
|
||||
href: '/products',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" /><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
),
|
||||
color: 'from-purple-500 to-pink-600',
|
||||
roles: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote: "Niki has transformed how we manage kind boxes. The tracking and reporting features are incredible.",
|
||||
name: "Maryam Ahmadi",
|
||||
role: "Admin, Tehran Province",
|
||||
avatar: "MA",
|
||||
},
|
||||
{
|
||||
quote: "As a benefactor, I can easily request kind boxes and track their delivery. The system is very user-friendly.",
|
||||
name: "Reza Mohammadi",
|
||||
role: "Benefactor",
|
||||
avatar: "RM",
|
||||
},
|
||||
{
|
||||
quote: "The agent dashboard makes it simple to manage deliveries and returns. Everything I need in one place.",
|
||||
name: "Sara Hosseini",
|
||||
role: "Delivery Agent",
|
||||
avatar: "SH",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<>
|
||||
<LandingNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-20 left-1/4 w-72 h-72 bg-blue-400/20 dark:bg-blue-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute top-40 right-1/4 w-96 h-96 bg-purple-400/20 dark:bg-purple-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-1/3 w-64 h-64 bg-teal-400/10 dark:bg-teal-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 pt-20 pb-16 sm:pt-28 sm:pb-20 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-sm font-medium text-blue-600 dark:text-blue-400 mb-8">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
Kind Box Management System v2.0
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-6 tracking-tight leading-tight">
|
||||
Connecting{' '}
|
||||
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Kindness
|
||||
</span>{' '}
|
||||
with Those in Need
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
|
||||
<p className="text-lg sm:text-xl text-gray-500 dark:text-gray-400 max-w-3xl mx-auto leading-relaxed mb-10">
|
||||
Niki is a comprehensive kind box management platform that connects benefactors,
|
||||
agents, and administrators to streamline donations, deliveries, and tracking
|
||||
across cities.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="btn-primary px-8 py-3.5 text-base shadow-xl shadow-blue-500/25 hover:shadow-blue-500/35 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
||||
</svg>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="btn-outline px-8 py-3.5 text-base hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" /><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
Browse Shop
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="max-w-6xl mx-auto px-4 pb-16">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="relative overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 text-center group hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-blue-500/5 to-purple-500/5 rounded-bl-full" />
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-1">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section className="max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-2xl mx-auto text-lg">
|
||||
From registration to delivery — Niki makes the entire process seamless and transparent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{howItWorks.map((item, index) => (
|
||||
<div key={item.step} className="relative">
|
||||
{/* Connector line */}
|
||||
{index < howItWorks.length - 1 && (
|
||||
<div className="hidden lg:block absolute top-12 left-[60%] w-[80%] h-px border-t-2 border-dashed border-gray-300 dark:border-gray-600" />
|
||||
)}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${item.color} flex items-center justify-center text-white mb-5 shadow-lg`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2">
|
||||
Step {item.step}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed max-w-xs">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* System Portals */}
|
||||
<section className="max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
System Portals
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-2xl mx-auto text-lg">
|
||||
Role-based dashboards designed for each user type.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{portals.map((portal) => (
|
||||
<Link
|
||||
key={portal.title}
|
||||
href={portal.href}
|
||||
className="group relative overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 hover:shadow-xl hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${portal.color} flex items-center justify-center text-white mb-4 shadow-lg shadow-${portal.color.split(' ')[0].replace('from-', '')}/20`}>
|
||||
{portal.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{portal.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{portal.description}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-1 text-sm font-medium text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Access portal <span>→</span>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 px-2.5 py-0.5 text-[10px] font-medium text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 rounded-bl-lg">
|
||||
{portal.roles}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section className="max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
What Our Users Say
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-2xl mx-auto text-lg">
|
||||
Hear from the people who use Niki every day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{testimonials.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className="relative rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
{/* Quote icon */}
|
||||
<svg className="w-8 h-8 text-blue-200 dark:text-blue-900 mb-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10H14.017zM0 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151C7.546 6.068 5.983 8.789 5.983 11H10v10H0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed mb-6 italic">
|
||||
“{t.quote}”
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{t.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{t.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-blue-600 via-blue-700 to-purple-700 px-8 py-16 text-center">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-60 h-60 bg-white/5 rounded-full translate-y-1/3 -translate-x-1/4" />
|
||||
<div className="absolute top-1/2 left-1/4 w-20 h-20 bg-white/5 rounded-full" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-blue-100 max-w-2xl mx-auto text-lg mb-8">
|
||||
Join thousands of benefactors, agents, and administrators making a difference
|
||||
through the Niki kind box management system.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white text-blue-700 px-8 py-3.5 text-base font-semibold hover:bg-blue-50 hover:-translate-y-0.5 transition-all duration-300 shadow-xl"
|
||||
>
|
||||
Create Free Account
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14" /><path d="M12 5l7 7-7 7" /></svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/10 text-white border border-white/20 px-8 py-3.5 text-base font-semibold hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-300 backdrop-blur-sm"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
|
||||
const mockPatients = [
|
||||
{ id: 1, firstName: 'Zahra', lastName: 'Moradi', phone: '09171234567', sex: 'female' as const, caseStatus: 'open' as const, referralSource: 'hospital' as const, dateOfBirth: '1980-03-12' },
|
||||
{ id: 2, firstName: 'Hassan', lastName: 'Jafari', phone: '09181234567', sex: 'male' as const, caseStatus: 'inProgress' as const, referralSource: 'community' as const, dateOfBirth: '1995-07-25' },
|
||||
];
|
||||
|
||||
export default function PatientsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockPatients.filter((p) => `${p.firstName} ${p.lastName} ${p.phone}`.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Patients" description="Manage patient cases" />
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search patients..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Sex</th>
|
||||
<th>Case Status</th>
|
||||
<th>Referral</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="font-medium">#{p.id}</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{p.firstName} {p.lastName}</td>
|
||||
<td className="font-mono text-sm">{p.phone}</td>
|
||||
<td><span className={p.sex === 'male' ? 'text-blue-600' : 'text-pink-600'}>{p.sex}</span></td>
|
||||
<td><span className={`badge ${p.caseStatus === 'open' ? 'bg-yellow-100 text-yellow-800' : p.caseStatus === 'inProgress' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'} dark:${p.caseStatus === 'open' ? 'bg-yellow-900/30 text-yellow-400' : p.caseStatus === 'inProgress' ? 'bg-blue-900/30 text-blue-400' : 'bg-gray-900/30 text-gray-400'}`}>{p.caseStatus}</span></td>
|
||||
<td className="capitalize">{p.referralSource}</td>
|
||||
<td className="text-right"><button className="btn-secondary btn-sm">View</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="🏥" title="No patients found" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
const categories = ['All', 'Care Packages', 'Health', 'Education', 'Food', 'Clothing', 'Hygiene'] as const;
|
||||
|
||||
const mockProducts = [
|
||||
// Care Packages
|
||||
{ id: 1, name: 'Basic Care Package', slug: 'basic-care', description: 'Essential items for daily needs including rice, oil, and canned goods', price: 250000, stock: 50, isActive: true, category: 'Care Packages', image: '📦' },
|
||||
{ id: 2, name: 'Family Support Pack', slug: 'family-support', description: 'Monthly family essentials bundle for a family of four', price: 580000, stock: 25, isActive: true, category: 'Care Packages', image: '🏠' },
|
||||
{ id: 3, name: 'Emergency Relief Kit', slug: 'emergency-relief', description: 'Quick response kit with food, water, and first aid supplies', price: 420000, stock: 15, isActive: true, category: 'Care Packages', image: '🚨' },
|
||||
// Health
|
||||
{ id: 4, name: 'Health Kit', slug: 'health-kit', description: 'Medical and hygiene supplies including bandages and sanitizers', price: 500000, stock: 30, isActive: true, category: 'Health', image: '💊' },
|
||||
{ id: 5, name: 'First Aid Bundle', slug: 'first-aid', description: 'Complete first aid kit for home and community use', price: 350000, stock: 40, isActive: true, category: 'Health', image: '🩹' },
|
||||
{ id: 6, name: 'Wellness Package', slug: 'wellness', description: 'Vitamins, supplements, and wellness products for all ages', price: 620000, stock: 20, isActive: true, category: 'Health', image: '🌿' },
|
||||
// Education
|
||||
{ id: 7, name: 'Education Pack', slug: 'education-pack', description: 'School supplies, notebooks, and books for students', price: 350000, stock: 20, isActive: true, category: 'Education', image: '📚' },
|
||||
{ id: 8, name: 'Student Starter Kit', slug: 'student-starter', description: 'Backpack filled with essential school supplies', price: 450000, stock: 35, isActive: true, category: 'Education', image: '🎒' },
|
||||
{ id: 9, name: 'Library Bundle', slug: 'library-bundle', description: 'Collection of educational books and learning materials', price: 750000, stock: 10, isActive: true, category: 'Education', image: '📖' },
|
||||
// Food
|
||||
{ id: 10, name: 'Monthly Food Basket', slug: 'food-basket', description: 'Rice, beans, cooking oil, pasta, and canned vegetables', price: 380000, stock: 60, isActive: true, category: 'Food', image: '🍚' },
|
||||
{ id: 11, name: 'Nutrition Pack for Children', slug: 'kids-nutrition', description: 'Healthy snacks, milk powder, and cereals for children', price: 290000, stock: 45, isActive: true, category: 'Food', image: '🥛' },
|
||||
{ id: 12, name: 'Ramadan Essentials', slug: 'ramadan-essentials', description: 'Dates, nuts, cooking ingredients for the holy month', price: 520000, stock: 30, isActive: true, category: 'Food', image: '🌙' },
|
||||
// Clothing
|
||||
{ id: 13, name: 'Winter Clothing Set', slug: 'winter-clothing', description: 'Warm jackets, scarves, and gloves for cold weather', price: 680000, stock: 18, isActive: true, category: 'Clothing', image: '🧥' },
|
||||
{ id: 14, name: 'Children Clothing Pack', slug: 'kids-clothing', description: 'Set of seasonal clothes for children ages 2-12', price: 420000, stock: 25, isActive: true, category: 'Clothing', image: '👕' },
|
||||
// Hygiene
|
||||
{ id: 15, name: 'Hygiene Package', slug: 'hygiene-kit', description: 'Soap, shampoo, toothpaste, and sanitary products', price: 180000, stock: 80, isActive: true, category: 'Hygiene', image: '🧼' },
|
||||
{ id: 16, name: 'Baby Care Kit', slug: 'baby-care', description: 'Diapers, baby wipes, lotion, and baby food', price: 320000, stock: 22, isActive: true, category: 'Hygiene', image: '👶' },
|
||||
];
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('All');
|
||||
const [cartCount, setCartCount] = useState(0);
|
||||
|
||||
const filtered = mockProducts.filter((p) => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = activeCategory === 'All' || p.category === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const handleAddToCart = () => {
|
||||
setCartCount((c) => c + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Shop"
|
||||
description="Browse our products and support the community"
|
||||
action={cartCount > 0 ? {
|
||||
label: `Cart (${cartCount})`,
|
||||
href: '/cart',
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="form-input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${
|
||||
activeCategory === cat
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-500/20'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map((p) => (
|
||||
<div key={p.id} className="card group hover:shadow-xl hover:-translate-y-1 transition-all duration-300 overflow-hidden">
|
||||
{/* Product Image Placeholder */}
|
||||
<div className="h-40 bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-950/30 dark:to-purple-950/30 flex items-center justify-center relative overflow-hidden">
|
||||
<span className="text-5xl transition-transform duration-300 group-hover:scale-110">{p.image}</span>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="badge bg-white/80 dark:bg-gray-800/80 text-gray-600 dark:text-gray-300 text-[10px] backdrop-blur-sm border border-gray-200 dark:border-gray-600">
|
||||
{p.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-2">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 line-clamp-1">{p.name}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">{p.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(p.price)}
|
||||
</span>
|
||||
<span className={`text-xs ${p.stock <= 15 ? 'text-red-500' : 'text-gray-400'}`}>
|
||||
{p.stock <= 15 ? `Only ${p.stock} left` : `Stock: ${p.stock}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="btn-primary w-full mt-2 group/btn"
|
||||
>
|
||||
<svg className="w-4 h-4 transition-transform duration-200 group-hover/btn:scale-110" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
|
||||
</svg>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="🛍️" title="No products found" description="Try adjusting your search or filter" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
const mockStats = { totalRevenue: 15000000, totalOrders: 45, totalBenefactors: 120, totalCampaigns: 8 };
|
||||
const mockRecent = [
|
||||
{ id: 1, date: '2025-06-10', amount: 750000, orders: 3 },
|
||||
{ id: 2, date: '2025-06-09', amount: 500000, orders: 2 },
|
||||
{ id: 3, date: '2025-06-08', amount: 1250000, orders: 5 },
|
||||
];
|
||||
|
||||
export default function SalesReportsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Sales Reports" description="View sales and revenue analytics" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card"><p className="text-sm font-medium text-gray-500">Total Revenue</p><p className="text-2xl font-bold mt-2 text-gray-900 dark:text-gray-100">{formatCurrency(mockStats.totalRevenue)}</p></div>
|
||||
<div className="stat-card"><p className="text-sm font-medium text-gray-500">Total Orders</p><p className="text-2xl font-bold mt-2 text-gray-900 dark:text-gray-100">{mockStats.totalOrders}</p></div>
|
||||
<div className="stat-card"><p className="text-sm font-medium text-gray-500">Benefactors</p><p className="text-2xl font-bold mt-2 text-gray-900 dark:text-gray-100">{mockStats.totalBenefactors}</p></div>
|
||||
<div className="stat-card"><p className="text-sm font-medium text-gray-500">Campaigns</p><p className="text-2xl font-bold mt-2 text-gray-900 dark:text-gray-100">{mockStats.totalCampaigns}</p></div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h2 className="font-semibold">Recent Sales</h2></div>
|
||||
<div className="table-container border-0 rounded-none">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Orders</th>
|
||||
<th className="text-right">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockRecent.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.orders}</td>
|
||||
<td className="text-right font-medium text-green-600">{formatCurrency(r.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const mockStaff = [
|
||||
{ id: 1, firstName: 'Dr. Amir', lastName: 'Hosseini', phoneNumber: '09111234567', email: 'amir@niki.com', role: 'doctor', isActive: true, createdAt: '2025-01-15T08:00:00' },
|
||||
{ id: 2, firstName: 'Nurse Maryam', lastName: 'Akbari', phoneNumber: '09121234567', email: 'maryam@niki.com', role: 'nurse', isActive: true, createdAt: '2025-02-01T08:00:00' },
|
||||
];
|
||||
|
||||
export default function StaffPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = mockStaff.filter((s) => `${s.firstName} ${s.lastName}`.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Staff" description="Manage staff members" />
|
||||
<div className="relative max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
<input type="text" placeholder="Search staff..." value={search} onChange={(e) => setSearch(e.target.value)} className="form-input pl-10" />
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="font-medium">#{s.id}</td>
|
||||
<td className="font-medium text-gray-900 dark:text-gray-100">{s.firstName} {s.lastName}</td>
|
||||
<td className="font-mono text-sm">{s.phoneNumber}</td>
|
||||
<td className="text-sm text-gray-500">{s.email}</td>
|
||||
<td><span className="badge bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400 capitalize">{s.role}</span></td>
|
||||
<td><StatusBadge status={s.isActive ? 'active' : 'inactive'} /></td>
|
||||
<td className="text-sm text-gray-500">{formatDateTime(s.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="👔" title="No staff members found" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
|
||||
const mockWallet = { id: 1, balance: '2500000', currency: 'IRR' as const, status: 'active' as const, createdAt: '2025-01-01' };
|
||||
|
||||
const mockTransactions = [
|
||||
{ id: 1, amount: '500000', currency: 'IRR', actionType: 'deposit' as const, timestamp: '2025-06-10T09:00:00', description: 'Deposit' },
|
||||
{ id: 2, amount: '250000', currency: 'IRR', actionType: 'withdraw' as const, timestamp: '2025-06-08T14:30:00', description: 'Order #123 payment' },
|
||||
{ id: 3, amount: '100000', currency: 'IRR', actionType: 'donate' as const, timestamp: '2025-06-05T11:00:00', description: 'Donation to Winter Campaign' },
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
const [activeTab, setActiveTab] = useState<'wallet' | 'transactions'>('wallet');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Wallet" description="Manage your wallet and view transactions" />
|
||||
|
||||
<div className="card bg-gradient-to-br from-blue-500 to-purple-600 text-white">
|
||||
<div className="card-body">
|
||||
<p className="text-sm opacity-80">Current Balance</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatCurrency(Number(mockWallet.balance))}</p>
|
||||
<div className="flex items-center gap-4 mt-4 text-sm">
|
||||
<span className="opacity-80">Status: <StatusBadge status={mockWallet.status} /></span>
|
||||
<span className="opacity-80">Currency: {mockWallet.currency}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button className="btn bg-white/20 text-white hover:bg-white/30 border-0">Deposit</button>
|
||||
<button className="btn bg-white/20 text-white hover:bg-white/30 border-0">Withdraw</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 w-fit">
|
||||
<button onClick={() => setActiveTab('wallet')} className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${activeTab === 'wallet' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>Wallet Info</button>
|
||||
<button onClick={() => setActiveTab('transactions')} className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${activeTab === 'transactions' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>Transactions</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'transactions' && (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Description</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockTransactions.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="font-medium">#{t.id}</td>
|
||||
<td><span className={`badge ${t.actionType === 'deposit' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : t.actionType === 'withdraw' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'}`}>{t.actionType}</span></td>
|
||||
<td className={`font-medium ${t.actionType === 'deposit' ? 'text-green-600' : 'text-red-600'}`}>{t.actionType === 'deposit' ? '+' : '-'}{formatCurrency(Number(t.amount))}</td>
|
||||
<td className="text-gray-500">{t.description}</td>
|
||||
<td className="text-gray-500 text-sm">{formatDateTime(t.timestamp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
requiredRoles?: string[];
|
||||
}
|
||||
|
||||
export default function AuthGuard({ children, requiredRoles }: AuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading, role } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
// Check role requirements
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && requiredRoles && role) {
|
||||
if (!requiredRoles.includes(role)) {
|
||||
router.replace('/');
|
||||
}
|
||||
}
|
||||
}, [isLoading, isAuthenticated, role, requiredRoles, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="spinner-lg" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="spinner-lg" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Redirecting to login...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check role
|
||||
if (requiredRoles && role && !requiredRoles.includes(role)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="empty-state">
|
||||
<span className="empty-state-icon">🔒</span>
|
||||
<p className="empty-state-text">Access Denied</p>
|
||||
<p className="empty-state-description">You do not have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon = '📭', title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-state-icon">{icon}</span>
|
||||
<p className="empty-state-text">{title}</p>
|
||||
{description && <p className="empty-state-description">{description}</p>}
|
||||
{action && (
|
||||
<Link href={action.href} className="btn-primary mt-6">
|
||||
{action.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
const roleGroups = [
|
||||
{
|
||||
role: 'Super Admin / Admin',
|
||||
dashboards: [
|
||||
{ label: 'Admin Dashboard', href: '/admin/dashboard' },
|
||||
{ label: 'Benefactors', href: '/admin/benefactors' },
|
||||
{ label: 'Agents', href: '/admin/agents' },
|
||||
{ label: 'Kind Boxes', href: '/admin/kind-boxes' },
|
||||
{ label: 'Kind Box Requests', href: '/admin/kind-box-requests' },
|
||||
{ label: 'Refer Times', href: '/admin/refer-times' },
|
||||
{ label: 'Patients', href: '/patients' },
|
||||
{ label: 'Drivers', href: '/drivers' },
|
||||
{ label: 'Staff', href: '/staff' },
|
||||
{ label: 'Sales Reports', href: '/sales-reports' },
|
||||
{ label: 'Gamification', href: '/gamification' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Benefactor',
|
||||
dashboards: [
|
||||
{ label: 'My Kind Boxes', href: '/benefactor/kind-boxes' },
|
||||
{ label: 'My Requests', href: '/benefactor/kind-box-requests' },
|
||||
{ label: 'My Addresses', href: '/benefactor/addresses' },
|
||||
{ label: 'Refer Times', href: '/benefactor/refer-times' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Agent',
|
||||
dashboards: [
|
||||
{ label: 'Agent Dashboard', href: '/agent/dashboard' },
|
||||
{ label: 'Return Awaiting', href: '/agent/kind-boxes' },
|
||||
{ label: 'Delivery Awaiting', href: '/agent/kind-box-requests' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'General',
|
||||
dashboards: [
|
||||
{ label: 'Products', href: '/products' },
|
||||
{ label: 'Cart', href: '/cart' },
|
||||
{ label: 'Orders', href: '/orders' },
|
||||
{ label: 'Campaigns', href: '/campaigns' },
|
||||
{ label: 'Wallet', href: '/wallet' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-12 border-t border-gray-200 dark:border-gray-700 pt-8 pb-6">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Dashboard Links Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 mb-8">
|
||||
{roleGroups.map((group) => (
|
||||
<div key={group.role}>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{group.role}
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{group.dashboards.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-xs shrink-0">
|
||||
N
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Niki Kind Box Management System
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Link href="/" className="hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/admin/dashboard" className="hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Admin
|
||||
</Link>
|
||||
<Link href="/benefactor/kind-boxes" className="hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Benefactor
|
||||
</Link>
|
||||
<Link href="/agent/dashboard" className="hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Agent
|
||||
</Link>
|
||||
<span>© {new Date().getFullYear()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
const footerLinks = {
|
||||
platform: {
|
||||
title: 'Platform',
|
||||
links: [
|
||||
{ label: 'Admin Dashboard', href: '/admin/dashboard' },
|
||||
{ label: 'Benefactor Portal', href: '/benefactor/kind-boxes' },
|
||||
{ label: 'Agent Panel', href: '/agent/dashboard' },
|
||||
{ label: 'Marketplace', href: '/products' },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ label: 'Cart', href: '/cart' },
|
||||
{ label: 'Orders', href: '/orders' },
|
||||
{ label: 'Campaigns', href: '/campaigns' },
|
||||
{ label: 'Wallet', href: '/wallet' },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About Us', href: '/' },
|
||||
{ label: 'Contact', href: '/' },
|
||||
{ label: 'Privacy Policy', href: '/' },
|
||||
{ label: 'Terms of Service', href: '/' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function LandingFooter() {
|
||||
return (
|
||||
<footer className="border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="max-w-6xl mx-auto px-4 py-12">
|
||||
{/* Links Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-10">
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-xs">
|
||||
N
|
||||
</div>
|
||||
<span className="text-base font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Niki
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed max-w-xs">
|
||||
Kind box management system connecting benefactors with those in need across cities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{Object.values(footerLinks).map((group) => (
|
||||
<div key={group.title}>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
|
||||
{group.title}
|
||||
</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
© {new Date().getFullYear()} Niki. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/" className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/" className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/" className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
export default function LandingNavbar() {
|
||||
const { isAuthenticated, user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 w-full border-b border-gray-200/60 dark:border-gray-700/60 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl supports-[backdrop-filter]:bg-white/60">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/30 transition-shadow">
|
||||
N
|
||||
</div>
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Niki
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav Links */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
<Link
|
||||
href="/cart"
|
||||
className="relative px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
<span className="sr-only">Cart</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-2" />
|
||||
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all duration-200"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
{user && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 hidden lg:inline">
|
||||
{user.firstName} {user.lastName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/login?register=true"
|
||||
className="btn-primary px-4 py-2 text-sm"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="sm:hidden flex items-center gap-2">
|
||||
<Link href="/cart" className="relative p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="21" r="1" /><circle cx="20" cy="21" r="1" /><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" /></svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={isAuthenticated ? '/admin/dashboard' : '/login'}
|
||||
className="btn-primary px-3 py-1.5 text-xs"
|
||||
>
|
||||
{isAuthenticated ? 'Dashboard' : 'Login'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useSidebar } from '@/lib/SidebarContext';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function MainContent({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { collapsed } = useSidebar();
|
||||
const isPublicPage = pathname === '/login' || pathname === '/';
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex-1 min-h-screen p-6 page-enter transition-all duration-300 flex flex-col"
|
||||
style={{ marginLeft: isPublicPage ? '0' : (collapsed ? '60px' : '260px') }}
|
||||
>
|
||||
<div className="flex-1">{children}</div>
|
||||
{!isPublicPage && <Footer />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && (
|
||||
<Link href={action.href} className="btn-primary shrink-0">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{action.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { usePathname } from 'next/navigation';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { getUserRole } from '@/lib/api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSidebar } from '@/lib/SidebarContext';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
|
|
@ -17,7 +19,7 @@ interface NavItem {
|
|||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
href: '/admin/dashboard',
|
||||
icon: '📊',
|
||||
},
|
||||
{
|
||||
|
|
@ -160,19 +162,23 @@ const navItems: NavItem[] = [
|
|||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { collapsed, toggle } = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
setRole(getUserRole());
|
||||
}, []);
|
||||
|
||||
// Don't render sidebar on login or landing page
|
||||
if (pathname === '/login' || pathname === '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredItems = navItems.filter((item) => {
|
||||
if (!item.roles || item.roles.length === 0) return true;
|
||||
return role && item.roles.includes(role);
|
||||
});
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
|
|
@ -189,9 +195,17 @@ export default function Sidebar() {
|
|||
N
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="font-bold text-lg bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Niki
|
||||
</span>
|
||||
<>
|
||||
<span className="font-bold text-lg bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent flex-1">
|
||||
Niki
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -220,7 +234,7 @@ export default function Sidebar() {
|
|||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onClick={toggle}
|
||||
className="p-3 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex items-center justify-center text-gray-400"
|
||||
>
|
||||
<span className="text-sm">{collapsed ? '→' : '←'}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { getStatusColor, getStatusLabel } from '@/lib/utils';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, size = 'sm' }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${
|
||||
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm'
|
||||
} ${getStatusColor(status)}`}
|
||||
>
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Read saved preference, fall back to system preference
|
||||
const saved = localStorage.getItem('niki-theme');
|
||||
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
setDark(true);
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
setDark(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const isDark = !dark;
|
||||
setDark(isDark);
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('niki-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('niki-theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent flash of wrong icon during SSR
|
||||
if (!mounted) {
|
||||
return <div className="w-8 h-8" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'relative w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
'transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50',
|
||||
'group'
|
||||
)}
|
||||
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={dark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{/* Sun icon - shown in dark mode */}
|
||||
<svg
|
||||
className={cn(
|
||||
'absolute w-5 h-5 transition-all duration-300',
|
||||
dark ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-50 rotate-90'
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
|
||||
{/* Moon icon - shown in light mode */}
|
||||
<svg
|
||||
className={cn(
|
||||
'absolute w-5 h-5 transition-all duration-300',
|
||||
!dark ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-50 -rotate-90'
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
getToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
setUser as storeUser,
|
||||
getUser as getStoredUser,
|
||||
getUserRole,
|
||||
authApi,
|
||||
} from './api';
|
||||
import type { Admin, Benefactor } from './types';
|
||||
|
||||
type User = (Admin | Benefactor) & { role: string };
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
role: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (phoneNumber: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
loginBenefactor: (phoneNumber: string, code: string) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
role: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
login: async () => ({ success: false }),
|
||||
loginBenefactor: async () => ({ success: false }),
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
const stored = getStoredUser() as User | null;
|
||||
if (stored) {
|
||||
setUser(stored);
|
||||
setRole(getUserRole());
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (phoneNumber: string, password: string) => {
|
||||
try {
|
||||
const res = await authApi.adminLogin({ phoneNumber, password });
|
||||
setTokens(res.token, res.refreshToken);
|
||||
const u = { ...res.user, role: res.user.role };
|
||||
storeUser(u, u.role);
|
||||
setUser(u);
|
||||
setRole(u.role);
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
// Fallback: Mock login when API is not available
|
||||
if (phoneNumber === '09111111111' && password === 'admin123') {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
phoneNumber: '09111111111',
|
||||
email: 'admin@niki.com',
|
||||
role: 'super-admin',
|
||||
description: '',
|
||||
gender: 'male',
|
||||
status: 'active',
|
||||
};
|
||||
setTokens('mock-token-' + Date.now(), 'mock-refresh-' + Date.now());
|
||||
storeUser(mockUser, 'super-admin');
|
||||
setUser(mockUser);
|
||||
setRole('super-admin');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Invalid credentials. Try 09111111111 / admin123' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginBenefactor = useCallback(async (phoneNumber: string, code: string) => {
|
||||
try {
|
||||
const res = await authApi.benefactorLoginRegister({ phoneNumber, code });
|
||||
setTokens(res.token, res.refreshToken);
|
||||
const u = { ...res.user, role: 'benefactor' } as User;
|
||||
storeUser(u, 'benefactor');
|
||||
setUser(u);
|
||||
setRole('benefactor');
|
||||
return { success: true };
|
||||
} catch {
|
||||
// Mock login for demo
|
||||
if (code === '123456') {
|
||||
const mockUser: User = {
|
||||
id: 2,
|
||||
firstName: 'Ali',
|
||||
lastName: 'Mohammadi',
|
||||
phoneNumber,
|
||||
email: 'ali@example.com',
|
||||
role: 'benefactor',
|
||||
description: '',
|
||||
gender: 'male',
|
||||
status: 'active',
|
||||
birthDate: '1990-05-15',
|
||||
};
|
||||
setTokens('mock-token-' + Date.now(), 'mock-refresh-' + Date.now());
|
||||
storeUser(mockUser, 'benefactor');
|
||||
setUser(mockUser);
|
||||
setRole('benefactor');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Invalid code. Try 123456' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setRole(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
role,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
loginBenefactor,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextType {
|
||||
collapsed: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType>({
|
||||
collapsed: false,
|
||||
toggle: () => {},
|
||||
});
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const toggle = () => setCollapsed((prev) => !prev);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, toggle }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
return useContext(SidebarContext);
|
||||
}
|
||||
Loading…
Reference in New Issue