feat(admin): implement live global search, fixed position sidebar, click-to-navigate dashboard cards, and new orders count badge on sidebar
Some checks failed
Deploy Canina / deploy (push) Failing after 2m37s

This commit is contained in:
parsa aghaei 2026-07-15 19:33:33 +03:30
parent 72650dba36
commit fd45910387
4 changed files with 112 additions and 14 deletions

View File

@ -9,7 +9,7 @@ export default function Layout() {
return (
<div className="flex min-h-screen bg-gray-50 text-gray-900 font-vazir relative overflow-hidden" dir="rtl">
<Sidebar isOpen={isMobileMenuOpen} setIsOpen={setIsMobileMenuOpen} />
<div className="flex-1 flex flex-col min-h-screen w-full lg:w-auto">
<div className="flex-1 flex flex-col min-h-screen w-full lg:w-auto lg:pr-64">
<Topbar toggleMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
<main className="flex-1 p-6 overflow-y-auto">
<Outlet />

View File

@ -1,5 +1,7 @@
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image } from 'lucide-react';
import api from '../services/api';
const menuGroups = [
{
@ -50,6 +52,24 @@ interface SidebarProps {
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
const location = useLocation();
const navigate = useNavigate();
const [newOrdersCount, setNewOrdersCount] = useState(0);
useEffect(() => {
const fetchOrdersCount = async () => {
try {
const response = await api.get('/admin/dashboard/stats');
if (response.data?.success) {
setNewOrdersCount(response.data.data.newOrders);
}
} catch (err) {
console.error('Failed to fetch stats in sidebar', err);
}
};
fetchOrdersCount();
// Poll stats every 60 seconds
const interval = setInterval(fetchOrdersCount, 60000);
return () => clearInterval(interval);
}, []);
const handleLogout = () => {
localStorage.removeItem('adminToken');
@ -66,7 +86,7 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
/>
)}
<aside className={`w-64 bg-white border-l border-gray-200 h-screen fixed lg:sticky top-0 flex flex-col font-vazir shadow-sm z-50 transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'
<aside className={`w-64 bg-white border-l border-gray-200 h-screen fixed lg:fixed lg:right-0 lg:top-0 lg:bottom-0 flex flex-col font-vazir shadow-sm z-50 transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'
}`}>
<div className="h-16 flex items-center justify-center border-b border-gray-200 shrink-0">
<h1 className="text-xl font-black text-purple-600">کانینا | ادمینپنل</h1>
@ -92,6 +112,11 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
>
<Icon className="w-5 h-5" />
<span>{item.label}</span>
{item.label === 'سفارشات' && newOrdersCount > 0 && (
<span className="mr-auto bg-purple-600 text-white text-[11px] font-black px-2 py-0.5 rounded-full min-w-[20px] text-center">
{newOrdersCount}
</span>
)}
</Link>
);
})}

View File

@ -1,13 +1,57 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
interface TopbarProps {
toggleMenu: () => void;
}
const SEARCHABLE_PAGES = [
{ label: 'داشبورد (خلاصه وضعیت فروشگاه)', path: '/' },
{ label: 'گزارشات کامل فروش و بازدیدها', path: '/reports' },
{ label: 'مدیریت سفارشات مشتریان', path: '/orders' },
{ label: 'مدیریت محصولات فروشگاه', path: '/products' },
{ label: 'دسته‌بندی‌های محصولات (Categories)', path: '/categories' },
{ label: 'کدهای تخفیف و کوپن‌های خرید', path: '/coupons' },
{ label: 'مدیریت کاربران و مشتریان سایت', path: '/users' },
{ label: 'مدیریت سگ‌ها و گربه‌ها (Pets)', path: '/pets' },
{ label: 'مدیریت مقالات وبلاگ', path: '/blogs' },
{ label: 'مدیریت دانشنامه و مقالات علمی (Wiki)', path: '/wiki' },
{ label: 'تنظیمات کلی سیستم و درگاه پرداخت', path: '/settings' },
{ label: 'متون رابط کاربری و ترجمه‌ها', path: '/ui-texts' },
{ label: 'مدیریت رسانه، عکس‌ها و گالری', path: '/media' },
];
export default function Topbar({ toggleMenu }: TopbarProps) {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const searchContainerRef = useRef<HTMLDivElement>(null);
// Close search results dropdown on clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredResults = SEARCHABLE_PAGES.filter(page =>
page.label.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleResultClick = (path: string) => {
navigate(path);
setSearchQuery('');
setShowResults(false);
};
return (
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-10 shadow-sm font-vazir">
<div className="flex-1 flex items-center gap-3 max-w-xl">
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-40 shadow-sm font-vazir">
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
<button
onClick={toggleMenu}
className="lg:hidden p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
@ -20,9 +64,37 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
</div>
<input
type="text"
placeholder="جستجو در پنل..."
className="w-full bg-gray-50 border border-gray-200 text-gray-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block pr-10 p-2.5 outline-none font-bold"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowResults(true);
}}
onFocus={() => setShowResults(true)}
placeholder="جستجو در پنل ادمین (مثال: محصولات، سفارشات...)"
className="w-full bg-gray-50 border border-gray-200 text-gray-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block pr-10 p-2.5 outline-none font-bold transition-all"
/>
{/* Live Search Results Dropdown */}
{showResults && searchQuery.trim() !== '' && (
<div className="absolute top-full right-0 left-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-2xl z-50 overflow-hidden max-h-60 overflow-y-auto">
{filteredResults.length > 0 ? (
<div className="py-1">
{filteredResults.map((result, idx) => (
<button
key={idx}
onClick={() => handleResultClick(result.path)}
className="w-full text-right px-4 py-3 text-sm font-bold text-gray-700 hover:bg-purple-50 hover:text-purple-600 transition-colors flex items-center justify-between"
>
<span>{result.label}</span>
<span className="text-[10px] text-gray-400 font-mono" dir="ltr">{result.path}</span>
</button>
))}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-400 font-bold">نتیجهای یافت نشد.</div>
)}
</div>
)}
</div>
</div>

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { DollarSign, ShoppingCart, Users, Activity } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import api from '../services/api';
@ -43,10 +44,10 @@ export default function Dashboard() {
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];
const stats = [
{ title: 'درآمد کل', value: `${data.revenue.toLocaleString()} تومان`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-100' },
{ title: 'سفارشات جدید', value: data.newOrders.toString(), icon: ShoppingCart, color: 'text-blue-600', bg: 'bg-blue-100' },
{ title: 'کاربران فعال', value: data.users.toString(), icon: Users, color: 'text-purple-600', bg: 'bg-purple-100' },
{ title: 'بازدید امروز', value: data.todayVisits.toString(), icon: Activity, color: 'text-orange-600', bg: 'bg-orange-100' },
{ title: 'درآمد کل', value: `${data.revenue.toLocaleString()} تومان`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-100', link: '/reports' },
{ title: 'سفارشات جدید', value: data.newOrders.toString(), icon: ShoppingCart, color: 'text-blue-600', bg: 'bg-blue-100', link: '/orders' },
{ title: 'کاربران فعال', value: data.users.toString(), icon: Users, color: 'text-purple-600', bg: 'bg-purple-100', link: '/users' },
{ title: 'بازدید امروز', value: data.todayVisits.toString(), icon: Activity, color: 'text-orange-600', bg: 'bg-orange-100', link: '/reports' },
];
return (
@ -60,7 +61,7 @@ export default function Dashboard() {
{stats.map((stat, i) => {
const Icon = stat.icon;
return (
<div key={i} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<Link key={i} to={stat.link} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4 hover:shadow-md hover:border-purple-200 transition-all cursor-pointer">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${stat.bg}`}>
<Icon className={`w-6 h-6 ${stat.color}`} />
</div>
@ -68,7 +69,7 @@ export default function Dashboard() {
<p className="text-sm font-bold text-gray-500">{stat.title}</p>
<p className="text-xl font-black text-gray-900 mt-1">{stat.value}</p>
</div>
</div>
</Link>
);
})}
</div>