feat(seo): implement technical SEO for product catalog, dynamic sitemaps, RSS feeds and graphify update
Some checks failed
Deploy Canina / deploy (push) Has been cancelled

This commit is contained in:
parsa aghaei 2026-08-24 17:04:31 +03:30
parent 5705354429
commit 75b9d55a24
14 changed files with 826 additions and 153 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Product | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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 */}
<div className={activeTab === 'seo' ? 'block' : 'hidden'}>
<div className={activeTab === 'seo' ? 'block space-y-6' : 'hidden'}>
{(() => {
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 (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Right Column: SEO Inputs */}
<div className="lg:col-span-7 space-y-4">
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>نامک (Slug) محصول</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
آدرس مستقیم محصول در سایت. در صورت خالی بودن، به طور خودکار از روی نام انگلیسی ساخته میشود.
</div>
<div className="lg:col-span-7 space-y-5">
{/* Slug & Canonical Card */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<h4 className="text-sm font-black text-gray-800 flex items-center gap-2 border-b pb-2">
🔗 آدرس و ساختار پیوند (URL & Canonical)
</h4>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center justify-between">
<span className="flex items-center gap-1.5">
<span>نامک آدرس (URL Slug)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
اسلاگ انگلیسی یا لاتین کالا که بعد از /shop/ در آدرس مرورگر قرار میگیرد.
</div>
</div>
</span>
<button
type="button"
onClick={() => setFormData({ ...formData, slug: slugify(formData.nameEn || '') })}
className="text-[11px] text-purple-600 hover:text-purple-700 font-bold hover:underline cursor-pointer"
>
ساخت خودکار از نام انگلیسی
</button>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-mono text-xs pointer-events-none">/shop/</span>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full pl-16 pr-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs text-left"
dir="ltr"
placeholder={derivedSlug}
/>
</div>
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
dir="ltr"
placeholder={derivedSlug}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center justify-between">
<span className="flex items-center gap-1.5">
<span>آدرس کانونیکال (Canonical URL)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
آدرس نسخه اصلی صفحه جهت جلوگیری از جریمه محتوای تکراری و اتلاف بودجه خزش گوگل.
</div>
</div>
</span>
<button
type="button"
onClick={() => setFormData({ ...formData, canonicalUrl: `/shop/${derivedSlug}` })}
className="text-[11px] text-purple-600 hover:text-purple-700 font-bold hover:underline cursor-pointer"
>
تولید خودکار استاندارد
</button>
</label>
<input
type="text"
value={formData.canonicalUrl}
onChange={(e) => setFormData({ ...formData, canonicalUrl: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs text-left"
dir="ltr"
placeholder={derivedCanonicalUrl}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>عنوان متا (Meta Title)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
عنوان اصلی صفحه در گوگل (حداکثر ۶۰ کاراکتر). در صورت خالی بودن، از ساختار پیشفرض استفاده میشود.
{/* Meta Tags & Character Counters Card */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<h4 className="text-sm font-black text-gray-800 flex items-center gap-2 border-b pb-2">
📝 متاتگها و متون SERP
</h4>
{/* Meta Title */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1.5">
<span>عنوان سئو (Meta Title)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
عنوان صفحه در نتایج جستجو گوگل (طول پیشنهادی ۵۰ تا ۶۰ کاراکتر).
</div>
</div>
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFormData({ ...formData, metaTitle: defaultGeneratedTitle })}
className="text-[11px] text-purple-600 hover:text-purple-700 font-bold hover:underline cursor-pointer"
>
فرمت خودکار استاندارد
</button>
<span className={`text-[11px] font-mono px-2 py-0.5 rounded-full font-bold ${
titleLen === 0 ? 'bg-gray-100 text-gray-500' :
titleLen >= 40 && titleLen <= 60 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
titleLen > 60 ? 'bg-rose-50 text-rose-700 border border-rose-200' :
'bg-amber-50 text-amber-700 border border-amber-200'
}`}>
{titleLen} / ۶۰ کاراکتر
</span>
</div>
</div>
</label>
<input
type="text"
value={formData.metaTitle}
onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
placeholder={derivedMetaTitle}
/>
<input
type="text"
value={formData.metaTitle}
onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs"
placeholder={defaultGeneratedTitle}
/>
</div>
{/* Meta Description */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1.5">
<span>توضیحات متا (Meta Description)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
خلاصه توضیحات برای ترغیب کلیک در گوگل (طول پیشنهادی ۱۲۰ تا ۱۶۰ کاراکتر).
</div>
</div>
</label>
<div className="flex items-center gap-2">
{formData.shortDescription && (
<button
type="button"
onClick={() => setFormData({ ...formData, metaDescription: formData.shortDescription || '' })}
className="text-[11px] text-purple-600 hover:text-purple-700 font-bold hover:underline cursor-pointer"
>
کپی از توضیح کوتاه
</button>
)}
<span className={`text-[11px] font-mono px-2 py-0.5 rounded-full font-bold ${
descLen === 0 ? 'bg-gray-100 text-gray-500' :
descLen >= 120 && descLen <= 160 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
descLen > 160 ? 'bg-rose-50 text-rose-700 border border-rose-200' :
'bg-amber-50 text-amber-700 border border-amber-200'
}`}>
{descLen} / ۱۶۰ کاراکتر
</span>
</div>
</div>
<textarea
rows={3}
value={formData.metaDescription}
onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs leading-relaxed"
placeholder={derivedMetaDescription}
/>
</div>
{/* Keywords & Image Alt */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 block">کلمات کلیدی هدف (Keywords)</label>
<input
type="text"
value={formData.keywords}
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs"
placeholder="مثال: مکمل سگ، مفاصل، کنهیدروکس"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 block">متن جایگزین تصویر اصلی (Alt Tag)</label>
<input
type="text"
value={formData.featuredImageAlt}
onChange={(e) => setFormData({ ...formData, featuredImageAlt: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs"
placeholder={formData.nameFa ? `خرید و قیمت ${formData.nameFa}` : 'توضیح تصویر برای گوگل'}
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>توضیحات متا (Meta Description)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
خلاصه توضیحات صفحه در نتایج جستجو (حداکثر ۱۵۵ کاراکتر). در صورت خالی بودن، از توضیحات کوتاه محصول استفاده میشود.
</div>
</div>
</label>
<textarea
rows={3}
value={formData.metaDescription}
onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
placeholder={derivedMetaDescription}
></textarea>
</div>
{/* Inventory Status & Indexing Directives */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<h4 className="text-sm font-black text-gray-800 flex items-center gap-2 border-b pb-2">
وضعیت موجودی و خزش روباتها (Robots & Stock)
</h4>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>کلمات کلیدی سئو (Keywords)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
کلمات کلیدی مرتبط با محصول که با کاما (,) از هم جدا شدهاند.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-center">
{/* Stock Status */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 block">وضعیت موجودی در اسکیما (Schema Availability)</label>
<select
value={formData.stockStatus}
onChange={(e) => setFormData({ ...formData, stockStatus: e.target.value as 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED' })}
className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs bg-white font-bold"
>
<option value="IN_STOCK">🟢 موجود در انبار (In Stock)</option>
<option value="OUT_OF_STOCK">🔴 ناموجود (Out of Stock)</option>
<option value="PRE_ORDER">🟡 پیشخرید (Pre Order)</option>
<option value="DISCONTINUED"> توقف تولید (Discontinued)</option>
</select>
</div>
</label>
<input
type="text"
value={formData.keywords}
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
placeholder="مثال: مکمل سگ, تقویت مفاصل, کنهیدروکس"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>آدرس کانونی (Canonical URL)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
آدرس اصلی محصول جهت جلوگیری از جریمه محتوای تکراری در گوگل.
</div>
{/* OG Image */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 flex items-center justify-between">
<span>تصویر اختصاصی اشتراکگذاری (og:image)</span>
<button
type="button"
onClick={() => {
setMediaTargetField('ogImage');
setIsMediaSelectorOpen(true);
}}
className="text-[10px] text-purple-600 font-bold hover:underline cursor-pointer"
>
انتخاب رسانه
</button>
</label>
<input
type="text"
value={formData.ogImage}
onChange={(e) => setFormData({ ...formData, ogImage: e.target.value })}
className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs text-left"
dir="ltr"
placeholder="در صورت خالی بودن، تصویر اصلی استفاده می‌شود"
/>
</div>
</label>
<input
type="text"
value={formData.canonicalUrl}
onChange={(e) => setFormData({ ...formData, canonicalUrl: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
dir="ltr"
placeholder={derivedCanonicalUrl}
/>
</div>
{/* Robots Switches */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-gray-100">
<label className="flex items-center justify-between p-3 rounded-xl border border-gray-100 hover:bg-gray-50/80 cursor-pointer transition-colors">
<div>
<span className="text-xs font-bold text-gray-800 block">عدم ایندکس (noindex)</span>
<span className="text-[10px] text-gray-400">صفحه در نتایج جستجوی گوگل لیست نشود</span>
</div>
<input
type="checkbox"
checked={formData.noIndex}
onChange={(e) => setFormData({ ...formData, noIndex: e.target.checked })}
className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 border-gray-300"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-xl border border-gray-100 hover:bg-gray-50/80 cursor-pointer transition-colors">
<div>
<span className="text-xs font-bold text-gray-800 block">عدم دنبالکردن لینکها (nofollow)</span>
<span className="text-[10px] text-gray-400">اعتبار به لینکهای درون این صفحه منتقل نشود</span>
</div>
<input
type="checkbox"
checked={formData.noFollow}
onChange={(e) => setFormData({ ...formData, noFollow: e.target.checked })}
className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 border-gray-300"
/>
</label>
</div>
</div>
</div>
{/* Left Column: Sticky Google SERP Preview */}
<div className="lg:col-span-5 lg:sticky lg:top-4 bg-gray-100 border border-gray-200 rounded-2xl p-5 space-y-3">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest block">پیشنمایش نتایج در گوگل (Google SERP Preview)</span>
{/* Left Column: Live Previews (Google SERP + Social Card) */}
<div className="lg:col-span-5 space-y-4 lg:sticky lg:top-4">
{/* Google SERP Preview */}
<div className="bg-white border border-gray-200 rounded-2xl p-4 shadow-sm space-y-2.5">
<span className="text-[10px] font-black text-purple-700 uppercase tracking-wider flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-purple-600 animate-pulse"></span>
پیشنمایش گوگل (Google SERP Snippet)
</span>
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-right font-sans" dir="rtl">
{/* Domain / Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-[#202124] mb-1">
<span className="font-medium text-gray-800">caninoiran.ir</span>
<span className="text-gray-400"></span>
<span className="text-gray-500">shop</span>
<span className="text-gray-400"></span>
<span className="text-gray-500 max-w-[150px] truncate">{derivedSlug}</span>
<div className="bg-[#f8f9fa] rounded-xl p-3.5 border border-gray-200/80 text-right font-sans" dir="rtl">
{/* Breadcrumb row */}
<div className="flex items-center gap-1 text-[11px] text-[#202124] mb-1">
<span className="w-3.5 h-3.5 rounded-full bg-purple-100 flex items-center justify-center text-[9px] text-purple-700 font-bold">C</span>
<span className="font-medium text-gray-800">canina.ir</span>
<span className="text-gray-400"></span>
<span className="text-gray-500">shop</span>
<span className="text-gray-400"></span>
<span className="text-gray-500 max-w-[120px] truncate">{derivedSlug}</span>
</div>
{/* Title */}
<h4 className="text-[17px] text-[#1a0dab] hover:underline cursor-pointer leading-snug font-medium mb-1 line-clamp-2 select-none">
{derivedMetaTitle}
</h4>
{/* Description */}
<p className="text-xs text-[#4d5156] leading-relaxed select-none line-clamp-3">
{derivedMetaDescription}
</p>
{/* Rich Snippet Preview Badges */}
<div className="mt-2.5 pt-2 border-t border-gray-200/60 flex items-center gap-2 text-[10px] font-bold text-gray-600">
{formData.priceValue && (
<span className="bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-200 font-mono">
{Number(formData.priceValue).toLocaleString('fa-IR')} تومان
</span>
)}
<span className={`px-2 py-0.5 rounded border ${
formData.stockStatus === 'IN_STOCK' ? 'bg-green-50 text-green-700 border-green-200' :
formData.stockStatus === 'PRE_ORDER' ? 'bg-amber-50 text-amber-700 border-amber-200' :
'bg-red-50 text-red-700 border-red-200'
}`}>
{formData.stockStatus === 'IN_STOCK' ? 'موجود در انبار' :
formData.stockStatus === 'PRE_ORDER' ? 'پیش‌خرید' : 'ناموجود'}
</span>
</div>
</div>
</div>
{/* Social Card Preview (OpenGraph / Twitter) */}
<div className="bg-white border border-gray-200 rounded-2xl p-4 shadow-sm space-y-2.5">
<span className="text-[10px] font-black text-blue-700 uppercase tracking-wider flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-600"></span>
کارت اشتراک در شبکههای اجتماعی (Social Card 1200×630)
</span>
<div className="border border-gray-200 rounded-xl overflow-hidden bg-gray-50 text-right" dir="rtl">
<div className="aspect-[1.91/1] w-full bg-gray-200 relative overflow-hidden flex items-center justify-center">
{socialCardImage ? (
<img
src={getProductImageUrl(socialCardImage)}
alt="Social Preview"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="text-gray-400 text-xs flex flex-col items-center gap-1">
<ImageIcon className="w-6 h-6" />
<span>فاقد تصویر شاخص</span>
</div>
)}
</div>
<div className="p-3 bg-white space-y-1">
<span className="text-[10px] text-gray-400 uppercase font-mono block">CANINA.IR</span>
<h5 className="text-xs font-bold text-gray-900 line-clamp-1">{derivedMetaTitle}</h5>
<p className="text-[11px] text-gray-500 line-clamp-2 leading-relaxed">{derivedMetaDescription}</p>
</div>
</div>
{/* Title */}
<h4 className="text-[19px] text-[#1a0dab] hover:underline cursor-pointer leading-tight font-medium font-sans mb-1 select-none">
{derivedMetaTitle}
</h4>
{/* Description */}
<p className="text-sm text-[#4d5156] leading-relaxed font-sans select-none break-words">
{derivedMetaDescription}
</p>
</div>
<p className="text-[10px] text-gray-400 font-medium">تغییرات فیلدهای سمت راست، بلافاصله در پیشنمایش بالا منعکس میشود.</p>
</div>
</div>
);

View File

@ -29,6 +29,10 @@ export default function robots(): MetadataRoute.Robots {
'/api/'
],
},
sitemap: `${baseUrl}/sitemap.xml`,
sitemap: [
`${baseUrl}/sitemap.xml`,
`${baseUrl}/sitemap-products.xml`,
`${baseUrl}/sitemap-categories.xml`,
],
};
}

View File

@ -20,24 +20,68 @@ export async function generateMetadata(
const nameFa = product.nameFa || product.name;
const nameEn = product.nameEn ? ` (${product.nameEn})` : '';
const rawTitle = `${nameFa}${nameEn}`;
const title = formatPageTitle(rawTitle, config.brandNameFa, config.titleSeparator, config.titlePosition, false);
const description = (product.shortDescription || product.description || '').substring(0, 155).trim() || config.defaultMetaDescription;
const rawKeywords = (product as unknown as Record<string, unknown>).keywords;
const keywords = typeof rawKeywords === 'string' ? rawKeywords.split(',').map(k => k.trim()) : (product.main_ingredients || config.keywords);
const canonicalUrl = `${config.canonicalBaseUrl.replace(/\/$/, '')}/shop/${resolvedParams.slug}`;
const title = product.metaTitle || formatPageTitle(rawTitle, config.brandNameFa, config.titleSeparator, config.titlePosition, false);
const description = product.metaDescription || (product.shortDescription || product.description || '').substring(0, 155).trim() || config.defaultMetaDescription;
const rawKeywords = product.keywords;
const keywords = typeof rawKeywords === 'string'
? rawKeywords.split(',').map(k => k.trim())
: (product.main_ingredients?.length ? product.main_ingredients : config.keywords);
const baseCanonical = config.canonicalBaseUrl.replace(/\/$/, '');
const canonicalUrl = product.canonicalUrl?.startsWith('http')
? product.canonicalUrl
: `${baseCanonical}${product.canonicalUrl?.startsWith('/') ? product.canonicalUrl : `/shop/${resolvedParams.slug}`}`;
const primaryImage = product.ogImage || product.image || config.ogImageUrl;
const imageAlt = product.featuredImageAlt || nameFa;
const galleryImages = [
{
url: primaryImage,
width: 1200,
height: 630,
alt: imageAlt,
},
...(product.images || [])
.filter(img => img && img !== primaryImage)
.map(img => ({
url: img,
width: 800,
height: 800,
alt: `${nameFa} - گالری تصویر`,
}))
];
return {
title,
description,
keywords,
robots: {
index: !product.noIndex,
follow: !product.noFollow,
googleBot: {
index: !product.noIndex,
follow: !product.noFollow,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
openGraph: {
title,
description,
images: [product.image || config.ogImageUrl],
images: galleryImages,
url: canonicalUrl,
siteName: config.brandNameFa,
type: "website",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [primaryImage],
},
alternates: {
canonical: canonicalUrl,
}
@ -56,17 +100,46 @@ export default async function ShopProductPage({ params }: { params: Promise<{ sl
const productUrl = `${siteUrl}/shop/${resolvedParams.slug}`;
const priceInRial = product.priceValue * 10;
const productJsonLd = {
// Map stock status to Schema.org availability
let availabilityUrl = 'https://schema.org/InStock';
if (product.stockStatus === 'OUT_OF_STOCK') {
availabilityUrl = 'https://schema.org/OutOfStock';
} else if (product.stockStatus === 'PRE_ORDER') {
availabilityUrl = 'https://schema.org/PreOrder';
} else if (product.stockStatus === 'DISCONTINUED') {
availabilityUrl = 'https://schema.org/Discontinued';
}
// Collect unique images
const allImages = Array.from(new Set([
product.image,
product.ogImage,
...(product.images || [])
])).filter(Boolean) as string[];
// 1 year valid price offer
const priceValidUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Process approved reviews and ratings
const approvedReviews = (product.reviews || []).filter(r => r.status === 'APPROVED' || !r.status);
const totalReviewsCount = approvedReviews.length;
const averageRating = totalReviewsCount > 0
? Number((approvedReviews.reduce((sum, r) => sum + (Number(r.rating) || 5), 0) / totalReviewsCount).toFixed(1))
: 5;
const productJsonLd: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.nameFa || product.name,
image: product.image,
description: product.shortDescription || product.description,
name: product.nameEn ? `${product.nameFa || product.name} (${product.nameEn})` : (product.nameFa || product.name),
image: allImages.length > 0 ? allImages : [product.image],
description: product.metaDescription || product.shortDescription || product.description,
sku: product.artNo,
mpn: product.artNo,
...(product.barcode ? { gtin: product.barcode, gtin13: product.barcode } : {}),
brand: {
'@type': 'Brand',
name: 'Canina pharma GmbH'
name: 'Canina pharma GmbH',
url: 'https://canina.de'
},
category: product.category,
offers: {
@ -74,7 +147,8 @@ export default async function ShopProductPage({ params }: { params: Promise<{ sl
url: productUrl,
priceCurrency: 'IRR',
price: priceInRial,
availability: 'https://schema.org/InStock',
priceValidUntil,
availability: availabilityUrl,
itemCondition: 'https://schema.org/NewCondition',
seller: {
'@type': 'Organization',
@ -83,29 +157,75 @@ export default async function ShopProductPage({ params }: { params: Promise<{ sl
}
};
// Add aggregateRating & reviews only when reviews exist
if (totalReviewsCount > 0) {
productJsonLd.aggregateRating = {
'@type': 'AggregateRating',
ratingValue: averageRating,
reviewCount: totalReviewsCount,
bestRating: 5,
worstRating: 1
};
productJsonLd.review = approvedReviews.slice(0, 10).map(rev => ({
'@type': 'Review',
author: {
'@type': 'Person',
name: rev.userName || 'کاربر کنینا'
},
datePublished: rev.createdAt ? new Date(rev.createdAt).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
reviewRating: {
'@type': 'Rating',
ratingValue: rev.rating || 5,
bestRating: 5,
worstRating: 1
},
reviewBody: rev.comment
}));
}
// Multi-level BreadcrumbList Schema
const breadcrumbElements = [
{
'@type': 'ListItem',
position: 1,
name: 'صفحه اصلی',
item: siteUrl
},
{
'@type': 'ListItem',
position: 2,
name: 'فروشگاه مکمل‌ها',
item: `${siteUrl}/shop`
}
];
if (product.category) {
breadcrumbElements.push({
'@type': 'ListItem',
position: 3,
name: product.category,
item: `${siteUrl}/shop?category=${product.categorySlug || 'all'}`
});
breadcrumbElements.push({
'@type': 'ListItem',
position: 4,
name: product.nameFa || product.name,
item: productUrl
});
} else {
breadcrumbElements.push({
'@type': 'ListItem',
position: 3,
name: product.nameFa || product.name,
item: productUrl
});
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'صفحه اصلی',
item: siteUrl
},
{
'@type': 'ListItem',
position: 2,
name: 'فروشگاه مکمل‌ها',
item: `${siteUrl}/shop`
},
{
'@type': 'ListItem',
position: 3,
name: product.nameFa || product.name,
item: productUrl
}
]
itemListElement: breadcrumbElements
};
const safeProductJsonLd = JSON.stringify(productJsonLd).replace(/</g, '\\u003c');

View File

@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import { productService } from '../../../lib/services/productService';
import { PRODUCTS } from '../../../lib/data/products';
export const dynamic = 'force-dynamic';
export const revalidate = 3600; // Revalidate every hour
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://canina.ir';
let productsList: any[] = [];
try {
const res = await productService.getProducts({ limit: 999 });
productsList = res.data && res.data.length > 0 ? res.data : PRODUCTS;
} catch {
productsList = PRODUCTS;
}
// Only active, indexable products
const indexableProducts = productsList.filter(p => !p.noIndex && p.stockStatus !== 'DISCONTINUED');
const itemsXml = indexableProducts.map(product => {
const slug = product.slug || product.artNo || product.id;
const link = `${baseUrl}/shop/${slug}`;
const id = product.artNo || product.id;
const title = product.nameEn ? `${product.nameFa || product.name} (${product.nameEn})` : (product.nameFa || product.name);
const description = product.metaDescription || product.shortDescription || product.description || 'مکمل درمانی اصل کنینا آلمان';
const primaryImg = product.ogImage || product.image;
const imageLink = primaryImg ? (primaryImg.startsWith('http') ? primaryImg : `${baseUrl}${primaryImg}`) : '';
let availability = 'in_stock';
if (product.stockStatus === 'OUT_OF_STOCK') availability = 'out_of_stock';
if (product.stockStatus === 'PRE_ORDER') availability = 'preorder';
const price = product.priceValue ? `${product.priceValue * 10} IRR` : '0 IRR';
const gtinTag = product.barcode ? `<g:gtin>${product.barcode}</g:gtin>` : '';
const additionalImages = (product.images || [])
.filter((img: string) => img && img !== primaryImg)
.slice(0, 5)
.map((img: string) => `<g:additional_image_link>${img.startsWith('http') ? img : `${baseUrl}${img}`}</g:additional_image_link>`)
.join('\n ');
return ` <item>
<g:id>${id}</g:id>
<title><![CDATA[${title}]]></title>
<description><![CDATA[${description}]]></description>
<link>${link}</link>
<g:image_link>${imageLink}</g:image_link>
${additionalImages}
<g:availability>${availability}</g:availability>
<g:price>${price}</g:price>
<g:brand>Canina pharma GmbH</g:brand>
<g:mpn>${product.artNo}</g:mpn>
${gtinTag}
<g:condition>new</g:condition>
<g:product_type><![CDATA[${product.category || 'مکمل حیوانات'}]]></g:product_type>
</item>`;
}).join('\n');
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
<channel>
<title>فید محصولات و مکملهای تخصصی کنینا ایران</title>
<link>${baseUrl}/shop</link>
<description>فید رسمی محصولات و مکملهای دارویی سگ و گربه برند Canina آلمان</description>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${itemsXml}
</channel>
</rss>`;
return new NextResponse(xmlContent, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { productService } from '../../lib/services/productService';
export const dynamic = 'force-dynamic';
export const revalidate = 86400; // Revalidate daily
const DEFAULT_CATEGORIES = [
{ slug: 'joints', name: 'مفاصل و استخوان' },
{ slug: 'immune', name: 'تقویت سیستم ایمنی و گوارش' },
{ slug: 'energy', name: 'ویتامین‌ها و انرژی‌بخش‌ها' },
{ slug: 'special-care', name: 'مراقبت‌های ویژه (پوست، دندان و چشم)' },
{ slug: 'general', name: 'تقویت عمومی' },
{ slug: 'nutrition', name: 'تغذیه تخصصی' },
{ slug: 'supplements', name: 'مکمل‌های غذایی و درمانی' },
];
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://canina.ir';
let categories = DEFAULT_CATEGORIES;
try {
const filters = await productService.getActiveFilters();
if (filters?.categories?.length) {
categories = filters.categories;
}
} catch {
categories = DEFAULT_CATEGORIES;
}
const now = new Date().toISOString();
const urlsXml = categories.map(cat => {
const loc = `${baseUrl}/shop?category=${cat.slug}`;
return ` <url>
<loc>${loc}</loc>
<lastmod>${now}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`;
}).join('\n');
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlsXml}
</urlset>`;
return new NextResponse(xmlContent, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800',
},
});
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { productService } from '../../lib/services/productService';
import { PRODUCTS } from '../../lib/data/products';
export const dynamic = 'force-dynamic';
export const revalidate = 3600; // Revalidate every hour
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://canina.ir';
let productsList: any[] = [];
try {
const res = await productService.getProducts({ limit: 999 });
productsList = res.data && res.data.length > 0 ? res.data : PRODUCTS;
} catch {
productsList = PRODUCTS;
}
// Filter out noIndex products
const indexableProducts = productsList.filter(p => !p.noIndex);
const urlsXml = indexableProducts.map(product => {
const slug = product.slug || product.artNo || product.id;
const loc = `${baseUrl}/shop/${slug}`;
const lastmod = product.updatedAt ? new Date(product.updatedAt).toISOString() : new Date().toISOString();
const primaryImg = product.ogImage || product.image;
const title = product.nameFa || product.name || 'مکمل درمانی کنینا';
const imageTag = primaryImg ? `
<image:image>
<image:loc>${primaryImg.startsWith('http') ? primaryImg : `${baseUrl}${primaryImg}`}</image:loc>
<image:title><![CDATA[${title}]]></image:title>
</image:image>` : '';
return ` <url>
<loc>${loc}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>${imageTag}
</url>`;
}).join('\n');
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${urlsXml}
</urlset>`;
return new NextResponse(xmlContent, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@ -57,21 +57,39 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const res = await productService.getProducts({ limit: 999 });
const products = res.data && res.data.length > 0 ? res.data : PRODUCTS;
productRoutes = products.map((product) => ({
url: `${baseUrl}/shop/${product.slug || product.id}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
}));
productRoutes = products
.filter((product) => !product.noIndex)
.map((product) => ({
url: `${baseUrl}/shop/${product.slug || product.artNo || product.id}`,
lastModified: product.updatedAt ? new Date(product.updatedAt) : new Date(),
changeFrequency: 'daily',
priority: 0.9,
}));
} catch {
productRoutes = PRODUCTS.map((product) => ({
url: `${baseUrl}/shop/${product.slug || product.id}`,
url: `${baseUrl}/shop/${product.slug || product.artNo || product.id}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
changeFrequency: 'daily',
priority: 0.9,
}));
}
// Dynamic shop categories
let categoryRoutes: MetadataRoute.Sitemap = [];
try {
const filters = await productService.getActiveFilters();
if (filters?.categories?.length) {
categoryRoutes = filters.categories.map((cat) => ({
url: `${baseUrl}/shop?category=${cat.slug}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
}));
}
} catch {
categoryRoutes = [];
}
// Dynamic blogs
let blogRoutes: MetadataRoute.Sitemap = [];
try {
@ -95,6 +113,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
blogRoutes = [];
}
return [...staticRoutes, ...productRoutes, ...blogRoutes];
return [...staticRoutes, ...categoryRoutes, ...productRoutes, ...blogRoutes];
}

View File

@ -73,6 +73,25 @@ export interface Product {
pdfTitle?: string;
pdfDescription?: string;
pdfCover?: string;
barcode?: string;
metaTitle?: string;
metaDescription?: string;
canonicalUrl?: string;
keywords?: string;
stockStatus?: 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED';
noIndex?: boolean;
noFollow?: boolean;
ogImage?: string;
featuredImageAlt?: string;
updatedAt?: string | Date;
reviews?: Array<{
id?: string;
userName: string;
rating: number;
comment: string;
status: string;
createdAt?: string | Date;
}>;
relatedProducts?: string[];
faqs?: FAQ[];
}

View File

@ -210,6 +210,25 @@ export class ProductService {
if (data.pdfCover.startsWith('/uploads/')) return `${apiBase}${data.pdfCover}`;
return data.pdfCover;
})(),
barcode: (inputData.barcode as string) || undefined,
metaTitle: (inputData.metaTitle as string) || undefined,
metaDescription: (inputData.metaDescription as string) || undefined,
canonicalUrl: (inputData.canonicalUrl as string) || undefined,
keywords: typeof inputData.keywords === 'string' ? inputData.keywords : undefined,
stockStatus: (inputData.stockStatus as 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED') || 'IN_STOCK',
noIndex: Boolean(inputData.noIndex),
noFollow: Boolean(inputData.noFollow),
ogImage: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
const og = inputData.ogImage as string;
if (!og) return undefined;
if (og.startsWith('http://') || og.startsWith('https://')) return og;
if (og.startsWith('/uploads/')) return `${apiBase}${og}`;
return og;
})(),
featuredImageAlt: (inputData.featuredImageAlt as string) || undefined,
updatedAt: (inputData.updatedAt as string) || (inputData.createdAt as string) || undefined,
reviews: Array.isArray(inputData.reviews) ? inputData.reviews as Product['reviews'] : undefined,
};
}

1
graphify-out/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.graphify_python