Files
ESPDocs/components/LanguageSelector.jsx
T
Lucas RayanandAntigravity 83c0ba3e74 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>
2026-11-01 09:31:22 -03:00

233 lines
7.2 KiB
React

"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>
);
}