352 lines
11 KiB
TypeScript
352 lines
11 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 ||
|
|
(process.env.NODE_ENV === 'production' ? 'https://api.canina.ir/api' : 'http://127.0.0.1:4400/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
|
|
*/
|
|
/**
|
|
* Clean and strip any repeated brand affixes from a title.
|
|
* Removes occurrences of brand names (Fa / En) and separators so the template or title can format cleanly.
|
|
*/
|
|
export function cleanPageTitle(rawTitle: string, brandName = 'کنینا ایران'): string {
|
|
if (!rawTitle || !rawTitle.trim()) {
|
|
return '';
|
|
}
|
|
|
|
let clean = rawTitle.trim();
|
|
|
|
// Known brand aliases to strip to avoid repetitive keyword stuffing
|
|
const brandKeywords = [
|
|
brandName,
|
|
'کنینا ایران',
|
|
'فروشگاه کنینا ایران',
|
|
'فروشگاه کنینا',
|
|
'کنینا',
|
|
'Canina Iran',
|
|
'Canina',
|
|
];
|
|
|
|
for (const b of brandKeywords) {
|
|
if (!b) continue;
|
|
const escaped = b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(`(\\s*[-|•/]\\s*${escaped}|${escaped}\\s*[-|•/]\\s*)`, 'gi');
|
|
clean = clean.replace(regex, ' ').trim();
|
|
}
|
|
|
|
// Remove dangling separators at beginning or end
|
|
clean = clean.replace(/^[-|•/\s]+|[-|•/\s]+$/g, '').trim();
|
|
|
|
return clean;
|
|
}
|
|
|
|
/**
|
|
* Uniform Page Title Formatter according to Store Brand Configuration
|
|
* For Next.js subpages, returns a clean title so layout.tsx template can affix brand name cleanly without repetition.
|
|
*/
|
|
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, return the full configured home title as-is
|
|
if (isHomePage) {
|
|
return clean;
|
|
}
|
|
|
|
// Clean any brand suffixes/prefixes already embedded in rawTitle
|
|
const baseTitle = cleanPageTitle(clean, brandName);
|
|
|
|
// Return baseTitle directly so layout.tsx's `title.template` handles affixing brand name exactly once!
|
|
// If baseTitle becomes empty (e.g. rawTitle was just "کنینا ایران"), fallback to rawTitle or brandName
|
|
return baseTitle || clean || 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')));
|
|
|
|
const ogTitle = isHome
|
|
? title
|
|
: (titlePosition === 'prefix' ? `${brandNameFa} ${titleSeparator} ${title}` : `${title} ${titleSeparator} ${brandNameFa}`);
|
|
|
|
return {
|
|
title: isHome ? { absolute: title } : title,
|
|
description,
|
|
keywords,
|
|
alternates: {
|
|
canonical: canonicalUrl,
|
|
},
|
|
openGraph: {
|
|
title: ogTitle,
|
|
description,
|
|
url: canonicalUrl,
|
|
siteName: brandNameFa,
|
|
locale: 'fa_IR',
|
|
type: 'website',
|
|
images: [
|
|
{
|
|
url: image,
|
|
width: 1200,
|
|
height: 630,
|
|
alt: ogTitle,
|
|
},
|
|
],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title: ogTitle,
|
|
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,
|
|
},
|
|
},
|
|
};
|
|
}
|