canina/frontend/application/lib/seo.ts
parsa aghaei f81aabb479
Some checks failed
Deploy Canina / deploy (push) Successful in 2m30s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 0s
fix(seo): prevent duplicate brand affixes in page titles and add checkout success metadata
2026-09-05 19:43:28 +03:30

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,
},
},
};
}