refactor: centralize locale constants to prevent breaking changes

Created src/lib/i18n/locales.ts as single source of truth for:
- SUPPORTED_LOCALES array
- LOCALE_COOKIE name
- DEFAULT_LOCALE
- LOCALE_CONFIG (labels, flags, Saleor locale mapping)
- Helper functions (isValidLocale, getSaleorLocale, getLocaleFromPath)

Updated all files to use centralized constants:
- middleware.ts
- Header.tsx
- ProductCard.tsx
- sitemap.ts
- root layout and locale layout
- routing.ts

Benefits:
- Adding new locale only requires updating ONE file (locales.ts)
- No more hardcoded locale lists scattered across codebase
- Cookie name defined in one place
- Type-safe locale validation
This commit is contained in:
Unchained
2026-03-24 11:27:55 +02:00
parent a4e7a07adb
commit a5cd048a6e
8 changed files with 92 additions and 81 deletions

View File

@@ -1,12 +1,12 @@
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({
@@ -18,7 +18,7 @@ export async function generateMetadata({
const localePrefix = locale === "sr" ? "" : `/${locale}`;
const languages: Record<string, string> = {};
for (const loc of routing.locales) {
for (const loc of SUPPORTED_LOCALES) {
const prefix = loc === "sr" ? "" : `/${loc}`;
languages[loc] = `${baseUrl}${prefix}`;
}

View File

@@ -1,6 +1,7 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
@@ -13,12 +14,9 @@ export const metadata: Metadata = {
robots: "index, follow",
alternates: {
canonical: baseUrl,
languages: {
sr: baseUrl,
en: `${baseUrl}/en`,
de: `${baseUrl}/de`,
fr: `${baseUrl}/fr`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
openGraph: {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
@@ -48,4 +46,4 @@ export default async function RootLayout({
</body>
</html>
);
}
}

View File

@@ -1,7 +1,7 @@
import { MetadataRoute } from "next";
import { getProducts } from "@/lib/saleor";
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
const LOCALES = ["sr", "en", "de", "fr"] as const;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
interface SitemapEntry {
@@ -29,12 +29,9 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "daily",
priority: 1,
alternates: {
languages: {
sr: `${baseUrl}`,
en: `${baseUrl}/en`,
de: `${baseUrl}/de`,
fr: `${baseUrl}/fr`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
},
{
@@ -43,12 +40,9 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "daily",
priority: 0.9,
alternates: {
languages: {
sr: `${baseUrl}/products`,
en: `${baseUrl}/en/products`,
de: `${baseUrl}/de/products`,
fr: `${baseUrl}/fr/products`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
),
},
},
{
@@ -57,12 +51,9 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "monthly",
priority: 0.6,
alternates: {
languages: {
sr: `${baseUrl}/about`,
en: `${baseUrl}/en/about`,
de: `${baseUrl}/de/about`,
fr: `${baseUrl}/fr/about`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
),
},
},
{
@@ -71,12 +62,9 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "monthly",
priority: 0.6,
alternates: {
languages: {
sr: `${baseUrl}/contact`,
en: `${baseUrl}/en/contact`,
de: `${baseUrl}/de/contact`,
fr: `${baseUrl}/fr/contact`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/contact` : `${baseUrl}/${locale}/contact`])
),
},
},
{
@@ -85,12 +73,9 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "monthly",
priority: 0.5,
alternates: {
languages: {
sr: `${baseUrl}/checkout`,
en: `${baseUrl}/en/checkout`,
de: `${baseUrl}/de/checkout`,
fr: `${baseUrl}/fr/checkout`,
},
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/checkout` : `${baseUrl}/${locale}/checkout`])
),
},
},
];
@@ -98,7 +83,13 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
const productUrls: SitemapEntry[] = [];
for (const product of products) {
for (const locale of LOCALES) {
const hreflangs: Record<string, string> = {};
for (const locale of SUPPORTED_LOCALES) {
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
hreflangs[locale] = `${baseUrl}${path}`;
}
for (const locale of SUPPORTED_LOCALES) {
const localePrefix = locale === "sr" ? "" : `/${locale}`;
productUrls.push({
url: `${baseUrl}${localePrefix}/products/${product.slug}`,
@@ -106,12 +97,7 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
changeFrequency: "weekly",
priority: 0.8,
alternates: {
languages: {
sr: `${baseUrl}/products/${product.slug}`,
en: `${baseUrl}/en/products/${product.slug}`,
de: `${baseUrl}/de/products/${product.slug}`,
fr: `${baseUrl}/fr/products/${product.slug}`,
},
languages: hreflangs,
},
});
}

View File

@@ -9,11 +9,8 @@ import { useTranslations } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer";
const LOCALES = [
{ code: "sr", label: "Srpski", flag: "🇷🇸" },
{ code: "en", label: "English", flag: "🇬🇧" },
] as const;
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
interface HeaderProps {
locale?: string;
@@ -29,9 +26,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount();
const localePath = `/${locale}`;
const currentLocale = LOCALES.find((l) => l.code === locale) || LOCALES[0];
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -48,7 +43,11 @@ export default function Header({ locale = "sr" }: HeaderProps) {
setLangDropdownOpen(false);
return;
}
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
if (!isValidLocale(newLocale)) {
setLangDropdownOpen(false);
return;
}
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
const pathWithoutLocale = pathname.replace(/^\/(sr|en|de|fr)/, "") || "/";
const newPath = `/${newLocale}${pathWithoutLocale}`;
window.location.replace(newPath);
@@ -115,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
))}
</nav>
<Link href={localePath || "/"} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
<Link href={`/${locale}`} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"
@@ -144,16 +143,16 @@ export default function Header({ locale = "sr" }: HeaderProps) {
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
>
{LOCALES.map((loc) => (
{SUPPORTED_LOCALES.map((loc) => (
<button
key={loc.code}
onClick={() => switchLocale(loc.code)}
key={loc}
onClick={() => switchLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
loc.code === locale ? "bg-black/5 font-medium" : ""
loc === locale ? "bg-black/5 font-medium" : ""
}`}
>
<span>{loc.flag}</span>
<span>{loc.label}</span>
<span>{LOCALE_CONFIG[loc].flag}</span>
<span>{LOCALE_CONFIG[loc].label}</span>
</button>
))}
</motion.div>
@@ -195,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
>
<div className="container h-full flex flex-col">
<div className="flex items-center justify-between h-[72px]">
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}>
<Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"
@@ -260,4 +259,4 @@ export default function Header({ locale = "sr" }: HeaderProps) {
<CartDrawer />
</>
);
}
}

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
interface ProductCardProps {
product: Product;
@@ -13,13 +14,13 @@ interface ProductCardProps {
locale?: string;
}
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
const t = useTranslations("ProductCard");
const image = getProductImage(product);
const price = getProductPrice(product);
const localized = getLocalizedProduct(product, locale);
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
const localized = getLocalizedProduct(product, saleorLocale);
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
const urlLocale = locale === "SR" ? "sr" : "en";
return (
<motion.div
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/${urlLocale}/products/${localized.slug}`} className="group block">
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? (
<img
@@ -75,4 +76,4 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
</Link>
</motion.div>
);
}
}

View File

@@ -1,7 +1,8 @@
import { defineRouting } from "next-intl/routing";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
export const routing = defineRouting({
locales: ["sr", "en", "de", "fr"],
defaultLocale: "sr",
locales: SUPPORTED_LOCALES,
defaultLocale: DEFAULT_LOCALE,
localePrefix: "as-needed",
});
});

26
src/lib/i18n/locales.ts Normal file
View File

@@ -0,0 +1,26 @@
export const SUPPORTED_LOCALES = ["sr", "en", "de", "fr"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "sr";
export const LOCALE_COOKIE = "NEXT_LOCALE";
export const LOCALE_CONFIG: Record<Locale, { label: string; flag: string; saleorLocale: string }> = {
sr: { label: "Srpski", flag: "🇷🇸", saleorLocale: "SR" },
en: { label: "English", flag: "🇬🇧", saleorLocale: "EN" },
de: { label: "Deutsch", flag: "🇩🇪", saleorLocale: "EN" },
fr: { label: "Français", flag: "🇫🇷", saleorLocale: "EN" },
};
export function isValidLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale);
}
export function getSaleorLocale(locale: Locale): string {
return LOCALE_CONFIG[locale].saleorLocale;
}
export function getLocaleFromPath(pathname: string): string {
const match = pathname.match(/^\/(sr|en|de|fr)/);
return match ? match[1] : DEFAULT_LOCALE;
}