feat: implement DEV_TICKETS including product categories seeding, SEO metaTitle/metaDescription, scientificTagline, requiresRx notice, and suitableFor normalization
Some checks failed
Deploy Canina / deploy (push) Successful in 2m19s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s

This commit is contained in:
پارسا آقایی 2026-09-24 03:20:46 +03:30
parent 913e6acd3d
commit d6d4158cbe
7 changed files with 116 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -526,7 +526,7 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
>
<SafeImage
src={activeMedia?.url || product.image}
alt={product.name}
alt={product.featuredImageAlt || product.nameFa || product.name}
priority={true}
sizes="(max-width: 768px) 100vw, 400px"
className="w-full h-full"
@ -727,14 +727,39 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
{product.nameEn}
</h2>
)}
</div>
<p className="text-base sm:text-xl text-canina-blue font-bold italic opacity-80 leading-relaxed max-w-2xl font-vazir mb-4 text-center md:text-right" dir="rtl">
{isMounted && pets.length > 0 ? (
product.optimisticTemplate?.replace("[PetName]", activePet?.name || "").replace("[OnSet]", product.onSetOfAction || "به زودی")
) : (
product.scientificTagline
{product.scientificTagline && (
<div className="mt-1 text-xs sm:text-sm md:text-base text-canina-blue font-bold font-vazir flex items-center justify-center md:justify-start gap-1.5" dir="rtl">
<span className="w-1.5 h-1.5 rounded-full bg-canina-blue shrink-0" />
<span>{product.scientificTagline}</span>
</div>
)}
</p>
</div>
{/* B4 / Pet Optimistic OnSet Template (if pet selected) */}
{isMounted && pets.length > 0 && product.optimisticTemplate && (
<p className="text-xs sm:text-sm text-canina-blue font-bold italic opacity-90 leading-relaxed max-w-2xl font-vazir mb-3 text-center md:text-right" dir="rtl">
{product.optimisticTemplate?.replace("[PetName]", activePet?.name || "").replace("[OnSet]", product.onSetOfAction || "به زودی")}
</p>
)}
{/* A9: Veterinary Prescription Notice (requiresRx) */}
{product.requiresRx && (
<div className="w-full text-right bg-amber-50 border-r-4 border-amber-500 p-3 sm:p-4 rounded-2xl mb-3 flex items-start gap-3 shadow-2xs" dir="rtl">
<div className="w-8 h-8 rounded-xl bg-amber-500/10 text-amber-700 flex items-center justify-center shrink-0 mt-0.5">
<Stethoscope className="w-4 h-4" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-black text-amber-950 font-vazir">داروی تخصصی تحت نظارت دامپزشکی (Rx Required)</span>
<span className="text-[10px] bg-amber-200/80 text-amber-900 font-black px-2 py-0.5 rounded-full">نسخهای</span>
</div>
<p className="text-[11px] sm:text-xs text-amber-900/90 font-medium font-vazir leading-relaxed">
این فرآورده دارویی بالینی با دوز درمانی ویژه میباشد. جهت تضمین سلامت پت، مصرف آن تحت نظر دامپزشک توصیه شده و ارسال سفارش پس از هماهنگی واحد پشتیبانی تخصصی انجام خواهد گرفت.
</p>
</div>
</div>
)}
{fullProduct.shortDescription && (
<div className="w-full text-right bg-canina-blue/5 border-r-4 border-canina-blue p-4 sm:p-6 rounded-2xl mb-2">
<p className="text-sm sm:text-lg text-medical-gray-600 font-bold leading-relaxed font-vazir">