canina/frontend/application/lib/seo.ts
parsa aghaei f0457b5748
Some checks failed
Deploy Canina / deploy (push) Successful in 2m14s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s
feat: complete Google Search Console and Analytics integration across admin panel and storefront
2026-09-02 11:34:10 +03:30

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