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>
This commit is contained in:
1 parent
7b673aeb4b
commit
7fce46b9e1
104 files changed
+33369
-686
No files matched your search
@@ -58,10 +58,62 @@ const VALUE_MAP_ES = {
|
||||
"Opcional": "Opcional",
|
||||
};
|
||||
|
||||
const HEADER_MAP_FR = {
|
||||
"Nome": "Nom",
|
||||
"Descrição": "Description",
|
||||
"Série": "Série",
|
||||
"Loja Oficial": "Boutique officielle",
|
||||
"Fabricante": "Fabricant",
|
||||
"Processador": "Processeur",
|
||||
"Memória Flash": "Mémoire Flash",
|
||||
"PSRAM": "PSRAM",
|
||||
"Conector USB": "Connecteur USB",
|
||||
"Interface USB": "Interface USB",
|
||||
"Antena": "Antenne",
|
||||
"Bateria": "Batterie",
|
||||
"Display": "Écran",
|
||||
"Sensores": "Capteurs",
|
||||
"Botões": "Boutons",
|
||||
"LEDs": "LEDs",
|
||||
};
|
||||
|
||||
const VALUE_MAP_FR = {
|
||||
"Sim": "Oui",
|
||||
"Não": "Non",
|
||||
"Opcional": "Optionnel",
|
||||
};
|
||||
|
||||
const HEADER_MAP_DE = {
|
||||
"Nome": "Name",
|
||||
"Descrição": "Beschreibung",
|
||||
"Série": "Serie",
|
||||
"Loja Oficial": "Offizieller Store",
|
||||
"Fabricante": "Hersteller",
|
||||
"Processador": "Prozessor",
|
||||
"Memória Flash": "Flash-Speicher",
|
||||
"PSRAM": "PSRAM",
|
||||
"Conector USB": "USB-Anschluss",
|
||||
"Interface USB": "USB-Schnittstelle",
|
||||
"Antena": "Antenne",
|
||||
"Bateria": "Batterie",
|
||||
"Display": "Display",
|
||||
"Sensores": "Sensoren",
|
||||
"Botões": "Tasten",
|
||||
"LEDs": "LEDs",
|
||||
};
|
||||
|
||||
const VALUE_MAP_DE = {
|
||||
"Sim": "Ja",
|
||||
"Não": "Nein",
|
||||
"Opcional": "Optional",
|
||||
};
|
||||
|
||||
export default function PlacasPage() {
|
||||
const pathname = usePathname() || "";
|
||||
const isEn = pathname === "/en" || pathname.startsWith("/en/");
|
||||
const isEs = pathname === "/es" || pathname.startsWith("/es/");
|
||||
const isFr = pathname === "/fr" || pathname.startsWith("/fr/");
|
||||
const isDe = pathname === "/de" || pathname.startsWith("/de/");
|
||||
|
||||
const [boards, setBoards] = useState([]);
|
||||
const [filteredBoards, setFilteredBoards] = useState([]);
|
||||
@@ -90,7 +142,7 @@ export default function PlacasPage() {
|
||||
const response = await fetch("/api/placas");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(isEn ? "Failed to load board data" : isEs ? "Error al cargar los datos de las placas" : "Erro ao carregar dados das placas");
|
||||
throw new Error(isEn ? "Failed to load board data" : isEs ? "Error al cargar los datos de las placas" : isFr ? "Erreur lors du chargement des données des cartes" : isDe ? "Fehler beim Laden der Board-Daten" : "Erro ao carregar dados das placas");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -195,15 +247,21 @@ export default function PlacasPage() {
|
||||
<PageHero
|
||||
isEn={isEn}
|
||||
isEs={isEs}
|
||||
trail={[{ label: isEn ? "Board Catalog" : isEs ? "Catálogo de placas" : "Catálogo de placas" }]}
|
||||
isFr={isFr}
|
||||
isDe={isDe}
|
||||
trail={[{ label: isEn ? "Board Catalog" : isEs ? "Catálogo de placas" : isFr ? "Catalogue de cartes" : isDe ? "Board-Katalog" : "Catálogo de placas" }]}
|
||||
icon={ShoppingCart}
|
||||
label={isEn ? "Real DevKits and modules" : isEs ? "DevKits y módulos reales" : "DevKits e módulos reais"}
|
||||
title={isEn ? "ESP32 Board Catalog" : isEs ? "Catálogo de placas ESP32" : "Catálogo de placas ESP32"}
|
||||
label={isEn ? "Real DevKits and modules" : isEs ? "DevKits y módulos reales" : isFr ? "DevKits et modules réels" : isDe ? "Echte DevKits und Module" : "DevKits e módulos reais"}
|
||||
title={isEn ? "ESP32 Board Catalog" : isEs ? "Catálogo de placas ESP32" : isFr ? "Catalogue de cartes ESP32" : isDe ? "ESP32-Board-Katalog" : "Catálogo de placas ESP32"}
|
||||
lede={
|
||||
isEn
|
||||
? "Compare official and third-party boards for your project: USB interface, on-board sensors and direct store links."
|
||||
: isEs
|
||||
? "Compare placas oficiales y alternativas para su proyecto: interfaz USB, sensores integrados y enlace directo a la tienda."
|
||||
: isFr
|
||||
? "Comparez les cartes officielles et alternatives pour votre projet : interface USB, capteurs intégrés et lien direct vers la boutique."
|
||||
: isDe
|
||||
? "Vergleichen Sie offizielle und Drittanbieter-Boards für Ihr Projekt: USB-Schnittstelle, integrierte Sensoren und direkter Store-Link."
|
||||
: "Compare placas oficiais e alternativas para o seu projeto: interface USB, sensores integrados e link direto para a loja."
|
||||
}
|
||||
/>
|
||||
@@ -215,14 +273,14 @@ export default function PlacasPage() {
|
||||
<Header />
|
||||
<main id="conteudo">
|
||||
{hero}
|
||||
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : isEs ? "Cargando placas" : "Carregando placas"}>
|
||||
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : isEs ? "Cargando placas" : isFr ? "Chargement des cartes" : isDe ? "Boards werden geladen" : "Carregando placas"}>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="card h-72 animate-pulse bg-surface-2" />
|
||||
))}
|
||||
</div>
|
||||
<p role="status" className="mt-6 text-center text-sm text-muted">
|
||||
{isEn ? "Querying boards database…" : isEs ? "Consultando la base de datos de placas…" : "Consultando o banco de placas…"}
|
||||
{isEn ? "Querying boards database…" : isEs ? "Consultando la base de datos de placas…" : isFr ? "Consultation de la base de cartes…" : isDe ? "Board-Datenbank wird abgefragt…" : "Consultando o banco de placas…"}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
@@ -242,14 +300,14 @@ export default function PlacasPage() {
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-ink">
|
||||
{isEn ? "Could not load boards" : isEs ? "No se pudieron cargar las placas" : "Não foi possível carregar as placas"}
|
||||
{isEn ? "Could not load boards" : isEs ? "No se pudieron cargar las placas" : isFr ? "Impossible de charger les cartes" : isDe ? "Boards konnten nicht geladen werden" : "Não foi possível carregar as placas"}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{error}. {isEn ? "Check your connection and try again." : isEs ? "Compruebe la conexión y vuelva a intentarlo." : "Verifique a conexão e tente de novo."}
|
||||
{error}. {isEn ? "Check your connection and try again." : isEs ? "Compruebe la conexión y vuelva a intentarlo." : isFr ? "Vérifiez votre connexion et réessayez." : isDe ? "Prüfen Sie Ihre Verbindung und versuchen Sie es erneut." : "Verifique a conexão e tente de novo."}
|
||||
</p>
|
||||
<button type="button" onClick={fetchBoards} className="btn btn-primary btn-sm mt-4">
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
{isEn ? "Try again" : isEs ? "Reintentar" : "Tentar novamente"}
|
||||
{isEn ? "Try again" : isEs ? "Reintentar" : isFr ? "Réessayer" : isDe ? "Erneut versuchen" : "Tentar novamente"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,14 +322,14 @@ export default function PlacasPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="busca-placa" className="text-sm font-semibold text-ink">
|
||||
{isEn ? "Search board" : isEs ? "Buscar placa" : "Buscar placa"}
|
||||
{isEn ? "Search board" : isEs ? "Buscar placa" : isFr ? "Rechercher une carte" : isDe ? "Board suchen" : "Buscar placa"}
|
||||
</label>
|
||||
<div className="relative mt-2">
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" aria-hidden="true" />
|
||||
<input
|
||||
id="busca-placa"
|
||||
type="search"
|
||||
placeholder={isEn ? "Name or description" : isEs ? "Nombre o descripción" : "Nome ou descrição"}
|
||||
placeholder={isEn ? "Name or description" : isEs ? "Nombre o descripción" : isFr ? "Nom ou description" : isDe ? "Name oder Beschreibung" : "Nome ou descrição"}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="field pl-10 text-sm"
|
||||
@@ -282,7 +340,7 @@ export default function PlacasPage() {
|
||||
{filterableColumns.map((column) => {
|
||||
const filterData = filterOptions[column];
|
||||
if (!filterData) return null;
|
||||
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : isEs ? (HEADER_MAP_ES[column] || column) : column;
|
||||
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : isEs ? (HEADER_MAP_ES[column] || column) : isFr ? (HEADER_MAP_FR[column] || column) : isDe ? (HEADER_MAP_DE[column] || column) : column;
|
||||
|
||||
if (filterData.isBinary) {
|
||||
return (
|
||||
@@ -306,7 +364,7 @@ export default function PlacasPage() {
|
||||
</legend>
|
||||
<div className="mt-2 max-h-44 space-y-0.5 overflow-y-auto pr-1">
|
||||
{filterData.values.map((option) => {
|
||||
const optLabel = isEn ? (VALUE_MAP_EN[option] || option) : isEs ? (VALUE_MAP_ES[option] || option) : option;
|
||||
const optLabel = isEn ? (VALUE_MAP_EN[option] || option) : isEs ? (VALUE_MAP_ES[option] || option) : isFr ? (VALUE_MAP_FR[option] || option) : isDe ? (VALUE_MAP_DE[option] || option) : option;
|
||||
return (
|
||||
<label key={option} className="flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-muted transition-colors hover:bg-surface-2 hover:text-ink">
|
||||
<input
|
||||
@@ -333,14 +391,14 @@ export default function PlacasPage() {
|
||||
<main id="conteudo">
|
||||
{hero}
|
||||
|
||||
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "Boards" : isEs ? "Placas" : "Placas"}>
|
||||
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "Boards" : isEs ? "Placas" : isFr ? "Cartes" : isDe ? "Boards" : "Placas"}>
|
||||
<div className="grid gap-8 lg:grid-cols-[17rem_1fr] lg:gap-10">
|
||||
<aside className="lg:sticky lg:top-28 lg:self-start">
|
||||
<details className="panel" open={filtrosAbertos} onToggle={(e) => setFiltrosAbertos(e.currentTarget.open)}>
|
||||
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-5 py-3 lg:cursor-default">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<SlidersHorizontal className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||
{isEn ? "Filters" : isEs ? "Filtros" : "Filtros"}
|
||||
{isEn ? "Filters" : isEs ? "Filtros" : isFr ? "Filtres" : isDe ? "Filter" : "Filtros"}
|
||||
{activeFilterCount > 0 && <span className="chip chip-accent px-2 py-0">{activeFilterCount}</span>}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
@@ -352,7 +410,7 @@ export default function PlacasPage() {
|
||||
}}
|
||||
className="text-sm font-medium text-accent hover:text-accent-strong"
|
||||
>
|
||||
{isEn ? "Clear" : isEs ? "Limpiar" : "Limpar"}
|
||||
{isEn ? "Clear" : isEs ? "Limpiar" : isFr ? "Effacer" : isDe ? "Zurücksetzen" : "Limpar"}
|
||||
</button>
|
||||
)}
|
||||
</summary>
|
||||
@@ -371,6 +429,14 @@ export default function PlacasPage() {
|
||||
? filteredBoards.length === 1
|
||||
? "placa encontrada"
|
||||
: "placas encontradas"
|
||||
: isFr
|
||||
? filteredBoards.length === 1
|
||||
? "carte trouvée"
|
||||
: "cartes trouvées"
|
||||
: isDe
|
||||
? filteredBoards.length === 1
|
||||
? "Board gefunden"
|
||||
: "Boards gefunden"
|
||||
: filteredBoards.length === 1
|
||||
? "placa encontrada"
|
||||
: "placas encontradas"}
|
||||
@@ -380,17 +446,21 @@ export default function PlacasPage() {
|
||||
<div className="card mt-4 px-6 py-14 text-center">
|
||||
<Search className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
|
||||
<h2 className="mt-4 font-display text-xl font-semibold text-ink">
|
||||
{isEn ? "No boards match these filters" : isEs ? "Ninguna placa coincide con estos filtros" : "Nenhuma placa com esses filtros"}
|
||||
{isEn ? "No boards match these filters" : isEs ? "Ninguna placa coincide con estos filtros" : isFr ? "Aucune carte ne correspond à ces filtres" : isDe ? "Keine Boards entsprechen diesen Filtern" : "Nenhuma placa com esses filtros"}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
|
||||
{isEn
|
||||
? "Remove some filters or search by another name."
|
||||
: isEs
|
||||
? "Elimine algunos filtros o busque por otro nombre."
|
||||
: isFr
|
||||
? "Supprimez certains filtres ou recherchez un autre nom."
|
||||
: isDe
|
||||
? "Entfernen Sie einige Filter oder suchen Sie nach einem anderen Namen."
|
||||
: "Remova alguns filtros ou busque por outro nome."}
|
||||
</p>
|
||||
<button type="button" onClick={clearFilters} className="btn btn-primary mt-6">
|
||||
{isEn ? "Clear filters" : isEs ? "Limpiar filtros" : "Limpar filtros"}
|
||||
{isEn ? "Clear filters" : isEs ? "Limpiar filtros" : isFr ? "Effacer les filtres" : isDe ? "Filter zurücksetzen" : "Limpar filtros"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user