diff --git a/src/@core/utils/formatPersianDate.ts b/src/@core/utils/formatPersianDate.ts new file mode 100644 index 0000000..f4dba7e --- /dev/null +++ b/src/@core/utils/formatPersianDate.ts @@ -0,0 +1,35 @@ +const formatPersianDate = (dateString: string) => { + if (!dateString) return ""; + + // جدا کردن بخش تاریخ از ساعت: "1404-12-25" + const [datePart] = dateString.split(" "); + if (!datePart) return dateString; + + // جدا کردن سال، ماه و روز + const [year, month, day] = datePart.split("-"); + + const persianMonths = [ + "فروردین", + "اردیبهشت", + "خرداد", + "تیر", + "مرداد", + "شهریور", + "مهر", + "آبان", + "آذر", + "دی", + "بهمن", + "اسفند", + ]; + + // پیدا کردن نام ماه (ایندکس آرایه از 0 شروع می‌شود پس یکی کم می‌کنیم) + const monthName = persianMonths[parseInt(month, 10) - 1]; + + // حذف صفر پشت روز (مثلا 05 بشود 5) + const cleanDay = parseInt(day, 10); + + return `${cleanDay} ${monthName} ${year}`; +}; + +export default formatPersianDate; diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 3d76dfe..d2e4829 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -16,7 +16,8 @@ import { ShieldCheck, CreditCard, Edit2, - Trash2 + Trash2, + Star } from "lucide-react"; import { useUpdateUserProfile, useUserProfile } from "@/services/useUser"; import { @@ -26,11 +27,140 @@ import { useDeleteAddress } from "@/services/useAddresses"; -// تعریف تایپ پروفایل کاربر (بر اساس نیاز پروژه خود آپدیت کنید) +// === Types === interface UserProfile { name: string; } +interface Address { + id: number; + address: string; + postal_code: string; + is_default: boolean; + latitude?: number; + longitude?: number; +} + +// ========================================== +// کامپوننت کارت آدرس (برای مدیریت مستقل هوک هر آدرس) +// ========================================== +const AddressCard = ({ + addr, + selectedAddressId, + setSelectedAddressId, + handleOpenEditForm, + handleDeleteAddress, + isDeletingAddress, + deletingId +}: { + addr: Address; + selectedAddressId: number | null; + setSelectedAddressId: (id: number) => void; + handleOpenEditForm: (id: number, address: string, postalCode: string) => void; + handleDeleteAddress: (id: number) => void; + isDeletingAddress: boolean; + deletingId: number | null; +}) => { + // هوک آپدیت اختصاصی برای همین آدرس جهت تنظیم به عنوان پیش‌فرض + const { mutate: setAsDefault, isPending: isSettingDefault } = useUpdateAddress(addr.id); + + const handleMakeDefault = (e: React.MouseEvent) => { + e.stopPropagation(); // جلوگیری از انتخاب شدن آدرس هنگام کلیک روی دکمه + setAsDefault( + { + address: addr.address, + postal_code: addr.postal_code, + is_default: true, + }, + { + onSuccess: () => toast.success("آدرس پیش‌فرض با موفقیت تغییر کرد"), + onError: () => toast.error("خطا در تغییر آدرس پیش‌فرض"), + } + ); + }; + + return ( +
setSelectedAddressId(addr.id)} + className={`relative flex flex-col sm:flex-row justify-between p-5 rounded-2xl border-2 cursor-pointer transition-all ${ + selectedAddressId === addr.id + ? "border-salona-500 bg-salona-50/30" + : "border-gray-100 hover:border-salona-200" + }`} + > +
+ {selectedAddressId === addr.id && ( + + )} + + {/* نشانگر آدرس پیش‌فرض */} +
+ {addr.is_default ? ( + + + آدرس پیش‌فرض + + ) : ( + + )} +
+ +

{addr.address}

