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,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getLocaleFromPath } from "@/lib/i18n/locales";
const LOCALE_COOKIE = "NEXT_LOCALE"; const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
export default function middleware(request: NextRequest) { export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname; const pathname = request.nextUrl.pathname;
@@ -9,9 +10,9 @@ export default function middleware(request: NextRequest) {
const acceptLanguage = request.headers.get("accept-language") || ""; const acceptLanguage = request.headers.get("accept-language") || "";
if (pathname === "/" || pathname === "") { if (pathname === "/" || pathname === "") {
let locale = "sr"; let locale = DEFAULT_LOCALE;
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) { if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as typeof SUPPORTED_LOCALES[number])) {
locale = cookieLocale; locale = cookieLocale;
} else if (acceptLanguage.includes("en")) { } else if (acceptLanguage.includes("en")) {
locale = "en"; locale = "en";
@@ -22,15 +23,14 @@ export default function middleware(request: NextRequest) {
return NextResponse.redirect(url, 301); return NextResponse.redirect(url, 301);
} }
const oldSerbianPaths = ["products", "about", "contact", "checkout"]; const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
const isOldSerbianPath = oldSerbianPaths.some(
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`) (path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
); );
if (isOldSerbianPath) { if (isOldSerbianPath) {
let locale = "sr"; let locale = DEFAULT_LOCALE;
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) { if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as typeof SUPPORTED_LOCALES[number])) {
locale = cookieLocale; locale = cookieLocale;
} else if (acceptLanguage.includes("en")) { } else if (acceptLanguage.includes("en")) {
locale = "en"; locale = "en";

View File

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

View File

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

View File

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

View File

@@ -9,11 +9,8 @@ import { useTranslations } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react"; import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer"; import CartDrawer from "@/components/cart/CartDrawer";
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale } from "@/lib/i18n/locales";
const LOCALES = [ import type { Locale } from "@/lib/i18n/locales";
{ code: "sr", label: "Srpski", flag: "🇷🇸" },
{ code: "en", label: "English", flag: "🇬🇧" },
] as const;
interface HeaderProps { interface HeaderProps {
locale?: string; locale?: string;
@@ -29,9 +26,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore(); const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount(); const itemCount = getLineCount();
const localePath = `/${locale}`; const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
const currentLocale = LOCALES.find((l) => l.code === locale) || LOCALES[0];
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -48,7 +43,11 @@ export default function Header({ locale = "sr" }: HeaderProps) {
setLangDropdownOpen(false); setLangDropdownOpen(false);
return; 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 pathWithoutLocale = pathname.replace(/^\/(sr|en|de|fr)/, "") || "/";
const newPath = `/${newLocale}${pathWithoutLocale}`; const newPath = `/${newLocale}${pathWithoutLocale}`;
window.location.replace(newPath); window.location.replace(newPath);
@@ -115,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
))} ))}
</nav> </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 <Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils" alt="ManoonOils"
@@ -144,16 +143,16 @@ export default function Header({ locale = "sr" }: HeaderProps) {
exit={{ opacity: 0, y: -10 }} 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" 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 <button
key={loc.code} key={loc}
onClick={() => switchLocale(loc.code)} 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 ${ 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>{LOCALE_CONFIG[loc].flag}</span>
<span>{loc.label}</span> <span>{LOCALE_CONFIG[loc].label}</span>
</button> </button>
))} ))}
</motion.div> </motion.div>
@@ -195,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
> >
<div className="container h-full flex flex-col"> <div className="container h-full flex flex-col">
<div className="flex items-center justify-between h-[72px]"> <div className="flex items-center justify-between h-[72px]">
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}> <Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
<Image <Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils" alt="ManoonOils"

View File

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

View File

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