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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user