From d6d4158cbe6c581b8e6ef50a244fff2a1059cb99 Mon Sep 17 00:00:00 2001 From: parsaaghayi Date: Thu, 24 Sep 2026 03:20:46 +0330 Subject: [PATCH] feat: implement DEV_TICKETS including product categories seeding, SEO metaTitle/metaDescription, scientificTagline, requiresRx notice, and suitableFor normalization --- backend/src/admin/admin.service.ts | 20 ++++++++- backend/src/admin/categories.service.ts | 32 ++++++++++++++- backend/src/products/dto/get-products.dto.ts | 4 +- backend/src/products/products.service.ts | 14 +++++-- frontend/admin-panel/src/pages/Products.tsx | 9 +++- frontend/application/app/shop/[slug]/page.tsx | 20 ++++++--- .../application/components/ProductPage.tsx | 41 +++++++++++++++---- 7 files changed, 116 insertions(+), 24 deletions(-) diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 9027df8..5c3fd07 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -508,6 +508,14 @@ export class AdminService { : {}), }; + let resolvedCategorySlug = data.categorySlug; + if (data.categoryId && (!resolvedCategorySlug || resolvedCategorySlug === 'general')) { + const cat = await this.prisma.category.findUnique({ where: { id: data.categoryId } }); + if (cat) { + resolvedCategorySlug = cat.slug; + } + } + const product = await this.prisma.product.create({ data: { artNo: data.artNo || `ART-${Date.now()}`, @@ -518,7 +526,7 @@ export class AdminService { shortDescription: data.shortDescription || '', ingredients: data.ingredients || null, categoryId: data.categoryId || '', - categorySlug: data.categorySlug || 'general', + categorySlug: resolvedCategorySlug || 'general', buyPrice: data.buyPrice !== undefined ? data.buyPrice : 0, marginRetailPercent: marginRetail !== undefined ? marginRetail : undefined, @@ -662,6 +670,14 @@ export class AdminService { : {}), }; + let resolvedCategorySlug = data.categorySlug; + if (data.categoryId && !resolvedCategorySlug) { + const cat = await this.prisma.category.findUnique({ where: { id: data.categoryId } }); + if (cat) { + resolvedCategorySlug = cat.slug; + } + } + const product = await this.prisma.product.update({ where: { id }, data: { @@ -673,7 +689,7 @@ export class AdminService { shortDescription: data.shortDescription, ingredients: data.ingredients !== undefined ? data.ingredients : undefined, categoryId: data.categoryId, - categorySlug: data.categorySlug, + categorySlug: resolvedCategorySlug !== undefined ? resolvedCategorySlug : undefined, buyPrice: data.buyPrice !== undefined ? data.buyPrice : undefined, marginRetailPercent: marginRetail !== undefined ? marginRetail : undefined, diff --git a/backend/src/admin/categories.service.ts b/backend/src/admin/categories.service.ts index 84a2fb3..a276d32 100644 --- a/backend/src/admin/categories.service.ts +++ b/backend/src/admin/categories.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -9,9 +9,37 @@ export class CategoryQuery { } @Injectable() -export class CategoriesService { +export class CategoriesService implements OnModuleInit { constructor(private prisma: PrismaService) {} + async onModuleInit() { + await this.seedDefaultCategories(); + } + + private async seedDefaultCategories() { + try { + const defaultCategories = [ + { name: 'مفاصل و استخوان', slug: 'joints', description: 'مکمل‌های ارتوپدی و مفاصل حاوی عصاره صدف سبز و GAG' }, + { name: 'سیستم ایمنی و گوارش', slug: 'immune', description: 'تقویت سیستم دفاعی بدن و پروبیوتیک‌های تخصصی' }, + { name: 'ویتامین‌ها و انرژی‌بخش‌ها', slug: 'energy', description: 'مولتی‌ویتامین‌ها و تونیک‌های انرژی‌بخش' }, + { name: 'مراقبت‌های ویژه (پوست، دندان و چشم)', slug: 'special-care', description: 'محلول‌های نقره، قطره‌های شستشو و ژل دندان' }, + { name: 'تقویت عمومی', slug: 'general', description: 'مکمل‌های پایه‌ای و روزانه سگ و گربه' }, + { name: 'تغذیه تخصصی', slug: 'nutrition', description: 'شیر خشک، پودر خون گاو و مکمل‌های تخصصی پرورش' }, + { name: 'مکمل‌های غذایی و درمانی', slug: 'supplements', description: 'طیف کامل مکمل‌های گرید دارویی Canina' }, + ]; + + for (const cat of defaultCategories) { + await this.prisma.category.upsert({ + where: { slug: cat.slug }, + update: { name: cat.name, description: cat.description }, + create: { name: cat.name, slug: cat.slug, description: cat.description }, + }); + } + } catch (err) { + console.warn('[CategoriesService] Failed to seed default categories:', err); + } + } + async getCategories(query: CategoryQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; diff --git a/backend/src/products/dto/get-products.dto.ts b/backend/src/products/dto/get-products.dto.ts index 66df75d..9f72321 100644 --- a/backend/src/products/dto/get-products.dto.ts +++ b/backend/src/products/dto/get-products.dto.ts @@ -15,10 +15,10 @@ export class GetProductsDto extends PaginationDto { @ApiPropertyOptional({ description: 'فیلتر بر اساس نوع حیوان', - enum: ['سگ', 'گربه', 'all'], + enum: ['سگ', 'گربه', 'سگ و گربه', 'هر دو', 'all'], }) @IsOptional() - @IsEnum(['سگ', 'گربه', 'all']) + @IsEnum(['سگ', 'گربه', 'سگ و گربه', 'هر دو', 'all']) petType?: string; @ApiPropertyOptional({ description: 'فیلتر بر اساس یک علامت درمانی خاص' }) diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 6ce64d5..c040c0b 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -21,7 +21,7 @@ export class ProductsService { minPrice, maxPrice, page = 1, - limit = 10, + limit = 50, sortBy = 'createdAt', sortOrder = 'desc', } = filters; @@ -39,7 +39,9 @@ export class ProductsService { } if (petType && petType !== 'all') { - andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } }); + andConditions.push({ + suitableFor: { in: [petType, 'سگ و گربه', 'هر دو'] }, + }); } if (symptom) { @@ -230,7 +232,13 @@ export class ProductsService { return { categories, symptoms: symptomsDb.map((s) => s.symptom).filter(Boolean), - petTypes: petTypesDb.map((p) => p.suitableFor).filter(Boolean), + petTypes: Array.from( + new Set( + petTypesDb + .map((p) => (p.suitableFor === 'هر دو' ? 'سگ و گربه' : p.suitableFor)) + .filter(Boolean), + ), + ), }; } diff --git a/frontend/admin-panel/src/pages/Products.tsx b/frontend/admin-panel/src/pages/Products.tsx index 72a68f9..abbb7f9 100644 --- a/frontend/admin-panel/src/pages/Products.tsx +++ b/frontend/admin-panel/src/pages/Products.tsx @@ -652,11 +652,16 @@ export default function Products() { // Fetch categories separately try { - const catRes = await api.get('/admin/categories'); - if (catRes.data?.data) { + const catRes = await api.get('/admin/categories/all'); + if (catRes.data?.data && Array.isArray(catRes.data.data)) { setCategories(catRes.data.data); } else if (Array.isArray(catRes.data)) { setCategories(catRes.data); + } else { + const fallbackRes = await api.get('/admin/categories', { params: { limit: 100 } }); + if (fallbackRes.data?.data && Array.isArray(fallbackRes.data.data)) { + setCategories(fallbackRes.data.data); + } } } catch (err) { console.warn('Could not fetch categories', err); diff --git a/frontend/application/app/shop/[slug]/page.tsx b/frontend/application/app/shop/[slug]/page.tsx index 02f1245..62931ff 100644 --- a/frontend/application/app/shop/[slug]/page.tsx +++ b/frontend/application/app/shop/[slug]/page.tsx @@ -21,10 +21,20 @@ export async function generateMetadata( const nameFa = product.nameFa || product.name; const nameEn = product.nameEn ? ` (${product.nameEn})` : ''; - const rawTitle = product.metaTitle || `${nameFa}${nameEn}`; - const title = formatPageTitle(rawTitle, config.brandNameFa, config.titleSeparator, config.titlePosition, false); - const ogTitle = `${title} ${config.titleSeparator} ${config.brandNameFa}`; - const description = product.metaDescription || (product.shortDescription || product.description || '').substring(0, 155).trim() || config.defaultMetaDescription; + + // B1: If custom metaTitle is specified for the product, use it directly (absolute to bypass layout template) + const metaTitleTrimmed = product.metaTitle?.trim(); + const rawTitle = metaTitleTrimmed || `${nameFa}${nameEn}`; + const title = metaTitleTrimmed + ? { absolute: metaTitleTrimmed } + : formatPageTitle(rawTitle, config.brandNameFa, config.titleSeparator, config.titlePosition, false); + + const ogTitle = metaTitleTrimmed || `${typeof title === 'string' ? title : rawTitle} ${config.titleSeparator} ${config.brandNameFa}`; + + // B2: Connect description directly to metaDescription written for all 35 products + const description = product.metaDescription?.trim() || + (product.shortDescription || product.description || '').substring(0, 155).trim() || + config.defaultMetaDescription; const rawKeywords = product.keywords; const keywords = typeof rawKeywords === 'string' @@ -90,7 +100,7 @@ export async function generateMetadata( }, other: { product_id: String(product.artNo || product.id || resolvedParams.slug), - product_name: title, + product_name: rawTitle, product_price: String(product.priceValue || 0), product_old_price: String(product.priceValue || 0), availability: (product.stockStatus === 'DISCONTINUED' || String(product.stockStatus || '').toLowerCase().includes('out')) ? 'outofstock' : 'instock', diff --git a/frontend/application/components/ProductPage.tsx b/frontend/application/components/ProductPage.tsx index 92fd224..b77f729 100644 --- a/frontend/application/components/ProductPage.tsx +++ b/frontend/application/components/ProductPage.tsx @@ -526,7 +526,7 @@ export default function ProductPage({ productSlug }: { productSlug: string }) { > )} - -

- {isMounted && pets.length > 0 ? ( - product.optimisticTemplate?.replace("[PetName]", activePet?.name || "").replace("[OnSet]", product.onSetOfAction || "به زودی") - ) : ( - product.scientificTagline + {product.scientificTagline && ( +

+ + {product.scientificTagline} +
)} -

+ + + {/* B4 / Pet Optimistic OnSet Template (if pet selected) */} + {isMounted && pets.length > 0 && product.optimisticTemplate && ( +

+ {product.optimisticTemplate?.replace("[PetName]", activePet?.name || "").replace("[OnSet]", product.onSetOfAction || "به زودی")} +

+ )} + + {/* A9: Veterinary Prescription Notice (requiresRx) */} + {product.requiresRx && ( +
+
+ +
+
+
+ داروی تخصصی تحت نظارت دامپزشکی (Rx Required) + نسخه‌ای +
+

+ این فرآورده دارویی بالینی با دوز درمانی ویژه می‌باشد. جهت تضمین سلامت پت، مصرف آن تحت نظر دامپزشک توصیه شده و ارسال سفارش پس از هماهنگی واحد پشتیبانی تخصصی انجام خواهد گرفت. +

+
+
+ )} + {fullProduct.shortDescription && (