+ {/* Left Column: Live Previews (Google SERP + Social Card) */}
+
+ {/* Google SERP Preview */}
+
+
+
+ پیشنمایش گوگل (Google SERP Snippet)
+
-
- {/* Domain / Breadcrumb */}
-
-
caninoiran.ir
-
›
-
shop
-
›
-
{derivedSlug}
+
+ {/* Breadcrumb row */}
+
+ C
+ canina.ir
+ ›
+ shop
+ ›
+ {derivedSlug}
+
+
+ {/* Title */}
+
+ {derivedMetaTitle}
+
+
+ {/* Description */}
+
+ {derivedMetaDescription}
+
+
+ {/* Rich Snippet Preview Badges */}
+
+ {formData.priceValue && (
+
+ {Number(formData.priceValue).toLocaleString('fa-IR')} تومان
+
+ )}
+
+ {formData.stockStatus === 'IN_STOCK' ? 'موجود در انبار' :
+ formData.stockStatus === 'PRE_ORDER' ? 'پیشخرید' : 'ناموجود'}
+
+
+
+
+
+ {/* Social Card Preview (OpenGraph / Twitter) */}
+
+
+
+ کارت اشتراک در شبکههای اجتماعی (Social Card 1200×630)
+
+
+
+
+ {socialCardImage ? (
+
})
{
+ (e.target as HTMLElement).style.display = 'none';
+ }}
+ />
+ ) : (
+
+
+ فاقد تصویر شاخص
+
+ )}
+
+
+
CANINA.IR
+
{derivedMetaTitle}
+
{derivedMetaDescription}
+
-
- {/* Title */}
-
- {derivedMetaTitle}
-
-
- {/* Description */}
-
- {derivedMetaDescription}
-
-
تغییرات فیلدهای سمت راست، بلافاصله در پیشنمایش بالا منعکس میشود.
);
diff --git a/frontend/application/app/robots.ts b/frontend/application/app/robots.ts
index 992d0fd..f94c5b9 100644
--- a/frontend/application/app/robots.ts
+++ b/frontend/application/app/robots.ts
@@ -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`,
+ ],
};
}
diff --git a/frontend/application/app/shop/[slug]/page.tsx b/frontend/application/app/shop/[slug]/page.tsx
index 25cbae3..bd5d3a5 100644
--- a/frontend/application/app/shop/[slug]/page.tsx
+++ b/frontend/application/app/shop/[slug]/page.tsx
@@ -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
).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 = {
'@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(/ 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 ? `${product.barcode}` : '';
+ const additionalImages = (product.images || [])
+ .filter((img: string) => img && img !== primaryImg)
+ .slice(0, 5)
+ .map((img: string) => `${img.startsWith('http') ? img : `${baseUrl}${img}`}`)
+ .join('\n ');
+
+ return ` -
+ ${id}
+
+
+ ${link}
+ ${imageLink}
+ ${additionalImages}
+ ${availability}
+ ${price}
+ Canina pharma GmbH
+ ${product.artNo}
+ ${gtinTag}
+ new
+
+
`;
+ }).join('\n');
+
+ const xmlContent = `
+
+
+ فید محصولات و مکملهای تخصصی کنینا ایران
+ ${baseUrl}/shop
+ فید رسمی محصولات و مکملهای دارویی سگ و گربه برند Canina آلمان
+ ${new Date().toUTCString()}
+${itemsXml}
+
+`;
+
+ 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',
+ },
+ });
+}
diff --git a/frontend/application/app/sitemap-categories.xml/route.ts b/frontend/application/app/sitemap-categories.xml/route.ts
new file mode 100644
index 0000000..02b0e76
--- /dev/null
+++ b/frontend/application/app/sitemap-categories.xml/route.ts
@@ -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 `
+ ${loc}
+ ${now}
+ weekly
+ 0.8
+ `;
+ }).join('\n');
+
+ const xmlContent = `
+
+${urlsXml}
+`;
+
+ 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',
+ },
+ });
+}
diff --git a/frontend/application/app/sitemap-products.xml/route.ts b/frontend/application/app/sitemap-products.xml/route.ts
new file mode 100644
index 0000000..6165f59
--- /dev/null
+++ b/frontend/application/app/sitemap-products.xml/route.ts
@@ -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 ? `
+
+ ${primaryImg.startsWith('http') ? primaryImg : `${baseUrl}${primaryImg}`}
+
+ ` : '';
+
+ return `
+ ${loc}
+ ${lastmod}
+ daily
+ 0.9${imageTag}
+ `;
+ }).join('\n');
+
+ const xmlContent = `
+
+${urlsXml}
+`;
+
+ 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',
+ },
+ });
+}
diff --git a/frontend/application/app/sitemap.ts b/frontend/application/app/sitemap.ts
index 7ad3c37..b6879c9 100644
--- a/frontend/application/app/sitemap.ts
+++ b/frontend/application/app/sitemap.ts
@@ -57,21 +57,39 @@ export default async function sitemap(): Promise {
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 {
blogRoutes = [];
}
- return [...staticRoutes, ...productRoutes, ...blogRoutes];
+ return [...staticRoutes, ...categoryRoutes, ...productRoutes, ...blogRoutes];
}
diff --git a/frontend/application/lib/data/products.ts b/frontend/application/lib/data/products.ts
index 4b8a6da..82bff9f 100644
--- a/frontend/application/lib/data/products.ts
+++ b/frontend/application/lib/data/products.ts
@@ -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[];
}
diff --git a/frontend/application/lib/services/productService.ts b/frontend/application/lib/services/productService.ts
index 9cdabca..3165487 100644
--- a/frontend/application/lib/services/productService.ts
+++ b/frontend/application/lib/services/productService.ts
@@ -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,
};
}
diff --git a/graphify-out/.gitignore b/graphify-out/.gitignore
new file mode 100644
index 0000000..5acf84f
--- /dev/null
+++ b/graphify-out/.gitignore
@@ -0,0 +1 @@
+.graphify_python