YeboMart Admin Dashboard
Internal dashboard for platform administration and shop management.
Overview
The YeboMart Admin Dashboard is a separate React application for platform administrators to:
- Monitor overall platform metrics
- Manage shops (view, suspend, delete)
- View subscription status
- Manage admin users
- Access system settings
Technology Stack
| Component | Technology |
|---|---|
| Framework | React 18 |
| Language | TypeScript |
| Routing | React Router 6 |
| Charts | Recharts |
| Icons | Lucide React |
| Styling | Tailwind CSS |
| Build | Vite |
Application Structure
admin/
├── src/
│ ├── api/
│ │ └── client.ts # Admin API client
│ ├── components/
│ │ ├── Card.tsx
│ │ ├── Layout.tsx
│ │ ├── ProtectedRoute.tsx
│ │ └── Sidebar.tsx
│ ├── context/
│ │ └── AuthContext.tsx # Admin auth context
│ ├── pages/
│ │ ├── Dashboard.tsx
│ │ ├── Login.tsx
│ │ ├── Shops.tsx
│ │ ├── ShopDetail.tsx
│ │ ├── Users.tsx
│ │ ├── UserDetail.tsx
│ │ ├── Subscriptions.tsx
│ │ └── Settings.tsx
│ └── App.tsx
└── package.jsonPages
Dashboard
Main overview with platform-wide metrics.
tsx
// src/pages/Dashboard.tsx
export default function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
useEffect(() => {
adminApi.getDashboard().then(setData);
}, []);
const stats = data ? [
{
name: 'Total Shops',
value: data.totalShops.toLocaleString(),
change: '+12%',
trend: 'up',
icon: Store,
color: 'from-blue-500 to-blue-600',
},
{
name: 'Active Shops',
value: data.activeShops.toLocaleString(),
change: '+8%',
trend: 'up',
icon: Activity,
color: 'from-green-500 to-green-600',
},
{
name: 'Total Revenue',
value: `E${data.totalRevenue.toLocaleString()}`,
change: '+23%',
trend: 'up',
icon: DollarSign,
color: 'from-amber-500 to-orange-600',
},
{
name: 'New Today',
value: data.newShopsToday.toString(),
change: '-3%',
trend: 'down',
icon: TrendingUp,
color: 'from-purple-500 to-purple-600',
},
] : [];
return (
<div className="space-y-6">
<h1>Dashboard</h1>
<p>Welcome back! Here's what's happening with YeboMart.</p>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<Card key={stat.name}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-400">{stat.name}</p>
<p className="text-2xl font-bold">{stat.value}</p>
<div className="flex items-center gap-1 mt-2">
{stat.trend === 'up' ? <ArrowUpRight /> : <ArrowDownRight />}
<span>{stat.change}</span>
</div>
</div>
<div className={`p-3 rounded-lg bg-gradient-to-br ${stat.color}`}>
<stat.icon className="w-5 h-5 text-white" />
</div>
</div>
</Card>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<h2>Shop Growth</h2>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={mockChartData}>
<Area type="monotone" dataKey="shops" stroke="#f59e0b" />
</AreaChart>
</ResponsiveContainer>
</Card>
<Card>
<h2>Revenue</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={mockChartData}>
<Bar dataKey="revenue" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
</Card>
</div>
{/* Recent Activity */}
<Card>
<h2>Recent Activity</h2>
<div className="divide-y divide-slate-700">
{mockActivity.map((activity) => (
<div key={activity.id} className="flex items-center gap-4 p-4">
<ActivityIcon type={activity.type} />
<div>
<p>{activity.message}</p>
<p className="text-slate-400">{activity.shopName}</p>
</div>
<span className="text-xs text-slate-500">{activity.timestamp}</span>
</div>
))}
</div>
</Card>
</div>
);
}Shops
List and manage all registered shops.
tsx
// src/pages/Shops.tsx
export default function Shops() {
const [shops, setShops] = useState<Shop[]>([]);
const [filters, setFilters] = useState({ status: '', tier: '', search: '' });
useEffect(() => {
adminApi.getShops(filters).then(setShops);
}, [filters]);
const handleSuspend = async (shopId: string) => {
await adminApi.updateShopStatus(shopId, 'SUSPENDED');
// Refresh list
};
const handleDelete = async (shopId: string) => {
if (confirm('Delete this shop permanently?')) {
await adminApi.deleteShop(shopId);
// Refresh list
}
};
return (
<div className="space-y-6">
<div className="flex justify-between">
<h1>Shops</h1>
<div className="flex gap-2">
<Input placeholder="Search shops..." onChange={handleSearch} />
<Select options={statusOptions} onChange={handleStatusFilter} />
<Select options={tierOptions} onChange={handleTierFilter} />
</div>
</div>
<table className="w-full">
<thead>
<tr>
<th>Shop Name</th>
<th>Owner</th>
<th>Country</th>
<th>Tier</th>
<th>Status</th>
<th>Products</th>
<th>Sales</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{shops.map((shop) => (
<tr key={shop.id}>
<td>
<Link to={`/shops/${shop.id}`}>{shop.name}</Link>
</td>
<td>{shop.ownerName}</td>
<td>{shop.countryCode}</td>
<td><Badge variant={tierColors[shop.tier]}>{shop.tier}</Badge></td>
<td><Badge variant={shop.status === 'ACTIVE' ? 'success' : 'danger'}>{shop.status}</Badge></td>
<td>{shop._count.products}</td>
<td>{shop._count.sales}</td>
<td>{formatDate(shop.createdAt)}</td>
<td>
<DropdownMenu>
<Link to={`/shops/${shop.id}`}>View</Link>
<button onClick={() => handleSuspend(shop.id)}>Suspend</button>
<button onClick={() => handleDelete(shop.id)}>Delete</button>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
<Pagination total={total} page={page} onPageChange={setPage} />
</div>
);
}Shop Detail
Detailed view of a single shop.
tsx
// src/pages/ShopDetail.tsx
export default function ShopDetail() {
const { id } = useParams();
const [shop, setShop] = useState<ShopDetail | null>(null);
useEffect(() => {
adminApi.getShop(id).then(setShop);
}, [id]);
return (
<div className="space-y-6">
{/* Shop Header */}
<div className="flex items-center gap-4">
{shop?.logoUrl && <img src={shop.logoUrl} className="w-16 h-16 rounded-lg" />}
<div>
<h1>{shop?.name}</h1>
<p className="text-slate-400">{shop?.ownerName} • {shop?.ownerPhone}</p>
</div>
<Badge variant={shop?.status === 'ACTIVE' ? 'success' : 'danger'}>
{shop?.status}
</Badge>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-4 gap-4">
<Card>
<p className="text-sm text-slate-400">Tier</p>
<p className="text-2xl font-bold">{shop?.tier}</p>
</Card>
<Card>
<p className="text-sm text-slate-400">Products</p>
<p className="text-2xl font-bold">{shop?._count.products}</p>
</Card>
<Card>
<p className="text-sm text-slate-400">Total Sales</p>
<p className="text-2xl font-bold">{shop?._count.sales}</p>
</Card>
<Card>
<p className="text-sm text-slate-400">Staff</p>
<p className="text-2xl font-bold">{shop?._count.users}</p>
</Card>
</div>
{/* Shop Info */}
<div className="grid grid-cols-2 gap-6">
<Card>
<h2>Business Details</h2>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-slate-400">Business Type</dt>
<dd>{shop?.businessType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">Country</dt>
<dd>{shop?.countryCode} ({shop?.currency})</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">Timezone</dt>
<dd>{shop?.timezone}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">Created</dt>
<dd>{formatDate(shop?.createdAt)}</dd>
</div>
</dl>
</Card>
<Card>
<h2>Subscription</h2>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-slate-400">Current Tier</dt>
<dd><Badge>{shop?.tier}</Badge></dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">License Expiry</dt>
<dd>{shop?.licenseExpiry ? formatDate(shop.licenseExpiry) : 'N/A'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">Monthly Transactions</dt>
<dd>{shop?.monthlyTransactions}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-400">AI Queries Used</dt>
<dd>{shop?.monthlyAiQueries}</dd>
</div>
</dl>
</Card>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button onClick={() => setShowUpdateTier(true)}>Update Tier</Button>
<Button variant="warning" onClick={() => handleSuspend()}>
{shop?.status === 'ACTIVE' ? 'Suspend' : 'Activate'}
</Button>
<Button variant="danger" onClick={() => handleDelete()}>Delete Shop</Button>
</div>
{/* Staff List */}
<Card>
<h2>Staff Members</h2>
<table className="w-full">
<thead>
<tr><th>Name</th><th>Role</th><th>Phone</th><th>Last Login</th></tr>
</thead>
<tbody>
{shop?.users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td><Badge>{user.role}</Badge></td>
<td>{user.phone}</td>
<td>{user.lastLoginAt ? formatDate(user.lastLoginAt) : 'Never'}</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
);
}Users
All users across all shops.
tsx
// src/pages/Users.tsx
export default function Users() {
const [users, setUsers] = useState<User[]>([]);
return (
<div className="space-y-6">
<h1>All Users</h1>
<table className="w-full">
<thead>
<tr><th>Name</th><th>Shop</th><th>Role</th><th>Phone</th><th>Status</th><th>Last Login</th></tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td><Link to={`/users/${user.id}`}>{user.name}</Link></td>
<td><Link to={`/shops/${user.shopId}`}>{user.shop.name}</Link></td>
<td><Badge>{user.role}</Badge></td>
<td>{user.phone}</td>
<td><Badge variant={user.isActive ? 'success' : 'danger'}>{user.isActive ? 'Active' : 'Inactive'}</Badge></td>
<td>{user.lastLoginAt ? formatDate(user.lastLoginAt) : 'Never'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Subscriptions
Subscription and billing overview.
tsx
// src/pages/Subscriptions.tsx
export default function Subscriptions() {
const [stats, setStats] = useState({
byTier: [],
recentPayments: [],
mrr: 0,
});
return (
<div className="space-y-6">
<h1>Subscriptions</h1>
{/* Tier Distribution */}
<Card>
<h2>Shops by Tier</h2>
<div className="grid grid-cols-5 gap-4">
{stats.byTier.map((tier) => (
<div key={tier.name}>
<p className="text-3xl font-bold">{tier.count}</p>
<p className="text-sm text-slate-400">{tier.name}</p>
</div>
))}
</div>
</Card>
{/* MRR */}
<Card>
<h2>Monthly Recurring Revenue</h2>
<p className="text-4xl font-bold text-emerald-400">E{stats.mrr.toLocaleString()}</p>
</Card>
{/* Recent Payments */}
<Card>
<h2>Recent Payments</h2>
<table className="w-full">
<thead>
<tr><th>Shop</th><th>Tier</th><th>Amount</th><th>Date</th><th>Status</th></tr>
</thead>
<tbody>
{stats.recentPayments.map((payment) => (
<tr key={payment.id}>
<td>{payment.shopName}</td>
<td>{payment.tier}</td>
<td>E{payment.amount}</td>
<td>{formatDate(payment.createdAt)}</td>
<td><Badge variant="success">Paid</Badge></td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
);
}Authentication
AuthContext
Admin authentication using React Context.
tsx
// src/context/AuthContext.tsx
interface AuthContextType {
admin: Admin | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => void;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [admin, setAdmin] = useState<Admin | null>(null);
useEffect(() => {
const token = localStorage.getItem('admin_token');
if (token) {
adminApi.getMe().then(setAdmin).catch(() => logout());
}
}, []);
const login = async (email: string, password: string) => {
const { data } = await adminApi.login(email, password);
if (data) {
localStorage.setItem('admin_token', data.token);
setAdmin(data.admin);
return true;
}
return false;
};
const logout = () => {
localStorage.removeItem('admin_token');
setAdmin(null);
};
return (
<AuthContext.Provider value={{ admin, isAuthenticated: !!admin, login, logout }}>
{children}
</AuthContext.Provider>
);
}ProtectedRoute
Require admin authentication.
tsx
// src/components/ProtectedRoute.tsx
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}API Client
typescript
// src/api/client.ts
class AdminAPI {
private baseUrl = 'https://api.yebomart.com/api/v1/admin';
private getHeaders() {
const token = localStorage.getItem('admin_token');
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
async login(email: string, password: string) {
const res = await fetch(`${this.baseUrl}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return res.json();
}
async getDashboard() {
const res = await fetch(`${this.baseUrl}/dashboard`, {
headers: this.getHeaders(),
});
return res.json();
}
async getShops(filters?: { status?: string; tier?: string; search?: string }) {
const params = new URLSearchParams(filters);
const res = await fetch(`${this.baseUrl}/shops?${params}`, {
headers: this.getHeaders(),
});
return res.json();
}
async getShop(id: string) {
const res = await fetch(`${this.baseUrl}/shops/${id}`, {
headers: this.getHeaders(),
});
return res.json();
}
async updateShopStatus(id: string, status: 'ACTIVE' | 'SUSPENDED') {
const res = await fetch(`${this.baseUrl}/shops/${id}/status`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ status }),
});
return res.json();
}
async deleteShop(id: string) {
const res = await fetch(`${this.baseUrl}/shops/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
return res.json();
}
async updateSubscription(id: string, tier: string, expiresAt?: Date) {
const res = await fetch(`${this.baseUrl}/subscriptions/${id}`, {
method: 'PUT',
headers: this.getHeaders(),
body: JSON.stringify({ tier, expiresAt }),
});
return res.json();
}
async getUsers() {
const res = await fetch(`${this.baseUrl}/users`, {
headers: this.getHeaders(),
});
return res.json();
}
async getSubscriptions() {
const res = await fetch(`${this.baseUrl}/subscriptions`, {
headers: this.getHeaders(),
});
return res.json();
}
}
export const adminApi = new AdminAPI();Deployment
bash
cd admin
npm run build
npx wrangler pages deploy dist --project-name=yebomart-adminAccess at: https://admin.yebomart.com