Files
ESPDocs/components/LanguageSelector.jsx
T
Lucas RayanandAntigravity 7fce46b9e1
CI / Lint e auditorias (push) Successful in 49s
CI / Trivy (dependências e segredos) (push) Successful in 11s
CI / Imagem Docker (linux/arm64) (push) Successful in 3m14s
feat(i18n): French and German localization (phase 5)
- Add full message catalogs and content for French (fr) and German (de)
- Implement ASCII canonical slugs for French and German routes
- Add language-specific search endpoints (/api/busca/fr, /api/busca/de)
- Configure strict 404 responses for /fr/livro and /de/livro
- Localize SEO, OpenGraph (fr_FR, de_DE), Twitter and JSON-LD schemas
- Add localized code example comments across 14 examples for fr and de
- Enable 'fr' and 'de' in ENABLED_LOCALES with root negotiation and redirect
- Extend test suite in check-i18n, smoke-imagem and verifica-vazamentos

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-01 22:06:36 -03:00

143 lines
4.5 KiB
React

"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { ChevronDown, Globe } from "lucide-react";
import { LOCALES, getLocalizedHref } from "@/i18n/links";
export { LOCALES, getLocalizedHref };
export default function LanguageSelector() {
if (LOCALES.length <= 1) {
return null;
}
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={
currentLocaleCode === "en"
? `Current language: ${currentLocale.label}. Click to switch language.`
: currentLocaleCode === "es"
? `Idioma actual: ${currentLocale.label}. Haga clic para cambiar de idioma.`
: currentLocaleCode === "fr"
? `Langue actuelle : ${currentLocale.label}. Cliquez pour changer de langue.`
: currentLocaleCode === "de"
? `Aktuelle Sprache: ${currentLocale.label}. Klicken Sie, um die Sprache zu wechseln.`
: `Idioma atual: ${currentLocale.label}. Clique para trocar de idioma.`
}
title={
currentLocaleCode === "en"
? `Language: ${currentLocale.label}`
: currentLocaleCode === "fr"
? `Langue : ${currentLocale.label}`
: currentLocaleCode === "de"
? `Sprache: ${currentLocale.label}`
: `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={
currentLocaleCode === "en"
? "Available languages"
: currentLocaleCode === "es"
? "Idiomas disponibles"
: currentLocaleCode === "fr"
? "Langues disponibles"
: currentLocaleCode === "de"
? "Verfügbare Sprachen"
: "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>
);
}