From a84647db6cc2135971a9c50cb4d0e1a6f4dd213f Mon Sep 17 00:00:00 2001 From: Unchained Date: Tue, 24 Mar 2026 08:09:27 +0200 Subject: [PATCH] feat: add language switcher with cookie persistence and browser detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- middleware.ts | 49 ++++++++++++----------- src/app/page.tsx | 19 +++++++-- src/components/layout/Header.tsx | 68 +++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/middleware.ts b/middleware.ts index d7b7270..323ce0c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,28 +1,25 @@ -import createMiddleware from "next-intl/middleware"; import { NextResponse } 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) { const pathname = request.nextUrl.pathname; - - const hasLocalePrefix = routing.locales.some( - (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`) - ); - - if (hasLocalePrefix) { - const intlMiddleware = createMiddleware({ - ...routing, - }); - return intlMiddleware(request); - } + const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; + const acceptLanguage = request.headers.get("accept-language") || ""; if (pathname === "/" || pathname === "") { - const intlMiddleware = createMiddleware({ - ...routing, - localeDetection: true, - }); - return intlMiddleware(request); + 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(); + url.pathname = `/${locale}`; + return NextResponse.redirect(url, 302); } const oldSerbianPaths = ["products", "about", "contact", "checkout"]; @@ -31,16 +28,20 @@ export default function middleware(request: NextRequest) { ); 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(); - url.pathname = newPathname; + url.pathname = `/${locale}${pathname}`; return NextResponse.redirect(url, 301); } - const intlMiddleware = createMiddleware({ - ...routing, - }); - return intlMiddleware(request); + return NextResponse.next(); } export const config = { diff --git a/src/app/page.tsx b/src/app/page.tsx index 5bccb19..d58d873 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,18 @@ import { redirect } from "next/navigation"; +import { cookies, headers } from "next/headers"; -export default function RootPage() { - redirect("/sr"); -} \ No newline at end of file +export default async function RootPage() { + 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}`); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 716a7e2..c99f842 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,27 +1,57 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import Link from "next/link"; import Image from "next/image"; +import { useRouter, usePathname } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { useTranslations } from "next-intl"; 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"; +const LOCALES = [ + { code: "sr", label: "Srpski", flag: "🇷🇸" }, + { code: "en", label: "English", flag: "🇬🇧" }, +] as const; + interface HeaderProps { locale?: string; } export default function Header({ locale = "sr" }: HeaderProps) { const t = useTranslations("Header"); + const router = useRouter(); + const pathname = usePathname(); + const dropdownRef = useRef(null); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [scrolled, setScrolled] = useState(false); + const [langDropdownOpen, setLangDropdownOpen] = useState(false); const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore(); const itemCount = getLineCount(); 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(() => { initCheckout(); }, [initCheckout]); @@ -94,6 +124,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
+
+ + + {langDropdownOpen && ( + + {LOCALES.map((loc) => ( + + ))} + + )} + +
+