feat: add language switcher with cookie persistence and browser detection

Changes:
- Root page.tsx now detects browser language (Accept-Language header)
  and cookie preference to redirect to correct locale (/sr or /en)
- Middleware handles old Serbian URLs (/products, /about, etc.) with
  301 redirects to /sr/* while respecting locale cookie
- Header component now includes language switcher dropdown with
  flag icons (Serbian and English)
- Language selection sets NEXT_LOCALE cookie and persists preference

Behavior:
- User visits / → redirects to /sr or /en based on cookie/browser
- User selects English from dropdown → cookie set, redirects to /en/*
- User visits /products with en cookie → /en/products (301)
- User visits /products with sr/no cookie → /sr/products (301)
This commit is contained in:
Unchained
2026-03-24 08:09:27 +02:00
parent 8244ba161b
commit a84647db6c
3 changed files with 107 additions and 29 deletions

View File

@@ -1,28 +1,25 @@
import createMiddleware from "next-intl/middleware";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { routing } from "./src/i18n/routing";
const LOCALE_COOKIE = "NEXT_LOCALE";
export default function middleware(request: NextRequest) { export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname; const pathname = request.nextUrl.pathname;
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const hasLocalePrefix = routing.locales.some( const acceptLanguage = request.headers.get("accept-language") || "";
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
);
if (hasLocalePrefix) {
const intlMiddleware = createMiddleware({
...routing,
});
return intlMiddleware(request);
}
if (pathname === "/" || pathname === "") { if (pathname === "/" || pathname === "") {
const intlMiddleware = createMiddleware({ let locale = "sr";
...routing,
localeDetection: true, if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
}); locale = cookieLocale;
return intlMiddleware(request); } else if (acceptLanguage.includes("en")) {
locale = "en";
}
const url = request.nextUrl.clone();
url.pathname = `/${locale}`;
return NextResponse.redirect(url, 302);
} }
const oldSerbianPaths = ["products", "about", "contact", "checkout"]; const oldSerbianPaths = ["products", "about", "contact", "checkout"];
@@ -31,16 +28,20 @@ export default function middleware(request: NextRequest) {
); );
if (isOldSerbianPath) { if (isOldSerbianPath) {
const newPathname = `/sr${pathname}`; let locale = "sr";
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
locale = cookieLocale;
} else if (acceptLanguage.includes("en")) {
locale = "en";
}
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = newPathname; url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url, 301); return NextResponse.redirect(url, 301);
} }
const intlMiddleware = createMiddleware({ return NextResponse.next();
...routing,
});
return intlMiddleware(request);
} }
export const config = { export const config = {

View File

@@ -1,5 +1,18 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies, headers } from "next/headers";
export default function RootPage() { export default async function RootPage() {
redirect("/sr"); const headersList = await headers();
} const cookieStore = await cookies();
const acceptLanguage = headersList.get("accept-language") || "";
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
let locale = "sr";
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
locale = cookieLocale;
} else if (acceptLanguage.includes("en")) {
locale = "en";
}
redirect(`/${locale}`);
}

View File

@@ -1,27 +1,57 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter, usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X } from "lucide-react"; import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer"; import CartDrawer from "@/components/cart/CartDrawer";
const LOCALES = [
{ code: "sr", label: "Srpski", flag: "🇷🇸" },
{ code: "en", label: "English", flag: "🇬🇧" },
] as const;
interface HeaderProps { interface HeaderProps {
locale?: string; locale?: string;
} }
export default function Header({ locale = "sr" }: HeaderProps) { export default function Header({ locale = "sr" }: HeaderProps) {
const t = useTranslations("Header"); const t = useTranslations("Header");
const router = useRouter();
const pathname = usePathname();
const dropdownRef = useRef<HTMLDivElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore(); const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount(); const itemCount = getLineCount();
const localePath = `/${locale}`; const localePath = `/${locale}`;
const currentLocale = LOCALES.find((l) => l.code === locale) || LOCALES[0];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setLangDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const switchLocale = (newLocale: string) => {
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
const currentPath = pathname.replace(`/${locale}`, "") || "/";
const newPath = newLocale === "sr" ? currentPath : `/${newLocale}${currentPath}`;
router.push(newPath);
setLangDropdownOpen(false);
};
useEffect(() => { useEffect(() => {
initCheckout(); initCheckout();
}, [initCheckout]); }, [initCheckout]);
@@ -94,6 +124,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
</Link> </Link>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div ref={dropdownRef} className="relative">
<button
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
aria-label="Select language"
>
<Globe className="w-5 h-5" strokeWidth={1.5} />
<span className="text-sm">{currentLocale.flag}</span>
</button>
<AnimatePresence>
{langDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
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) => (
<button
key={loc.code}
onClick={() => switchLocale(loc.code)}
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" : ""
}`}
>
<span>{loc.flag}</span>
<span>{loc.label}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<button <button
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block" className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
aria-label={t("account")} aria-label={t("account")}