All checks were successful
Build Frontend / build (push) Successful in 1m10s
Standardizes browser detection logic across admin and storefront pages to more accurately identify Firefox. Updates table rendering logic to provide better compatibility and fallback for Firefox, including conditional use of AnimatePresence and improved loading/empty states. Refines table UI styles for consistency and accessibility.
331 lines
14 KiB
TypeScript
331 lines
14 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, Users, ShoppingBag, CreditCard, UserX } from "lucide-react";
|
|
import { fetchClient } from "@/lib/api-client";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
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[]>([]);
|
|
// State for browser detection
|
|
// Browser detection
|
|
const [isFirefox, setIsFirefox] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
|
}, []);
|
|
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);
|
|
|
|
const stats = [
|
|
{
|
|
title: "Total Users",
|
|
value: users.length,
|
|
description: "Registered users",
|
|
icon: Users,
|
|
},
|
|
{
|
|
title: "Users with Orders",
|
|
value: usersWithOrders.length,
|
|
description: `${users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate`,
|
|
icon: ShoppingBag,
|
|
},
|
|
{
|
|
title: "Total Revenue",
|
|
value: formatCurrency(totalSpent),
|
|
description: `${totalOrders} total orders`,
|
|
icon: DollarSign,
|
|
},
|
|
{
|
|
title: "Returning",
|
|
value: returningCustomers.length,
|
|
description: `${usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers`,
|
|
icon: Repeat,
|
|
},
|
|
{
|
|
title: "Blocked",
|
|
value: blockedUsers.length,
|
|
description: `${users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate`,
|
|
icon: UserX,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6 animate-in fade-in duration-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Telegram Users</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid gap-4 md:grid-cols-5">
|
|
{stats.map((stat, i) => (
|
|
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
|
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="h-12 flex items-center">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/50" />
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
|
<div className="text-2xl font-bold">{stat.value}</div>
|
|
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
|
<CardHeader className="pb-4">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-lg font-medium">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.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users..."
|
|
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border border-border/50 overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="bg-muted/30">
|
|
<TableRow className="border-border/50 hover:bg-transparent">
|
|
<TableHead className="w-[100px]">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>
|
|
<AnimatePresence mode="popLayout">
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="h-24 text-center">
|
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading users...
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : users.length > 0 ? (
|
|
users.map((user, index) => (
|
|
<motion.tr
|
|
key={user.telegramUserId}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
|
className={`group border-b border-border/50 transition-colors ${user.isBlocked ? "bg-destructive/5 hover:bg-destructive/10" : "hover:bg-muted/40"}`}
|
|
>
|
|
<TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
|
|
{user.isBlocked && (
|
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{user.totalOrders}</TableCell>
|
|
<TableCell>{formatCurrency(user.totalSpent)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col gap-1 text-xs">
|
|
<span className="text-emerald-500">{user.completedOrders} Completed</span>
|
|
<span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{user.firstOrderDate ? new Date(user.firstOrderDate).toLocaleDateString() : "-"}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{user.lastOrderDate ? new Date(user.lastOrderDate).toLocaleDateString() : "-"}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
{user.isBlocked ? (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
|
|
<UserCheck className="h-4 w-4 mr-1" />
|
|
Unblock
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Unblock this user</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="outline" className="h-8 border-destructive/20 text-destructive hover:bg-destructive/10 hover:text-destructive">
|
|
<Ban className="h-4 w-4 mr-1" />
|
|
Block
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Block access to the store</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</motion.tr>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
|
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
<Users className="h-8 w-8 opacity-20" />
|
|
<p>No users found</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</AnimatePresence>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={!pagination.hasPrevPage}
|
|
className="h-8"
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={!pagination.hasNextPage}
|
|
className="h-8"
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|