325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import ProductPage from "../../../components/ProductPage";
|
|
import { productService } from "../../../lib/services/productService";
|
|
import type { Metadata } from 'next';
|
|
import { Suspense } from 'react';
|
|
import { getSeoConfig, formatPageTitle } from "../../../lib/seo";
|
|
|
|
export const revalidate = 3600;
|
|
|
|
export async function generateMetadata(
|
|
{ params }: { params: Promise<{ slug: string }> }
|
|
): Promise<Metadata> {
|
|
const resolvedParams = await params;
|
|
const product = await productService.getProductBySlug(resolvedParams.slug);
|
|
const config = await getSeoConfig();
|
|
|
|
if (!product) {
|
|
return {
|
|
title: formatPageTitle('مکمل درمانی یافت نشد', config.brandNameFa, config.titleSeparator, config.titlePosition),
|
|
};
|
|
}
|
|
|
|
const nameFa = product.nameFa || product.name;
|
|
const nameEn = product.nameEn ? ` (${product.nameEn})` : '';
|
|
const rawTitle = product.metaTitle || `${nameFa}${nameEn}`;
|
|
const title = formatPageTitle(rawTitle, config.brandNameFa, config.titleSeparator, config.titlePosition, false);
|
|
const ogTitle = `${title} ${config.titleSeparator} ${config.brandNameFa}`;
|
|
const description = product.metaDescription || (product.shortDescription || product.description || '').substring(0, 155).trim() || config.defaultMetaDescription;
|
|
|
|
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: ogTitle,
|
|
description,
|
|
images: galleryImages,
|
|
url: canonicalUrl,
|
|
siteName: config.brandNameFa,
|
|
type: "website",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title,
|
|
description,
|
|
images: [primaryImage],
|
|
},
|
|
alternates: {
|
|
canonical: canonicalUrl,
|
|
},
|
|
other: {
|
|
product_id: String(product.artNo || product.id || resolvedParams.slug),
|
|
product_name: title,
|
|
product_price: String(product.priceValue || 0),
|
|
product_old_price: String(product.priceValue || 0),
|
|
availability: (product.stockStatus === 'DISCONTINUED' || String(product.stockStatus || '').toLowerCase().includes('out')) ? 'outofstock' : 'instock',
|
|
guarantee: 'ضمانت اصالت ۱۰۰٪ کمپانی Canina آلمان و اصالت کالا',
|
|
'og:image': primaryImage.startsWith('http') ? primaryImage : `${baseCanonical}${primaryImage.startsWith('/') ? primaryImage : `/${primaryImage}`}`,
|
|
}
|
|
};
|
|
}
|
|
|
|
export default async function ShopProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
const resolvedParams = await params;
|
|
const product = await productService.getProductBySlug(resolvedParams.slug);
|
|
|
|
if (!product) {
|
|
return <ProductPage productSlug={resolvedParams.slug} />;
|
|
}
|
|
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://canina.ir';
|
|
const productUrl = `${siteUrl}/shop/${resolvedParams.slug}`;
|
|
const priceInRial = product.priceValue * 10;
|
|
|
|
// 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 based on product timestamp
|
|
const baseTime = product.updatedAt ? new Date(product.updatedAt).getTime() : 1771939200000;
|
|
const priceValidUntil = new Date(baseTime + 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.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',
|
|
url: 'https://canina.de'
|
|
},
|
|
category: product.category,
|
|
offers: {
|
|
'@type': 'Offer',
|
|
url: productUrl,
|
|
priceCurrency: 'IRR',
|
|
price: priceInRial,
|
|
priceValidUntil,
|
|
availability: availabilityUrl,
|
|
itemCondition: 'https://schema.org/NewCondition',
|
|
seller: {
|
|
'@type': 'Organization',
|
|
name: 'کنینا ایران'
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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: breadcrumbElements
|
|
};
|
|
|
|
const graphEntities: Record<string, unknown>[] = [productJsonLd, breadcrumbJsonLd];
|
|
|
|
// 1. FAQPage Schema only if faqs exist and are configured
|
|
if (product.showFaqs !== false && Array.isArray(product.faqs) && product.faqs.length > 0) {
|
|
const validFaqs = product.faqs.filter(f => f && f.question?.trim() && f.answer?.trim());
|
|
if (validFaqs.length > 0) {
|
|
graphEntities.push({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'FAQPage',
|
|
mainEntity: validFaqs.map(f => ({
|
|
'@type': 'Question',
|
|
name: f.question,
|
|
acceptedAnswer: {
|
|
'@type': 'Answer',
|
|
text: f.answer
|
|
}
|
|
}))
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. AudioObject Schema for AI Podcast
|
|
if (product.podcastUrl) {
|
|
graphEntities.push({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'AudioObject',
|
|
name: `پادکست تحلیل علمی ${product.nameFa || product.name}`,
|
|
description: `بررسی جامع و علمی مشخصات، مواد مؤثره و نحوه عملکرد ${product.nameFa || product.name} توسط هوش مصنوعی و کادر تخصصی کنینا`,
|
|
contentUrl: product.podcastUrl,
|
|
encodingFormat: 'audio/mpeg',
|
|
inLanguage: 'fa-IR'
|
|
});
|
|
}
|
|
|
|
// 3. VideoObject Schema for Educational Video
|
|
if (product.videoUrl) {
|
|
graphEntities.push({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'VideoObject',
|
|
name: product.videoTitle || `ویدیو راهنما و معرفی ${product.nameFa || product.name}`,
|
|
description: product.videoDescription || `ویدیو بررسی و نحوه مصرف ${product.nameFa || product.name} - Canina Germany`,
|
|
thumbnailUrl: [product.videoCover || product.image || `${siteUrl}/images/canina-default-thumb.jpg`],
|
|
uploadDate: product.updatedAt ? new Date(product.updatedAt).toISOString() : '2026-01-01T00:00:00Z',
|
|
contentUrl: product.videoUrl,
|
|
inLanguage: 'fa-IR'
|
|
});
|
|
}
|
|
|
|
const unifiedJsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@graph': graphEntities
|
|
};
|
|
|
|
const safeUnifiedJsonLd = JSON.stringify(unifiedJsonLd).replace(/</g, '\\u003c');
|
|
|
|
return (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeUnifiedJsonLd }}
|
|
/>
|
|
<Suspense fallback={
|
|
<div className="min-h-screen bg-medical-gray-50 p-6 max-w-7xl mx-auto font-vazir" dir="rtl">
|
|
<div className="animate-pulse space-y-6">
|
|
<div className="h-8 bg-medical-gray-200 rounded-2xl w-48" />
|
|
<div className="grid md:grid-cols-12 gap-8">
|
|
<div className="md:col-span-5 h-80 bg-white border border-medical-gray-200 rounded-[2.5rem]" />
|
|
<div className="md:col-span-7 space-y-4">
|
|
<div className="h-10 bg-medical-gray-200 rounded-2xl w-3/4" />
|
|
<div className="h-6 bg-medical-gray-100 rounded-xl w-1/2" />
|
|
<div className="h-24 bg-medical-gray-100 rounded-2xl w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}>
|
|
<ProductPage productSlug={resolvedParams.slug} />
|
|
</Suspense>
|
|
</>
|
|
);
|
|
}
|