woohoo
This commit is contained in:
238
app/dashboard/storefront/customers/page.tsx
Normal file
238
app/dashboard/storefront/customers/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { getCustomers, CustomerStats } from "@/services/customerService";
|
||||||
|
import { formatCurrency } from "@/utils/format";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import Layout from "@/components/layout/layout";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2, Users } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CustomerManagementPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customers, setCustomers] = useState<CustomerStats[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<CustomerStats | null>(null);
|
||||||
|
|
||||||
|
const fetchCustomers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getCustomers(page, itemsPerPage);
|
||||||
|
setCustomers(response.customers);
|
||||||
|
setTotalPages(Math.ceil(response.total / itemsPerPage));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to fetch customers");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, itemsPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, [fetchCustomers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authToken = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("Authorization="))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsPerPageChange = (value: string) => {
|
||||||
|
setItemsPerPage(parseInt(value, 10));
|
||||||
|
setPage(1); // Reset to first page when changing items per page
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Users className="mr-2 h-6 w-6" />
|
||||||
|
Customer Management
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-gray-500 dark:text-gray-400">Show:</div>
|
||||||
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
<SelectValue placeholder="Page Size" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[5, 10, 25, 50, 100].map(size => (
|
||||||
|
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 flex justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">No customers found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[300px]">Customer</TableHead>
|
||||||
|
<TableHead>Total Orders</TableHead>
|
||||||
|
<TableHead>Total Spent</TableHead>
|
||||||
|
<TableHead className="w-[300px]">Order Status</TableHead>
|
||||||
|
<TableHead>Last Order</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<TableRow
|
||||||
|
key={customer.userId}
|
||||||
|
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">@{customer.telegramUsername}</div>
|
||||||
|
<div className="text-sm text-gray-500">ID: {customer.telegramUserId}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{customer.totalOrders}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(customer.totalSpent)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
Paid: {customer.ordersByStatus.paid} |
|
||||||
|
Completed: {customer.ordersByStatus.completed} |
|
||||||
|
Shipped: {customer.ordersByStatus.shipped}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(customer.lastOrderDate).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="ml-1">Previous</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
<span className="mr-1">Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Details Modal */}
|
||||||
|
{selectedCustomer && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">Customer Details</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCustomer(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Customer Information</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>Telegram Username: @{selectedCustomer.telegramUsername}</p>
|
||||||
|
<p>Telegram User ID: {selectedCustomer.telegramUserId}</p>
|
||||||
|
<p>Chat ID: {selectedCustomer.chatId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Order Statistics</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>Total Orders: {selectedCustomer.totalOrders}</p>
|
||||||
|
<p>Total Spent: {formatCurrency(selectedCustomer.totalSpent)}</p>
|
||||||
|
<p>First Order: {new Date(selectedCustomer.firstOrderDate).toLocaleDateString()}</p>
|
||||||
|
<p>Last Order: {new Date(selectedCustomer.lastOrderDate).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Order Status Breakdown</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-2 rounded">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">Paid</p>
|
||||||
|
<p className="font-medium">{selectedCustomer.ordersByStatus.paid}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 p-2 rounded">
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">Completed</p>
|
||||||
|
<p className="font-medium">{selectedCustomer.ordersByStatus.completed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 p-2 rounded">
|
||||||
|
<p className="text-sm text-purple-700 dark:text-purple-300">Acknowledged</p>
|
||||||
|
<p className="font-medium">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">Shipped</p>
|
||||||
|
<p className="font-medium">{selectedCustomer.ordersByStatus.shipped}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,9 +34,9 @@ export function HomeNavbar() {
|
|||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMenuOpen(!menuOpen)} className="text-white hover:bg-gray-900">
|
<Button variant="ghost" size="icon" onClick={() => setMenuOpen(!menuOpen)} className="text-white hover:bg-gray-900">
|
||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|||||||
5
config/index.ts
Normal file
5
config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Configuration values for the Ember Market frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, Package, Box, Truck, Settings, FolderTree, MessageCircle, BarChart3, Tag } from "lucide-react"
|
import { Home, Package, Box, Truck, Settings, FolderTree, MessageCircle, BarChart3, Tag, Users } from "lucide-react"
|
||||||
|
|
||||||
export const sidebarConfig = [
|
export const sidebarConfig = [
|
||||||
{
|
{
|
||||||
@@ -12,6 +12,7 @@ export const sidebarConfig = [
|
|||||||
items: [
|
items: [
|
||||||
{ name: "Orders", href: "/dashboard/orders", icon: Package },
|
{ name: "Orders", href: "/dashboard/orders", icon: Package },
|
||||||
{ name: "Customer Chats", href: "/dashboard/chats", icon: MessageCircle },
|
{ name: "Customer Chats", href: "/dashboard/chats", icon: MessageCircle },
|
||||||
|
{ name: "Customers", href: "/dashboard/storefront/customers", icon: Users },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
29
services/customerService.ts
Normal file
29
services/customerService.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { clientFetch } from '@/lib/client-utils';
|
||||||
|
|
||||||
|
export interface CustomerStats {
|
||||||
|
userId: string;
|
||||||
|
telegramUserId: number;
|
||||||
|
telegramUsername: string;
|
||||||
|
totalOrders: number;
|
||||||
|
totalSpent: number;
|
||||||
|
ordersByStatus: {
|
||||||
|
paid: number;
|
||||||
|
completed: number;
|
||||||
|
acknowledged: number;
|
||||||
|
shipped: number;
|
||||||
|
};
|
||||||
|
lastOrderDate: string;
|
||||||
|
firstOrderDate: string;
|
||||||
|
chatId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCustomers = async (page: number = 1, limit: number = 25): Promise<{
|
||||||
|
customers: CustomerStats[];
|
||||||
|
total: number;
|
||||||
|
}> => {
|
||||||
|
return clientFetch(`/customers?page=${page}&limit=${limit}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomerDetails = async (userId: string): Promise<CustomerStats> => {
|
||||||
|
return clientFetch(`/customers/${userId}`);
|
||||||
|
};
|
||||||
6
utils/format.ts
Normal file
6
utils/format.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'GBP'
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user