feat(i18n): routing and localization infrastructure (phase 1)

Implement Phase 1 of i18n spec:
- Configure next-intl with 6 locales (pt, en, es, fr, de, zh-CN) and pathnames
- Move routes to app/[locale] structure with localized layouts and metadata
- Add middleware for root language negotiation (307), legacy redirects (308), and 404 for non-pt book
- Implement language selector dropdown in header with cookie persistence
- Add system typography rules for zh-CN and word-break/hyphenation for de/fr

Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
Lucas RayanandAntigravity committed 2026-11-01 09:31:22 -03:00
1 parent e92aea0c84
commit 83c0ba3e74
38 files changed
+1699 -324

No files matched your search

+80 -65
View File
@@ -1,17 +1,21 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Logo from "@/components/Logo";
import GiteaMark from "@/components/GiteaMark";
import { ArrowUpRight } from "lucide-react";
import { SITE } from "@/lib/seo";
import { LOCALES, getLocalizedHref } from "@/components/LanguageSelector";
const COLUMNS = [
const BASE_COLUMNS = [
{
title: "Explorar",
links: [
{ href: "/series", label: "Séries ESP32" },
{ href: "/comparacao", label: "Comparador" },
{ href: "/catalogo", label: "Catálogo de placas" },
{ href: "/livro", label: "O livro" },
{ href: "/livro", label: "O livro", livroOnly: true },
],
},
{
@@ -35,14 +39,26 @@ const COLUMNS = [
];
export default function Footer() {
const pathname = usePathname() || "";
const year = new Date().getFullYear();
const currentLocale =
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
const isPt = currentLocale === "pt";
const getLinkHref = (href) => getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
const columns = BASE_COLUMNS.map((col) => ({
...col,
links: col.links.filter((l) => !l.livroOnly || isPt),
}));
return (
<footer className="mt-20 border-t border-line md:mt-28" aria-label="Rodapé do ESPDocs">
<div className="shell py-14 md:py-20">
<div className="grid gap-12 lg:grid-cols-[1.1fr_2fr]">
<div>
<Link href="/" className="inline-flex min-h-11 items-center gap-2.5" aria-label="ESPDocs, página inicial">
<Link href={getLinkHref("/")} className="inline-flex min-h-11 items-center gap-2.5" aria-label="ESPDocs, página inicial">
<Logo className="h-7 w-7" />
<span className="font-display text-2xl font-semibold tracking-[-0.045em] text-ink">ESPDocs</span>
</Link>
@@ -51,41 +67,45 @@ export default function Footer() {
Dados públicos, curadoria editorial e links para as fontes oficiais.
</p>
<Link
href="/livro"
className="group mt-7 grid grid-cols-[3rem_1fr_auto] items-center gap-3.5 rounded-2xl border border-line bg-surface p-3.5 transition-colors duration-300 hover:border-line-strong"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={SITE.livroCapa}
alt=""
aria-hidden="true"
width={48}
height={72}
className="h-[72px] w-12 rounded-md object-cover shadow-md"
/>
<span className="min-w-0">
<span className="text-xs font-semibold text-accent">Livro novo</span>
<span className="mt-0.5 block font-display text-base font-semibold leading-tight text-ink">Microcontroladores ESP32</span>
<span className="mt-1 block text-xs text-muted">Conectividade, eficiência e desempenho</span>
</span>
<ArrowUpRight className="h-4 w-4 text-faint transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" aria-hidden="true" />
</Link>
{isPt && (
<>
<Link
href="/pt/livro"
className="group mt-7 grid grid-cols-[3rem_1fr_auto] items-center gap-3.5 rounded-2xl border border-line bg-surface p-3.5 transition-colors duration-300 hover:border-line-strong"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={SITE.livroCapa}
alt=""
aria-hidden="true"
width={48}
height={72}
className="h-[72px] w-12 rounded-md object-cover shadow-md"
/>
<span className="min-w-0">
<span className="text-xs font-semibold text-accent">Livro novo</span>
<span className="mt-0.5 block font-display text-base font-semibold leading-tight text-ink">Microcontroladores ESP32</span>
<span className="mt-1 block text-xs text-muted">Conectividade, eficiência e desempenho</span>
</span>
<ArrowUpRight className="h-4 w-4 text-faint transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" aria-hidden="true" />
</Link>
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm">
<a href={SITE.livroAmazon} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 text-muted transition-colors hover:text-accent">
Amazon
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
<a href={SITE.livroUiclap} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 text-muted transition-colors hover:text-accent">
UICLAP
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</div>
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm">
<a href={SITE.livroAmazon} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 text-muted transition-colors hover:text-accent">
Amazon
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
<a href={SITE.livroUiclap} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 text-muted transition-colors hover:text-accent">
UICLAP
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</div>
</>
)}
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{COLUMNS.map((column) => (
{columns.map((column) => (
<nav key={column.title} aria-label={column.title}>
<h2 className="text-sm font-semibold text-ink">{column.title}</h2>
<ul className="mt-2 space-y-0">
@@ -102,7 +122,7 @@ export default function Footer() {
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
) : (
<Link href={link.href} className="inline-flex min-h-11 min-w-11 items-center text-sm text-muted transition-colors hover:text-accent">
<Link href={getLinkHref(link.href)} className="inline-flex min-h-11 min-w-11 items-center text-sm text-muted transition-colors hover:text-accent">
{link.label}
</Link>
)}
@@ -119,45 +139,40 @@ export default function Footer() {
<br />
<span className="text-xs text-faint">Ciência Embarcada</span>
</p>
<div className="mt-4 flex items-center gap-2">
<a
href={SITE.repositorio}
target="_blank"
rel="noopener noreferrer"
aria-label="Gitea do projeto (abre em nova aba)"
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:border-line-strong hover:bg-surface"
>
<GiteaMark className="h-4 w-4" />
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">
<a href={SITE.autorUrl} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center text-muted transition-colors hover:text-accent">
Site pessoal
</a>
<a
href={SITE.autorLinkedin}
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn do autor (abre em nova aba)"
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:border-line-strong hover:bg-surface"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5.001 2.5 2.5 0 0 1 0-5zM3 8.98h4v12H3v-12zm6.5 0h3.8v1.6h.1c.5-.9 1.8-1.9 3.6-1.9 3.8 0 4.5 2.5 4.5 5.8v6.5h-4v-5.8c0-1.4-.1-3.2-2-3.2-2 0-2.3 1.6-2.3 3.1v5.9h-4v-12z" />
</svg>
<a href={SITE.autorLinkedin} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center text-muted transition-colors hover:text-accent">
LinkedIn
</a>
</div>
</div>
</div>
</div>
<div className="mt-14 flex flex-col gap-4 border-t border-line pt-6 text-xs leading-relaxed text-faint md:flex-row md:items-center md:justify-between">
<p>© {year} ESPDocs. Projeto independente, sem afiliação com a Espressif Systems.</p>
<p className="flex flex-wrap items-center gap-x-5 gap-y-2">
<a href={SITE.blog} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 transition-colors hover:text-accent">
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-line pt-8 text-xs text-muted sm:flex-row">
<p>© {year} ESPDocs · Conteúdo sob licença MIT. Feito para a comunidade de sistemas embarcados.</p>
<div className="flex items-center gap-6">
<a
href={SITE.repositorio}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-1.5 transition-colors hover:text-accent"
>
<GiteaMark className="h-4 w-4" aria-hidden="true" />
<span>Gitea</span>
</a>
<span>·</span>
<a
href={SITE.blog}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center transition-colors hover:text-accent"
>
Ciência Embarcada
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
<a href={SITE.autorUrl} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 transition-colors hover:text-accent">
lucasrguerra.dev.br
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
<span>Fontes oficiais: Espressif Systems.</span>
</p>
</div>
</div>
</div>
</footer>
+58 -39
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import Link from "next/link";
import Logo from "@/components/Logo";
import SearchDialog from "@/components/SearchDialog";
import LanguageSelector, { LOCALES, getLocalizedHref } from "@/components/LanguageSelector";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import {
@@ -69,12 +70,16 @@ const MOBILE_LINKS = [
{ href: "/sobre", label: "Sobre" },
];
function isActivePath(pathname, href) {
return href === "/" ? pathname === "/" : pathname === href || String(pathname).startsWith(`${href}/`);
function isActivePath(pathname, href, currentLocale) {
const localized = getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
if (href === "/" || localized === `/${currentLocale}/` || localized === `/${currentLocale}`) {
return pathname === `/${currentLocale}` || pathname === `/${currentLocale}/`;
}
return pathname === localized || String(pathname).startsWith(`${localized}/`);
}
export default function Header() {
const pathname = usePathname();
const pathname = usePathname() || "";
const [mounted, setMounted] = useState(false);
const [openMenu, setOpenMenu] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -82,6 +87,12 @@ export default function Header() {
const closeRef = useRef(null);
const menuButtonRef = useRef(null);
const currentLocale =
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
const isPt = currentLocale === "pt";
const getLinkHref = (href) => getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
useEffect(() => setMounted(true), []);
useEffect(() => {
@@ -118,18 +129,20 @@ export default function Header() {
};
}, [drawerOpen]);
const visibleMobileLinks = isPt ? MOBILE_LINKS : MOBILE_LINKS.filter((l) => l.href !== "/livro");
return (
<header className="sticky top-0 z-50 pt-3 sm:pt-4">
<div className="shell">
<div className="nav-pill flex h-16 items-center justify-between gap-3 rounded-full px-3 sm:px-4">
<Link href="/" className="flex min-h-11 shrink-0 items-center gap-2.5 rounded-full pl-1 pr-2" aria-label="ESPDocs, página inicial">
<Link href={getLinkHref("/")} className="flex min-h-11 shrink-0 items-center gap-2.5 rounded-full pl-1 pr-2" aria-label="ESPDocs, página inicial">
<Logo className="h-[30px] w-[30px]" />
<span className="font-display text-[1.3rem] font-semibold leading-none tracking-[-0.045em] text-ink">ESPDocs</span>
</Link>
<nav ref={navRef} className="hidden items-center gap-0.5 lg:flex" aria-label="Navegação principal">
{MENUS.map((menu) => {
const active = menu.items.some((item) => isActivePath(pathname, item.href));
const active = menu.items.some((item) => isActivePath(pathname, item.href, currentLocale));
const expanded = openMenu === menu.id;
return (
<div key={menu.id} className="relative">
@@ -150,12 +163,13 @@ export default function Header() {
>
{menu.items.map((item) => {
const Icon = item.icon;
const localizedHref = getLinkHref(item.href);
return (
<Link
key={item.href}
href={item.href}
href={localizedHref}
className="group flex items-start gap-3 rounded-2xl px-3 py-3 transition-colors hover:bg-surface-2"
aria-current={isActivePath(pathname, item.href) ? "page" : undefined}
aria-current={isActivePath(pathname, item.href, currentLocale) ? "page" : undefined}
>
<span className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent transition-colors group-hover:bg-surface">
<Icon className="h-4 w-4" aria-hidden="true" />
@@ -173,20 +187,23 @@ export default function Header() {
);
})}
<Link href="/livro" className={`nav-link ${isActivePath(pathname, "/livro") ? "is-active" : ""}`} aria-current={isActivePath(pathname, "/livro") ? "page" : undefined}>
Livro
</Link>
<Link href="/sobre" className={`nav-link ${isActivePath(pathname, "/sobre") ? "is-active" : ""}`} aria-current={isActivePath(pathname, "/sobre") ? "page" : undefined}>
{isPt && (
<Link href={getLinkHref("/livro")} className={`nav-link ${isActivePath(pathname, "/livro", currentLocale) ? "is-active" : ""}`} aria-current={isActivePath(pathname, "/livro", currentLocale) ? "page" : undefined}>
Livro
</Link>
)}
<Link href={getLinkHref("/sobre")} className={`nav-link ${isActivePath(pathname, "/sobre", currentLocale) ? "is-active" : ""}`} aria-current={isActivePath(pathname, "/sobre", currentLocale) ? "page" : undefined}>
Sobre
</Link>
</nav>
<div className="flex items-center gap-1.5">
<Link href="/seletor" className="btn btn-primary btn-sm hidden lg:inline-flex">
<Link href={getLinkHref("/seletor")} className="btn btn-primary btn-sm hidden lg:inline-flex">
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
Selecionar chip
</Link>
<SearchDialog />
<LanguageSelector />
<ThemeToggle />
<button
ref={menuButtonRef}
@@ -238,38 +255,40 @@ export default function Header() {
</button>
</div>
<div className="px-4 pb-2">
<Link
href="/livro"
onClick={() => setDrawerOpen(false)}
className="group grid grid-cols-[3.25rem_1fr_auto] items-center gap-3 rounded-2xl bg-[#0b0d10] p-3 text-[#f4f6f8] ring-1 ring-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/livro/capa.jpg"
alt=""
aria-hidden="true"
width={52}
height={78}
className="h-[78px] w-[52px] rounded-md object-cover shadow-lg"
/>
<span className="min-w-0">
<span className="text-[0.6875rem] font-semibold text-[#75cdc1]">Livro novo</span>
<span className="mt-1 block font-display text-lg font-semibold leading-tight">Microcontroladores ESP32</span>
<span className="mt-1 block text-xs text-[#a6afbd]">Conectividade, eficiência e desempenho</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0 text-[#75cdc1] transition-transform duration-300 group-hover:translate-x-1" aria-hidden="true" />
</Link>
</div>
{isPt && (
<div className="px-4 pb-2">
<Link
href="/pt/livro"
onClick={() => setDrawerOpen(false)}
className="group grid grid-cols-[3.25rem_1fr_auto] items-center gap-3 rounded-2xl bg-[#0b0d10] p-3 text-[#f4f6f8] ring-1 ring-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/livro/capa.jpg"
alt=""
aria-hidden="true"
width={52}
height={78}
className="h-[78px] w-[52px] rounded-md object-cover shadow-lg"
/>
<span className="min-w-0">
<span className="text-[0.6875rem] font-semibold text-[#75cdc1]">Livro novo</span>
<span className="mt-1 block font-display text-lg font-semibold leading-tight">Microcontroladores ESP32</span>
<span className="mt-1 block text-xs text-[#a6afbd]">Conectividade, eficiência e desempenho</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0 text-[#75cdc1] transition-transform duration-300 group-hover:translate-x-1" aria-hidden="true" />
</Link>
</div>
)}
<nav className="flex-1 overflow-y-auto px-3 py-2" aria-label="Navegação móvel">
{MOBILE_LINKS.map((link) => (
{visibleMobileLinks.map((link) => (
<Link
key={link.href}
href={link.href}
href={getLinkHref(link.href)}
onClick={() => setDrawerOpen(false)}
className="group flex min-h-11 items-center justify-between gap-3 rounded-xl px-3 text-sm font-medium text-ink-soft transition-colors hover:bg-surface-2 hover:text-ink"
aria-current={isActivePath(pathname, link.href) ? "page" : undefined}
aria-current={isActivePath(pathname, link.href, currentLocale) ? "page" : undefined}
>
{link.label}
<ArrowRight className="h-4 w-4 text-faint transition-transform duration-300 group-hover:translate-x-0.5 group-hover:text-accent" aria-hidden="true" />
@@ -278,7 +297,7 @@ export default function Header() {
</nav>
<div className="p-4 pt-2">
<Link href="/seletor" onClick={() => setDrawerOpen(false)} className="btn btn-primary w-full">
<Link href={getLinkHref("/seletor")} onClick={() => setDrawerOpen(false)} className="btn btn-primary w-full">
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
Selecionar meu chip
</Link>
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { ChevronDown, Globe } from "lucide-react";
export const LOCALES = [
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR" },
{ code: "en", label: "English", short: "EN", lang: "en" },
{ code: "es", label: "Español", short: "ES", lang: "es-419" },
{ code: "fr", label: "Français", short: "FR", lang: "fr" },
{ code: "de", label: "Deutsch", short: "DE", lang: "de" },
{ code: "zh-CN", label: "简体中文", short: "中", lang: "zh-CN" },
];
const CANONICAL_ROUTES = [
{
id: "series_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)\/([^/]+)/,
getSlug: (loc, key) => (loc === "de" ? `/serien/${key}` : `/series/${key}`),
},
{
id: "frameworks_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks\/([^/]+)/,
getSlug: (loc, key) => `/frameworks/${key}`,
},
{
id: "series",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)(?:\/|$)/,
getSlug: (loc) => (loc === "de" ? "/serien" : "/series"),
},
{
id: "catalogo",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:catalogo|catalog|catalogue|katalog)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "de") return "/katalog";
if (loc === "fr") return "/catalogue";
if (loc === "en" || loc === "zh-CN") return "/catalog";
return "/catalogo";
},
},
{
id: "comparacao",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:comparacao|compare|comparacion|comparaison|vergleich)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/compare";
if (loc === "es") return "/comparacion";
if (loc === "fr") return "/comparaison";
if (loc === "de") return "/vergleich";
return "/comparacao";
},
},
{
id: "componentes",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:componentes|components|composants|komponenten)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/components";
if (loc === "fr") return "/composants";
if (loc === "de") return "/komponenten";
return "/componentes";
},
},
{
id: "diagnostico",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:diagnostico|diagnostics|diagnostic|diagnose)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/diagnostics";
if (loc === "fr") return "/diagnostic";
if (loc === "de") return "/diagnose";
return "/diagnostico";
},
},
{
id: "frameworks",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks(?:\/|$)/,
getSlug: () => "/frameworks",
},
{
id: "glossario",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:glossario|glossary|glosario|glossaire|glossar)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/glossary";
if (loc === "es") return "/glosario";
if (loc === "fr") return "/glossaire";
if (loc === "de") return "/glossar";
return "/glossario";
},
},
{
id: "seletor",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:seletor|selector|selecteur|auswahl)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "es" || loc === "zh-CN") return "/selector";
if (loc === "fr") return "/selecteur";
if (loc === "de") return "/auswahl";
return "/seletor";
},
},
{
id: "sobre",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:sobre|about|acerca|a-propos|ueber-uns)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/about";
if (loc === "es") return "/acerca";
if (loc === "fr") return "/a-propos";
if (loc === "de") return "/ueber-uns";
return "/sobre";
},
},
{
id: "livro",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/livro(?:\/|$)/,
getSlug: (loc) => (loc === "pt" ? "/livro" : ""),
},
];
export function getLocalizedHref(pathname, targetLocale) {
if (!pathname || pathname === "/" || pathname === `/${targetLocale}` || pathname === `/${targetLocale}/`) {
return `/${targetLocale}/`;
}
for (const route of CANONICAL_ROUTES) {
const m = pathname.match(route.match);
if (m) {
const slug = route.getSlug(targetLocale, m[1]);
return slug ? `/${targetLocale}${slug}` : `/${targetLocale}/`;
}
}
return `/${targetLocale}/`;
}
export default function LanguageSelector() {
const pathname = usePathname() || "";
const [open, setOpen] = useState(false);
const [hash, setHash] = useState("");
const containerRef = useRef(null);
// Detecta locale da URL atual
const currentLocaleCode =
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
const currentLocale = LOCALES.find((l) => l.code === currentLocaleCode) || LOCALES[0];
useEffect(() => {
if (typeof window !== "undefined") {
setHash(window.location.hash || "");
}
}, [pathname]);
useEffect(() => {
function onPointerDown(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
function onKeyDown(e) {
if (e.key === "Escape") {
setOpen(false);
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, []);
const handleSelect = (code) => {
if (typeof document !== "undefined") {
document.cookie = `NEXT_LOCALE=${code}; path=/; max-age=31536000; SameSite=Lax`;
}
setOpen(false);
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-11 min-w-[44px] items-center gap-1.5 rounded-full border border-line bg-surface px-3 text-sm font-semibold text-ink transition-colors hover:bg-surface-2 focus-visible:outline-2 focus-visible:outline-accent"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Idioma atual: ${currentLocale.label}. Clique para trocar de idioma.`}
title={`Idioma: ${currentLocale.label}`}
>
<Globe className="h-4 w-4 text-muted" aria-hidden="true" />
<span className="font-semibold">{currentLocale.short}</span>
<ChevronDown
className={`h-3 w-3 text-muted transition-transform duration-200 ${open ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{open && (
<div
role="listbox"
aria-label="Idiomas disponíveis"
className="absolute right-0 top-full mt-2 w-48 rounded-2xl border border-line bg-surface p-1.5 shadow-[0_24px_60px_-20px_rgba(15,23,42,0.35)] dark:shadow-[0_24px_60px_-20px_rgba(0,0,0,0.8)] z-50"
>
{LOCALES.map((loc) => {
const isActive = loc.code === currentLocaleCode;
const href = `${getLocalizedHref(pathname, loc.code)}${hash}`;
return (
<a
key={loc.code}
href={href}
lang={loc.lang}
hrefLang={loc.lang}
role="option"
aria-selected={isActive}
aria-current={isActive ? "true" : undefined}
onClick={() => handleSelect(loc.code)}
className={`flex min-h-[44px] items-center justify-between rounded-xl px-3 py-2 text-sm transition-colors ${
isActive
? "bg-accent-soft text-accent font-semibold"
: "text-ink-soft hover:bg-surface-2 hover:text-ink"
}`}
>
<span>{loc.label}</span>
<span className={`text-xs ${isActive ? "text-accent" : "text-muted"}`}>
{loc.short}
</span>
</a>
);
})}
</div>
)}
</div>
);
}