+
+ + کد پستی: {addr.postal_code} + +
+
+ + {/* اکشن‌های ویرایش و حذف */} +
+ + +
+
+ ); +}; + +// ========================================== +// کامپوننت اصلی صفحه +// ========================================== export default function CheckoutPage() { // === Redux State === const { totalAmount, totalQuantity, items } = useSelector((state: RootState) => state.cart); @@ -47,7 +177,7 @@ export default function CheckoutPage() { // === Local States === const [selectedAddressId, setSelectedAddressId] = useState(null); - const [deletingId, setDeletingId] = useState(null); // برای نمایش لودینگ روی دکمه حذف خاص + const [deletingId, setDeletingId] = useState(null); // استیت‌های فرم نام گیرنده const [recipientName, setRecipientName] = useState(""); @@ -61,7 +191,7 @@ export default function CheckoutPage() { postal_code: "", }); - // Hook ویرایش آدرس (چون آیدی نیاز دارد، آیدی آدرس در حال ویرایش یا 0 را پاس می‌دهیم) + // Hook ویرایش آدرس برای فرم (استفاده مجزا از فرم) const { mutate: updateAddress, isPending: isUpdatingAddress } = useUpdateAddress(editingAddressId || 0); // === Effects === @@ -73,10 +203,14 @@ export default function CheckoutPage() { } }, [userProfile, isProfileLoading]); + // انتخاب خودکار آدرس پیش‌فرض برای صورتحساب useEffect(() => { - if (addressesData?.addresses && addressesData.addresses.length > 0 && !selectedAddressId) { - const defaultAddress = addressesData.addresses.find((a) => a.is_default); - setSelectedAddressId(defaultAddress ? defaultAddress.id : addressesData.addresses[0].id); + if (addressesData?.addresses && addressesData.addresses.length > 0) { + const defaultAddress = addressesData.addresses.find((a: Address) => a.is_default); + // اگر کاربر به صورت دستی آدرسی انتخاب نکرده بود، آدرس پیش‌فرض را انتخاب کن + if (!selectedAddressId) { + setSelectedAddressId(defaultAddress ? defaultAddress.id : addressesData.addresses[0].id); + } } }, [addressesData, selectedAddressId]); @@ -98,28 +232,24 @@ export default function CheckoutPage() { ); }; - // باز کردن فرم برای افزودن آدرس جدید const handleOpenAddForm = () => { setEditingAddressId(null); setAddressForm({ address: "", postal_code: "" }); setIsAddressFormOpen(true); }; - // باز کردن فرم برای ویرایش آدرس موجود const handleOpenEditForm = (addressId: number, currentAddress: string, currentPostalCode: string) => { setEditingAddressId(addressId); setAddressForm({ address: currentAddress, postal_code: currentPostalCode }); setIsAddressFormOpen(true); }; - // بستن فرم آدرس const handleCloseAddressForm = () => { setIsAddressFormOpen(false); setEditingAddressId(null); setAddressForm({ address: "", postal_code: "" }); }; - // ارسال فرم آدرس (تصمیم‌گیری بین افزودن یا ویرایش) const handleAddressSubmit = (e: FormEvent) => { e.preventDefault(); if (!addressForm.address || !addressForm.postal_code) { @@ -128,11 +258,12 @@ export default function CheckoutPage() { } if (editingAddressId) { - // منطق ویرایش updateAddress( { address: addressForm.address, postal_code: addressForm.postal_code, + // حفظ وضعیت پیش‌فرض قبلی هنگام ویرایش متن + is_default: addressesData?.addresses?.find((a: Address) => a.id === editingAddressId)?.is_default || false }, { onSuccess: () => { @@ -143,14 +274,14 @@ export default function CheckoutPage() { } ); } else { - // منطق افزودن addAddress( { address: addressForm.address, postal_code: addressForm.postal_code, latitude: 0, longitude: 0, - is_default: false, + // اگر اولین آدرس است، خودکار پیش‌فرض شود + is_default: (addressesData?.addresses?.length || 0) === 0, }, { onSuccess: () => { @@ -163,14 +294,13 @@ export default function CheckoutPage() { } }; - // منطق حذف آدرس const handleDeleteAddress = (addressId: number) => { setDeletingId(addressId); deleteAddress(addressId, { onSuccess: () => { toast.success("آدرس با موفقیت حذف شد"); if (selectedAddressId === addressId) { - setSelectedAddressId(null); // ریست کردن آدرس انتخاب شده اگر همان آدرس حذف شود + setSelectedAddressId(null); } setDeletingId(null); }, @@ -278,59 +408,17 @@ export default function CheckoutPage() { {/* لیست آدرس‌های موجود */}
- {addressesData?.addresses?.map((addr) => ( -
( + setSelectedAddressId(addr.id)} - className={`relative flex flex-col sm:flex-row justify-between p-5 rounded-2xl border-2 cursor-pointer transition-all ${ - selectedAddressId === addr.id - ? "border-salona-500 bg-salona-50/30" - : "border-gray-100 hover:border-salona-200" - }`} - > -
- {selectedAddressId === addr.id && ( - - )} -

{addr.address}

-
- - کد پستی: {addr.postal_code} - -
-
- - {/* اکشن‌های ویرایش و حذف */} -
- - -
-
+ addr={addr} + selectedAddressId={selectedAddressId} + setSelectedAddressId={setSelectedAddressId} + handleOpenEditForm={handleOpenEditForm} + handleDeleteAddress={handleDeleteAddress} + isDeletingAddress={isDeletingAddress} + deletingId={deletingId} + /> ))}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 45a7d5e..fe16d53 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,7 +6,7 @@ import { ArrowLeft, Sparkles, TrendingUp } from "lucide-react"; import { useCategories } from "@/services/useCategories"; import { useProducts } from "@/services/useProducts"; import { useFavorites } from "@/services/useFavorites"; // اضافه شدن هوک علاقه‌مندی‌ها -import { ProductCard } from "@/components/products/ProductCard"; +import { ProductCard } from "@/components/shared/ProductsCard"; import { Category, Product } from "@/types/api"; export default function HomePage() { @@ -16,14 +16,16 @@ export default function HomePage() { per_page: 8, }); + const user = JSON.parse(localStorage.getItem('user')) + // دریافت لیست علاقه‌مندی‌ها برای تطبیق با محصولات صفحه اصلی - const { data: favoritesData } = useFavorites(); + const { data: favoritesData } = useFavorites(!!user); return (
{/* 1. Hero Section */}
-
+
جشنواره بهاره سالونا @@ -45,7 +47,8 @@ export default function HomePage() {
-
+ {/* bg-linear-to-tr from-salona-200 to-salona-100 */} +
diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx index 967bcfb..740e109 100644 --- a/src/app/products/[id]/page.tsx +++ b/src/app/products/[id]/page.tsx @@ -16,37 +16,40 @@ import { Loader2, // اضافه شدن لودر برای دکمه قلب } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import type { Product } from "@/types/api"; import { addToCart, removeFromCart } from "@/store/slices/cartSlice"; // پیشنهاد: برای پرفورمنس بهتر در Next.js از next/image استفاده کنید import Image from "next/image"; +import ProductComments from "@/components/shared/ProductComments"; export default function ProductDetailPage() { const params = useParams(); const router = useRouter(); const productId = Number(params.id); - // راه‌اندازی Redux const dispatch = useDispatch(); - // دریافت اطلاعات محصول در سبد خرید از استیت گلوبال const cartItem = useSelector((state: any) => state.cart.items.find((item: any) => item.id === String(productId))); const quantityInCart = cartItem ? cartItem.quantity : 0; - // واکشی اطلاعات محصول از API const { data: response, isLoading, isError } = useProductDetail(productId); //@ts-ignore const product: Product | undefined = response?.product; - // --- بخش مربوط به علاقه‌مندی‌ها (Favorites) --- - const { data: favoritesData } = useFavorites(); + // دریافت وضعیت کاربر به صورت ایمن + const [user, setUser] = useState(null); + useEffect(() => { + const storedUser = localStorage.getItem("user"); + if (storedUser) setUser(JSON.parse(storedUser)); + }, []); + + const { data: favoritesData } = useFavorites(!!user); const { mutate: addFavorite, isPending: isAddingFavorite } = useAddFavorite(); const { mutate: deleteFavorite, isPending: isDeletingFavorite } = useDeleteFavorite(); - // بررسی اینکه آیا این محصول در لیست علاقه‌مندی‌های کاربر وجود دارد یا خیر const favoriteRecord = favoritesData?.favorites?.find( (fav: any) => fav.product_id === product?.id || (fav.product && fav.product.id === product?.id), ); @@ -54,22 +57,17 @@ export default function ProductDetailPage() { const favoriteId = favoriteRecord?.id; const isFavoriteLoading = isAddingFavorite || isDeletingFavorite; - // تابع هندل کردن کلیک روی دکمه قلب const handleToggleFavorite = () => { if (!product) return; - if (isFavorited && favoriteId) { deleteFavorite(favoriteId); } else { addFavorite(product.id); } }; - // ---------------------------------------------- - // استیت برای مدیریت تصویری که کاربر در گالری انتخاب کرده است const [selectedImage, setSelectedImage] = useState(0); - // توابع مدیریت سبد خرید const handleAddToCart = () => { if (!product) return; dispatch( @@ -87,7 +85,6 @@ export default function ProductDetailPage() { dispatch(removeFromCart(String(productId))); }; - // کامپوننت اسکلتون برای حالت لودینگ if (isLoading) { return (
@@ -115,7 +112,7 @@ export default function ProductDetailPage() { if (isError || !product) { return ( -
+

محصول مورد نظر یافت نشد!

@@ -148,7 +149,6 @@ export default function ProductDetailPage() {
- {/* بخش گالری تصاویر محصول */}
- {/* بخش اطلاعات محصول */}
{brandName &&
{brandName}
} -

- {product.name} -

+
+

+ {product.name} +

+ + +
-
- 0 ? "fill-current" : "text-gray-300"}`} - /> - - {product.total_ratings > 0 ? product.average_rating : "بدون امتیاز"} - {product.total_ratings > 0 && ( - + {/* NEW: بخش امتیازدهی با 5 ستاره */} +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {product.total_ratings > 0 ? ( + + {product.average_rating} از ۵ + ({product.total_ratings} دیدگاه) - )} - + + ) : ( + بدون امتیاز + )}
@@ -257,14 +286,20 @@ export default function ProductDetailPage() {
)} + {/* NEW: بخش ویژگی‌ها با استایل جدید */} {product.specifications && Object.keys(product.specifications).length > 0 && (
-

ویژگی‌های اصلی

-
- {Object.entries(product.specifications).map(([key, value], idx) => ( -
- {key}: - {String(value)} +

ویژگی‌ها

+
+ {Object.entries(product.specifications).map(([key, value]) => ( +
+

{key}

+

+ {String(value)} +

))}
@@ -272,7 +307,6 @@ export default function ProductDetailPage() { )}
- {/* بخش قیمت و عملیات سبد خرید */}
{isOutOfStock ? ( @@ -289,26 +323,6 @@ export default function ProductDetailPage() {
- {/* دکمه علاقه‌مندی (اصلاح شده) */} - - - {/* مدیریت وضعیت‌های مختلف دکمه سبد خرید */} {isOutOfStock ? (
) : ( - // حالت پیش‌فرض افزودن به سبد خرید
+ + {/* فراخوانی کامپوننت نظرات در انتهای صفحه */} +
); } diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx new file mode 100644 index 0000000..a8d0924 --- /dev/null +++ b/src/app/products/page.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Search, + Filter, + SlidersHorizontal, + ChevronRight, + ChevronLeft, + PackageSearch, + X, + Check, + Banknote +} from "lucide-react"; +import { useCategories } from "@/services/useCategories"; +import { useFavorites } from "@/services/useFavorites"; +import { ProductCard } from "@/components/shared/ProductsCard"; +import { Category, Product } from "@/types/api"; +import { useSearchProducts } from "@/services/useProducts"; + +export default function ProductsPage() { + // --- States --- + const [user, setUser] = useState(null); + const [page, setPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(null); + const [sortBy, setSortBy] = useState("new"); + + // استیت‌های مربوط به فیلتر قیمت + const [minPrice, setMinPrice] = useState(""); + const [maxPrice, setMaxPrice] = useState(""); + const [debouncedMinPrice, setDebouncedMinPrice] = useState(""); + const [debouncedMaxPrice, setDebouncedMaxPrice] = useState(""); + + // مدیریت دریافت اطلاعات کاربر در کلاینت‌ساید + useEffect(() => { + if (typeof window !== "undefined") { + const storedUser = localStorage.getItem("user"); + if (storedUser) { + setUser(JSON.parse(storedUser)); + } + } + }, []); + + // دی‌باونس برای جستجو و قیمت‌ها + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery); + // حذف کاماها قبل از ارسال به API + setDebouncedMinPrice(minPrice.replace(/,/g, "")); + setDebouncedMaxPrice(maxPrice.replace(/,/g, "")); + setPage(1); + }, 600); + return () => clearTimeout(timer); + }, [searchQuery, minPrice, maxPrice]); + + // --- Queries --- + const { data: categoriesData, isLoading: isCategoriesLoading } = useCategories(); + + // پارامترهای ارسالی به API + const { data: searchData, isLoading: isSearchLoading } = useSearchProducts({ + page, + page_per: 12, + q: debouncedSearch, + category_id: selectedCategory || undefined, + sort_by: sortBy as any, + min_price: debouncedMinPrice ? Number(debouncedMinPrice) : undefined, + max_price: debouncedMaxPrice ? Number(debouncedMaxPrice) : undefined, + }); + + const { data: favoritesData } = useFavorites(!!user); + + // --- Handlers --- + const handleCategoryChange = (categoryId: number | null) => { + setSelectedCategory(categoryId); + setPage(1); + }; + + const handleSortChange = (e: React.ChangeEvent) => { + setSortBy(e.target.value); + setPage(1); + }; + + // تابع فرمت‌دهی اعداد با کاما برای نمایش زیباتر + const handlePriceChange = (value: string, setter: (val: string) => void) => { + // حذف تمام کاراکترهای غیر عددی + const numericValue = value.replace(/\D/g, ""); + if (!numericValue) { + setter(""); + return; + } + // اضافه کردن کاما به عنوان جداکننده هزارگان + const formattedValue = numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + setter(formattedValue); + }; + + const clearFilters = () => { + setSearchQuery(""); + setDebouncedSearch(""); + setSelectedCategory(null); + setSortBy("new"); + setMinPrice(""); + setMaxPrice(""); + setDebouncedMinPrice(""); + setDebouncedMaxPrice(""); + setPage(1); + }; + + // بررسی اینکه آیا فیلتری فعال است یا خیر + const hasActiveFilters = selectedCategory !== null || searchQuery !== "" || minPrice !== "" || maxPrice !== ""; + + return ( +
+ {/* 1. Header & Breadcrumb */} +
+
+

+ + فروشگاه محصولات +

+

+ {searchData?.total ? `${searchData?.total} محصول پیدا شد` : "در حال جستجو..."} +

+
+ + {/* Search Bar */} +
+ setSearchQuery(e.target.value)} + placeholder="جستجوی نام محصول..." + className="w-full bg-gray-50 border border-gray-200 rounded-2xl pl-4 pr-11 py-3 text-sm focus:outline-none focus:border-salona-500 focus:ring-1 focus:ring-salona-500 transition-all" + /> + + {searchQuery && ( + + )} +
+
+ +
+ {/* 2. Sidebar Filters */} + + + {/* 3. Products Main Content */} +
+ {/* گرید محصولات */} + {isSearchLoading ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : searchData?.products?.length > 0 ? ( +
+ {searchData.products.map((product: Product) => { + const favoriteRecord = favoritesData?.favorites?.find( + (fav: any) => fav.product_id === product.id, + ); + + return ( + + ); + })} +
+ ) : ( + /* استیت خالی */ +
+ +

محصولی یافت نشد!

+

+ با فیلترها و کلمات کلیدی فعلی هیچ محصولی پیدا نکردیم. لطفاً فیلترها را تغییر دهید یا + عبارت دیگری جستجو کنید. +

+ +
+ )} + + {/* 4. صفحه‌بندی (Pagination) */} + {searchData && searchData?.pages > 1 && ( +
+ + + + صفحه {searchData.current_page} از {searchData.pages} + + + +
+ )} +
+
+
+ ); +} diff --git a/src/app/profile/favorites/page.tsx b/src/app/profile/favorites/page.tsx index 2c1cb82..828a5e9 100644 --- a/src/app/profile/favorites/page.tsx +++ b/src/app/profile/favorites/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { HeartCrack, Loader2 } from "lucide-react"; import { useFavorites } from "@/services/useFavorites"; -import { ProductCard } from "@/components/products/ProductCard"; +import { ProductCard } from "@/components/shared/ProductsCard"; import { Favorite } from "@/types/api"; export default function FavoritesPage() { diff --git a/src/app/tickets/page.tsx b/src/app/tickets/page.tsx new file mode 100644 index 0000000..d9e2f12 --- /dev/null +++ b/src/app/tickets/page.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useState, useRef, useEffect, FormEvent } from "react"; +import { + MessageSquare, + Plus, + ChevronLeft, + Send, + Clock, + CheckCircle2, + AlertCircle, + X, + Search, + User, + Headset, + Loader2, +} from "lucide-react"; +import { useCreateTicket, useReplyTicket, useTicketDetail, useTickets } from "@/services/useTickets"; + +// فرض بر این است که هوک‌های شما در این مسیر قرار دارند + +// --- Types (جهت جلوگیری از خطای تایپ‌اسکریپت - با بک‌اند خود تطبیق دهید) --- +interface Ticket { + id: number; + subject: string; + status: "pending" | "answered" | "closed"; + created_at: string; + updated_at: string; +} + +interface TicketMessage { + id: number; + content: string; + is_admin: boolean; // یا sender_type + created_at: string; +} + +interface TicketDetailType extends Ticket { + messages: TicketMessage[]; +} +// -------------------------------------------------------------------------- + +export default function TicketsPage() { + // === States === + const [page, setPage] = useState(1); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + // فرم تیکت جدید + const [newTicket, setNewTicket] = useState({ subject: "", content: "" }); + // فرم پاسخ + const [replyContent, setReplyContent] = useState(""); + + const messagesEndRef = useRef(null); + + // === Hooks === + const { data: ticketsData, isLoading: isTicketsLoading } = useTickets(page); + const { data: ticketDetail, isLoading: isDetailLoading } = useTicketDetail(selectedTicketId!); + + const { mutate: createTicket, isPending: isCreating } = useCreateTicket(); + const { mutate: replyTicket, isPending: isReplying } = useReplyTicket(); + + // اسکرول خودکار به انتهای چت هنگام باز کردن تیکت یا دریافت پیام جدید + useEffect(() => { + if (ticketDetail?.messages) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [ticketDetail]); + + // === Handlers === + const handleCreateTicket = (e: FormEvent) => { + e.preventDefault(); + if (!newTicket.subject.trim() || !newTicket.content.trim()) return; + + createTicket(newTicket, { + onSuccess: () => { + setIsModalOpen(false); + setNewTicket({ subject: "", content: "" }); + setPage(1); // برگشت به صفحه اول برای دیدن تیکت جدید + }, + }); + }; + + const handleReply = (e: FormEvent) => { + e.preventDefault(); + if (!replyContent.trim() || !selectedTicketId) return; + + replyTicket( + { ticket_id: selectedTicketId, content: replyContent }, + { + onSuccess: () => { + setReplyContent(""); + }, + }, + ); + }; + + // === Helper Functions === + // تابع کمکی برای استایل دادن به وضعیت تیکت + const getStatusStyle = (status: string) => { + switch (status) { + case "answered": + return { + label: "پاسخ داده شده", + colors: "bg-emerald-50 text-emerald-600 border-emerald-200", + icon: CheckCircle2, + }; + case "closed": + return { label: "بسته شده", colors: "bg-gray-100 text-gray-500 border-gray-200", icon: AlertCircle }; + case "pending": + default: + return { label: "در انتظار پاسخ", colors: "bg-amber-50 text-amber-600 border-amber-200", icon: Clock }; + } + }; + + // فرض می‌کنیم دیتا در data.results قرار دارد (مخصوص صفحه‌بندی) + const ticketsList: Ticket[] = ticketsData?.tickets || ticketsData || []; + + return ( +
+ {/* Header */} +
+
+

+ + پشتیبانی و تیکت‌ها +

+

پیگیری مشکلات و ارتباط با تیم پشتیبانی سالونا

+
+ +
+ + {/* Main Layout Container */} +
+ {/* ----------------------------------------- */} + {/* 1. بخش لیست تیکت‌ها (Sidebar) */} + {/* در موبایل اگر تیکتی انتخاب شده باشد، این بخش مخفی می‌شود */} +
+ {/* سرچ باکس لیست تیکت */} +
+
+ + +
+
+ + {/* لیست تیکت‌ها */} +
+ {isTicketsLoading ? ( + // لودینگ لیست + Array.from({ length: 5 }).map((_, i) => ( +
+ )) + ) : ticketsList.length === 0 ? ( + // حالت خالی +
+ + هیچ تیکتی ثبت نشده است +
+ ) : ( + ticketsList?.map((ticket) => { + const statusInfo = getStatusStyle(ticket.status); + const StatusIcon = statusInfo.icon; + const isSelected = selectedTicketId === ticket.id; + + return ( +
setSelectedTicketId(ticket.id)} + className={`cursor-pointer p-4 rounded-2xl border transition-all duration-200 ${ + isSelected + ? "bg-salona-50 border-salona-200 shadow-sm" + : "bg-white border-gray-100 hover:border-salona-200 hover:shadow-sm" + }`} + > +
+

+ {ticket.subject} +

+ + {new Date(ticket.created_at).toLocaleDateString("fa-IR")} + +
+
+ + #TK-{ticket.id} + +
+ + {statusInfo.label} +
+
+
+ ); + }) + )} +
+
+ + {/* ----------------------------------------- */} + {/* 2. بخش چت و جزئیات تیکت (Main View) */} +
+ {!selectedTicketId ? ( + // حالت انتخاب نشدن تیکت در دسکتاپ +
+
+ +
+

یک تیکت را برای مشاهده انتخاب کنید

+
+ ) : ( + <> + {/* هدر چت */} +
+
+ +
+

+ {ticketDetail?.subject || "در حال بارگذاری..."} +

+ + شماره پیگیری: #{selectedTicketId} + +
+
+ + {ticketDetail && ( +
+ {getStatusStyle(ticketDetail.status).label} +
+ )} +
+ + {/* بدنه چت (محل نمایش پیام‌ها) */} +
+ {isDetailLoading ? ( +
+ +
+ ) : ( + ticketDetail?.messages?.map((msg: TicketMessage) => { + const isAdmin = msg.is_admin; + + return ( +
+
+ {/* آواتار فرستنده */} +
+
+ {isAdmin ? ( + + ) : ( + + )} +
+
+ + {/* حباب پیام */} +
+

+ {msg.content} +

+ + {new Date(msg.created_at).toLocaleTimeString("fa-IR", { + hour: "2-digit", + minute: "2-digit", + })} + +
+
+
+ ); + }) + )} +
+
+ + {/* بخش ارسال پیام */} + {ticketDetail?.status !== "closed" ? ( +
+
+ +
+ + +
+
+ ) : ( + <> + {comment.title &&

{comment.title}

} +

{comment.comment}

+ + )} + + {/* Action Buttons */} +
+ {!isReply && currentUser && !isCurrentlyEditing && ( + + )} + + {/* Edit & Delete Buttons for Comment Owner */} + {isMine && !isCurrentlyEditing && ( +
+ + +
+ )} +
+ + {/* Replies Rendering */} + {comment.replies && comment.replies.length > 0 && ( +
+ {comment.replies.map((reply: any) => ( + + ))} +
+ )} +
+ ); + }; + + return ( +
+
+ +

نظرات کاربران

+ + {response?.total || 0} نظر + +
+ +
+ {/* بخش فرم ثبت نظر */} +
+
+

ثبت دیدگاه جدید

+ + {currentUser ? ( + + {replyTo && ( +
+ + در حال پاسخ به: {replyTo.author} + + +
+ )} + +
+ + setCommentTitle(e.target.value)} + className="w-full bg-white border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-salona-500 focus:ring-1 focus:ring-salona-500 transition-all" + placeholder="مثلاً: کیفیت عالی" + /> +
+
+ + +
+ + + ) : ( +
+

+ برای ثبت نظر، ابتدا وارد حساب کاربری خود شوید. +

+ + ورود به حساب + +
+ )} +
+
+ + {/* بخش لیست نظرات */} +
+
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : comments.length > 0 ? ( + comments.map((comment: any) => ) + ) : ( +
+ +

هنوز دیدگاهی ثبت نشده است

+

+ شما اولین نفری باشید که در مورد این محصول نظر می‌دهید. +

+
+ )} +
+ + {/* بخش صفحه‌بندی (Pagination) */} + {response && response.pages > 1 && ( +
+ + + صفحه {response.current_page} از {response.pages} + + +
+ )} +
+
+
+ ); +}; + +export default ProductComments; diff --git a/src/components/products/ProductCard.tsx b/src/components/shared/ProductsCard/index.tsx similarity index 51% rename from src/components/products/ProductCard.tsx rename to src/components/shared/ProductsCard/index.tsx index ad67f6a..503e2c1 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/shared/ProductsCard/index.tsx @@ -1,23 +1,17 @@ "use client"; import Link from "next/link"; -import { useDispatch, useSelector } from "react-redux"; -import { ShoppingCart, Heart, Star, AlertCircle, Plus, Minus, Loader2 } from "lucide-react"; +import { Heart, Star, AlertCircle, Loader2 } from "lucide-react"; import type { Product } from "@/types/api"; -import { addToCart, removeFromCart } from "@/store/slices/cartSlice"; -// فقط متدهای اکشن (Mutation) ایمپورت می‌شوند import { useAddFavorite, useDeleteFavorite } from "@/services/useFavorites"; interface ProductCardProps { product: Product; - // پراپ‌های جدید برای مدیریت علاقه‌مندی از سمت والد isFavorited?: boolean; favoriteId?: number; } export const ProductCard = ({ product, isFavorited = false, favoriteId }: ProductCardProps) => { - const dispatch = useDispatch(); - const { mutate: addFavorite, isPending: isAdding } = useAddFavorite(); const { mutate: removeFavorite, isPending: isRemoving } = useDeleteFavorite(); @@ -26,30 +20,14 @@ export const ProductCard = ({ product, isFavorited = false, favoriteId }: Produc const mainImage = product.images && product.images.length > 0 ? product.images[0] : "/placeholder.png"; const isOutOfStock = product.stock === 0; - const cartItem = useSelector((state: any) => state.cart.items.find((item: any) => item.id === String(product.id))); - const quantityInCart = cartItem ? cartItem.quantity : 0; - - const handleAddToCart = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (isOutOfStock) return; - - dispatch( - addToCart({ - id: String(product.id), - name: product.name, - price: product.final_price || 0, - quantity: 1, - image: mainImage, - }), - ); - }; - - const handleRemoveFromCart = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - dispatch(removeFromCart(String(product.id))); - }; + // محاسبه وضعیت تخفیف + // فرض بر این است که قیمت اصلی در product.price قرار دارد. + // در صورت تفاوت نام فیلد در بک‌اند، این قسمت را ویرایش کنید. + const originalPrice = (product as any).price || product.final_price; + const hasDiscount = originalPrice && product.final_price ? originalPrice > product.final_price : false; + const discountPercent = hasDiscount + ? Math.round(((originalPrice - (product.final_price || 0)) / originalPrice) * 100) + : 0; const handleToggleFavorite = (e: React.MouseEvent) => { e.preventDefault(); @@ -58,16 +36,14 @@ export const ProductCard = ({ product, isFavorited = false, favoriteId }: Produc if (isFavoriteLoading) return; if (isFavorited && favoriteId) { - // حذف از علاقه‌مندی‌ها با استفاده از شناسه رکورد پاس داده شده removeFavorite(favoriteId); } else { - // افزودن محصول به علاقه‌مندی‌ها addFavorite(product.id); } }; return ( -
+
) : ( - // استفاده از کلاس fill-current برای توپر شدن قلب در صورت علاقه‌مندی )} @@ -132,55 +107,40 @@ export const ProductCard = ({ product, isFavorited = false, favoriteId }: Produc
-
-
- {isOutOfStock ? ( - در حال حاضر موجود نیست - ) : ( -
- - {product.final_price?.toLocaleString()} - - تومان +
+ {isOutOfStock ? ( + + در حال حاضر موجود نیست + + ) : ( + <> + {/* جایگزین سبد خرید: نمایش درصد تخفیف (در صورت وجود) */} +
+ {hasDiscount ? ( + + {discountPercent}٪ + + ) : ( +
// فضای خالی برای تراز ماندن المان‌ها + )}
- )} -
-
- {isOutOfStock ? ( - - ) : quantityInCart > 0 ? ( -
- - - {quantityInCart} - - + {/* بخش قیمت اصلی خط‌خورده و قیمت نهایی */} +
+ {hasDiscount && ( + + {originalPrice?.toLocaleString()} + + )} +
+ + {product.final_price?.toLocaleString()} + + تومان +
- ) : ( - - )} -
+ + )}
diff --git a/src/services/useComment.ts b/src/services/useComment.ts new file mode 100644 index 0000000..647ca64 --- /dev/null +++ b/src/services/useComment.ts @@ -0,0 +1,136 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { axiosInstance } from "@/lib/axios"; + +// ========================================== +// 1. تعریف تایپ‌ها (Types & Interfaces) +// ========================================== + +export interface ProductSummary { + id: number; + name: string; + slug: string; +} + +export interface Comment { + id: number; + product_id: number; + user_id: number; + author: string; + parent_id: number | null; + title: string | null; + comment: string; + is_verified_purchase: boolean; + helpful_count: number; + created_at: string; + replies: Comment[]; // پشتیبانی از کامنت‌های تودرتو (Threaded) +} + +export interface CommentsResponse { + product: ProductSummary; + comments: Comment[]; + total: number; + pages: number; + current_page: number; + per_page: number; + has_next: boolean; + has_prev: boolean; +} + +export interface CommentFilters { + page?: number; + per_page?: number; +} + +export interface CreateCommentPayload { + comment: string; + title?: string; + parent_id?: number; + is_verified_purchase?: boolean; +} + +export interface UpdateCommentPayload { + title?: string; + comment?: string; + is_verified_purchase?: boolean; +} + +// ========================================== +// 2. هوک‌های دریافت داده (Queries) +// ========================================== + +// دریافت کامنت‌های یک محصول همراه با صفحه‌بندی +export const useProductComments = (productId: string | number, page:number = 1) => { + return useQuery({ + queryKey: ["product-comments", productId, page], + queryFn: async () => { + const { data } = await axiosInstance.get(`/get_product_comments/${productId}/${page}`); + return data; + }, + enabled: !!productId, + retry: false, + }); +}; + +// ========================================== +// 3. هوک‌های تغییر داده (Mutations) +// ========================================== + +// ثبت کامنت جدید برای محصول +export const useAddProductComment = (productId: string | number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: CreateCommentPayload) => { + const { data } = await axiosInstance.post<{ message: string; comment: Comment }>( + `/add_product_comment/${productId}`, + payload, + ); + return data; + }, + onSuccess: () => { + // پس از ثبت موفق، لیست کامنت‌های این محصول را بروزرسانی می‌کنیم + queryClient.invalidateQueries({ queryKey: ["product-comments", productId] }); + }, + }); +}; + +// ویرایش کامنت موجود +export const useEditProductComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ commentId, payload }: { commentId: string | number; payload: UpdateCommentPayload }) => { + const { data } = await axiosInstance.put<{ message: string; comment: Comment }>( + `/edit_product_comment/${commentId}`, + payload, + ); + return data; + }, + onSuccess: (data) => { + // برای سادگی، کل کامنت‌های مربوط به محصولِ این کامنت بروزرسانی می‌شود + if (data.comment?.product_id) { + queryClient.invalidateQueries({ queryKey: ["product-comments", data.comment.product_id] }); + } + }, + }); +}; + +// حذف کامنت +export const useDeleteProductComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (commentId: string | number) => { + const { data } = await axiosInstance.delete<{ message: string; id_deleted: number }>( + `/delete_product_comment/${commentId}`, + ); + return data; + }, + onSuccess: () => { + // از آنجایی که ID محصول را به صورت مستقیم در پاسخ حذف نداریم، + // می‌توانیم تمام کش‌های مربوط به کامنت‌ها را منقضی کنیم + // یا آن را از کامپوننت فراخوانی کننده مدیریت کنیم. + queryClient.invalidateQueries({ queryKey: ["product-comments"] }); + }, + }); +}; diff --git a/src/services/useFavorites.ts b/src/services/useFavorites.ts index e7ba610..c5e4c8c 100644 --- a/src/services/useFavorites.ts +++ b/src/services/useFavorites.ts @@ -8,13 +8,14 @@ interface FavoritesResponse { } // 1. دریافت لیست علاقه‌مندی‌ها -export const useFavorites = () => { +export const useFavorites = (isAuthenticated: boolean = true) => { return useQuery({ queryKey: ["favorites"], queryFn: async () => { const { data } = await axiosInstance.get("/api/users/me/favorites"); return data; }, + enabled: isAuthenticated, retry:false }); }; diff --git a/src/services/useProducts.ts b/src/services/useProducts.ts index 00ff50a..aaa404e 100644 --- a/src/services/useProducts.ts +++ b/src/services/useProducts.ts @@ -1,10 +1,16 @@ import { useQuery } from "@tanstack/react-query"; import { axiosInstance } from "@/lib/axios"; -import { Product, PaginationMeta } from "@/types/api"; +import { Product, SearchProductsParams, SearchProductsResponse } from "@/types/api"; interface ProductsResponse { products: Product[]; - pagination: PaginationMeta; + // pagination: PaginationMeta; + current_page: number; + has_prev: Boolean; + has_next: number; + page: number; + per_page: number; + total: number; } interface ProductFilters { @@ -23,7 +29,7 @@ export const useProducts = (filters: ProductFilters) => { }); return data; }, - retry:false + retry: false, }); }; @@ -36,6 +42,27 @@ export const useProductDetail = (productId: string | number) => { return data; }, enabled: !!productId, // کوئری فقط زمانی اجرا می‌شود که آیدی محصول وجود داشته باشد - retry:false + retry: false, + }); +}; + +const searchProducts = async (params: SearchProductsParams): Promise => { + // جدا کردن شماره صفحه از بقیه پارامترها برای قرار دادن در URL + const { page = 1, ...bodyParams } = params; + + // فراخوانی API با متد POST + const response = await axiosInstance.post(`/api/products/search/${page}`, bodyParams); + + return response.data; +}; + +export const useSearchProducts = (params: SearchProductsParams) => { + return useQuery({ + // کلید کوئری: هر تغییری در params باعث فچ شدن مجدد دیتا می‌شود + queryKey: ["products", "search", params], + queryFn: () => searchProducts(params), + // در صورت نیاز به حفظ دیتای قبلی هنگام تغییر صفحه (برای جلوگیری از پرش UI) + // keepPreviousData: true, + // staleTime: 60000, // در صورت نیاز به تنظیم کش }); }; diff --git a/src/services/useSearch.ts b/src/services/useSearch.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/services/useTickets.ts b/src/services/useTickets.ts index 6923af0..7567278 100644 --- a/src/services/useTickets.ts +++ b/src/services/useTickets.ts @@ -7,7 +7,7 @@ export const useTickets = (page: number = 1) => { return useQuery({ queryKey: ["tickets", page], queryFn: async () => { - const { data } = await axiosInstance.get("/api/tickets", { params: { page } }); + const { data } = await axiosInstance.get(`/api/tickets/${page}`); return data; }, retry:false diff --git a/src/services/useUser.ts b/src/services/useUser.ts index 00bd848..c14a69e 100644 --- a/src/services/useUser.ts +++ b/src/services/useUser.ts @@ -12,7 +12,8 @@ export type UpdateProfilePayload = { }; // 1. دریافت اطلاعات کاربر فعلی (GET) -export const useUserProfile = () => { +// پارامتر isAuthenticated اضافه شد تا اجرای ریکوئست را کنترل کند +export const useUserProfile = (isAuthenticated: boolean = true) => { return useQuery({ // استفاده از یک کلید یکتا برای کش کردن اطلاعات کاربر queryKey: ["userProfile"], @@ -20,7 +21,9 @@ export const useUserProfile = () => { const { data } = await axiosInstance.get("/api/users/me"); return data; }, - retry:false + // تا زمانی که isAuthenticated برابر با false باشد، این ریکوئست به سمت سرور ارسال نمی‌شود + enabled: isAuthenticated, + retry: false }); }; @@ -38,6 +41,6 @@ export const useUpdateUserProfile = () => { // تا اطلاعات جدید بلافاصله از سرور دریافت و در رابط کاربری (UI) بروزرسانی شود queryClient.invalidateQueries({ queryKey: ["userProfile"] }); }, - retry:false + retry: false }); }; diff --git a/src/types/api.ts b/src/types/api.ts index f5426f8..5f9509a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -19,7 +19,7 @@ export interface Category { level: number; full_path: string; product_count: number; - image_url: string | null; + image_url: string | null; is_active: boolean; children?: Category[]; } @@ -48,17 +48,21 @@ export interface Product { specifications?: Record; } -export interface PaginationMeta { - page: number; - per_page: number; - total_pages: number; - total_items: number; -} +// export interface PaginationMeta { +// page: number; +// per_page: number; +// total_pages: number; +// total_items: number; +// } // ایجاد اینترفیس اختصاصی برای ریسپانس لیست محصولات export interface ProductsResponse { products: Product[]; - pagination: PaginationMeta; + // pagination: PaginationMeta; + page: number; + per_page: number; + total_pages: number; + total_items: number; } // سایر تایپ‌های شما ... @@ -89,3 +93,29 @@ export interface ReplyTicketPayload { ticket_id: number; content: string; } + +// تایپ مربوط به پارامترهای ارسالی به API +export interface SearchProductsParams { + page?: number; + q?: string; + min_price?: number; + max_price?: number; + category_id?: number | string; + is_active?: boolean; + is_digital?: boolean; + in_stock?: boolean; + sort_by?: "price_asc" | "price_desc" | "rating_desc" | "new"; + page_per?: number; +} + +// تایپ پاسخ دریافتی از سرور +export interface SearchProductsResponse { + products: Product[]; + total: number; + pages: number; + current_page: number; + per_page: number; // یا page_per (بر اساس متن فایل در صفحات مختلف دوگانگی وجود دارد) + has_next: boolean; // یا next_has + has_prev: boolean; // یا prev_has + query: Record; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index b522807..eb6b2b6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -58,7 +58,26 @@ const config: Config = { }, }, }, - plugins: [require("tailwindcss-animate")], + // plugins: [require("tailwindcss-animate")], + + plugins: [ + require("tailwindcss-animate"), + function ({ addUtilities }) { + addUtilities({ + ".scrollbar-hide": { + "-ms-overflow-style": "none", + "scrollbar-width": "none", + "&::-webkit-scrollbar": { display: "none" }, + }, + ".text-shadow-white": { + textShadow: '4px -2px 4px #fff, 2px 2px 4px #fff', + }, + ".text-shadow-gray": { + textShadow: '4px -2px 4px rgba(0,0,0,0.2), 2px 2px 4px rgba(0,0,0,0.2)', + }, + }); + }, + ], }; export default config;