canina/frontend/application/app/shop/[slug]/page.tsx
parsa aghaei 3cbcad7b65
Some checks failed
E2E Playwright Tests / Run Full E2E & Security Suites (push) Waiting to run
Deploy Canina / deploy (push) Has been cancelled
feat(seo,ux,cro): enhance product page with JSON-LD @graph schemas, above-the-fold CRO highlights, PAO/storage badges, and dynamic reviews
2026-09-02 10:51:48 +03:30

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `${nameFa}${nameEn}`;
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: 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 if faqs exist or derived
const productFaqs = (product.faqs && product.faqs.length > 0) ? product.faqs : [
{
question: `آیا لیسیدن ${product.nameFa || product.name} توسط حیوان خطرناک است؟`,
answer: `خیر، ترکیبات این محصول با استانداردهای دارویی آلمان فرموله شده و فاقد مواد نگهدارنده شیمیایی مضر است. لیسیدن محدود آن باعث مسمومیت نمی‌شود؛ اما جهت اثربخشی و جذب حداکثری، پیشنهاد می‌شود ۱۰ تا ۱۵ دقیقه حواس حیوان پرت شود یا از پاپوش/محافظ استفاده گردد.`
},
{
question: `مدت زمان ماندگاری و شرایط نگهداری ${product.nameFa || product.name} چگونه است؟`,
answer: `این محصول در جای خشک، خنک (دمای زیر ۲۵ درجه سانتی‌گراد) و دور از تابش مستقیم نور خورشید نگهداری شود. مدت زمان مجاز مصرف پس از باز شدن درب (PAO) ۱۲ ماه می‌باشد.`
},
{
question: `آیا برای توله‌ها و سگ‌های باردار یا شیرده قابل استفاده است؟`,
answer: `بله، با توجه به فرمولاسیون طبیعی و عدم وجود هورمون یا مواد شیمیایی سنگین، استفاده از این محصول برای تمامی رده‌های سنی (توله‌ها، سگ‌های بالغ و مسن) و دوران حساس بارداری و شیردهی طبق دستورالعمل ایمن است.`
}
];
if (productFaqs.length > 0) {
graphEntities.push({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: productFaqs.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>
</>
);
}