feat: full symptoms filtering system — backend API, megamenu pills, sidebar server-side filter, PDP tags

This commit is contained in:
parsa aghaei 2026-07-15 15:03:09 +03:30
parent 4676f3a592
commit 6dafe86344
7 changed files with 124 additions and 27 deletions

View File

@ -12,4 +12,9 @@ export class GetProductsDto extends PaginationDto {
@IsOptional()
@IsEnum(['سگ', 'گربه', 'all'])
petType?: string;
@ApiPropertyOptional({ description: 'فیلتر بر اساس یک علامت درمانی خاص' })
@IsOptional()
@IsString()
symptom?: string;
}

View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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 && (

View File

@ -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));