320 lines
9.4 KiB
TypeScript
320 lines
9.4 KiB
TypeScript
import type { Metadata } from 'next';
|
|
|
|
export interface SeoConfig {
|
|
brandNameFa: string;
|
|
brandNameEn: string;
|
|
titleSeparator: string;
|
|
titlePosition: 'suffix' | 'prefix' | 'none';
|
|
defaultMetaTitle: string;
|
|
defaultMetaDescription: string;
|
|
keywords: string[];
|
|
canonicalBaseUrl: string;
|
|
ogImageUrl: string;
|
|
googleSiteVerification?: string;
|
|
googleAnalyticsId?: string;
|
|
googleTagManagerId?: string;
|
|
enableGoogleAnalytics?: boolean;
|
|
enableGoogleTagManager?: boolean;
|
|
clarityProjectId?: string;
|
|
texts: Record<string, string>;
|
|
}
|
|
|
|
const DEFAULT_SEO_CONFIG: SeoConfig = {
|
|
brandNameFa: 'کنینا ایران',
|
|
brandNameEn: 'Canina Iran',
|
|
titleSeparator: '|',
|
|
titlePosition: 'suffix',
|
|
defaultMetaTitle: 'کنینا ایران | نماینده رسمی مکملهای دارویی Canina آلمان',
|
|
defaultMetaDescription: 'فروشگاه تخصصی و نماینده انحصاری مکملهای درمانی و تقویتی سگ و گربه کنینا آلمان با فرمولاسیون دارویی و تاییدیه بالینی.',
|
|
keywords: ['کنینا', 'مکمل حیوانات خانگی', 'مکمل سگ', 'مکمل گربه', 'داروی حیوانات', 'Canina Iran'],
|
|
canonicalBaseUrl: 'https://canina.ir',
|
|
ogImageUrl: '/assets/images/regenerated_image_1779109861747.png',
|
|
googleSiteVerification: '',
|
|
googleAnalyticsId: '',
|
|
googleTagManagerId: '',
|
|
enableGoogleAnalytics: true,
|
|
enableGoogleTagManager: true,
|
|
clarityProjectId: '',
|
|
texts: {},
|
|
};
|
|
|
|
/**
|
|
* Fetch dynamic SEO settings and UI texts on server with 60-second revalidation
|
|
*/
|
|
export async function getSeoConfig(): Promise<SeoConfig> {
|
|
const apiBase =
|
|
process.env.INTERNAL_API_URL ||
|
|
process.env.NEXT_PUBLIC_API_URL ||
|
|
'http://localhost:4001/api';
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/settings/ui-texts`, {
|
|
next: { revalidate: 60, tags: ['ui-texts', 'settings'] },
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const texts: Record<string, string> = {};
|
|
|
|
if (Array.isArray(data)) {
|
|
data.forEach((item: { key: string; value: string }) => {
|
|
if (item?.key) texts[item.key] = item.value;
|
|
});
|
|
} else if (typeof data === 'object' && data !== null) {
|
|
Object.entries(data).forEach(([k, v]) => {
|
|
texts[k] = String(v);
|
|
});
|
|
}
|
|
|
|
const brandNameFa =
|
|
texts['brand_name_fa'] ||
|
|
texts['BRAND_LOGO_TEXT_FA'] ||
|
|
texts['site_logo_text_fa'] ||
|
|
DEFAULT_SEO_CONFIG.brandNameFa;
|
|
|
|
const brandNameEn =
|
|
texts['brand_name_en'] ||
|
|
texts['BRAND_LOGO_TEXT_EN'] ||
|
|
DEFAULT_SEO_CONFIG.brandNameEn;
|
|
|
|
const titleSeparator =
|
|
texts['title_separator'] || DEFAULT_SEO_CONFIG.titleSeparator;
|
|
|
|
const titlePositionRaw = texts['title_position']?.toLowerCase();
|
|
const titlePosition: 'suffix' | 'prefix' | 'none' =
|
|
titlePositionRaw === 'prefix' || titlePositionRaw === 'none'
|
|
? titlePositionRaw
|
|
: 'suffix';
|
|
|
|
const defaultMetaTitle =
|
|
texts['defaultMetaTitle'] ||
|
|
texts['home_meta_title'] ||
|
|
DEFAULT_SEO_CONFIG.defaultMetaTitle;
|
|
|
|
const defaultMetaDescription =
|
|
texts['defaultMetaDescription'] ||
|
|
texts['home_meta_desc'] ||
|
|
DEFAULT_SEO_CONFIG.defaultMetaDescription;
|
|
|
|
const rawKeywords = texts['keywords'] || texts['home_keywords'];
|
|
const keywords = rawKeywords
|
|
? rawKeywords.split(',').map((k) => k.trim()).filter(Boolean)
|
|
: DEFAULT_SEO_CONFIG.keywords;
|
|
|
|
const canonicalBaseUrl =
|
|
texts['canonicalBaseUrl'] ||
|
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
|
DEFAULT_SEO_CONFIG.canonicalBaseUrl;
|
|
|
|
const ogImageUrl =
|
|
texts['ogImageUrl'] || DEFAULT_SEO_CONFIG.ogImageUrl;
|
|
|
|
const googleSiteVerification =
|
|
texts['googleSiteVerification'] ||
|
|
texts['google_site_verification'] ||
|
|
process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION ||
|
|
'';
|
|
|
|
const googleAnalyticsId =
|
|
texts['googleAnalyticsId'] ||
|
|
texts['ga_measurement_id'] ||
|
|
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ||
|
|
'';
|
|
|
|
const googleTagManagerId =
|
|
texts['googleTagManagerId'] ||
|
|
texts['gtm_id'] ||
|
|
process.env.NEXT_PUBLIC_GTM_ID ||
|
|
'';
|
|
|
|
const enableGoogleAnalytics =
|
|
texts['enableGoogleAnalytics'] !== undefined
|
|
? texts['enableGoogleAnalytics'] === 'true'
|
|
: true;
|
|
|
|
const enableGoogleTagManager =
|
|
texts['enableGoogleTagManager'] !== undefined
|
|
? texts['enableGoogleTagManager'] === 'true'
|
|
: true;
|
|
|
|
const clarityProjectId =
|
|
texts['clarityProjectId'] ||
|
|
texts['clarity_project_id'] ||
|
|
process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID ||
|
|
'';
|
|
|
|
return {
|
|
brandNameFa,
|
|
brandNameEn,
|
|
titleSeparator,
|
|
titlePosition,
|
|
defaultMetaTitle,
|
|
defaultMetaDescription,
|
|
keywords,
|
|
canonicalBaseUrl,
|
|
ogImageUrl,
|
|
googleSiteVerification,
|
|
googleAnalyticsId,
|
|
googleTagManagerId,
|
|
enableGoogleAnalytics,
|
|
enableGoogleTagManager,
|
|
clarityProjectId,
|
|
texts,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('[SEO] Unable to fetch dynamic settings from backend, using fallback:', error);
|
|
}
|
|
|
|
return DEFAULT_SEO_CONFIG;
|
|
}
|
|
|
|
/**
|
|
* Uniform Page Title Formatter according to Store Brand Configuration
|
|
*/
|
|
export function formatPageTitle(
|
|
rawTitle: string,
|
|
brandName = 'کنینا ایران',
|
|
separator = '|',
|
|
position: 'suffix' | 'prefix' | 'none' = 'suffix',
|
|
isHomePage = false
|
|
): string {
|
|
if (!rawTitle || !rawTitle.trim()) {
|
|
return brandName;
|
|
}
|
|
|
|
const clean = rawTitle.trim();
|
|
|
|
// If this is the home page and has a custom full title configured, return it directly
|
|
if (isHomePage) {
|
|
return clean;
|
|
}
|
|
|
|
// Check if the title already explicitly contains the brand name
|
|
const escapedBrand = brandName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const brandRegex = new RegExp(`(\\s*[-|•]\\s*${escapedBrand}|${escapedBrand}\\s*[-|•]\\s*)`, 'gi');
|
|
|
|
// Strip existing repeated brand affix if present so we can format cleanly
|
|
const baseTitle = clean.replace(brandRegex, '').trim();
|
|
|
|
if (position === 'none') {
|
|
return baseTitle || clean;
|
|
}
|
|
|
|
if (position === 'prefix') {
|
|
return `${brandName} ${separator} ${baseTitle || clean}`;
|
|
}
|
|
|
|
// Default: 'suffix' -> "{PageTitle} {separator} {BrandName}"
|
|
return `${baseTitle || clean} ${separator} ${brandName}`;
|
|
}
|
|
|
|
export interface PageMetadataOptions {
|
|
fallbackTitle?: string;
|
|
fallbackDesc?: string;
|
|
fallbackKeywords?: string[];
|
|
canonicalPath?: string;
|
|
ogImage?: string;
|
|
isDynamicEntity?: boolean; // For PDP / Blog / Wiki detail
|
|
noIndex?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Generate standard Next.js Metadata object dynamically from server settings
|
|
*/
|
|
export async function getPageMetadata(
|
|
pageKey: string,
|
|
options: PageMetadataOptions = {}
|
|
): Promise<Metadata> {
|
|
const config = await getSeoConfig();
|
|
const { texts, brandNameFa, titleSeparator, titlePosition, canonicalBaseUrl, ogImageUrl } = config;
|
|
|
|
const isHome = pageKey === 'home';
|
|
const rawTitle =
|
|
texts[`${pageKey}_meta_title`] ||
|
|
options.fallbackTitle ||
|
|
config.defaultMetaTitle;
|
|
|
|
const title = options.isDynamicEntity
|
|
? formatPageTitle(rawTitle, brandNameFa, titleSeparator, titlePosition, false)
|
|
: formatPageTitle(rawTitle, brandNameFa, titleSeparator, titlePosition, isHome);
|
|
|
|
const description =
|
|
texts[`${pageKey}_meta_desc`] ||
|
|
options.fallbackDesc ||
|
|
config.defaultMetaDescription;
|
|
|
|
const rawKeywords = texts[`${pageKey}_keywords`];
|
|
const keywords = rawKeywords
|
|
? rawKeywords.split(',').map((k) => k.trim()).filter(Boolean)
|
|
: options.fallbackKeywords || config.keywords;
|
|
|
|
const explicitCanonical = texts[`${pageKey}_canonical`];
|
|
const relativePath = options.canonicalPath ?? (isHome ? '/' : `/${pageKey.replace(/_/g, '-')}`);
|
|
const canonicalUrl = explicitCanonical || `${canonicalBaseUrl.replace(/\/$/, '')}${relativePath}`;
|
|
|
|
const image = options.ogImage || ogImageUrl;
|
|
|
|
const isStaging =
|
|
options.noIndex ||
|
|
process.env.NEXT_PUBLIC_APP_ENV === 'staging' ||
|
|
process.env.NODE_ENV !== 'production' ||
|
|
(typeof process.env.NEXT_PUBLIC_SITE_URL === 'string' &&
|
|
(process.env.NEXT_PUBLIC_SITE_URL.includes('stage') ||
|
|
process.env.NEXT_PUBLIC_SITE_URL.includes('test') ||
|
|
process.env.NEXT_PUBLIC_SITE_URL.includes('dev')));
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
keywords,
|
|
alternates: {
|
|
canonical: canonicalUrl,
|
|
},
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
url: canonicalUrl,
|
|
siteName: brandNameFa,
|
|
locale: 'fa_IR',
|
|
type: 'website',
|
|
images: [
|
|
{
|
|
url: image,
|
|
width: 1200,
|
|
height: 630,
|
|
alt: title,
|
|
},
|
|
],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title,
|
|
description,
|
|
images: [image],
|
|
},
|
|
robots: isStaging
|
|
? {
|
|
index: false,
|
|
follow: false,
|
|
nocache: true,
|
|
googleBot: {
|
|
index: false,
|
|
follow: false,
|
|
noimageindex: true,
|
|
},
|
|
}
|
|
: {
|
|
index: true,
|
|
follow: true,
|
|
googleBot: {
|
|
index: true,
|
|
follow: true,
|
|
'max-video-preview': -1,
|
|
'max-image-preview': 'large',
|
|
'max-snippet': -1,
|
|
},
|
|
},
|
|
};
|
|
}
|