Skip to content

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

ComponentTechnology
FrameworkReact 18
LanguageTypeScript
RoutingReact Router 6
ChartsRecharts
IconsLucide React
StylingTailwind CSS
BuildVite

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.json

Pages

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-admin

Access at: https://admin.yebomart.com

One chat. Everything done.