better login
This commit is contained in:
@@ -1,736 +1,20 @@
|
|||||||
'use client';
|
import { Suspense } from "react";
|
||||||
|
import { DashboardClient } from "@/components/dashboard/dashboard-client";
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
User, Crown, Home, ShoppingBag, Wallet, MapPin,
|
|
||||||
Headphones, Heart, IdCard, LogOut, TrendingUp,
|
|
||||||
Plus, Hash, Clock, AlertCircle, Eye, Reply,
|
|
||||||
CreditCard, Gift, Edit, Trash2, CircleCheck, Info, Box, Camera, Check, Shield, ShieldAlert, Mail, CheckCircle, ShieldX
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// === رابطها و کامپوننتهای کمکی ===
|
|
||||||
interface PriceDisplayProps {
|
|
||||||
amount: number | string | null;
|
|
||||||
currency?: string;
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PriceDisplay: React.FC<PriceDisplayProps> = ({ amount, currency = 'تومان', unit }) => {
|
|
||||||
if (amount === null || amount === undefined || amount === 0) {
|
|
||||||
return <span className="text-gray-400">استعلام</span>;
|
|
||||||
}
|
|
||||||
const formattedAmount = typeof amount === 'number'
|
|
||||||
? amount.toLocaleString('fa-IR')
|
|
||||||
: Number(amount.toString().replace(/,/g, '')).toLocaleString('fa-IR');
|
|
||||||
|
|
||||||
|
function DashboardFallback() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-[#f7f3ec] px-4 py-16" dir="rtl">
|
||||||
{formattedAmount} {unit ? unit : currency}
|
<div className="mx-auto max-w-6xl rounded-[32px] border border-[#ebe4d8] bg-white px-8 py-20 text-center text-[#6e675b] shadow-[0_20px_50px_rgba(48,40,28,0.06)]">
|
||||||
</>
|
در حال بارگذاری پیشخوان...
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// === دادههای نمونه ===
|
|
||||||
const sampleOrders = [
|
|
||||||
{
|
|
||||||
id: "PR-10452",
|
|
||||||
status: "در حال پردازش",
|
|
||||||
statusColor: "amber",
|
|
||||||
items: [
|
|
||||||
{ name: "پمپ آب صنعتی مدل PX-200", qty: 2, price: "۳۰۰,۰۰۰" },
|
|
||||||
{ name: "فیلتر هوای کابین خودرو", qty: 1, price: "۲۰۰,۰۰۰" },
|
|
||||||
],
|
|
||||||
total: "۵۰۰,۰۰۰",
|
|
||||||
regDate: "۱۵ دی ۱۴۰۴",
|
|
||||||
deliveryDate: "۲۰ دی ۱۴۰۴",
|
|
||||||
delivered: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "PR-10451",
|
|
||||||
status: "تحویل شده",
|
|
||||||
statusColor: "green",
|
|
||||||
items: [
|
|
||||||
{ name: "روغن موتور ۵W-30 کاسترول", qty: 4, price: "۱۲۰,۰۰۰" },
|
|
||||||
],
|
|
||||||
total: "۱۲۰,۰۰۰",
|
|
||||||
regDate: "۱۰ دی ۱۴۰۴",
|
|
||||||
deliveryDate: "۱۲ دی ۱۴۰۴",
|
|
||||||
delivered: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const sampleTickets = [
|
|
||||||
{
|
|
||||||
id: "54321",
|
|
||||||
title: "عدم تطابق قطعه ارسالی با فاکتور",
|
|
||||||
date: "۱۴ دی ۱۴۰۴",
|
|
||||||
department: "بخش فروش",
|
|
||||||
priority: "اولویت بالا",
|
|
||||||
status: "در حال بررسی",
|
|
||||||
statusColor: "amber",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const sampleAddresses = [
|
|
||||||
{
|
|
||||||
id: "addr1",
|
|
||||||
text: "شیراز، بلوار معالی آباد، خیابان پزشکان، ساختمان پارس، واحد ۴",
|
|
||||||
receiver: "علی محمدی",
|
|
||||||
phone: "۰۹۱۲۳۴۵۶۷۸۹",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "addr2",
|
|
||||||
text: "تهران، خیابان سعدی جنوبی، کوچه ناظمالاطباء، پلاک ۲۰",
|
|
||||||
receiver: "دفتر تهران (شرکت پترو صدف)",
|
|
||||||
phone: "۰۲۱-۳۳۹۰۰۰۰",
|
|
||||||
isDefault: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('dash');
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const success = searchParams.get("success");
|
|
||||||
const [authorized, setAuthorized] = useState<boolean | null>(null);
|
|
||||||
React.useEffect(() => {
|
|
||||||
const token = localStorage.getItem("accessToken");
|
|
||||||
const role = localStorage.getItem("role");
|
|
||||||
|
|
||||||
if (token && (role === "user" || role === "admin")) {
|
|
||||||
setAuthorized(true);
|
|
||||||
} else {
|
|
||||||
setAuthorized(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ id: 'dash', label: 'پیشخوان', icon: Home },
|
|
||||||
{ id: 'orders', label: 'سفارشهای من', icon: ShoppingBag },
|
|
||||||
{ id: 'wallet', label: 'کیف پول', icon: Wallet },
|
|
||||||
{ id: 'address', label: 'آدرسها', icon: MapPin },
|
|
||||||
{ id: 'tickets', label: 'تیکت پشتیبانی', icon: Headphones },
|
|
||||||
{ id: 'profile', label: 'مشخصات حساب', icon: IdCard },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ حالت در حال بررسی
|
|
||||||
if (authorized === null) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50" dir="rtl">
|
|
||||||
<p className="text-gray-600">در حال بررسی دسترسی...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ دسترسی ندارد
|
|
||||||
if (!authorized) {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4" dir="rtl">
|
|
||||||
<div className="bg-white shadow-xl rounded-2xl p-10 max-w-md w-full text-center border border-gray-100">
|
|
||||||
|
|
||||||
<div className="flex justify-center mb-6">
|
|
||||||
<div className="bg-red-100 p-4 rounded-full">
|
|
||||||
<ShieldX className="w-10 h-10 text-red-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
|
||||||
دسترسی غیرمجاز
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm leading-relaxed mb-6">
|
|
||||||
شما اجازه دسترسی به این صفحه را ندارید.
|
|
||||||
برای مشاهده داشبورد باید ابتدا وارد حساب کاربری خود شوید.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex cursor-pointer items-center gap-2 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm hover:bg-black transition"
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
بازگشت به صفحه اصلی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
|
||||||
<div className="container mx-auto px-4 max-w-6xl">
|
|
||||||
|
|
||||||
{success === "login" && (
|
|
||||||
<div className="mb-4 flex items-center gap-2 p-3 bg-[#d2f6dca3] text-green-700 rounded-xl text-sm">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
<span>ورود با موفقیت انجام شد</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success === "register" && (
|
|
||||||
<div className="mb-4 flex items-center gap-2 p-3 bg-[#d2f6dca3] text-green-700 rounded-xl text-sm">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
||||||
<span>ثبت نام با موفقیت انجام شد</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
|
|
||||||
<aside className="w-full lg:w-[280px] bg-white rounded-2xl shadow-sm flex-shrink-0 h-fit">
|
|
||||||
<div className="p-6 flex flex-col items-center border-b border-gray-100">
|
|
||||||
<div className="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center text-gray-400 mb-4">
|
|
||||||
<User size={32} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-bold text-gray-800 mb-1">علی محمدی</div>
|
|
||||||
<div className="text-sm text-gray-500 mb-4 font-mono" dir="ltr">0912 345 6789</div>
|
|
||||||
<div className="w-full flex flex-col items-center">
|
|
||||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-bold mb-3">
|
|
||||||
<Crown size={14} /> کاربر طلایی
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden mb-1.5">
|
|
||||||
<div className="h-full bg-yellow-400 rounded-full transition-all duration-500" style={{ width: '75%' }}></div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-gray-400 text-center">۷۵٪ تا سطح پلاتینی</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="p-4 flex flex-col gap-1">
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab(item.id)}
|
|
||||||
className={`w-full cursor-pointer flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${activeTab === item.id
|
|
||||||
? 'bg-[#1a2332] text-white shadow-md'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-[#ffb900]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon size={18} className={activeTab === item.id ? 'text-white' : 'text-gray-400'} />
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
<li className="mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<button className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-red-500 hover:bg-red-50 transition-colors">
|
|
||||||
<LogOut size={18} /> خروج
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex-1 overflow-hidden">
|
|
||||||
|
|
||||||
{/* -------------------- تب پیشخوان -------------------- */}
|
|
||||||
{activeTab === 'dash' && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">خوش آمدید، علی عزیز</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 flex-col shadow-sm flex items-center gap-4 hover:shadow-md transition-shadow">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center flex-shrink-0"><Wallet size={24} /></div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">موجودی کیف پول</h4>
|
|
||||||
<strong className="text-[1em] text-gray-800"><PriceDisplay amount={1500000} /></strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white flex-col p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4 hover:shadow-md transition-shadow">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-green-50 text-green-600 flex items-center justify-center flex-shrink-0"><ShoppingBag size={24} /></div>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">سفارشات جاری</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۲ <span className="text-xs font-normal text-gray-500">مورد</span></strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white flex-col p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4 hover:shadow-md transition-shadow">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-orange-50 text-orange-600 flex items-center justify-center flex-shrink-0"><Headphones size={24} /></div>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">تیکتهای باز</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۱ <span className="text-xs font-normal text-gray-500">مورد</span></strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white flex-col p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4 hover:shadow-md transition-shadow">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-purple-50 text-purple-600 flex items-center justify-center flex-shrink-0"><TrendingUp size={24} /></div>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">امتیاز وفاداری</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۲,۳۴۰ <span className="text-xs font-normal text-gray-500">امتیاز</span></strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<div className="flex justify-between items-center pb-4 border-b border-gray-100 mb-4">
|
|
||||||
<h3 className="font-bold text-gray-800">آخرین سفارشات</h3>
|
|
||||||
<button onClick={() => setActiveTab('orders')} className="text-sm text-blue-600 hover:text-blue-700">مشاهده همه</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 py-4 flex flex-col gap-3">
|
|
||||||
{/* پیشنمایش کوچکی از سفارشات */}
|
|
||||||
<div className="flex justify-between items-center bg-gray-50 p-3 rounded-lg">
|
|
||||||
<span className="font-medium text-gray-700">#PR-10452</span>
|
|
||||||
<span className="bg-amber-100 text-amber-700 px-2 py-1 rounded text-xs">در حال پردازش</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* -------------------- تب تیکتها -------------------- */}
|
|
||||||
{activeTab === 'tickets' && (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 m-0">تیکتهای پشتیبانی</h1>
|
|
||||||
<button onClick={() => alert('فرم تیکت جدید باز شود')} className="bg-[#1a2332] hover:bg-[#ffb900] text-white px-5 py-2.5 rounded-xl flex items-center gap-2 transition-colors text-sm font-medium">
|
|
||||||
<Plus size={18} /> تیکت جدید
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-gray-800 mb-2">۵</span>
|
|
||||||
<div className="text-sm text-gray-500">کل تیکتها</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-amber-600 mb-2">۱</span>
|
|
||||||
<div className="text-sm text-gray-500">در حال بررسی</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-green-700 mb-2">۳</span>
|
|
||||||
<div className="text-sm text-gray-500">پاسخ داده شده</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-red-700 mb-2">۱</span>
|
|
||||||
<div className="text-sm text-gray-500">بسته شده</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 overflow-x-auto pb-2 scrollbar-hide">
|
|
||||||
<div className="flex gap-2 min-w-max">
|
|
||||||
<button className="bg-gray-800 text-white px-5 py-2 rounded-full text-sm font-medium transition-colors">همه تیکتها</button>
|
|
||||||
<button className="bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 px-5 py-2 rounded-full text-sm font-medium transition-colors">باز</button>
|
|
||||||
<button className="bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 px-5 py-2 rounded-full text-sm font-medium transition-colors">پاسخ داده شده</button>
|
|
||||||
<button className="bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 px-5 py-2 rounded-full text-sm font-medium transition-colors">بسته شده</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sampleTickets.map(ticket => (
|
|
||||||
<div key={ticket.id} className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 transition-all hover:shadow-md">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{ticket.title}</h3>
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-xs sm:text-sm text-gray-500">
|
|
||||||
<span className="flex items-center gap-1"><Hash size={14} /> {ticket.id}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock size={14} /> {ticket.date}</span>
|
|
||||||
<span className="flex items-center gap-1"><User size={14} /> {ticket.department}</span>
|
|
||||||
<span className="flex items-center gap-1 text-red-600 font-medium"><AlertCircle size={14} /> {ticket.priority}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end border-t lg:border-0 pt-4 lg:pt-0 mt-2 lg:mt-0">
|
|
||||||
<span className="bg-amber-100 text-amber-800 px-3 py-1 rounded-full text-xs font-medium">
|
|
||||||
{ticket.status}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="w-10 h-10 rounded-full bg-gray-50 text-gray-600 flex items-center justify-center hover:bg-blue-50 hover:text-[#ffb900] transition-colors">
|
|
||||||
<Eye size={18} />
|
|
||||||
</button>
|
|
||||||
<button className="w-10 h-10 rounded-full bg-gray-50 text-gray-600 flex items-center justify-center hover:bg-blue-50 hover:text-[#ffb900] transition-colors">
|
|
||||||
<Reply size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* -------------------- تب آدرسها -------------------- */}
|
|
||||||
{activeTab === 'address' && (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 m-0">آدرسهای من</h1>
|
|
||||||
<button className="bg-[#1a2332] hover:bg-[#ffb900] text-white px-5 py-2.5 rounded-xl flex items-center gap-2 transition-colors text-sm font-medium">
|
|
||||||
<MapPin size={18} /> افزودن آدرس جدید
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{sampleAddresses.map((address) => (
|
|
||||||
<div
|
|
||||||
key={address.id}
|
|
||||||
className={`bg-white p-6 rounded-2xl border-2 ${address.isDefault ? 'border-[#ffd230] relative' : 'border-gray-200'} transition-all`}
|
|
||||||
>
|
|
||||||
{address.isDefault && (
|
|
||||||
<span className="absolute -top-3 right-6 bg-[#ffd230] text-white text-[10px] font-bold px-2 py-1 rounded">
|
|
||||||
پیشفرض
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="text-gray-800 leading-relaxed font-medium mb-4 text-sm sm:text-base">
|
|
||||||
{address.text}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 text-sm text-gray-500 mb-6">
|
|
||||||
<div className="flex items-center gap-2"><User size={16} /> گیرنده: {address.receiver}</div>
|
|
||||||
<div className="flex items-center gap-2"><Headphones size={16} /> تماس: {address.phone}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 pt-4 mt-auto">
|
|
||||||
{!address.isDefault && (
|
|
||||||
<button title="انتخاب به عنوان پیشفرض" className="w-10 h-10 rounded-full bg-gray-50 text-gray-600 flex items-center justify-center hover:bg-green-50 hover:text-green-600 transition-colors">
|
|
||||||
<CircleCheck size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="w-10 h-10 rounded-full bg-gray-50 text-gray-600 flex items-center justify-center hover:bg-blue-50 hover:text-[#ffb900] transition-colors">
|
|
||||||
<Edit size={18} />
|
|
||||||
</button>
|
|
||||||
<button className="w-10 h-10 rounded-full bg-red-50 text-red-500 flex items-center justify-center hover:bg-red-500 hover:text-white transition-colors">
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* -------------------- تب کیف پول -------------------- */}
|
|
||||||
{activeTab === 'wallet' && (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">کیف پول و مدیریت مالی</h1>
|
|
||||||
|
|
||||||
<div className="bg-gray-800 text-white p-6 sm:p-8 rounded-2xl flex flex-col md:flex-row justify-between items-center gap-6 mb-8 bg-[url('/pattern.png')] bg-cover bg-center">
|
|
||||||
<div className="text-center md:text-right">
|
|
||||||
<span className="text-gray-300 text-sm mb-2 block">موجودی فعلی حساب شما:</span>
|
|
||||||
<div className="text-3xl sm:text-4xl font-bold text-yellow-400">
|
|
||||||
۱,۵۰۰,۰۰۰ <span className="text-lg font-normal text-white">تومان</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-gray-400 flex items-center justify-center md:justify-start gap-1">
|
|
||||||
<CreditCard size={14} /> آخرین بروزرسانی: امروز ۱۴:۳۰
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 w-full md:w-auto">
|
|
||||||
<button className="bg-yellow-400 hover:bg-yellow-500 text-gray-900 px-8 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-colors">
|
|
||||||
<Plus size={18} /> افزایش موجودی
|
|
||||||
</button>
|
|
||||||
<button className="bg-transparent border border-gray-600 hover:bg-gray-700 text-white px-8 py-2.5 rounded-xl font-medium flex items-center justify-center gap-2 transition-colors">
|
|
||||||
<Plus size={18} className="rotate-180" /> برداشت وجه
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-green-100 text-green-700 flex items-center justify-center"><Plus size={24} /></div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">کل واریزیها</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۵,۲۰۰,۰۰۰ تومان</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-red-100 text-red-700 flex items-center justify-center"><Plus size={24} className="rotate-180" /></div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">کل برداشتها</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۳,۷۰۰,۰۰۰ تومان</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-blue-100 text-blue-700 flex items-center justify-center"><TrendingUp size={24} /></div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs text-gray-500 mb-1">امتیاز وفاداری</h4>
|
|
||||||
<strong className="text-lg text-gray-800">۲,۳۴۰ امتیاز</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center pb-4 border-b border-gray-100 mb-4 gap-4">
|
|
||||||
<div className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
|
||||||
<Clock size={20} className="text-gray-400" /> تاریخچه تراکنشها
|
|
||||||
</div>
|
|
||||||
<select className="bg-gray-50 border border-gray-200 text-gray-700 px-4 py-2 rounded-xl text-sm outline-none focus:border-blue-500">
|
|
||||||
<option>همه تراکنشها</option>
|
|
||||||
<option>واریزیها</option>
|
|
||||||
<option>برداشتها</option>
|
|
||||||
<option>خرید محصول</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-gray-50 rounded-xl gap-4 border border-transparent hover:border-gray-200 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-red-100 text-red-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<ShoppingBag size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-gray-800 text-sm mb-1">پرداخت بابت سفارش #PR-10452</div>
|
|
||||||
<div className="text-xs text-gray-500 mb-1">۱۵ دی ۱۴۰۴ - ساعت ۱4:۳۰</div>
|
|
||||||
<div className="text-xs text-gray-400">کد پیگیری: TXN-789456123</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-red-600 font-bold bg-red-50 px-3 py-1 rounded-full text-sm">
|
|
||||||
- ۵۰۰,۰۰۰ تومان
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-gray-50 rounded-xl gap-4 border border-transparent hover:border-gray-200 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-green-100 text-green-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<CreditCard size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-gray-800 text-sm mb-1">افزایش آنلاین موجودی (درگاه بانکی)</div>
|
|
||||||
<div className="text-xs text-gray-500 mb-1">۱۴ دی ۱۴۰۴ - ساعت ۱۰:۱۵</div>
|
|
||||||
<div className="text-xs text-gray-400">کد پیگیری: TXN-789456122</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full text-sm">
|
|
||||||
+ ۲,۰۰۰,۰۰۰ تومان
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-gray-50 rounded-xl gap-4 border border-transparent hover:border-gray-200 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-green-100 text-green-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Gift size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-gray-800 text-sm mb-1">استفاده از امتیاز وفاداری</div>
|
|
||||||
<div className="text-xs text-gray-500 mb-1">۱۲ دی ۱۴۰۴ - ساعت ۱۶:۲۰</div>
|
|
||||||
<div className="text-xs text-gray-400">تبدیل ۵۰۰ امتیاز به تومان</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full text-sm">
|
|
||||||
+ ۵۰,۰۰۰ تومان
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* -------------------- تب سفارشها -------------------- */}
|
|
||||||
{activeTab === 'orders' && (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">سفارشهای من</h1>
|
|
||||||
|
|
||||||
<div className="mb-6 overflow-x-auto pb-2 scrollbar-hide">
|
|
||||||
<div className="flex gap-2 min-w-max border-b border-gray-200 pb-2">
|
|
||||||
<button className="text-blue-600 border-b-2 border-blue-600 px-4 py-2 text-sm font-bold">همه سفارشات</button>
|
|
||||||
<button className="text-gray-500 hover:text-gray-800 px-4 py-2 text-sm font-medium transition-colors">در حال پردازش</button>
|
|
||||||
<button className="text-gray-500 hover:text-gray-800 px-4 py-2 text-sm font-medium transition-colors">ارسال شده</button>
|
|
||||||
<button className="text-gray-500 hover:text-gray-800 px-4 py-2 text-sm font-medium transition-colors">تحویل شده</button>
|
|
||||||
<button className="text-gray-500 hover:text-gray-800 px-4 py-2 text-sm font-medium transition-colors">لغو شده</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{sampleOrders.map((order) => (
|
|
||||||
<div key={order.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden hover:shadow-md transition-shadow">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 flex justify-between items-center border-b border-gray-100">
|
|
||||||
<div className="font-bold text-gray-800 flex items-center gap-2">
|
|
||||||
<Hash size={16} className="text-gray-400" /> {order.id}
|
|
||||||
</div>
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${order.statusColor === 'amber' ? 'bg-amber-100 text-amber-700' :
|
|
||||||
order.statusColor === 'green' ? 'bg-green-100 text-green-700' :
|
|
||||||
'bg-gray-200 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{order.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
{order.items.map((item, idx) => (
|
|
||||||
<div key={idx} className="flex justify-between items-center py-3 border-b border-gray-50 last:border-0 text-sm">
|
|
||||||
<span className="font-medium text-gray-700 flex-1">{item.name}</span>
|
|
||||||
<span className="text-gray-500 w-24 text-center">تعداد: {item.qty}</span>
|
|
||||||
<span className="font-bold text-gray-800 w-32 text-left">{item.price} تومان</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="w-full lg:w-64 bg-gray-50 rounded-xl p-4 flex flex-col gap-3 text-sm text-gray-600">
|
|
||||||
<div className="flex items-center gap-2"><Clock size={16} className="text-gray-400" /> تاریخ ثبت: {order.regDate}</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{order.delivered ? <CircleCheck size={16} className="text-green-500" /> : <Box size={16} className="text-blue-500" />}
|
|
||||||
{order.delivered ? 'تحویل شده:' : 'تحویل تا:'} {order.deliveryDate}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 border-t border-gray-200 pt-3 mt-1 font-bold text-gray-800">
|
|
||||||
<CreditCard size={16} className="text-gray-400" /> مبلغ کل: {order.total} تومان
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-6 pt-4 border-t border-gray-100 justify-end">
|
|
||||||
<button className="px-4 py-2 border border-gray-200 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-50 transition-colors">جزئیات سفارش</button>
|
|
||||||
{!order.delivered && (
|
|
||||||
<button className="px-4 py-2 border border-gray-200 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-50 transition-colors">پیگیری مرسوله</button>
|
|
||||||
)}
|
|
||||||
{order.delivered ? (
|
|
||||||
<>
|
|
||||||
<button className="px-4 py-2 bg-[#1a2332] text-white rounded-xl text-sm font-medium hover:bg-[#ffb900] transition-colors">خرید مجدد</button>
|
|
||||||
<button className="px-4 py-2 border border-gray-200 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-50 transition-colors">نظر و امتیاز</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button className="px-4 py-2 border border-red-200 text-red-500 rounded-xl text-sm font-medium hover:bg-red-50 transition-colors">لغو سفارش</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* -------------------- تب پروفایل -------------------- */}
|
|
||||||
{activeTab === 'profile' && (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">پروفایل کاربری</h1>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* اطلاعات شخصی */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-800 mb-6 border-b border-gray-100 pb-4">اطلاعات شخصی</h2>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
{/* Avatar Section */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-6 mb-8">
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src="/avatar-placeholder.png"
|
|
||||||
alt="User Avatar"
|
|
||||||
className="w-24 h-24 rounded-full object-cover border-4 border-gray-50 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center sm:items-start gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Camera size={18} />
|
|
||||||
تغییر تصویر
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-gray-500">فرمتهای مجاز: JPG, PNG. حداکثر حجم: ۲ مگابایت</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Fields Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">نام و نام خانوادگی <span className="text-red-500">*</span></label>
|
|
||||||
<input type="text" defaultValue="علی محمدی" required className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-gray-800" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">شماره موبایل</label>
|
|
||||||
<input type="text" defaultValue="09123456789" disabled className="w-full px-4 py-2 border border-gray-100 bg-gray-50 text-gray-500 rounded-xl cursor-not-allowed" />
|
|
||||||
<p className="text-xs text-gray-400 mt-1">برای تغییر شماره موبایل با پشتیبانی تماس بگیرید.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">پست الکترونیک</label>
|
|
||||||
<input type="email" defaultValue="ali.m@gmail.com" className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-left text-gray-800" dir="ltr" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">کد ملی</label>
|
|
||||||
<input type="text" defaultValue="2280000000" className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-gray-800" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاریخ تولد</label>
|
|
||||||
<input type="text" placeholder="مثال: ۱۳۷۰/۰۵/۱۲" className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-gray-800" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">جنسیت</label>
|
|
||||||
<select className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all appearance-none bg-white text-gray-800">
|
|
||||||
<option value="male">مرد</option>
|
|
||||||
<option value="female">زن</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex justify-end pt-4 border-t border-gray-50">
|
|
||||||
<button type="submit" className="flex items-center gap-2 bg-[#1a2332] text-white px-6 py-2.5 rounded-xl hover:bg-[#ffb900] transition-colors font-medium shadow-sm text-sm">
|
|
||||||
<Check size={18} />
|
|
||||||
ثبت تغییرات
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* تنظیمات امنیتی */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-800 mb-6 border-b border-gray-100 pb-4">تنظیمات امنیتی</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Change Password */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50 hover:bg-gray-100/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Shield size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-gray-800 text-sm">تغییر رمز عبور</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">آخرین تغییر: ۲ ماه پیش</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="mt-4 sm:mt-0 flex items-center justify-center px-4 py-2 border border-gray-200 text-gray-700 bg-white rounded-xl hover:bg-gray-50 transition-colors text-sm font-medium shadow-sm">
|
|
||||||
تغییر رمز
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-Factor Authentication */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50 hover:bg-gray-100/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<ShieldAlert size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-gray-800 text-sm">احراز هویت دو مرحلهای</h3>
|
|
||||||
<p className="text-xs mt-1 text-gray-500">
|
|
||||||
وضعیت: <span className="text-red-500 font-bold">غیرفعال</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="mt-4 sm:mt-0 bg-[#1a2332] text-white px-5 py-2 rounded-xl hover:bg-[#ffb900] transition-colors shadow-sm text-sm font-medium">
|
|
||||||
فعالسازی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Notifications */}
|
|
||||||
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50 hover:bg-gray-100/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-green-50 text-green-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Mail size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-gray-800 text-sm">اطلاعرسانی ایمیلی</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">دریافت ایمیل برای ورودهای جدید و هشدارهای امنیتی</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Toggle Switch */}
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#1a2332]"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DashboardFallback />}>
|
||||||
|
<DashboardClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
826
components/dashboard/dashboard-client.tsx
Normal file
826
components/dashboard/dashboard-client.tsx
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useDeferredValue, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
Clock3,
|
||||||
|
Filter,
|
||||||
|
Headphones,
|
||||||
|
Home,
|
||||||
|
IdCard,
|
||||||
|
LoaderCircle,
|
||||||
|
LogOut,
|
||||||
|
MapPin,
|
||||||
|
MessageSquareMore,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
ShoppingBag,
|
||||||
|
Sparkles,
|
||||||
|
User,
|
||||||
|
Wallet,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { logoutUser } from "@/public/src/services/auth/api";
|
||||||
|
import {
|
||||||
|
closeUserTicket,
|
||||||
|
createUserTicket,
|
||||||
|
getAdminTicketById,
|
||||||
|
getAdminTickets,
|
||||||
|
getUserTicketById,
|
||||||
|
getUserTickets,
|
||||||
|
sendAdminTicketMessage,
|
||||||
|
sendUserTicketMessage,
|
||||||
|
TicketDetail,
|
||||||
|
TicketListItem,
|
||||||
|
TicketListResponse,
|
||||||
|
TicketPriority,
|
||||||
|
TicketStatus,
|
||||||
|
updateAdminTicket,
|
||||||
|
} from "@/public/src/services/tickets/api";
|
||||||
|
|
||||||
|
type UserRole = "user" | "admin";
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
token: string;
|
||||||
|
role: UserRole;
|
||||||
|
fullName: string;
|
||||||
|
username: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TicketSummary = {
|
||||||
|
total: number;
|
||||||
|
waiting_for_admin: number;
|
||||||
|
waiting_for_user: number;
|
||||||
|
closed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleOrders = [
|
||||||
|
{ id: "PR-10452", status: "در حال پردازش", total: "500,000", date: "15 دی 1404" },
|
||||||
|
{ id: "PR-10451", status: "تحویل شده", total: "120,000", date: "10 دی 1404" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleAddresses = [
|
||||||
|
{ id: "addr1", title: "آدرس پیشفرض", text: "شیراز، بلوار معالی آباد، ساختمان پارس، واحد 4" },
|
||||||
|
{ id: "addr2", title: "دفتر تهران", text: "تهران، خیابان سعدی جنوبی، کوچه ناظمالاطباء، پلاک 20" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusLabels: Record<TicketStatus, string> = {
|
||||||
|
waiting_for_admin: "در انتظار پاسخ پشتیبانی",
|
||||||
|
waiting_for_user: "در انتظار پاسخ کاربر",
|
||||||
|
closed: "بسته شده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels: Record<TicketPriority, string> = {
|
||||||
|
low: "کم",
|
||||||
|
normal: "عادی",
|
||||||
|
high: "زیاد",
|
||||||
|
urgent: "فوری",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTone: Record<TicketStatus, string> = {
|
||||||
|
waiting_for_admin: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
waiting_for_user: "bg-sky-100 text-sky-800 border-sky-200",
|
||||||
|
closed: "bg-zinc-200 text-zinc-700 border-zinc-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityTone: Record<TicketPriority, string> = {
|
||||||
|
low: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
|
normal: "bg-zinc-100 text-zinc-700 border-zinc-200",
|
||||||
|
high: "bg-orange-100 text-orange-700 border-orange-200",
|
||||||
|
urgent: "bg-rose-100 text-rose-700 border-rose-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const userTicketStatusFilters: Array<{ label: string; value: "all" | TicketStatus }> = [
|
||||||
|
{ label: "همه", value: "all" },
|
||||||
|
{ label: statusLabels.waiting_for_admin, value: "waiting_for_admin" },
|
||||||
|
{ label: statusLabels.waiting_for_user, value: "waiting_for_user" },
|
||||||
|
{ label: statusLabels.closed, value: "closed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const priorityOptions: TicketPriority[] = ["low", "normal", "high", "urgent"];
|
||||||
|
const statusOptions: TicketStatus[] = ["waiting_for_admin", "waiting_for_user", "closed"];
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return "ثبت نشده";
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ className, children }: { className: string; children: React.ReactNode }) {
|
||||||
|
return <span className={cn("inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold", className)}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelCard({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={cn("rounded-[28px] border border-[#e7e3da] bg-white shadow-[0_16px_40px_rgba(34,30,23,0.05)]", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(
|
||||||
|
allTickets: TicketListResponse,
|
||||||
|
waitingAdmin: TicketListResponse,
|
||||||
|
waitingUser: TicketListResponse,
|
||||||
|
closedTickets: TicketListResponse
|
||||||
|
): TicketSummary {
|
||||||
|
return {
|
||||||
|
total: allTickets.pagination.total,
|
||||||
|
waiting_for_admin: waitingAdmin.pagination.total,
|
||||||
|
waiting_for_user: waitingUser.pagination.total,
|
||||||
|
closed: closedTickets.pagination.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const success = searchParams.get("success");
|
||||||
|
const currentTab = searchParams.get("tab");
|
||||||
|
|
||||||
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
|
const [authorized, setAuthorized] = useState<boolean | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState("dash");
|
||||||
|
const [ticketSummary, setTicketSummary] = useState<TicketSummary>({
|
||||||
|
total: 0,
|
||||||
|
waiting_for_admin: 0,
|
||||||
|
waiting_for_user: 0,
|
||||||
|
closed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userTickets, setUserTickets] = useState<TicketListItem[]>([]);
|
||||||
|
const [userTicketsLoading, setUserTicketsLoading] = useState(false);
|
||||||
|
const [userTicketsError, setUserTicketsError] = useState("");
|
||||||
|
const [userTicketsPagination, setUserTicketsPagination] = useState({ page: 1, totalPages: 1, total: 0 });
|
||||||
|
const [userTicketStatusFilter, setUserTicketStatusFilter] = useState<"all" | TicketStatus>("all");
|
||||||
|
const [userTicketPage, setUserTicketPage] = useState(1);
|
||||||
|
const [selectedUserTicketId, setSelectedUserTicketId] = useState<string | null>(null);
|
||||||
|
const [selectedUserTicket, setSelectedUserTicket] = useState<TicketDetail | null>(null);
|
||||||
|
const [selectedUserTicketLoading, setSelectedUserTicketLoading] = useState(false);
|
||||||
|
const [userReplyMessage, setUserReplyMessage] = useState("");
|
||||||
|
const [userReplySubmitting, setUserReplySubmitting] = useState(false);
|
||||||
|
const [newTicketOpen, setNewTicketOpen] = useState(false);
|
||||||
|
const [newTicketSubmitting, setNewTicketSubmitting] = useState(false);
|
||||||
|
const [newTicketError, setNewTicketError] = useState("");
|
||||||
|
const [newTicketForm, setNewTicketForm] = useState({ subject: "", priority: "normal" as TicketPriority, message: "" });
|
||||||
|
|
||||||
|
const [adminTickets, setAdminTickets] = useState<TicketListItem[]>([]);
|
||||||
|
const [adminTicketsLoading, setAdminTicketsLoading] = useState(false);
|
||||||
|
const [adminTicketsError, setAdminTicketsError] = useState("");
|
||||||
|
const [adminTicketsPagination, setAdminTicketsPagination] = useState({ page: 1, totalPages: 1, total: 0 });
|
||||||
|
const [adminStatusFilter, setAdminStatusFilter] = useState<"" | TicketStatus>("");
|
||||||
|
const [adminPriorityFilter, setAdminPriorityFilter] = useState<"" | TicketPriority>("");
|
||||||
|
const [adminSearchInput, setAdminSearchInput] = useState("");
|
||||||
|
const deferredAdminSearch = useDeferredValue(adminSearchInput);
|
||||||
|
const [adminTicketPage, setAdminTicketPage] = useState(1);
|
||||||
|
const [selectedAdminTicketId, setSelectedAdminTicketId] = useState<string | null>(null);
|
||||||
|
const [selectedAdminTicket, setSelectedAdminTicket] = useState<TicketDetail | null>(null);
|
||||||
|
const [selectedAdminTicketLoading, setSelectedAdminTicketLoading] = useState(false);
|
||||||
|
const [adminReplyMessage, setAdminReplyMessage] = useState("");
|
||||||
|
const [adminReplySubmitting, setAdminReplySubmitting] = useState(false);
|
||||||
|
const [adminUpdateSubmitting, setAdminUpdateSubmitting] = useState(false);
|
||||||
|
const [adminUpdateForm, setAdminUpdateForm] = useState({
|
||||||
|
status: "waiting_for_user" as TicketStatus,
|
||||||
|
priority: "normal" as TicketPriority,
|
||||||
|
assignedAdminId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
const role = localStorage.getItem("role") as UserRole | null;
|
||||||
|
|
||||||
|
if (!token || (role !== "user" && role !== "admin")) {
|
||||||
|
setAuthorized(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
token,
|
||||||
|
role,
|
||||||
|
fullName: localStorage.getItem("fullName") || localStorage.getItem("username") || "کاربر پارس شاپ",
|
||||||
|
username: localStorage.getItem("username") || "user",
|
||||||
|
phone: localStorage.getItem("phone") || "شماره ثبت نشده",
|
||||||
|
});
|
||||||
|
setAuthorized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validTabs = new Set(["dash", "orders", "wallet", "address", "tickets", "profile"]);
|
||||||
|
setActiveTab(currentTab && validTabs.has(currentTab) ? currentTab : "dash");
|
||||||
|
}, [currentTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
void refreshTicketSummary(session);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || activeTab !== "tickets") return;
|
||||||
|
|
||||||
|
if (session.role === "admin") {
|
||||||
|
void loadAdminTickets(session.token, {
|
||||||
|
status: adminStatusFilter || undefined,
|
||||||
|
priority: adminPriorityFilter || undefined,
|
||||||
|
search: deferredAdminSearch || undefined,
|
||||||
|
page: adminTicketPage,
|
||||||
|
limit: 12,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadUserTickets(session.token, {
|
||||||
|
status: userTicketStatusFilter === "all" ? undefined : userTicketStatusFilter,
|
||||||
|
page: userTicketPage,
|
||||||
|
limit: 12,
|
||||||
|
});
|
||||||
|
}, [activeTab, adminPriorityFilter, adminStatusFilter, adminTicketPage, deferredAdminSearch, session, userTicketPage, userTicketStatusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || session.role !== "user" || !selectedUserTicketId) return;
|
||||||
|
void loadUserTicketDetail(session.token, selectedUserTicketId);
|
||||||
|
}, [selectedUserTicketId, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || session.role !== "admin" || !selectedAdminTicketId) return;
|
||||||
|
void loadAdminTicketDetail(session.token, selectedAdminTicketId);
|
||||||
|
}, [selectedAdminTicketId, session]);
|
||||||
|
|
||||||
|
async function refreshTicketSummary(currentSession: SessionState) {
|
||||||
|
try {
|
||||||
|
if (currentSession.role === "admin") {
|
||||||
|
const [allTickets, waitingAdmin, waitingUser, closedTickets] = await Promise.all([
|
||||||
|
getAdminTickets(currentSession.token, { page: 1, limit: 1 }),
|
||||||
|
getAdminTickets(currentSession.token, { status: "waiting_for_admin", page: 1, limit: 1 }),
|
||||||
|
getAdminTickets(currentSession.token, { status: "waiting_for_user", page: 1, limit: 1 }),
|
||||||
|
getAdminTickets(currentSession.token, { status: "closed", page: 1, limit: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTicketSummary(buildSummary(allTickets, waitingAdmin, waitingUser, closedTickets));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allTickets, waitingAdmin, waitingUser, closedTickets] = await Promise.all([
|
||||||
|
getUserTickets(currentSession.token, { page: 1, limit: 1 }),
|
||||||
|
getUserTickets(currentSession.token, { status: "waiting_for_admin", page: 1, limit: 1 }),
|
||||||
|
getUserTickets(currentSession.token, { status: "waiting_for_user", page: 1, limit: 1 }),
|
||||||
|
getUserTickets(currentSession.token, { status: "closed", page: 1, limit: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTicketSummary(buildSummary(allTickets, waitingAdmin, waitingUser, closedTickets));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load ticket summary", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserTickets(token: string, query: { status?: TicketStatus; page: number; limit: number }) {
|
||||||
|
setUserTicketsLoading(true);
|
||||||
|
setUserTicketsError("");
|
||||||
|
try {
|
||||||
|
const response = await getUserTickets(token, query);
|
||||||
|
setUserTickets(response.items);
|
||||||
|
setUserTicketsPagination({ page: response.pagination.page, totalPages: response.pagination.totalPages, total: response.pagination.total });
|
||||||
|
if (response.items.length > 0 && !selectedUserTicketId) setSelectedUserTicketId(response.items[0].id);
|
||||||
|
if (response.items.length === 0) {
|
||||||
|
setSelectedUserTicketId(null);
|
||||||
|
setSelectedUserTicket(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setUserTicketsError(error?.message || "دریافت تیکتها با خطا مواجه شد.");
|
||||||
|
} finally {
|
||||||
|
setUserTicketsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserTicketDetail(token: string, ticketId: string) {
|
||||||
|
setSelectedUserTicketLoading(true);
|
||||||
|
try {
|
||||||
|
setSelectedUserTicket(await getUserTicketById(token, ticketId));
|
||||||
|
} finally {
|
||||||
|
setSelectedUserTicketLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminTickets(token: string, query: { status?: TicketStatus; priority?: TicketPriority; search?: string; page: number; limit: number }) {
|
||||||
|
setAdminTicketsLoading(true);
|
||||||
|
setAdminTicketsError("");
|
||||||
|
try {
|
||||||
|
const response = await getAdminTickets(token, query);
|
||||||
|
setAdminTickets(response.items);
|
||||||
|
setAdminTicketsPagination({ page: response.pagination.page, totalPages: response.pagination.totalPages, total: response.pagination.total });
|
||||||
|
if (response.items.length > 0 && !selectedAdminTicketId) setSelectedAdminTicketId(response.items[0].id);
|
||||||
|
if (response.items.length === 0) {
|
||||||
|
setSelectedAdminTicketId(null);
|
||||||
|
setSelectedAdminTicket(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setAdminTicketsError(error?.message || "دریافت تیکتهای ادمین با خطا مواجه شد.");
|
||||||
|
} finally {
|
||||||
|
setAdminTicketsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminTicketDetail(token: string, ticketId: string) {
|
||||||
|
setSelectedAdminTicketLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await getAdminTicketById(token, ticketId);
|
||||||
|
setSelectedAdminTicket(detail);
|
||||||
|
setAdminUpdateForm({
|
||||||
|
status: detail.status,
|
||||||
|
priority: detail.priority,
|
||||||
|
assignedAdminId: detail.assignedAdmin?.id || "",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSelectedAdminTicketLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
try {
|
||||||
|
if (token) await logoutUser(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
localStorage.removeItem("username");
|
||||||
|
localStorage.removeItem("fullName");
|
||||||
|
localStorage.removeItem("phone");
|
||||||
|
localStorage.removeItem("role");
|
||||||
|
setAuthorized(false);
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateTicket() {
|
||||||
|
if (!session) return;
|
||||||
|
setNewTicketError("");
|
||||||
|
if (!newTicketForm.subject.trim() || !newTicketForm.message.trim()) {
|
||||||
|
setNewTicketError("موضوع و متن پیام را کامل کنید.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewTicketSubmitting(true);
|
||||||
|
try {
|
||||||
|
const detail = await createUserTicket(session.token, {
|
||||||
|
subject: newTicketForm.subject.trim(),
|
||||||
|
priority: newTicketForm.priority,
|
||||||
|
message: newTicketForm.message.trim(),
|
||||||
|
});
|
||||||
|
setNewTicketOpen(false);
|
||||||
|
setNewTicketForm({ subject: "", priority: "normal", message: "" });
|
||||||
|
setSelectedUserTicketId(detail.id);
|
||||||
|
setUserTicketPage(1);
|
||||||
|
await Promise.all([
|
||||||
|
loadUserTickets(session.token, { status: userTicketStatusFilter === "all" ? undefined : userTicketStatusFilter, page: 1, limit: 12 }),
|
||||||
|
refreshTicketSummary(session),
|
||||||
|
]);
|
||||||
|
} catch (error: any) {
|
||||||
|
setNewTicketError(error?.message || "ثبت تیکت انجام نشد.");
|
||||||
|
} finally {
|
||||||
|
setNewTicketSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendUserReply() {
|
||||||
|
if (!session || !selectedUserTicketId || !userReplyMessage.trim()) return;
|
||||||
|
setUserReplySubmitting(true);
|
||||||
|
try {
|
||||||
|
setSelectedUserTicket(await sendUserTicketMessage(session.token, selectedUserTicketId, { message: userReplyMessage.trim() }));
|
||||||
|
setUserReplyMessage("");
|
||||||
|
await Promise.all([
|
||||||
|
loadUserTickets(session.token, { status: userTicketStatusFilter === "all" ? undefined : userTicketStatusFilter, page: userTicketPage, limit: 12 }),
|
||||||
|
refreshTicketSummary(session),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setUserReplySubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloseUserTicket() {
|
||||||
|
if (!session || !selectedUserTicketId) return;
|
||||||
|
setSelectedUserTicket(await closeUserTicket(session.token, selectedUserTicketId));
|
||||||
|
await Promise.all([
|
||||||
|
loadUserTickets(session.token, { status: userTicketStatusFilter === "all" ? undefined : userTicketStatusFilter, page: userTicketPage, limit: 12 }),
|
||||||
|
refreshTicketSummary(session),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminReply() {
|
||||||
|
if (!session || !selectedAdminTicketId || !adminReplyMessage.trim()) return;
|
||||||
|
setAdminReplySubmitting(true);
|
||||||
|
try {
|
||||||
|
setSelectedAdminTicket(await sendAdminTicketMessage(session.token, selectedAdminTicketId, { message: adminReplyMessage.trim() }));
|
||||||
|
setAdminReplyMessage("");
|
||||||
|
await Promise.all([
|
||||||
|
loadAdminTickets(session.token, { status: adminStatusFilter || undefined, priority: adminPriorityFilter || undefined, search: deferredAdminSearch || undefined, page: adminTicketPage, limit: 12 }),
|
||||||
|
refreshTicketSummary(session),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setAdminReplySubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminUpdateTicket() {
|
||||||
|
if (!session || !selectedAdminTicketId) return;
|
||||||
|
setAdminUpdateSubmitting(true);
|
||||||
|
try {
|
||||||
|
setSelectedAdminTicket(await updateAdminTicket(session.token, selectedAdminTicketId, {
|
||||||
|
status: adminUpdateForm.status,
|
||||||
|
priority: adminUpdateForm.priority,
|
||||||
|
assignedAdminId: adminUpdateForm.assignedAdminId.trim() || undefined,
|
||||||
|
}));
|
||||||
|
await Promise.all([
|
||||||
|
loadAdminTickets(session.token, { status: adminStatusFilter || undefined, priority: adminPriorityFilter || undefined, search: deferredAdminSearch || undefined, page: adminTicketPage, limit: 12 }),
|
||||||
|
refreshTicketSummary(session),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setAdminUpdateSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorized === null) {
|
||||||
|
return <LoaderBlock label="در حال بررسی دسترسی..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorized || !session) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f7f3ec] px-4 py-16" dir="rtl">
|
||||||
|
<PanelCard className="mx-auto max-w-xl p-10 text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-rose-100 text-rose-600">
|
||||||
|
<XCircle className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold text-[#1f1c17]">دسترسی غیرمجاز</h1>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-[#6e675b]">برای مشاهده پیشخوان باید ابتدا وارد حساب کاربری شوید.</p>
|
||||||
|
<button onClick={() => router.push("/")} className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#1f1c17] px-5 py-3 text-sm font-semibold text-white">
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</button>
|
||||||
|
</PanelCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ id: "dash", label: "پیشخوان", icon: Home },
|
||||||
|
{ id: "orders", label: "سفارشهای من", icon: ShoppingBag },
|
||||||
|
{ id: "wallet", label: "کیف پول", icon: Wallet },
|
||||||
|
{ id: "address", label: "آدرسها", icon: MapPin },
|
||||||
|
{ id: "tickets", label: session.role === "admin" ? "مدیریت تیکتها" : "تیکت پشتیبانی", icon: Headphones },
|
||||||
|
{ id: "profile", label: "مشخصات حساب", icon: IdCard },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[linear-gradient(180deg,#f7f3ec_0%,#fbf9f5_50%,#f6f2ea_100%)] py-8" dir="rtl">
|
||||||
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
|
{success === "login" && <NoticeBanner text="ورود با موفقیت انجام شد." />}
|
||||||
|
{success === "register" && <NoticeBanner text="ثبتنام با موفقیت انجام شد." />}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[290px_minmax(0,1fr)]">
|
||||||
|
<PanelCard className="overflow-hidden">
|
||||||
|
<div className="border-b border-[#efe9dd] bg-[radial-gradient(circle_at_top_right,#fff3c9_0%,#fffaf0_45%,#ffffff_100%)] px-6 py-8">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-[28px] bg-white text-[#1f1c17] shadow-[0_14px_30px_rgba(56,46,32,0.08)]">
|
||||||
|
<User className="h-9 w-9" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 text-xl font-bold text-[#1f1c17]">{session.fullName}</div>
|
||||||
|
<div className="mt-1 text-sm text-[#6e675b]" dir="ltr">{session.phone}</div>
|
||||||
|
<div className="mt-5 inline-flex items-center gap-2 rounded-full bg-[#1f1c17] px-3 py-1.5 text-xs font-semibold text-white">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{session.role === "admin" ? "ادمین پنل" : "حساب کاربری فعال"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActiveTab(item.id)}
|
||||||
|
className={cn("flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm font-semibold transition", activeTab === item.id ? "bg-[#1f1c17] text-white" : "text-[#5f584e] hover:bg-[#f7f2ea]")}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-[#efe9dd] pt-4">
|
||||||
|
<button onClick={handleLogout} className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm font-semibold text-rose-600 hover:bg-rose-50">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activeTab === "dash" && (
|
||||||
|
<>
|
||||||
|
<PanelCard className="px-6 py-7">
|
||||||
|
<p className="text-sm font-medium text-[#8a7f6e]">{session.role === "admin" ? "مرکز کنترل تیکتها" : "پیشخوان شخصی شما"}</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold text-[#1f1c17]">{session.role === "admin" ? `سلام ${session.fullName}، مدیریت پشتیبانی آماده است` : `سلام ${session.fullName}، خوش برگشتی`}</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-7 text-[#6e675b]">{session.role === "admin" ? "تیکتها را اولویتبندی کن و پاسخها را از همینجا مدیریت کن." : "سفارشها، تیکتها و اطلاعات حساب را در یک فضای جمعوجور و مینیمال مدیریت کن."}</p>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard title="کل تیکتها" value={ticketSummary.total} icon={<MessageSquareMore className="h-5 w-5" />} />
|
||||||
|
<StatCard title="در انتظار پشتیبانی" value={ticketSummary.waiting_for_admin} icon={<AlertCircle className="h-5 w-5" />} />
|
||||||
|
<StatCard title="در انتظار کاربر" value={ticketSummary.waiting_for_user} icon={<Clock3 className="h-5 w-5" />} />
|
||||||
|
<StatCard title="بسته شده" value={ticketSummary.closed} icon={<CheckCircle2 className="h-5 w-5" />} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "tickets" && (session.role === "admin" ? renderAdminTicketsView() : renderUserTicketsView())}
|
||||||
|
|
||||||
|
{activeTab === "orders" && <SimpleListCard title="سفارشهای من" items={sampleOrders.map((order) => `${order.id} • ${order.status} • ${order.total} تومان`)} />}
|
||||||
|
{activeTab === "wallet" && <SimpleListCard title="کیف پول" items={["موجودی فعلی: 1,500,000 تومان", "کل واریزیها: 5,200,000 تومان", "کل برداشتها: 3,700,000 تومان"]} />}
|
||||||
|
{activeTab === "address" && <SimpleListCard title="آدرسها" items={sampleAddresses.map((address) => `${address.title} • ${address.text}`)} />}
|
||||||
|
{activeTab === "profile" && <SimpleListCard title="مشخصات حساب" items={[session.fullName, session.username, session.phone, session.role === "admin" ? "ادمین" : "کاربر"]} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newTicketOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#1f1c17]/40 px-4 backdrop-blur-sm" dir="rtl">
|
||||||
|
<div className="absolute inset-0" onClick={() => setNewTicketOpen(false)}></div>
|
||||||
|
<PanelCard className="relative z-10 w-full max-w-2xl p-6">
|
||||||
|
<h3 className="text-xl font-bold text-[#1f1c17]">ثبت تیکت جدید</h3>
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
<input value={newTicketForm.subject} onChange={(event) => setNewTicketForm((prev) => ({ ...prev, subject: event.target.value }))} placeholder="موضوع تیکت" className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]" />
|
||||||
|
<select value={newTicketForm.priority} onChange={(event) => setNewTicketForm((prev) => ({ ...prev, priority: event.target.value as TicketPriority }))} className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]">
|
||||||
|
{priorityOptions.map((option) => <option key={option} value={option}>{priorityLabels[option]}</option>)}
|
||||||
|
</select>
|
||||||
|
<textarea value={newTicketForm.message} onChange={(event) => setNewTicketForm((prev) => ({ ...prev, message: event.target.value }))} rows={6} placeholder="متن پیام" className="resize-none rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm leading-7 outline-none focus:border-[#d5b96b]" />
|
||||||
|
{newTicketError ? <div className="text-sm text-rose-600">{newTicketError}</div> : null}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={handleCreateTicket} disabled={newTicketSubmitting} className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-[#ffcf53] px-5 py-3 text-sm font-bold text-[#1f1c17] disabled:opacity-60">
|
||||||
|
{newTicketSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
ثبت تیکت
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setNewTicketOpen(false)} className="rounded-2xl border border-[#e7e3da] px-5 py-3 text-sm font-semibold text-[#5f584e]">بستن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderUserTicketsView() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||||
|
<PanelCard className="overflow-hidden">
|
||||||
|
<div className="bg-[linear-gradient(135deg,#fffaf0_0%,#f8f0dc_100%)] px-6 py-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-[#1f1c17]">تیکتهای پشتیبانی</h2>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[#6e675b]">درخواستها را از اینجا پیگیری کن و پاسخها را در همان گفتگو ببین.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setNewTicketOpen(true)} className="inline-flex items-center gap-2 rounded-2xl bg-[#1f1c17] px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
تیکت جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-b border-[#efe9dd] p-6 md:grid-cols-4">
|
||||||
|
<SummaryPill title="کل" value={ticketSummary.total} />
|
||||||
|
<SummaryPill title="در انتظار پشتیبانی" value={ticketSummary.waiting_for_admin} />
|
||||||
|
<SummaryPill title="در انتظار شما" value={ticketSummary.waiting_for_user} />
|
||||||
|
<SummaryPill title="بسته" value={ticketSummary.closed} />
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-[#efe9dd] p-4">
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{userTicketStatusFilters.map((filter) => (
|
||||||
|
<button key={filter.value} onClick={() => { setUserTicketStatusFilter(filter.value); setUserTicketPage(1); }} className={cn("whitespace-nowrap rounded-full px-4 py-2 text-xs font-semibold", userTicketStatusFilter === filter.value ? "bg-[#1f1c17] text-white" : "border border-[#e7e3da] bg-white text-[#6e675b]")}>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[720px] overflow-auto p-4">
|
||||||
|
{userTicketsLoading ? <LoaderBlock label="در حال دریافت تیکتها..." /> : userTicketsError ? <div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{userTicketsError}</div> : userTickets.length === 0 ? <EmptyState title="هنوز تیکتی ندارید" description="اگر سوال یا مشکلی دارید، از همینجا یک تیکت جدید ثبت کنید." /> : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{userTickets.map((ticket) => (
|
||||||
|
<button key={ticket.id} onClick={() => setSelectedUserTicketId(ticket.id)} className={cn("w-full rounded-[24px] border p-4 text-right", selectedUserTicketId === ticket.id ? "border-[#d7bc76] bg-[#fffaf0]" : "border-[#efe9dd] bg-[#fcfbf8]")}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-[#1f1c17]">{ticket.subject}</div>
|
||||||
|
<div className="mt-2 text-xs text-[#7b7367]">{ticket.ticketNumber}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusTone[ticket.status]}>{statusLabels[ticket.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge className={priorityTone[ticket.priority]}>{priorityLabels[ticket.priority]}</Badge>
|
||||||
|
<span className="text-xs text-[#7b7367]">{formatDateTime(ticket.lastMessageAt || ticket.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-2xl bg-white/80 px-3 py-3 text-xs leading-6 text-[#5f584e]">{ticket.lastMessage?.message || "هنوز پیامی ثبت نشده است."}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PaginationControls page={userTicketsPagination.page} totalPages={userTicketsPagination.totalPages} onPrev={() => setUserTicketPage((prev) => Math.max(1, prev - 1))} onNext={() => setUserTicketPage((prev) => Math.min(userTicketsPagination.totalPages, prev + 1))} />
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
{selectedUserTicketLoading ? <LoaderBlock label="در حال دریافت جزئیات تیکت..." /> : selectedUserTicket ? (
|
||||||
|
<TicketConversationCard ticket={selectedUserTicket} headerExtras={<div className="flex flex-wrap gap-2"><Badge className={statusTone[selectedUserTicket.status]}>{statusLabels[selectedUserTicket.status]}</Badge><Badge className={priorityTone[selectedUserTicket.priority]}>{priorityLabels[selectedUserTicket.priority]}</Badge></div>} composerValue={userReplyMessage} onComposerChange={setUserReplyMessage} onSend={handleSendUserReply} onCloseTicket={selectedUserTicket.status !== "closed" ? handleCloseUserTicket : undefined} sending={userReplySubmitting} composerDisabled={selectedUserTicket.status === "closed"} composerPlaceholder={selectedUserTicket.status === "closed" ? "این تیکت بسته شده و امکان ارسال پیام جدید ندارد." : "پاسخ خود را بنویسید..."} />
|
||||||
|
) : <EmptyState title="یک تیکت را انتخاب کنید" description="برای دیدن جزئیات گفتگو، یکی از تیکتها را انتخاب کنید." />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminTicketsView() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[440px_minmax(0,1fr)]">
|
||||||
|
<PanelCard className="overflow-hidden">
|
||||||
|
<div className="bg-[linear-gradient(135deg,#fffaf0_0%,#f8f0dc_100%)] px-6 py-6">
|
||||||
|
<h2 className="text-2xl font-bold text-[#1f1c17]">مدیریت تیکتها</h2>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[#6e675b]">تیکتها را با فیلتر و جستجو مدیریت کن و پاسخها را درجا ارسال کن.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-b border-[#efe9dd] p-6 md:grid-cols-4">
|
||||||
|
<SummaryPill title="کل" value={ticketSummary.total} />
|
||||||
|
<SummaryPill title="در انتظار پشتیبانی" value={ticketSummary.waiting_for_admin} />
|
||||||
|
<SummaryPill title="در انتظار کاربر" value={ticketSummary.waiting_for_user} />
|
||||||
|
<SummaryPill title="بسته" value={ticketSummary.closed} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 border-b border-[#efe9dd] p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[#918573]" />
|
||||||
|
<input value={adminSearchInput} onChange={(event) => { setAdminSearchInput(event.target.value); setAdminTicketPage(1); }} placeholder="جستجو در موضوع یا شماره تیکت" className="w-full rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] py-3 pr-11 pl-4 text-sm outline-none focus:border-[#d5b96b]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<select value={adminStatusFilter} onChange={(event) => { setAdminStatusFilter(event.target.value as "" | TicketStatus); setAdminTicketPage(1); }} className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]">
|
||||||
|
<option value="">همه وضعیتها</option>
|
||||||
|
{statusOptions.map((option) => <option key={option} value={option}>{statusLabels[option]}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={adminPriorityFilter} onChange={(event) => { setAdminPriorityFilter(event.target.value as "" | TicketPriority); setAdminTicketPage(1); }} className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]">
|
||||||
|
<option value="">همه اولویتها</option>
|
||||||
|
{priorityOptions.map((option) => <option key={option} value={option}>{priorityLabels[option]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[720px] overflow-auto p-4">
|
||||||
|
{adminTicketsLoading ? <LoaderBlock label="در حال دریافت تیکتها..." /> : adminTicketsError ? <div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{adminTicketsError}</div> : adminTickets.length === 0 ? <EmptyState title="تیکتی پیدا نشد" description="فیلترها را تغییر بده یا عبارت جستجو را پاک کن." /> : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{adminTickets.map((ticket) => (
|
||||||
|
<button key={ticket.id} onClick={() => setSelectedAdminTicketId(ticket.id)} className={cn("w-full rounded-[24px] border p-4 text-right", selectedAdminTicketId === ticket.id ? "border-[#d7bc76] bg-[#fffaf0]" : "border-[#efe9dd] bg-[#fcfbf8]")}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-[#1f1c17]">{ticket.subject}</div>
|
||||||
|
<div className="mt-2 text-xs text-[#7b7367]">{ticket.ticketNumber}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusTone[ticket.status]}>{statusLabels[ticket.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge className={priorityTone[ticket.priority]}>{priorityLabels[ticket.priority]}</Badge>
|
||||||
|
<span className="text-xs text-[#7b7367]">{ticket.assignedAdmin?.fullName || "بدون ادمین مسئول"}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PaginationControls page={adminTicketsPagination.page} totalPages={adminTicketsPagination.totalPages} onPrev={() => setAdminTicketPage((prev) => Math.max(1, prev - 1))} onNext={() => setAdminTicketPage((prev) => Math.min(adminTicketsPagination.totalPages, prev + 1))} />
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
{selectedAdminTicketLoading ? <LoaderBlock label="در حال دریافت جزئیات تیکت..." /> : selectedAdminTicket ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PanelCard className="p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[#8a7f6e]">{selectedAdminTicket.ticketNumber}</div>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-[#1f1c17]">{selectedAdminTicket.subject}</h3>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2"><Badge className={statusTone[selectedAdminTicket.status]}>{statusLabels[selectedAdminTicket.status]}</Badge><Badge className={priorityTone[selectedAdminTicket.priority]}>{priorityLabels[selectedAdminTicket.priority]}</Badge></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-[#fcfbf8] px-4 py-3 text-sm text-[#5f584e]">آخرین بروزرسانی: {formatDateTime(selectedAdminTicket.updatedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<ProfileField label="کاربر" value={selectedAdminTicket.user.fullName} />
|
||||||
|
<ProfileField label="نام کاربری" value={selectedAdminTicket.user.username} dir="ltr" />
|
||||||
|
<ProfileField label="شماره تماس" value={selectedAdminTicket.user.phone || "ثبت نشده"} dir="ltr" />
|
||||||
|
<ProfileField label="ادمین مسئول" value={selectedAdminTicket.assignedAdmin?.fullName || "تخصیص داده نشده"} />
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
<PanelCard className="p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-lg font-bold text-[#1f1c17]"><Filter className="h-5 w-5 text-[#9b7a21]" />تنظیمات تیکت</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<select value={adminUpdateForm.status} onChange={(event) => setAdminUpdateForm((prev) => ({ ...prev, status: event.target.value as TicketStatus }))} className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]">{statusOptions.map((option) => <option key={option} value={option}>{statusLabels[option]}</option>)}</select>
|
||||||
|
<select value={adminUpdateForm.priority} onChange={(event) => setAdminUpdateForm((prev) => ({ ...prev, priority: event.target.value as TicketPriority }))} className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]">{priorityOptions.map((option) => <option key={option} value={option}>{priorityLabels[option]}</option>)}</select>
|
||||||
|
<input value={adminUpdateForm.assignedAdminId} onChange={(event) => setAdminUpdateForm((prev) => ({ ...prev, assignedAdminId: event.target.value }))} placeholder="assignedAdminId" className="rounded-2xl border border-[#e7e3da] bg-[#fcfbf8] px-4 py-3 text-sm outline-none focus:border-[#d5b96b]" />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleAdminUpdateTicket} disabled={adminUpdateSubmitting} className="mt-4 inline-flex items-center gap-2 rounded-2xl bg-[#1f1c17] px-5 py-3 text-sm font-semibold text-white disabled:opacity-60">{adminUpdateSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}ذخیره تنظیمات</button>
|
||||||
|
<p className="mt-3 text-xs text-[#7b7367]">به خاطر نبود endpoint لیست ادمینها، `assignedAdminId` بهصورت ورودی مستقیم قرار داده شده است.</p>
|
||||||
|
</PanelCard>
|
||||||
|
<TicketConversationCard ticket={selectedAdminTicket} composerValue={adminReplyMessage} onComposerChange={setAdminReplyMessage} onSend={handleAdminReply} sending={adminReplySubmitting} composerDisabled={selectedAdminTicket.status === "closed"} composerPlaceholder={selectedAdminTicket.status === "closed" ? "این تیکت بسته شده و پاسخ جدیدی ثبت نمیشود." : "پاسخ پشتیبانی را اینجا بنویسید..."} />
|
||||||
|
</div>
|
||||||
|
) : <EmptyState title="یک تیکت را انتخاب کنید" description="جزئیات هر تیکت و تاریخچه پیامها در این بخش نمایش داده میشود." />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeBanner({ text }: { text: string }) {
|
||||||
|
return <div className="mb-4 flex items-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"><CheckCircle2 className="h-4 w-4" />{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ title, description }: { title: string; description: string }) {
|
||||||
|
return <PanelCard className="p-8 text-center"><div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f6f1e8] text-[#866f43]"><MessageSquareMore className="h-7 w-7" /></div><h3 className="mt-4 text-lg font-bold text-[#1f1c17]">{title}</h3><p className="mt-2 text-sm leading-7 text-[#6e675b]">{description}</p></PanelCard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoaderBlock({ label }: { label: string }) {
|
||||||
|
return <div className="min-h-screen bg-[#f7f3ec] px-4 py-16" dir="rtl"><PanelCard className="mx-auto max-w-6xl px-8 py-20 text-center text-[#6e675b]"><LoaderCircle className="ml-3 inline h-5 w-5 animate-spin" />{label}</PanelCard></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryPill({ title, value }: { title: string; value: number }) {
|
||||||
|
return <div className="rounded-2xl bg-white px-4 py-3 text-center shadow-sm"><div className="text-xl font-bold text-[#1f1c17]">{value.toLocaleString("fa-IR")}</div><div className="mt-1 text-[11px] text-[#7b7367]">{title}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon }: { title: string; value: number; icon: React.ReactNode }) {
|
||||||
|
return <PanelCard className="p-5"><div className="flex items-center justify-between"><div><div className="text-sm text-[#7b7367]">{title}</div><div className="mt-3 text-3xl font-bold text-[#1f1c17]">{value.toLocaleString("fa-IR")}</div></div><div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#fcfbf8] text-[#9b7a21]">{icon}</div></div></PanelCard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SimpleListCard({ title, items }: { title: string; items: string[] }) {
|
||||||
|
return <PanelCard className="p-6"><h2 className="text-2xl font-bold text-[#1f1c17]">{title}</h2><div className="mt-6 space-y-3">{items.map((item, index) => <div key={`${title}-${index}`} className="rounded-2xl border border-[#efe9dd] bg-[#fcfbf8] px-4 py-4 text-sm leading-7 text-[#5f584e]">{item}</div>)}</div></PanelCard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileField({ label, value, dir }: { label: string; value: string; dir?: "rtl" | "ltr" }) {
|
||||||
|
return <div className="rounded-3xl border border-[#efe9dd] bg-[#fcfbf8] p-5"><div className="text-sm text-[#7b7367]">{label}</div><div className="mt-3 text-base font-semibold text-[#1f1c17]" dir={dir}>{value}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationControls({ page, totalPages, onPrev, onNext }: { page: number; totalPages: number; onPrev: () => void; onNext: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-t border-[#efe9dd] px-4 py-4">
|
||||||
|
<button onClick={onPrev} disabled={page <= 1} className="rounded-2xl border border-[#e7e3da] px-4 py-2 text-xs font-semibold text-[#5f584e] disabled:opacity-50">قبلی</button>
|
||||||
|
<div className="text-xs text-[#7b7367]">صفحه {page} از {totalPages || 1}</div>
|
||||||
|
<button onClick={onNext} disabled={page >= totalPages} className="inline-flex items-center gap-2 rounded-2xl border border-[#e7e3da] px-4 py-2 text-xs font-semibold text-[#5f584e] disabled:opacity-50">بعدی<ChevronLeft className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketConversationCard({
|
||||||
|
ticket,
|
||||||
|
headerExtras,
|
||||||
|
composerValue,
|
||||||
|
onComposerChange,
|
||||||
|
onSend,
|
||||||
|
onCloseTicket,
|
||||||
|
sending,
|
||||||
|
composerDisabled,
|
||||||
|
composerPlaceholder,
|
||||||
|
}: {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
headerExtras?: React.ReactNode;
|
||||||
|
composerValue: string;
|
||||||
|
onComposerChange: (value: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onCloseTicket?: () => void;
|
||||||
|
sending: boolean;
|
||||||
|
composerDisabled: boolean;
|
||||||
|
composerPlaceholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PanelCard className="overflow-hidden">
|
||||||
|
<div className="border-b border-[#efe9dd] bg-[#fcfbf8] px-6 py-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[#8a7f6e]">{ticket.ticketNumber}</div>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-[#1f1c17]">{ticket.subject}</h3>
|
||||||
|
<div className="mt-3">{headerExtras}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 text-sm text-[#6e675b]">
|
||||||
|
<span>ایجاد: {formatDateTime(ticket.createdAt)}</span>
|
||||||
|
<span>آخرین پاسخ: {formatDateTime(ticket.lastMessageAt || ticket.updatedAt)}</span>
|
||||||
|
{onCloseTicket ? <button onClick={onCloseTicket} className="mt-1 rounded-2xl border border-rose-200 px-4 py-2 text-xs font-semibold text-rose-600 hover:bg-rose-50">بستن تیکت</button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[520px] space-y-4 overflow-auto bg-[linear-gradient(180deg,#fffdfa_0%,#fbf8f2_100%)] px-6 py-6">
|
||||||
|
{ticket.messages.map((message) => (
|
||||||
|
<div key={message.id} className={cn("flex", message.senderType === "user" ? "justify-end" : "justify-start")}>
|
||||||
|
<div className={cn("max-w-[85%] rounded-[26px] px-4 py-4 shadow-sm", message.senderType === "user" ? "bg-[#1f1c17] text-white" : "border border-[#e7e3da] bg-white text-[#1f1c17]")}>
|
||||||
|
<div className={cn("text-xs font-semibold", message.senderType === "user" ? "text-white/75" : "text-[#8a7f6e]")}>{message.sender.fullName} • {message.senderType === "user" ? "کاربر" : "پشتیبانی"}</div>
|
||||||
|
<div className="mt-2 text-sm leading-7">{message.message}</div>
|
||||||
|
<div className={cn("mt-3 text-[11px]", message.senderType === "user" ? "text-white/70" : "text-[#9a907f]")}>{formatDateTime(message.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[#efe9dd] bg-white px-6 py-5">
|
||||||
|
<div className="rounded-[26px] border border-[#e7e3da] bg-[#fcfbf8] p-3">
|
||||||
|
<textarea value={composerValue} onChange={(event) => onComposerChange(event.target.value)} rows={4} disabled={composerDisabled} placeholder={composerPlaceholder} className="w-full resize-none bg-transparent px-2 py-2 text-sm leading-7 text-[#1f1c17] outline-none disabled:cursor-not-allowed disabled:text-[#9a907f]" />
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-[#8a7f6e]">{composerDisabled ? "ارسال پیام برای این تیکت غیرفعال است." : "پاسخ شما در تاریخچه گفتگو ثبت میشود."}</div>
|
||||||
|
<button onClick={onSend} disabled={composerDisabled || sending || !composerValue.trim()} className="inline-flex items-center gap-2 rounded-2xl bg-[#ffcf53] px-4 py-2.5 text-sm font-bold text-[#1f1c17] disabled:opacity-60">
|
||||||
|
{sending ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
ارسال پیام
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { ShoppingCart, Trash2, Search, X, ChevronDown } from "lucide-react";
|
import { ShoppingCart, Trash2, Search, X, ChevronDown, Home, ShoppingBag, Wallet, MapPin, Headphones, IdCard, LogOut } from "lucide-react";
|
||||||
import { useCart } from './context/cartcontext';
|
import { useCart } from './context/cartcontext';
|
||||||
import { products } from '@/lib/data';
|
import { products } from '@/lib/data';
|
||||||
import '@/public/src/css/header.css';
|
import '@/public/src/css/header.css';
|
||||||
import { registerUser } from '@/public/src/services/auth/api';
|
import { registerUser } from '@/public/src/services/auth/api';
|
||||||
import { loginUser } from '@/public/src/services/auth/api';
|
import { loginUser } from '@/public/src/services/auth/api';
|
||||||
|
import { logoutUser } from '@/public/src/services/auth/api';
|
||||||
import { useCategories } from './context/categoryprovider';
|
import { useCategories } from './context/categoryprovider';
|
||||||
|
|
||||||
const topBarLinks = [
|
const topBarLinks = [
|
||||||
@@ -25,6 +26,86 @@ const mainNavLinks = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
type FloatingInputProps = {
|
||||||
|
name?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
dir?: "rtl" | "ltr";
|
||||||
|
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
|
autoComplete?: string;
|
||||||
|
className?: string;
|
||||||
|
leftSlot?: React.ReactNode;
|
||||||
|
rightSlot?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FloatingInput({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
dir = "rtl",
|
||||||
|
inputMode,
|
||||||
|
autoComplete,
|
||||||
|
className = "",
|
||||||
|
leftSlot,
|
||||||
|
rightSlot,
|
||||||
|
}: FloatingInputProps) {
|
||||||
|
const hasLeftSlot = Boolean(leftSlot);
|
||||||
|
const hasRightSlot = Boolean(rightSlot);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="relative block w-full">
|
||||||
|
{leftSlot ? (
|
||||||
|
<div className="pointer-events-none absolute left-4 top-1/2 z-10 -translate-y-1/2">
|
||||||
|
{leftSlot}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{rightSlot ? (
|
||||||
|
<div className="absolute right-4 top-1/2 z-10 -translate-y-1/2">
|
||||||
|
{rightSlot}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
type={type}
|
||||||
|
dir={dir}
|
||||||
|
inputMode={inputMode}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
placeholder=" "
|
||||||
|
className={`peer w-full rounded-2xl border border-gray-200 bg-gray-50 py-4 text-sm text-[#1A2332] outline-none transition placeholder:text-gray-400 focus:border-[#ffb900] focus:bg-white ${hasLeftSlot ? "pl-24" : "pl-4"} ${hasRightSlot ? "pr-12" : "pr-4"} ${className}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute top-4 z-10 origin-right rounded-full bg-white px-2 text-sm text-gray-400 transition-all duration-200 peer-focus:top-0 peer-focus:-translate-y-1/2 peer-focus:scale-90 peer-focus:text-[#1A2332] peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:-translate-y-1/2 peer-[:not(:placeholder-shown)]:scale-90 ${hasRightSlot ? "right-12" : "right-3"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IranPhoneAdornment() {
|
||||||
|
return (
|
||||||
|
<div dir="ltr" className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span className="text-base leading-none">🇮🇷</span>
|
||||||
|
<span className="font-medium">+98</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardMenuItems = [
|
||||||
|
{ id: "dash", label: "پیشخوان", icon: Home, href: "/dashboard?tab=dash" },
|
||||||
|
{ id: "orders", label: "سفارشهای من", icon: ShoppingBag, href: "/dashboard?tab=orders" },
|
||||||
|
{ id: "wallet", label: "کیف پول", icon: Wallet, href: "/dashboard?tab=wallet" },
|
||||||
|
{ id: "address", label: "آدرسها", icon: MapPin, href: "/dashboard?tab=address" },
|
||||||
|
{ id: "tickets", label: "تیکت پشتیبانی", icon: Headphones, href: "/dashboard?tab=tickets" },
|
||||||
|
{ id: "profile", label: "مشخصات حساب", icon: IdCard, href: "/dashboard?tab=profile" },
|
||||||
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -39,11 +120,15 @@ export function Header() {
|
|||||||
const [activeTab, setActiveTab] = useState("login");
|
const [activeTab, setActiveTab] = useState("login");
|
||||||
const [registerError, setRegisterError] = useState("");
|
const [registerError, setRegisterError] = useState("");
|
||||||
const [loginError, setLoginError] = useState("");
|
const [loginError, setLoginError] = useState("");
|
||||||
|
const [registerUsernameError, setRegisterUsernameError] = useState("");
|
||||||
|
const [loginUsernameError, setLoginUsernameError] = useState("");
|
||||||
const { rootCategories } = useCategories();
|
const { rootCategories } = useCategories();
|
||||||
const [user, setUser] = useState<{ username: string } | null>(null);
|
const [user, setUser] = useState<{ username: string; displayName: string } | null>(null);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showLoginPassword, setShowLoginPassword] = useState(false);
|
const [showLoginPassword, setShowLoginPassword] = useState(false);
|
||||||
|
const [loginMobile, setLoginMobile] = useState("");
|
||||||
|
const [showRegisterSuccessDialog, setShowRegisterSuccessDialog] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +144,18 @@ export function Header() {
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const usernamePattern = /^[A-Za-z0-9]*$/;
|
||||||
|
const usernameErrorMessage = "نام کاربری فقط باید با حروف انگلیسی و عدد وارد شود";
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.name === "username") {
|
||||||
|
if (!usernamePattern.test(e.target.value)) {
|
||||||
|
setRegisterUsernameError(usernameErrorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegisterUsernameError("");
|
||||||
|
}
|
||||||
|
|
||||||
setRegisterForm({
|
setRegisterForm({
|
||||||
...registerForm,
|
...registerForm,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
@@ -70,9 +166,10 @@ export function Header() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("accessToken");
|
const token = localStorage.getItem("accessToken");
|
||||||
const username = localStorage.getItem("username");
|
const username = localStorage.getItem("username");
|
||||||
|
const fullName = localStorage.getItem("fullName");
|
||||||
|
|
||||||
if (token && username) {
|
if (token && username) {
|
||||||
setUser({ username });
|
setUser({ username, displayName: fullName || username });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -86,6 +183,11 @@ export function Header() {
|
|||||||
const password = registerForm.password.trim();
|
const password = registerForm.password.trim();
|
||||||
const fullName = registerForm.fullName.trim();
|
const fullName = registerForm.fullName.trim();
|
||||||
|
|
||||||
|
if (!usernamePattern.test(username)) {
|
||||||
|
setRegisterUsernameError(usernameErrorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ بررسی خالی بودن فیلدها
|
// ✅ بررسی خالی بودن فیلدها
|
||||||
if (!phone || !username || !password || !fullName) {
|
if (!phone || !username || !password || !fullName) {
|
||||||
setRegisterError("لطفاً فیلدها را پر کنید");
|
setRegisterError("لطفاً فیلدها را پر کنید");
|
||||||
@@ -103,17 +205,19 @@ export function Header() {
|
|||||||
|
|
||||||
const res = await registerUser(payload);
|
const res = await registerUser(payload);
|
||||||
const role = res.data?.user?.role;
|
const role = res.data?.user?.role;
|
||||||
|
const displayName = res.data?.user?.fullName || fullName || username;
|
||||||
|
|
||||||
localStorage.setItem("accessToken", res.data.accessToken);
|
localStorage.setItem("accessToken", res.data.accessToken);
|
||||||
localStorage.setItem("refreshToken", res.data.refreshToken);
|
localStorage.setItem("refreshToken", res.data.refreshToken);
|
||||||
localStorage.setItem("username", username);
|
localStorage.setItem("username", username);
|
||||||
|
localStorage.setItem("fullName", displayName);
|
||||||
localStorage.setItem("role", role.toLowerCase());
|
localStorage.setItem("role", role.toLowerCase());
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
|
||||||
|
|
||||||
setUser({ username });
|
setUser({ username, displayName });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
router.push("/dashboard?success=register");
|
setShowRegisterSuccessDialog(true);
|
||||||
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -144,6 +248,11 @@ export function Header() {
|
|||||||
const username = loginForm.username.trim();
|
const username = loginForm.username.trim();
|
||||||
const password = loginForm.password.trim();
|
const password = loginForm.password.trim();
|
||||||
|
|
||||||
|
if (!usernamePattern.test(username)) {
|
||||||
|
setLoginUsernameError(usernameErrorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setLoginError("لطفاً نام کاربری و رمز عبور را وارد کنید");
|
setLoginError("لطفاً نام کاربری و رمز عبور را وارد کنید");
|
||||||
return;
|
return;
|
||||||
@@ -152,12 +261,14 @@ export function Header() {
|
|||||||
try {
|
try {
|
||||||
const res = await loginUser({ username, password });
|
const res = await loginUser({ username, password });
|
||||||
const role = res.data?.user?.role;
|
const role = res.data?.user?.role;
|
||||||
|
const displayName = res.data?.user?.fullName || username;
|
||||||
localStorage.setItem("role", role.toLowerCase());
|
localStorage.setItem("role", role.toLowerCase());
|
||||||
localStorage.setItem("accessToken", res.data.accessToken);
|
localStorage.setItem("accessToken", res.data.accessToken);
|
||||||
localStorage.setItem("refreshToken", res.data.refreshToken);
|
localStorage.setItem("refreshToken", res.data.refreshToken);
|
||||||
localStorage.setItem("username", username);
|
localStorage.setItem("username", username);
|
||||||
|
localStorage.setItem("fullName", displayName);
|
||||||
console.log(res);
|
console.log(res);
|
||||||
setUser({ username });
|
setUser({ username, displayName });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
router.push("/dashboard?success=login");
|
router.push("/dashboard?success=login");
|
||||||
|
|
||||||
@@ -192,14 +303,29 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const clearAuthState = () => {
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken");
|
||||||
localStorage.removeItem("username");
|
localStorage.removeItem("username");
|
||||||
|
localStorage.removeItem("fullName");
|
||||||
localStorage.removeItem("role");
|
localStorage.removeItem("role");
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
await logoutUser(token);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
} finally {
|
||||||
|
clearAuthState();
|
||||||
|
setUserMenuOpen(false);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -519,35 +645,52 @@ export function Header() {
|
|||||||
<span>ورود / عضویت</span>
|
<span>ورود / عضویت</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative" onClick={() => setUserMenuOpen(!userMenuOpen)}
|
<div className="relative">
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
|
onClick={() => setUserMenuOpen((prev) => !prev)}
|
||||||
className="flex cursor-pointer items-center gap-2 px-3 py-2.5 bg-white border border-gray-300/60 rounded-xl text-xs text-gray-700 hover:bg-gray-50 transition"
|
className="flex cursor-pointer items-center gap-2 px-3 py-2.5 bg-white border border-gray-300/60 rounded-xl text-xs text-gray-700 hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5.121 17.804A9 9 0 1118.879 17.804M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5.121 17.804A9 9 0 1118.879 17.804M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<span>{user.username}</span>
|
<span>{user.displayName}</span>
|
||||||
|
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${userMenuOpen ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div className="absolute left-0 mt-2 w-40 bg-white border border-gray-200 rounded-xl shadow-lg p-2 text-xs">
|
<div className="absolute left-0 mt-2 w-56 overflow-hidden rounded-2xl border border-gray-200 bg-white p-2 text-xs shadow-lg">
|
||||||
|
<div className="mb-2 border-b border-gray-100 px-3 pb-2 text-right">
|
||||||
|
<p className="font-semibold text-[#1A2332]">{user.displayName}</p>
|
||||||
|
<p className="mt-1 text-[11px] text-gray-500">دسترسی سریع به بخشهای پیشخوان</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{dashboardMenuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/dashboard")}
|
key={item.id}
|
||||||
className="w-full cursor-pointer text-right px-3 py-2 rounded-lg hover:bg-gray-100"
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
router.push(item.href);
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-right text-gray-700 hover:bg-gray-50 hover:text-[#1A2332]"
|
||||||
>
|
>
|
||||||
داشبورد
|
<item.icon className="h-4 w-4 text-gray-400" />
|
||||||
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 border-t border-gray-100 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={() => {
|
||||||
className="w-full cursor-pointer text-right px-3 py-2 rounded-lg text-red-500 hover:bg-red-50"
|
setUserMenuOpen(false);
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-right text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
خروج
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>خروج</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -601,6 +744,136 @@ export function Header() {
|
|||||||
{/* ---------------- LOGIN ---------------- */}
|
{/* ---------------- LOGIN ---------------- */}
|
||||||
|
|
||||||
{activeTab === "login" && (
|
{activeTab === "login" && (
|
||||||
|
<div className="w-full space-y-5">
|
||||||
|
<div className="rounded-2xl border border-[#ffde7a] bg-[linear-gradient(135deg,#fff8dd_0%,#fffdf6_100%)] px-4 py-4 text-right">
|
||||||
|
<p className="text-base font-semibold text-[#1A2332]">ورود سریع به حساب کاربری</p>
|
||||||
|
<p className="mt-1 text-xs leading-6 text-gray-600">
|
||||||
|
سفارشها، وضعیت خرید و اطلاعات حسابتان را یکجا و بدون دردسر مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formType === "mobile" && (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<FloatingInput
|
||||||
|
value={loginMobile}
|
||||||
|
onChange={(e) => setLoginMobile(e.target.value)}
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
label="شماره موبایل"
|
||||||
|
className="text-left tracking-[0.18em]"
|
||||||
|
leftSlot={<IranPhoneAdornment />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className="w-full cursor-pointer rounded-2xl bg-[#ffb900] py-3.5 text-sm font-semibold text-[#1A2332] hover:bg-[#e5a600]">
|
||||||
|
ورود
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setFormType("password")}
|
||||||
|
className="w-full cursor-pointer text-xs text-gray-500 hover:text-[#1A2332]"
|
||||||
|
>
|
||||||
|
ورود با نام کاربری و رمز عبور
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formType === "password" && (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<p className="text-xs leading-6 text-gray-500">
|
||||||
|
خوش برگشتی. برای ادامه، نام کاربری و رمز عبورت را وارد کن.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
value={loginForm.username}
|
||||||
|
onChange={(e) => {
|
||||||
|
const username = e.target.value;
|
||||||
|
if (!usernamePattern.test(username)) {
|
||||||
|
setLoginUsernameError(usernameErrorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoginUsernameError("");
|
||||||
|
setLoginForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="username"
|
||||||
|
label="نام کاربری"
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
{loginUsernameError && (
|
||||||
|
<div className="mt-1 text-right text-xs text-red-500">
|
||||||
|
{loginUsernameError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
value={loginForm.password}
|
||||||
|
onChange={(e) => {
|
||||||
|
const password = e.target.value;
|
||||||
|
setLoginForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password,
|
||||||
|
}));
|
||||||
|
if (!password) {
|
||||||
|
setShowLoginPassword(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type={showLoginPassword ? "text" : "password"}
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="current-password"
|
||||||
|
label="رمز عبور"
|
||||||
|
className="text-left"
|
||||||
|
rightSlot={loginForm.password ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLoginPassword(!showLoginPassword)}
|
||||||
|
className="cursor-pointer p-1 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showLoginPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M3 3l18 18M10.477 10.477A3 3 0 0113.5 13.5m-7.09-2.664A9.956 9.956 0 003 12s2.91-6 9-6a9.953 9.953 0 016.328 2.318M15.54 15.54A9.953 9.953 0 0112 18c-6.09 0-9-6-9-6a9.956 9.956 0 012.41-3.868" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button onClick={handleLogin} className="w-full cursor-pointer rounded-2xl bg-[#ffb900] py-3.5 text-sm font-semibold text-[#1A2332] hover:bg-[#e5a600]">
|
||||||
|
ورود
|
||||||
|
</button>
|
||||||
|
{loginError && (
|
||||||
|
<div className="mt-2 text-center text-sm text-red-500">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setFormType("mobile")}
|
||||||
|
className="w-full cursor-pointer text-xs text-gray-500 hover:text-[#1A2332]"
|
||||||
|
>
|
||||||
|
ورود با شماره موبایل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{false && activeTab === "login" && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
||||||
{/* فرم موبایل */}
|
{/* فرم موبایل */}
|
||||||
@@ -708,6 +981,110 @@ export function Header() {
|
|||||||
|
|
||||||
{/* ---------------- REGISTER ---------------- */}
|
{/* ---------------- REGISTER ---------------- */}
|
||||||
{activeTab === "register" && (
|
{activeTab === "register" && (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-right">
|
||||||
|
<p className="text-base font-semibold text-[#1A2332]">چند قدم تا شروع خرید هوشمند</p>
|
||||||
|
<p className="mt-1 text-xs leading-6 text-gray-600">
|
||||||
|
با ساخت حساب، پیگیری سفارشها و دسترسی سریع به سبد خرید همیشه همراهت میماند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
name="phone"
|
||||||
|
value={registerForm.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
label="شماره موبایل"
|
||||||
|
className="text-left tracking-[0.18em]"
|
||||||
|
leftSlot={<IranPhoneAdornment />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
name="fullName"
|
||||||
|
value={registerForm.fullName}
|
||||||
|
onChange={handleChange}
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
label="نام و نام خانوادگی"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
name="username"
|
||||||
|
value={registerForm.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="username"
|
||||||
|
label="نام کاربری"
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
{registerUsernameError && (
|
||||||
|
<div className="mt-1 text-right text-xs text-red-500">
|
||||||
|
{registerUsernameError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FloatingInput
|
||||||
|
name="password"
|
||||||
|
value={registerForm.password}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange(e);
|
||||||
|
if (!e.target.value) {
|
||||||
|
setShowPassword(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="new-password"
|
||||||
|
label="رمز عبور"
|
||||||
|
className="text-left"
|
||||||
|
rightSlot={registerForm.password ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="cursor-pointer text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M3 3l18 18M10.477 10.477A3 3 0 0113.5 13.5m-7.09-2.664A9.956 9.956 0 003 12s2.91-6 9-6a9.953 9.953 0 016.328 2.318M15.54 15.54A9.953 9.953 0 0112 18c-6.09 0-9-6-9-6a9.956 9.956 0 012.41-3.868" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs leading-6 text-gray-500">
|
||||||
|
با ثبتنام، تجربه خرید سریعتر و دسترسی راحتتر به حساب کاربری برایت فعال میشود.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
className="w-full cursor-pointer rounded-2xl bg-[#ffb900] py-3.5 text-sm font-semibold text-[#1A2332] hover:bg-[#e5a600]"
|
||||||
|
>
|
||||||
|
ثبت نام
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{registerError && (
|
||||||
|
<div className="mt-2 text-center text-sm text-red-500">
|
||||||
|
{registerError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{false && activeTab === "register" && (
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
<input
|
<input
|
||||||
name="phone"
|
name="phone"
|
||||||
@@ -802,6 +1179,50 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showRegisterSuccessDialog && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-[#1A2332]/45 p-4 backdrop-blur-sm" dir="rtl">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
onClick={() => setShowRegisterSuccessDialog(false)}
|
||||||
|
></div>
|
||||||
|
<div className="relative w-full max-w-sm rounded-3xl bg-white p-8 shadow-2xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRegisterSuccessDialog(false)}
|
||||||
|
className="absolute left-5 top-4 cursor-pointer text-gray-400 transition hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#fff4cc] text-[#c78a00]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-[#1A2332]">تشکر از ثبت نام</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-gray-600">
|
||||||
|
حساب کاربری شما با موفقیت ساخته شد. حالا میتوانید وارد پیشخوان کاربری شوید و اطلاعات حساب و سفارشها را مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
onClick={() => setShowRegisterSuccessDialog(false)}
|
||||||
|
className="flex items-center justify-center rounded-2xl bg-[#ffb900] px-4 py-3 text-sm font-semibold text-[#1A2332] hover:bg-[#e5a600]"
|
||||||
|
>
|
||||||
|
ورود به پیشخوان کاربری
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRegisterSuccessDialog(false)}
|
||||||
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
بستن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -963,6 +963,70 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function registerUser(data: RegisterPayload) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function loginUser(data: LoginPayload) {
|
export async function loginUser(data: LoginPayload) {
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE_URL}/auth/login/password`,
|
`${API_BASE_URL}/auth/login/password`,
|
||||||
@@ -53,3 +53,27 @@ export async function registerUser(data: RegisterPayload) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logoutUser(token: string) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let result: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await response.json();
|
||||||
|
} catch {
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result?.message || "Logout failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
222
public/src/services/tickets/api.ts
Normal file
222
public/src/services/tickets/api.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { API_BASE_URL } from "../config";
|
||||||
|
|
||||||
|
export type TicketStatus = "waiting_for_admin" | "waiting_for_user" | "closed";
|
||||||
|
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
||||||
|
|
||||||
|
export interface TicketPagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketMessageSummary {
|
||||||
|
id: string;
|
||||||
|
senderType: "user" | "admin";
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketAssignee {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketListItem {
|
||||||
|
id: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
subject: string;
|
||||||
|
status: TicketStatus;
|
||||||
|
priority: TicketPriority;
|
||||||
|
isReadByAdmin: boolean;
|
||||||
|
isReadByUser: boolean;
|
||||||
|
messagesCount: number;
|
||||||
|
lastMessageAt: string | null;
|
||||||
|
lastMessage: TicketMessageSummary | null;
|
||||||
|
assignedAdmin: TicketAssignee | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketListResponse {
|
||||||
|
items: TicketListItem[];
|
||||||
|
pagination: TicketPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketSender {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
username: string;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketMessage {
|
||||||
|
id: string;
|
||||||
|
senderType: "user" | "admin";
|
||||||
|
sender: TicketSender;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDetail {
|
||||||
|
id: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
subject: string;
|
||||||
|
status: TicketStatus;
|
||||||
|
priority: TicketPriority;
|
||||||
|
isReadByAdmin?: boolean;
|
||||||
|
isReadByUser?: boolean;
|
||||||
|
user: TicketSender;
|
||||||
|
assignedAdmin: TicketAssignee | null;
|
||||||
|
messages: TicketMessage[];
|
||||||
|
lastMessageAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTicketsQuery {
|
||||||
|
status?: TicketStatus;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTicketPayload {
|
||||||
|
subject: string;
|
||||||
|
priority: TicketPriority;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendTicketMessagePayload {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTicketsQuery {
|
||||||
|
status?: TicketStatus;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
search?: string;
|
||||||
|
userId?: string;
|
||||||
|
assignedAdminId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdminTicketPayload {
|
||||||
|
status?: TicketStatus;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
assignedAdminId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuthHeaders(token: string, contentType = false) {
|
||||||
|
return {
|
||||||
|
Accept: "*/*",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(contentType ? { "Content-Type": "application/json" } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.message || "Ticket request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(path: string, query: Record<string, string | number | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== "") {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${path}${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserTickets(token: string, query: UserTicketsQuery = {}) {
|
||||||
|
const response = await fetch(
|
||||||
|
withQuery(`${API_BASE_URL}/users/me/tickets`, query),
|
||||||
|
{ headers: createAuthHeaders(token) }
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseResponse<TicketListResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserTicket(token: string, payload: CreateTicketPayload) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/me/tickets`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: createAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserTicketById(token: string, ticketId: string) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}`, {
|
||||||
|
headers: createAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendUserTicketMessage(token: string, ticketId: string, payload: SendTicketMessagePayload) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: createAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeUserTicket(token: string, ticketId: string) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}/close`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: createAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminTickets(token: string, query: AdminTicketsQuery = {}) {
|
||||||
|
const response = await fetch(
|
||||||
|
withQuery(`${API_BASE_URL}/admin/tickets`, query),
|
||||||
|
{ headers: createAuthHeaders(token) }
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseResponse<TicketListResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminTicketById(token: string, ticketId: string) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}`, {
|
||||||
|
headers: createAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminTicket(token: string, ticketId: string, payload: UpdateAdminTicketPayload) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: createAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAdminTicketMessage(token: string, ticketId: string, payload: SendTicketMessagePayload) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: createAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseResponse<TicketDetail>(response);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user