canina/frontend/application/components/ArchivePage.tsx

538 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useMemo, useEffect } from "react";
import { Product, PetType } from "../lib/data/products";
import { usePetStore } from "../lib/store/usePetStore";
import { productService } from "../lib/services/productService";
import { motion, AnimatePresence } from "motion/react";
import { ProductCardSkeleton } from "./Skeleton";
import { toast } from "sonner";
import {
Search,
Filter,
Dog,
Cat,
ShieldCheck,
ChevronLeft,
ChevronRight,
Activity,
Sparkles,
Stethoscope,
HeartPulse,
Heart,
AlertCircle,
Plus,
Loader2
} from "lucide-react";
import { useCartStore } from "../lib/store/cartStore";
import SafeImage from "./SafeImage";
const ICON_MAP: Record<string, React.ReactNode> = {
joints: <HeartPulse className="w-4 h-4" />,
immune: <ShieldCheck className="w-4 h-4" />,
energy: <Activity className="w-4 h-4" />,
"special-care": <Stethoscope className="w-4 h-4" />,
general: <Heart className="w-4 h-4" />,
nutrition: <Sparkles className="w-4 h-4" />,
};
const CATEGORY_MAP: Record<string, { category?: string; query?: string; symptoms?: string[] }> = {
// Main Categories
"joints": { category: "joints" },
"immune": { category: "immune" },
"energy": { category: "energy" },
"special-care": { category: "special-care" },
// Persian Fallbacks (for robust matching)
"مفاصل و استخوان": { category: "joints" },
"تقویت سیستم ایمنی و گوارش": { category: "immune" },
"ویتامین‌ها و انرژی‌بخش‌ها": { category: "energy" },
"مراقبت‌های ویژه (پوست، دندان و چشم)": { category: "special-care" },
// Solutions
"آرتروز سگ‌های پیر": { category: "joints", symptoms: ["درد مفاصل", "سختی در بلند شدن", "لنگیدن"] },
"رشد استخوانی توله‌سگ": { category: "joints", symptoms: ["رشد سریع توله‌سگ"] },
"تقویت رباط و تاندون": { category: "joints", query: "تاندون" },
"پیشگیری از بیماری": { category: "immune", symptoms: ["ضعف بعد از بیماری"] },
"رفع اسهال و یبوست": { category: "immune", symptoms: ["اسهال", "یبوست", "اسهال مزمن"] },
"پروبیوتیک‌ها": { category: "immune", query: "فیبر" },
"مکمل‌های مولتی‌ویتامین": { category: "energy", query: "ویتامین" },
"افزایش اشتها": { category: "energy", symptoms: ["بی‌اشتهایی"] },
"رشد و بلوغ": { category: "energy", query: "انرژی" },
"سلامت پوست و مفاصل": { category: "special-care", symptoms: ["خشکی پوست", "ریزش مو", "خارش"] },
"رفع جرم دندان": { category: "special-care", symptoms: ["جرم دندان", "بوی بد دهان", "التهاب لثه"] },
"شستشوی چشم": { category: "special-care", query: "چشم" },
};
const ArchiveProductCard: React.FC<{ product: Product, onClick: (p: Product) => void }> = ({ product, onClick }) => {
const { addItem } = useCartStore();
const [isAdding, setIsAdding] = useState(false);
const activePet = usePetStore(state => state.pets.find(p => p.id === state.activePetId) || null);
const handleAddToCart = (e: React.MouseEvent) => {
e.stopPropagation();
setIsAdding(true);
addItem(product, 1);
toast.success(`${product.name} به سبد خرید اضافه شد`);
setTimeout(() => setIsAdding(false), 800);
};
const compatibility = useMemo(() => {
if (!activePet) return null;
const sameSpecies = product.suitableFor === activePet.type || product.suitableFor === "هر دو";
const helpsSymptom = (activePet.medicalConditions || []).some(s => product.symptoms.includes(s));
if (!sameSpecies) return { type: 'alert', text: `مخصوص ${product.suitableFor}` };
if (helpsSymptom) return { type: 'success', text: `توصیه شده برای راهکار ${activePet.name}` };
if (sameSpecies) return { type: 'neutral', text: `مناسب برای ${activePet.name}` };
return null;
}, [product, activePet]);
return (
<motion.div
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => onClick(product)}
className="group bg-white rounded-[2.5rem] border border-medical-gray-200 overflow-hidden hover:shadow-2xl transition-all cursor-pointer flex flex-col h-full relative"
>
{product.specialBadge && (
<div className="absolute top-4 right-4 z-10 bg-canina-blue text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-lg">
{product.specialBadge}
</div>
)}
{compatibility && (
<div className={`absolute top-14 right-4 z-10 text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-md flex items-center gap-1 ${compatibility.type === 'alert' ? 'bg-amber-100 text-amber-700' :
compatibility.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-medical-gray-100 text-medical-gray-600'
}`}>
{compatibility.type === 'alert' ? <AlertCircle className="w-2.5 h-2.5" /> : compatibility.type === 'success' ? <Heart className="w-2.5 h-2.5" /> : <Sparkles className="w-2.5 h-2.5" />}
{compatibility.text}
</div>
)}
<div className="aspect-square bg-medical-gray-50 flex items-center justify-center p-8 overflow-hidden relative">
<SafeImage src={product.image} alt={product.name} className="w-full h-full group-hover:scale-110 transition-transform duration-700" imgClassName="object-contain" />
<div className="absolute bottom-4 left-4 flex gap-2">
{(product.suitableFor === "سگ" || product.suitableFor === "هر دو") && (
<div className="w-7 h-7 bg-white rounded-lg flex items-center justify-center text-medical-gray-400 border border-medical-gray-100 shadow-sm">
<Dog className="w-4 h-4" />
</div>
)}
{(product.suitableFor === "گربه" || product.suitableFor === "هر دو") && (
<div className="w-7 h-7 bg-white rounded-lg flex items-center justify-center text-medical-gray-400 border border-medical-gray-100 shadow-sm">
<Cat className="w-4 h-4" />
</div>
)}
</div>
</div>
<div className="p-6 flex flex-col flex-1">
<div className="text-[10px] font-bold text-canina-blue uppercase tracking-widest mb-2">{product.category}</div>
<div className="flex flex-col gap-1 mb-3">
<h3 dir="rtl" className="text-base font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors leading-tight text-right font-vazir">
{product.nameFa || product.name}
</h3>
{product.nameEn && (
<span dir="ltr" className="text-[11px] font-bold text-slate-400 font-sans text-left block">
{product.nameEn}
</span>
)}
</div>
<div className="flex flex-wrap gap-1">
{product.symptoms.slice(0, 2).map((s, i) => (
<span key={i} className="text-[9px] font-bold bg-medical-gray-100 text-medical-gray-500 px-2 py-0.5 rounded-md">
{s}
</span>
))}
</div>
<div className="mt-auto flex items-center justify-between pt-4 border-t border-medical-gray-50">
<span className="font-black text-medical-gray-900 font-vazir">{product.price}</span>
<button
onClick={handleAddToCart}
disabled={isAdding}
className="w-10 h-10 rounded-xl bg-medical-gray-50 text-canina-blue flex items-center justify-center group-hover:bg-canina-blue group-hover:text-white transition-all disabled:opacity-50"
>
{isAdding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
</button>
</div>
</div>
</motion.div>
);
}
import { useRouter } from 'next/navigation';
export default function ArchivePage({
initialCategory = "all",
initialSearch = "",
initialPetType = "all",
initialSymptoms = ""
}: {
initialCategory?: string,
initialSearch?: string,
initialPetType?: string,
initialSymptoms?: string
}) {
const router = useRouter();
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
const [selectedPet, setSelectedPet] = useState<PetType | "all">(initialPetType as any);
const [activeSymptoms, setActiveSymptoms] = useState<string[]>(initialSymptoms ? initialSymptoms.split(',') : []);
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [isUpdating, setIsUpdating] = useState(false);
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
const [page, setPage] = useState(1);
const [meta, setMeta] = useState({ total: 0, lastPage: 1 });
const [categories, setCategories] = useState<{ id: string; label: string; icon: React.ReactNode }[]>([]);
const [symptoms, setSymptoms] = useState<string[]>([]);
useEffect(() => {
const loadFilters = async () => {
try {
const filters = await productService.getActiveFilters();
const mapped = [
{ id: "all", label: "همه محصولات", icon: <Activity className="w-4 h-4" /> },
...filters.categories.map(c => ({
id: c.slug,
label: c.name,
icon: ICON_MAP[c.slug] || <Activity className="w-4 h-4" />
}))
];
setCategories(mapped);
setSymptoms(filters.symptoms);
} catch (e) {
console.error("Failed to load active filters:", e);
}
};
loadFilters();
}, []);
const CATEGORY_LABELS = useMemo(() => {
const labels: Record<string, string> = { all: "همه محصولات" };
categories.forEach(c => {
labels[c.id] = c.label;
});
return labels;
}, [categories]);
// Sync state with prop change (e.g. from Header menu)
useEffect(() => {
// Normalize Input (Trim and lowercase)
const normSearch = (initialSearch || "").trim().toLowerCase();
const normCat = initialCategory.trim().toLowerCase();
const mapKey = normSearch || normCat;
const mapping = CATEGORY_MAP[mapKey];
if (selectedCategory === initialCategory && searchQuery === initialSearch) {
return;
}
setIsUpdating(true);
// Reset ALL other filters when a new category/solution is selected from menu
setSelectedPet("all");
setActiveSymptoms([]);
setSearchQuery("");
if (mapping) {
if (mapping.category) setSelectedCategory(mapping.category);
if (mapping.query) setSearchQuery(mapping.query);
if (mapping.symptoms) {
setActiveSymptoms(mapping.symptoms);
}
} else {
setSelectedCategory(initialCategory);
setSearchQuery(initialSearch);
if (initialSearch && symptoms.includes(initialSearch)) {
setActiveSymptoms([initialSearch]);
}
}
const timer = setTimeout(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, 400);
return () => clearTimeout(timer);
}, [initialCategory, initialSearch, symptoms]);
// Update URL Query Parameters
useEffect(() => {
const params = new URLSearchParams();
if (selectedCategory && selectedCategory !== "all") params.set('category', selectedCategory);
if (selectedPet && selectedPet !== "all") params.set('petType', selectedPet);
if (searchQuery) params.set('search', searchQuery);
if (activeSymptoms.length > 0) params.set('symptoms', activeSymptoms.join(','));
const queryString = params.toString();
const newUrl = `/shop${queryString ? `?${queryString}` : ''}`;
router.push(newUrl, { scroll: false });
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms]);
const fetchProducts = async (resetPage = true) => {
setIsUpdating(true);
try {
const res = await productService.getProducts({
category: selectedCategory,
petType: selectedPet,
query: searchQuery,
// Send the first active symptom to the backend for server-side filtering
symptom: activeSymptoms.length === 1 ? activeSymptoms[0] : undefined,
page: resetPage ? 1 : page,
limit: 20,
});
let result = res.data;
// If multiple symptoms selected, do client-side intersection (rare case)
if (activeSymptoms.length > 1) {
result = result.filter(p => activeSymptoms.every(s => p.symptoms.includes(s)));
}
setFilteredProducts(result);
setMeta(res.meta);
if (resetPage) setPage(1);
} catch (error) {
console.error("Failed to fetch products:", error);
toast.error("خطا در دریافت لیست محصولات از سرور. لطفاً صفحه را رفرش کنید.");
} finally {
setIsUpdating(false);
}
};
useEffect(() => {
fetchProducts(true);
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms]);
const loadMore = async () => {
if (page >= meta.lastPage) return;
try {
const res = await productService.getProducts({
category: selectedCategory,
petType: selectedPet,
query: searchQuery,
symptom: activeSymptoms.length === 1 ? activeSymptoms[0] : undefined,
page: page + 1,
limit: 20,
});
let newItems = res.data;
if (activeSymptoms.length > 0) {
newItems = newItems.filter(p => p.symptoms.some(s => activeSymptoms.includes(s)));
}
setFilteredProducts(prev => [...prev, ...newItems]);
setMeta(res.meta);
setPage(prev => prev + 1);
} catch (error) {
console.error("Failed to load more:", error);
}
};
const toggleSymptom = (s: string) => {
setActiveSymptoms(prev => prev.includes(s) ? prev.filter(item => item !== s) : [...prev, s]);
};
return (
<div className="min-h-screen bg-medical-gray-50 pb-20 px-4 pt-10 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto">
{/* Mobile Navigation & Breadcrumbs */}
<div className="lg:hidden mb-10 space-y-4">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-medical-gray-500 font-bold hover:text-canina-blue transition-colors"
>
<ChevronRight className="w-5 h-5 font-vazir" />
<span>بازگشت</span>
</button>
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest overflow-x-auto whitespace-nowrap pb-2">
<span className="cursor-pointer hover:text-canina-blue" onClick={() => router.push('/')}>کانینا</span>
<ChevronLeft className="w-2.5 h-2.5" />
<span className="text-canina-blue">فروشگاه تخصصی</span>
</div>
</div>
{/* Desktop Breadcrumbs */}
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
<span className="cursor-pointer hover:text-canina-blue" onClick={() => router.push('/')}>خانه</span>
<ChevronLeft className="w-2.5 h-2.5" />
<span className="cursor-pointer hover:text-canina-blue" onClick={() => { setSelectedCategory("all"); }}>کاتالوگ</span>
{selectedCategory !== "all" && (
<>
<ChevronLeft className="w-2.5 h-2.5" />
<span className="text-canina-blue">{CATEGORY_LABELS[selectedCategory] || selectedCategory}</span>
</>
)}
</div>
<div className="flex flex-col lg:flex-row gap-10">
{/* Sidebar Filters */}
<aside className="lg:w-72 flex-shrink-0 space-y-8">
<div className="bg-white rounded-[2rem] p-6 border border-medical-gray-200 shadow-sm">
<div className="flex items-center gap-2 mb-6">
<Filter className="w-5 h-5 text-canina-blue" />
<h3 className="font-black text-medical-gray-900">فیلترهای تخصصی</h3>
</div>
<div className="space-y-6">
{/* Pet Type */}
<div>
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">نوع پت</span>
<div className="grid grid-cols-3 gap-2">
{[
{ id: "all", label: "هر دو", icon: <Activity className="w-3 h-3" /> },
{ id: "سگ", label: "سگ", icon: <Dog className="w-3 h-3" /> },
{ id: "گربه", label: "گربه", icon: <Cat className="w-3 h-3" /> },
].map(pet => (
<button
key={pet.id}
onClick={() => setSelectedPet(pet.id as any)}
className={`py-2 rounded-xl text-[10px] font-black border transition-all flex flex-col items-center gap-1 ${selectedPet === pet.id ? 'bg-canina-blue border-canina-blue text-white shadow-lg shadow-canina-blue/20' : 'bg-white border-medical-gray-200 text-medical-gray-400'}`}
>
{pet.icon}
{pet.label}
</button>
))}
</div>
</div>
{/* Solutions */}
<div>
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">راهکار درمانی</span>
<div className="flex flex-col gap-2">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-2xl text-xs font-bold transition-all border ${selectedCategory === cat.id ? 'bg-canina-blue border-canina-blue text-white' : 'bg-white border-medical-gray-100 text-medical-gray-600 hover:border-canina-blue/30'}`}
>
{cat.icon}
{cat.label}
</button>
))}
</div>
</div>
{/* Symptoms Pill Tags */}
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">جستجو بر اساس علائم</span>
{activeSymptoms.length > 0 && (
<button
onClick={() => setActiveSymptoms([])}
className="text-[10px] font-bold text-canina-blue hover:underline"
>
پاک کردن
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5 max-h-52 overflow-y-auto scrollbar-thin scrollbar-thumb-medical-gray-200 scrollbar-track-transparent pr-0.5">
{symptoms.length === 0 ? (
<span className="text-[10px] text-medical-gray-400">در حال بارگذاری...</span>
) : symptoms.map(s => (
<button
key={s}
onClick={() => toggleSymptom(s)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold transition-all border whitespace-nowrap ${activeSymptoms.includes(s)
? 'bg-canina-blue border-canina-blue text-white shadow-sm shadow-canina-blue/30'
: 'bg-medical-gray-50 border-medical-gray-200 text-medical-gray-500 hover:border-canina-blue/40 hover:text-canina-blue'
}`}
>
{s}
</button>
))}
</div>
</div>
</div>
</div>
{/* Promo Card */}
<div className="bg-medical-gray-900 rounded-[2rem] p-6 text-white relative overflow-hidden group">
<div className="absolute -top-10 -left-10 w-32 h-32 bg-canina-blue rounded-full blur-3xl opacity-20 group-hover:opacity-40 transition-opacity" />
<h4 className="text-lg font-black italic mb-4 relative z-10 underline decoration-canina-blue underline-offset-8">مشاوره رایگان</h4>
<p className="text-xs text-white/60 leading-relaxed mb-6 relative z-10">اگر نمیدانید کدام ترکیب برای پت شما مناسب است، همین حالا با دامپزشکان ما تماس بگیرید.</p>
<a href="tel:0218888" className="w-full bg-white text-medical-gray-900 py-3 rounded-xl text-xs font-black hover:scale-105 transition-transform relative z-10 flex items-center justify-center">شماره تماس: ۰۲۱-۸۸۸۸</a>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 relative">
<div className="flex flex-col md:flex-row items-center justify-between mb-8 gap-4">
<h2 className="text-3xl font-black text-medical-gray-900">کاتولوگ دارویی <span className="text-canina-blue">Canina</span></h2>
<div className="relative w-full md:w-80">
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
<input
type="text"
placeholder="جستجوی محصول..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-full py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
/>
</div>
</div>
<AnimatePresence mode="wait">
{isUpdating ? (
<div key="skeleton" className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
) : filteredProducts.length > 0 ? (
<>
<div key="grid" className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<AnimatePresence mode="popLayout">
{filteredProducts.map((p: Product) => (
<ArchiveProductCard key={p.id} product={p} onClick={(p) => router.push(`/shop/${p.slug || p.id}`)} />
))}
</AnimatePresence>
</div>
{page < meta.lastPage && (
<div className="flex justify-center mt-10">
<button
onClick={loadMore}
className="bg-canina-blue text-white px-8 py-3 rounded-xl font-black text-sm hover:scale-105 transition-transform shadow-lg shadow-canina-blue/20"
>
بارگذاری محصولات بیشتر
</button>
</div>
)}
</>
) : (
<div key="empty" className="bg-white rounded-[3rem] p-20 text-center border-2 border-dashed border-medical-gray-200 flex flex-col items-center">
<div className="w-20 h-20 bg-medical-gray-50 rounded-full flex items-center justify-center text-medical-gray-300 mb-6">
<Search className="w-10 h-10" />
</div>
<h3 className="text-2xl font-black text-medical-gray-900 mb-2">محصولی یافت نشد!</h3>
<p className="text-medical-gray-400 max-w-sm mb-8 font-bold">با فیلترهای فعلی محصولی مطابق با نیاز شما پیدا نکردیم. لطفاً فیلترها را گستردهتر کنید یا دکمه ریست را بزنید.</p>
<button
onClick={() => {
router.push('/shop');
setSelectedCategory("all");
setSelectedPet("all");
setActiveSymptoms([]);
setSearchQuery("");
}}
className="bg-canina-blue text-white px-8 py-3 rounded-xl font-black text-sm hover:scale-105 transition-transform shadow-lg shadow-canina-blue/20"
>
پاک کردن همه فیلترها و نمایش همه
</button>
</div>
)}
</AnimatePresence>
</main>
</div>
</div>
</div>
);
}