feat: full symptoms filtering system — backend API, megamenu pills, sidebar server-side filter, PDP tags
This commit is contained in:
parent
4676f3a592
commit
6dafe86344
@ -12,4 +12,9 @@ export class GetProductsDto extends PaginationDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['سگ', 'گربه', 'all'])
|
||||
petType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس یک علامت درمانی خاص' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
symptom?: string;
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export class ProductsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(filters: GetProductsDto) {
|
||||
const { category, petType, search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
||||
const { category, petType, search, symptom, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
||||
|
||||
const whereClause: any = {};
|
||||
|
||||
@ -19,12 +19,42 @@ export class ProductsService {
|
||||
whereClause.suitableFor = { in: [petType, 'هر دو'] };
|
||||
}
|
||||
|
||||
// Filter by a specific symptom (from URL param ?symptom=...)
|
||||
if (symptom) {
|
||||
whereClause.symptoms = {
|
||||
some: {
|
||||
symptom: { contains: symptom, mode: 'insensitive' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
// If symptom filter is already applied, extend via AND to also search names/desc
|
||||
// If not, use OR across names, description AND symptoms
|
||||
const searchConditions = [
|
||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
||||
{
|
||||
symptoms: {
|
||||
some: {
|
||||
symptom: { contains: search, mode: 'insensitive' }
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (symptom) {
|
||||
// Already have a symptom filter; combine with AND
|
||||
whereClause.AND = [
|
||||
{ symptoms: whereClause.symptoms },
|
||||
{ OR: searchConditions.filter(c => !('symptoms' in c)) },
|
||||
];
|
||||
delete whereClause.symptoms;
|
||||
} else {
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
@ -22,7 +22,11 @@ export default async function Shop({ searchParams }: { searchParams: Promise<{ [
|
||||
const category = typeof resolvedParams.category === 'string' ? resolvedParams.category : "all";
|
||||
const search = typeof resolvedParams.search === 'string' ? resolvedParams.search : "";
|
||||
const petType = typeof resolvedParams.petType === 'string' ? resolvedParams.petType : "all";
|
||||
const symptoms = typeof resolvedParams.symptoms === 'string' ? resolvedParams.symptoms : "";
|
||||
|
||||
// Support both ?symptoms= (multi, from sidebar URL state) and ?symptom= (single, from megamenu/PDP links)
|
||||
const symptomsParam = typeof resolvedParams.symptoms === 'string' ? resolvedParams.symptoms : "";
|
||||
const symptomSingle = typeof resolvedParams.symptom === 'string' ? resolvedParams.symptom : "";
|
||||
const symptoms = symptomsParam || (symptomSingle ? symptomSingle : "");
|
||||
|
||||
return (
|
||||
<ArchivePage
|
||||
|
||||
@ -278,26 +278,28 @@ export default function ArchivePage({
|
||||
router.push(newUrl, { scroll: false });
|
||||
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
const fetchProducts = async (resetPage = true) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const res = await productService.getProducts({
|
||||
category: selectedCategory,
|
||||
petType: selectedPet,
|
||||
query: searchQuery,
|
||||
page: 1,
|
||||
// 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 (activeSymptoms.length > 0) {
|
||||
result = result.filter(p => p.symptoms.some(s => activeSymptoms.includes(s)));
|
||||
// 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);
|
||||
setPage(1);
|
||||
if (resetPage) setPage(1);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
toast.error("خطا در دریافت لیست محصولات از سرور. لطفاً صفحه را رفرش کنید.");
|
||||
@ -306,7 +308,8 @@ export default function ArchivePage({
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
useEffect(() => {
|
||||
fetchProducts(true);
|
||||
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms]);
|
||||
|
||||
const loadMore = async () => {
|
||||
@ -316,6 +319,7 @@ export default function ArchivePage({
|
||||
category: selectedCategory,
|
||||
petType: selectedPet,
|
||||
query: searchQuery,
|
||||
symptom: activeSymptoms.length === 1 ? activeSymptoms[0] : undefined,
|
||||
page: page + 1,
|
||||
limit: 20,
|
||||
});
|
||||
@ -418,15 +422,31 @@ export default function ArchivePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Symptoms Checklist */}
|
||||
{/* Symptoms Pill Tags */}
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block mb-3">جستجو بر اساس علائم</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{symptoms.map(s => (
|
||||
<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-3 py-1.5 rounded-lg text-[10px] font-bold transition-all border ${activeSymptoms.includes(s) ? 'bg-medical-gray-900 border-medical-gray-900 text-white' : 'bg-medical-gray-50 border-medical-gray-100 text-medical-gray-500 hover:border-medical-gray-300'}`}
|
||||
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>
|
||||
|
||||
@ -154,33 +154,45 @@ export default function Header({
|
||||
initial={{ opacity: 0, y: 15, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 15, scale: 0.98 }}
|
||||
className="absolute top-[80%] right-0 w-[640px] bg-white border border-medical-gray-100 shadow-2xl rounded-[2.5rem] p-10 grid grid-cols-2 gap-10 z-50 pointer-events-auto"
|
||||
className="absolute top-[80%] right-0 w-[680px] bg-white border border-medical-gray-100 shadow-2xl rounded-[2.5rem] p-8 grid grid-cols-2 gap-8 z-50 pointer-events-auto"
|
||||
>
|
||||
{menuItems.map((item, idx) => (
|
||||
<div key={idx} className="group/item">
|
||||
<Link
|
||||
href={`/shop?category=${item.id}`}
|
||||
onClick={() => setIsMegaMenuOpen(false)}
|
||||
className="flex items-center gap-4 mb-4 cursor-pointer block"
|
||||
className="flex items-center gap-3 mb-4 cursor-pointer block"
|
||||
>
|
||||
<div className="p-3 bg-medical-gray-50 rounded-2xl text-canina-blue group-hover/item:bg-canina-blue group-hover/item:text-white transition-all shadow-sm">
|
||||
<div className="p-2.5 bg-medical-gray-50 rounded-xl text-canina-blue group-hover/item:bg-canina-blue group-hover/item:text-white transition-all shadow-sm">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h4 className="font-black text-medical-gray-900 text-sm font-vazir whitespace-nowrap">{item.title}</h4>
|
||||
<h4 className="font-black text-medical-gray-900 text-sm font-vazir">{item.title}</h4>
|
||||
</Link>
|
||||
<ul className="space-y-2.5 border-r-2 border-medical-gray-50 pr-4">
|
||||
{item.solutions.map((sol, sIdx) => (
|
||||
<li key={sIdx}>
|
||||
{item.solutions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 border-r-2 border-medical-gray-100 pr-3">
|
||||
{item.solutions.slice(0, 8).map((sol, sIdx) => (
|
||||
<Link
|
||||
href={`/shop?category=${item.id}&symptoms=${sol}`}
|
||||
className="text-[12px] text-medical-gray-400 hover:text-canina-blue hover:translate-x-[-4px] transition-all font-bold cursor-pointer font-vazir whitespace-nowrap block"
|
||||
key={sIdx}
|
||||
href={`/shop?category=${item.id}&symptom=${encodeURIComponent(sol)}`}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold bg-medical-gray-50 border border-medical-gray-200 text-medical-gray-500 hover:bg-canina-blue/5 hover:border-canina-blue/30 hover:text-canina-blue transition-all cursor-pointer font-vazir whitespace-nowrap"
|
||||
onClick={() => setIsMegaMenuOpen(false)}
|
||||
>
|
||||
{sol}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
{item.solutions.length > 8 && (
|
||||
<Link
|
||||
href={`/shop?category=${item.id}`}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold text-canina-blue hover:underline font-vazir whitespace-nowrap"
|
||||
onClick={() => setIsMegaMenuOpen(false)}
|
||||
>
|
||||
+{item.solutions.length - 8} بیشتر
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-medical-gray-400 border-r-2 border-medical-gray-100 pr-3 font-vazir">مشاهده همه محصولات</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@ -267,6 +267,30 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* ─── Symptoms / Therapeutic Indications Section ─── */}
|
||||
{fullProduct.symptoms && fullProduct.symptoms.length > 0 && (
|
||||
<section className="bg-canina-blue/[0.03] border border-canina-blue/10 rounded-[2rem] p-6 md:p-8 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-canina-blue/10 text-canina-blue rounded-xl flex items-center justify-center">
|
||||
<Stethoscope className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-base font-black text-medical-gray-900 font-vazir">علائم و موارد درمانی مرتبط</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-medical-gray-400 font-vazir font-bold">این محصول برای رفع علائم زیر توسط دامپزشکان توصیه میشود:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fullProduct.symptoms.map((symptom: string, idx: number) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={`/shop?symptom=${encodeURIComponent(symptom)}`}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-[11px] font-bold bg-white border border-canina-blue/20 text-canina-blue hover:bg-canina-blue hover:text-white hover:border-canina-blue transition-all shadow-sm cursor-pointer font-vazir whitespace-nowrap"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-60" />
|
||||
{symptom}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Key Benefits Section */}
|
||||
{fullProduct.keyBenefits && fullProduct.keyBenefits.length > 0 && (
|
||||
|
||||
@ -85,6 +85,7 @@ export class ProductService {
|
||||
category?: string;
|
||||
petType?: PetType | "all";
|
||||
query?: string;
|
||||
symptom?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ data: Product[]; meta: { total: number; page: number; lastPage: number; limit: number } }> {
|
||||
@ -92,6 +93,7 @@ export class ProductService {
|
||||
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
|
||||
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
|
||||
if (filters?.query) params.append('search', filters.query);
|
||||
if (filters?.symptom) params.append('symptom', filters.symptom);
|
||||
params.append('page', String(filters?.page || 1));
|
||||
params.append('limit', String(filters?.limit || 12));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user