diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7cd46c9..4ed8d50 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -223,7 +223,13 @@ model Product { metaDescription String? @map("meta_description") @db.Text canonicalUrl String? @map("canonical_url") @db.Text keywords String? @db.Text + stockStatus StockStatus @default(IN_STOCK) @map("stock_status") + noIndex Boolean @default(false) @map("no_index") + noFollow Boolean @default(false) @map("no_follow") + ogImage String? @map("og_image") @db.Text + featuredImageAlt String? @map("featured_image_alt") @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() category Category @relation(fields: [categoryId], references: [id]) ingredientList ProductIngredient[] @@ -242,6 +248,13 @@ model Product { @@map("products") } +enum StockStatus { + IN_STOCK + OUT_OF_STOCK + PRE_ORDER + DISCONTINUED +} + model ProductIngredient { diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 8b6d211..26d0065 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -436,6 +436,11 @@ export class AdminService { metaDescription: data.metaDescription || '', keywords: data.keywords || '', canonicalUrl: data.canonicalUrl || '', + stockStatus: data.stockStatus || 'IN_STOCK', + noIndex: data.noIndex ?? false, + noFollow: data.noFollow ?? false, + ogImage: data.ogImage || null, + featuredImageAlt: data.featuredImageAlt || null, slug: data.slug || data.artNo || `slug-${Date.now()}`, }, }); @@ -511,6 +516,11 @@ export class AdminService { metaDescription: data.metaDescription, keywords: data.keywords, canonicalUrl: data.canonicalUrl, + stockStatus: data.stockStatus !== undefined ? data.stockStatus : undefined, + noIndex: data.noIndex !== undefined ? data.noIndex : undefined, + noFollow: data.noFollow !== undefined ? data.noFollow : undefined, + ogImage: data.ogImage !== undefined ? data.ogImage : undefined, + featuredImageAlt: data.featuredImageAlt !== undefined ? data.featuredImageAlt : undefined, slug: data.slug || data.artNo, }, }); diff --git a/backend/src/admin/dto/product.dto.ts b/backend/src/admin/dto/product.dto.ts index da25703..62a4201 100644 --- a/backend/src/admin/dto/product.dto.ts +++ b/backend/src/admin/dto/product.dto.ts @@ -198,6 +198,31 @@ export class ProductDto { @IsArray() symptoms?: string[]; + @ApiPropertyOptional({ description: 'وضعیت موجودی کالا (IN_STOCK, OUT_OF_STOCK, PRE_ORDER, DISCONTINUED)' }) + @IsOptional() + @IsString() + stockStatus?: 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED'; + + @ApiPropertyOptional({ description: 'عدم ایندکس توسط موتورهای جستجو' }) + @IsOptional() + @IsBoolean() + noIndex?: boolean; + + @ApiPropertyOptional({ description: 'عدم دنبال کردن لینک‌ها توسط موتورهای جستجو' }) + @IsOptional() + @IsBoolean() + noFollow?: boolean; + + @ApiPropertyOptional({ description: 'تصویر کارت شبکه‌های اجتماعی (og:image)' }) + @IsOptional() + @IsString() + ogImage?: string | null; + + @ApiPropertyOptional({ description: 'متن جایگزین تصویر اصلی (alt)' }) + @IsOptional() + @IsString() + featuredImageAlt?: string | null; + @ApiPropertyOptional({ description: 'آیا پیش‌خرید فعال است؟' }) @IsOptional() @IsBoolean() diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 3b3b7c0..aed9d73 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -152,6 +152,18 @@ export class ProductsService { include: { ingredientList: true, symptoms: true, + reviews: { + where: { status: 'APPROVED' }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + userName: true, + rating: true, + comment: true, + status: true, + createdAt: true, + }, + }, }, }); diff --git a/frontend/admin-panel/src/pages/Products.tsx b/frontend/admin-panel/src/pages/Products.tsx index ef77f0f..c3c8346 100644 --- a/frontend/admin-panel/src/pages/Products.tsx +++ b/frontend/admin-panel/src/pages/Products.tsx @@ -45,6 +45,11 @@ export interface Product { keywords?: string; canonicalUrl?: string; slug?: string; + stockStatus?: 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED'; + noIndex?: boolean; + noFollow?: boolean; + ogImage?: string; + featuredImageAlt?: string; podcastUrl?: string; podcastTitle?: string; podcastDescription?: string; @@ -148,7 +153,7 @@ export default function Products() { // Modal State const [isModalOpen, setIsModalOpen] = useState(false); const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false); - const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'gallery'>('imageUrl'); + const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'ogImage' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'gallery'>('imageUrl'); const [editingProduct, setEditingProduct] = useState(null); const [deleteTargetId, setDeleteTargetId] = useState(null); const [activeTab, setActiveTab] = useState('general'); @@ -177,6 +182,11 @@ export default function Products() { keywords: '', canonicalUrl: '', slug: '', + stockStatus: 'IN_STOCK' as 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED', + noIndex: false, + noFollow: false, + ogImage: '', + featuredImageAlt: '', images: [] as string[], podcastUrl: '', podcastTitle: '', @@ -238,6 +248,11 @@ export default function Products() { metaDescription: product.metaDescription || '', keywords: product.keywords || '', canonicalUrl: product.canonicalUrl || '', + stockStatus: (product.stockStatus as 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED') || 'IN_STOCK', + noIndex: Boolean(product.noIndex), + noFollow: Boolean(product.noFollow), + ogImage: product.ogImage || '', + featuredImageAlt: product.featuredImageAlt || '', images: Array.isArray(product.images) ? product.images : [], podcastUrl: product.podcastUrl || '', podcastTitle: product.podcastTitle || '', @@ -263,6 +278,7 @@ export default function Products() { buyPrice: '', priceValue: '', wholesalePrice: '', priceValueMarginPercent: '', wholesaleMarginPercent: '', priceDisplay: '', unit: '', packageSize: '', dosageLogic: '', suitableFor: 'سگ و گربه', imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '', + stockStatus: 'IN_STOCK', noIndex: false, noFollow: false, ogImage: '', featuredImageAlt: '', images: [], podcastUrl: '', podcastTitle: '', podcastDescription: '', podcastCover: '', videoUrl: '', videoTitle: '', videoDescription: '', videoCover: '', @@ -415,6 +431,11 @@ export default function Products() { metaDescription: (formData.metaDescription || '').trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...', keywords: (formData.keywords || '').trim() || undefined, canonicalUrl: (formData.canonicalUrl || '').trim() || `/shop/${derivedSlug}`, + stockStatus: formData.stockStatus || 'IN_STOCK', + noIndex: Boolean(formData.noIndex), + noFollow: Boolean(formData.noFollow), + ogImage: (formData.ogImage || '').trim() || undefined, + featuredImageAlt: (formData.featuredImageAlt || '').trim() || undefined, slug: derivedSlug, symptoms: formData.symptoms || [], isPreorder: Boolean(formData.isPreorder), @@ -1189,140 +1210,362 @@ export default function Products() { {/* SEO Tab */} -
+
{(() => { const derivedSlug = formData.slug || slugify(formData.nameEn); - const derivedMetaTitle = formData.metaTitle || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`; + const packageSuffix = formData.packageSize ? ` (${formData.packageSize} ${formData.unit || 'واحد'})` : ''; + const defaultGeneratedTitle = `${formData.nameFa || 'نام محصول'}${packageSuffix} | مکمل درمانی کانینا ایران`; + const derivedMetaTitle = formData.metaTitle || defaultGeneratedTitle; const derivedMetaDescription = formData.metaDescription || (formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی و ضمانت اصالت فیزیکی...'); const derivedCanonicalUrl = formData.canonicalUrl || `/shop/${derivedSlug}`; + const socialCardImage = formData.ogImage || formData.imageUrl || ''; + + const titleLen = (formData.metaTitle || '').length; + const descLen = (formData.metaDescription || '').length; return (
{/* Right Column: SEO Inputs */} -
-
-