Files
ember-market-frontend/app/dashboard/admin/users/page.tsx
g 18ac2224ca Improve user search and optimize Next.js config
User search in the admin users page now queries the backend instead of filtering on the client, and resets pagination on search. Next.js config adds production compression, removes console logs in production (except error/warn), disables the poweredByHeader, and introduces custom webpack code splitting for better bundle optimization. Updated tsconfig target to ES2020.
2025-12-31 06:46:06 +00:00

350 lines
13 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat } from "lucide-react";
import { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
interface TelegramUser {
telegramUserId: string;
telegramUsername: string;
totalOrders: number;
totalSpent: number;
paidOrders: number;
completedOrders: number;
firstOrderDate: string | null;
lastOrderDate: string | null;
isBlocked: boolean;
blockedReason: string | null;
createdAt?: string;
}
interface PaginationResponse {
success: boolean;
users: TelegramUser[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
};
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
}).format(amount);
}
export default function AdminUsersPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<TelegramUser[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1);
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
useEffect(() => {
fetchUsers();
}, [page, searchQuery]);
const fetchUsers = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
limit: '25'
});
if (searchQuery.trim()) {
params.append('search', searchQuery.trim());
}
const data = await fetchClient<PaginationResponse>(`/admin/users?${params.toString()}`);
setUsers(data.users);
setPagination(data.pagination);
} catch (error: any) {
console.error("Failed to fetch users:", error);
toast({
title: "Error",
description: error.message || "Failed to load users",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const usersWithOrders = users.filter(u => u.totalOrders > 0);
const returningCustomers = users.filter(u => u.totalOrders > 1);
const blockedUsers = users.filter(u => u.isBlocked);
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1>
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="text-2xl font-bold">{users.length}</div>
<p className="text-xs text-muted-foreground">Registered users</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Users with Orders</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="text-2xl font-bold">{usersWithOrders.length}</div>
<p className="text-xs text-muted-foreground">
{users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="text-2xl font-bold">{formatCurrency(totalSpent)}</div>
<p className="text-xs text-muted-foreground">{totalOrders} total orders</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
<Repeat className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="text-2xl font-bold">{returningCustomers.length}</div>
<p className="text-xs text-muted-foreground">
{usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Blocked Users</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="text-2xl font-bold">{blockedUsers.length}</div>
<p className="text-xs text-muted-foreground">
{users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate
</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Search and Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Management</CardTitle>
<CardDescription>View and manage all Telegram user accounts</CardDescription>
</div>
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
className="pl-8 w-64"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? "No users found matching your search" : "No users found"}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Username</TableHead>
<TableHead>Orders</TableHead>
<TableHead>Total Spent</TableHead>
<TableHead>Status</TableHead>
<TableHead>First Order</TableHead>
<TableHead>Last Order</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.telegramUserId}>
<TableCell>
<div className="font-mono text-sm">{user.telegramUserId}</div>
</TableCell>
<TableCell>
<div className="font-medium">
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-muted-foreground" />
<span>{user.totalOrders}</span>
{user.completedOrders > 0 && (
<Badge variant="secondary" className="text-xs">
{user.completedOrders} completed
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{formatCurrency(user.totalSpent)}</span>
</div>
</TableCell>
<TableCell>
{user.isBlocked ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive">
<Ban className="h-3 w-3 mr-1" />
Blocked
</Badge>
</TooltipTrigger>
{user.blockedReason && (
<TooltipContent>
<p className="max-w-xs">{user.blockedReason}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : user.totalOrders > 0 ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="secondary">No Orders</Badge>
)}
</TableCell>
<TableCell>
{user.firstOrderDate
? new Date(user.firstOrderDate).toLocaleDateString()
: 'N/A'}
</TableCell>
<TableCell>
{user.lastOrderDate
? new Date(user.lastOrderDate).toLocaleDateString()
: 'N/A'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
{!user.isBlocked ? (
<Button variant="outline" size="sm">
<Ban className="h-4 w-4" />
</Button>
) : (
<Button variant="outline" size="sm">
<UserCheck className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total users)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={!pagination.hasPrevPage}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={!pagination.hasNextPage}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}