fix(i18n): render locale switch as links
Use locale-prefixed <Link> elements for EN/DE so language switching works even when client-side hydration is broken. Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -6,14 +6,14 @@ import { Menu, X, Mail } from "lucide-react";
|
|||||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
import { SiGithub, SiLinkedin } from "react-icons/si";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("nav");
|
const t = useTranslations("nav");
|
||||||
|
|
||||||
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||||
@@ -44,23 +44,11 @@ const Header = () => {
|
|||||||
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const switchLocale = (nextLocale: string) => {
|
|
||||||
try {
|
|
||||||
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
|
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
|
||||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
const qs = searchParams.toString();
|
||||||
// Rely on middleware to persist NEXT_LOCALE cookie.
|
const query = qs ? `?${qs}` : "";
|
||||||
// Use a hard navigation for maximum reliability (also fixes cases where
|
const enHref = `/en${pathWithoutLocale}${query}`;
|
||||||
// client-side router navigation can be prevented by runtime errors).
|
const deHref = `/de${pathWithoutLocale}${query}`;
|
||||||
const target = `/${nextLocale}${pathWithoutLocale}${hash}`;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.location.assign(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(target);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always render to prevent flash, but use opacity transition
|
// Always render to prevent flash, but use opacity transition
|
||||||
|
|
||||||
@@ -144,9 +132,8 @@ const Header = () => {
|
|||||||
|
|
||||||
<div className="hidden md:flex items-center space-x-3">
|
<div className="hidden md:flex items-center space-x-3">
|
||||||
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
|
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={enHref}
|
||||||
onClick={() => switchLocale("en")}
|
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
locale === "en"
|
locale === "en"
|
||||||
? "bg-stone-900 text-stone-50"
|
? "bg-stone-900 text-stone-50"
|
||||||
@@ -155,10 +142,9 @@ const Header = () => {
|
|||||||
aria-label="Switch language to English"
|
aria-label="Switch language to English"
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={deHref}
|
||||||
onClick={() => switchLocale("de")}
|
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
locale === "de"
|
locale === "de"
|
||||||
? "bg-stone-900 text-stone-50"
|
? "bg-stone-900 text-stone-50"
|
||||||
@@ -167,7 +153,7 @@ const Header = () => {
|
|||||||
aria-label="Sprache auf Deutsch umstellen"
|
aria-label="Sprache auf Deutsch umstellen"
|
||||||
>
|
>
|
||||||
DE
|
DE
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{socialLinks.map((social) => (
|
{socialLinks.map((social) => (
|
||||||
<motion.a
|
<motion.a
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ test.describe("i18n routing", () => {
|
|||||||
test("language switcher navigates between locales", async ({ page }) => {
|
test("language switcher navigates between locales", async ({ page }) => {
|
||||||
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
// Buttons have aria-labels; click the DE switcher.
|
// Locale switchers are links (work even without hydration)
|
||||||
const deButton = page.getByRole("button", { name: "Sprache auf Deutsch umstellen" });
|
const deLink = page.getByRole("link", { name: "Sprache auf Deutsch umstellen" });
|
||||||
if (await deButton.count()) {
|
if (await deLink.count()) {
|
||||||
// Verify an EN label is present before switching (nav.home)
|
// Verify an EN label is present before switching (nav.home)
|
||||||
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/de(\/|$)/, { timeout: 30000 }),
|
page.waitForURL(/\/de(\/|$)/, { timeout: 30000 }),
|
||||||
deButton.click(),
|
deLink.click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify the nav label updates after switching
|
// Verify the nav label updates after switching
|
||||||
|
|||||||
Reference in New Issue
Block a user