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
Some checks failed
Deploy Canina / deploy (push) Has been cancelled
This commit is contained in:
parent
5705354429
commit
75b9d55a24
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
79
frontend/application/app/shop/feed.xml/route.ts
Normal file
79
frontend/application/app/shop/feed.xml/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
54
frontend/application/app/sitemap-categories.xml/route.ts
Normal file
54
frontend/application/app/sitemap-categories.xml/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
56
frontend/application/app/sitemap-products.xml/route.ts
Normal file
56
frontend/application/app/sitemap-products.xml/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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
1
graphify-out/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.graphify_python
|
||||
Loading…
Reference in New Issue
Block a user