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

+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>