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:
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } 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) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
@@ -9,9 +10,9 @@ export default function middleware(request: NextRequest) {
|
||||
const acceptLanguage = request.headers.get("accept-language") || "";
|
||||
|
||||
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;
|
||||
} else if (acceptLanguage.includes("en")) {
|
||||
locale = "en";
|
||||
@@ -22,15 +23,14 @@ export default function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
|
||||
const oldSerbianPaths = ["products", "about", "contact", "checkout"];
|
||||
const isOldSerbianPath = oldSerbianPaths.some(
|
||||
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
|
||||
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
|
||||
);
|
||||
|
||||
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;
|
||||
} else if (acceptLanguage.includes("en")) {
|
||||
locale = "en";
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
26
src/lib/i18n/locales.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user