feat: implement DEV_TICKETS including product categories seeding, SEO metaTitle/metaDescription, scientificTagline, requiresRx notice, and suitableFor normalization
This commit is contained in:
parent
913e6acd3d
commit
d6d4158cbe
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -15,10 +15,10 @@ export class GetProductsDto extends PaginationDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'فیلتر بر اساس نوع حیوان',
|
||||
enum: ['سگ', 'گربه', 'all'],
|
||||
enum: ['سگ', 'گربه', 'سگ و گربه', 'هر دو', 'all'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(['سگ', 'گربه', 'all'])
|
||||
@IsEnum(['سگ', 'گربه', 'سگ و گربه', 'هر دو', 'all'])
|
||||
petType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس یک علامت درمانی خاص' })
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user