feat(i18n): English localization (phase 3)
- Translate content to English across messages/en.json, data/en, lib/data/en, and public/exemplos/en - Implement localized routing, slugs, and language negotiation with fallback to en - Build dedicated search index for English with strict book exclusion - Update components and pages to dynamically render localized strings and datasets - Verify full structural parity and exact 1:1 match on Portuguese pages via comparator Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
1 parent
22b1f26aed
commit
c1bd2c7e11
61 files changed
+18182
-1225
No files matched your search
+115
-65
@@ -1,29 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import BoardCard from "@/components/BoardCard";
|
||||
import { AlertTriangle, RefreshCw, Search, ShoppingCart, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
const HEADER_MAP_EN = {
|
||||
"Nome": "Name",
|
||||
"Descrição": "Description",
|
||||
"Série": "Series",
|
||||
"Loja Oficial": "Official Store",
|
||||
"Fabricante": "Manufacturer",
|
||||
"Processador": "Processor",
|
||||
"Memória Flash": "Flash Memory",
|
||||
"PSRAM": "PSRAM",
|
||||
"Conector USB": "USB Connector",
|
||||
"Interface USB": "USB Interface",
|
||||
"Antena": "Antenna",
|
||||
"Bateria": "Battery",
|
||||
"Display": "Display",
|
||||
"Sensores": "Sensors",
|
||||
"Botões": "Buttons",
|
||||
"LEDs": "LEDs",
|
||||
};
|
||||
|
||||
const VALUE_MAP_EN = {
|
||||
"Sim": "Yes",
|
||||
"Não": "No",
|
||||
"Opcional": "Optional",
|
||||
};
|
||||
|
||||
export default function PlacasPage() {
|
||||
const pathname = usePathname() || "";
|
||||
const isEn = pathname === "/en" || pathname.startsWith("/en/");
|
||||
|
||||
const [boards, setBoards] = useState([]);
|
||||
const [filteredBoards, setFilteredBoards] = useState([]);
|
||||
const [headers, setHeaders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeFilters, setActiveFilters] = useState({});
|
||||
const [filterOptions, setFilterOptions] = useState({});
|
||||
const [filtrosAbertos, setFiltrosAbertos] = useState(false);
|
||||
|
||||
const excludedColumns = ['Nome', 'Descrição', 'Link', 'Imagem', 'Loja Oficial'];
|
||||
const excludedColumns = ["Nome", "Descrição", "Link", "Imagem", "Loja Oficial"];
|
||||
|
||||
useEffect(() => {
|
||||
fetchBoards();
|
||||
// No desktop os filtros ficam sempre à vista; no celular começam recolhidos.
|
||||
setFiltrosAbertos(window.matchMedia('(min-width: 1024px)').matches);
|
||||
setFiltrosAbertos(window.matchMedia("(min-width: 1024px)").matches);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,17 +61,17 @@ export default function PlacasPage() {
|
||||
const fetchBoards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/placas');
|
||||
|
||||
const response = await fetch("/api/placas");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao carregar dados das placas');
|
||||
throw new Error(isEn ? "Failed to load board data" : "Erro ao carregar dados das placas");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setBoards(result.data);
|
||||
setHeaders(result.headers);
|
||||
setFilteredBoards(result.data);
|
||||
|
||||
|
||||
generateFilterOptions(result.data, result.headers);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -54,26 +82,24 @@ export default function PlacasPage() {
|
||||
|
||||
const generateFilterOptions = (data, headers) => {
|
||||
const options = {};
|
||||
const filterableColumns = headers.filter(h => !excludedColumns.includes(h));
|
||||
const filterableColumns = headers.filter((h) => !excludedColumns.includes(h));
|
||||
|
||||
filterableColumns.forEach(column => {
|
||||
filterableColumns.forEach((column) => {
|
||||
const uniqueValues = new Set();
|
||||
data.forEach(item => {
|
||||
data.forEach((item) => {
|
||||
const value = item[column];
|
||||
if (value && value.toString().trim()) {
|
||||
uniqueValues.add(value.toString().trim());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const values = Array.from(uniqueValues).sort();
|
||||
|
||||
// Verificar se é um filtro binário (apenas Sim/Não/Opcional)
|
||||
const binaryValues = ['Sim', 'Não', 'Opcional'];
|
||||
const isBinaryFilter = values.every(v => binaryValues.includes(v));
|
||||
|
||||
const binaryValues = ["Sim", "Não", "Opcional"];
|
||||
const isBinaryFilter = values.every((v) => binaryValues.includes(v));
|
||||
|
||||
options[column] = {
|
||||
values: values,
|
||||
isBinary: isBinaryFilter
|
||||
isBinary: isBinaryFilter,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -84,7 +110,7 @@ export default function PlacasPage() {
|
||||
let filtered = [...boards];
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(board => {
|
||||
filtered = filtered.filter((board) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
board.Nome?.toLowerCase().includes(searchLower) ||
|
||||
@@ -93,16 +119,13 @@ export default function PlacasPage() {
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(activeFilters).forEach(column => {
|
||||
Object.keys(activeFilters).forEach((column) => {
|
||||
if (activeFilters[column] && activeFilters[column].length > 0) {
|
||||
filtered = filtered.filter(board => {
|
||||
filtered = filtered.filter((board) => {
|
||||
const boardValue = board[column]?.toString().trim();
|
||||
|
||||
// Para filtros binários, o valor armazenado é 'binary-enabled'
|
||||
if (activeFilters[column].includes('binary-enabled')) {
|
||||
return boardValue === 'Sim' || boardValue === 'Opcional';
|
||||
if (activeFilters[column].includes("binary-enabled")) {
|
||||
return boardValue === "Sim" || boardValue === "Opcional";
|
||||
}
|
||||
|
||||
return activeFilters[column].includes(boardValue);
|
||||
});
|
||||
}
|
||||
@@ -112,21 +135,19 @@ export default function PlacasPage() {
|
||||
};
|
||||
|
||||
const handleFilterToggle = (column, value) => {
|
||||
setActiveFilters(prev => {
|
||||
setActiveFilters((prev) => {
|
||||
const newFilters = { ...prev };
|
||||
|
||||
|
||||
if (!newFilters[column]) {
|
||||
newFilters[column] = [value];
|
||||
} else {
|
||||
const index = newFilters[column].indexOf(value);
|
||||
if (index > -1) {
|
||||
// Remove o valor
|
||||
newFilters[column] = newFilters[column].filter(v => v !== value);
|
||||
newFilters[column] = newFilters[column].filter((v) => v !== value);
|
||||
if (newFilters[column].length === 0) {
|
||||
delete newFilters[column];
|
||||
}
|
||||
} else {
|
||||
// Adiciona o valor
|
||||
newFilters[column] = [...newFilters[column], value];
|
||||
}
|
||||
}
|
||||
@@ -137,7 +158,7 @@ export default function PlacasPage() {
|
||||
|
||||
const clearFilters = () => {
|
||||
setActiveFilters({});
|
||||
setSearchTerm('');
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.keys(activeFilters).length + (searchTerm ? 1 : 0);
|
||||
@@ -146,11 +167,15 @@ export default function PlacasPage() {
|
||||
|
||||
const hero = (
|
||||
<PageHero
|
||||
trail={[{ label: "Catálogo de placas" }]}
|
||||
trail={[{ label: isEn ? "Board Catalog" : "Catálogo de placas" }]}
|
||||
icon={ShoppingCart}
|
||||
label="DevKits e módulos reais"
|
||||
title="Catálogo de placas ESP32"
|
||||
lede="Compare placas oficiais e alternativas para o seu projeto: interface USB, sensores integrados e link direto para a loja."
|
||||
label={isEn ? "Real DevKits and modules" : "DevKits e módulos reais"}
|
||||
title={isEn ? "ESP32 Board Catalog" : "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."
|
||||
: "Compare placas oficiais e alternativas para o seu projeto: interface USB, sensores integrados e link direto para a loja."
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -160,13 +185,15 @@ export default function PlacasPage() {
|
||||
<Header />
|
||||
<main id="conteudo">
|
||||
{hero}
|
||||
<section className="shell section pt-10" aria-busy="true" aria-label="Carregando placas">
|
||||
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : "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">Consultando o banco de placas…</p>
|
||||
<p role="status" className="mt-6 text-center text-sm text-muted">
|
||||
{isEn ? "Querying boards database…" : "Consultando o banco de placas…"}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
@@ -184,11 +211,15 @@ export default function PlacasPage() {
|
||||
<div role="alert" className="callout callout-danger flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-ink">Não foi possível carregar as placas</p>
|
||||
<p className="mt-1">{error}. Verifique a conexão e tente de novo.</p>
|
||||
<p className="font-semibold text-ink">
|
||||
{isEn ? "Could not load boards" : "Não foi possível carregar as placas"}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{error}. {isEn ? "Check your connection and try again." : "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" />
|
||||
Tentar novamente
|
||||
{isEn ? "Try again" : "Tentar novamente"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,14 +234,14 @@ export default function PlacasPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="busca-placa" className="text-sm font-semibold text-ink">
|
||||
Buscar placa
|
||||
{isEn ? "Search board" : "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="Nome ou descrição"
|
||||
placeholder={isEn ? "Name or description" : "Nome ou descrição"}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="field pl-10 text-sm"
|
||||
@@ -221,11 +252,12 @@ export default function PlacasPage() {
|
||||
{filterableColumns.map((column) => {
|
||||
const filterData = filterOptions[column];
|
||||
if (!filterData) return null;
|
||||
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : column;
|
||||
|
||||
if (filterData.isBinary) {
|
||||
return (
|
||||
<label key={column} className="flex min-h-11 cursor-pointer items-center justify-between gap-3 border-t border-line pt-4 text-sm font-medium text-ink-soft">
|
||||
{column}
|
||||
{colLabel}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters[column]?.includes("binary-enabled") || false}
|
||||
@@ -239,21 +271,24 @@ export default function PlacasPage() {
|
||||
return (
|
||||
<fieldset key={column} className="border-t border-line pt-4">
|
||||
<legend className="flex items-center gap-2 pr-2 text-sm font-semibold text-ink">
|
||||
{column}
|
||||
{colLabel}
|
||||
{activeFilters[column]?.length > 0 && <span className="chip chip-accent px-2 py-0">{activeFilters[column].length}</span>}
|
||||
</legend>
|
||||
<div className="mt-2 max-h-44 space-y-0.5 overflow-y-auto pr-1">
|
||||
{filterData.values.map((option) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={activeFilters[column]?.includes(option) || false}
|
||||
onChange={() => handleFilterToggle(column, option)}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
{filterData.values.map((option) => {
|
||||
const optLabel = isEn ? (VALUE_MAP_EN[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
|
||||
type="checkbox"
|
||||
checked={activeFilters[column]?.includes(option) || false}
|
||||
onChange={() => handleFilterToggle(column, option)}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
{optLabel}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
@@ -268,23 +303,26 @@ export default function PlacasPage() {
|
||||
<main id="conteudo">
|
||||
{hero}
|
||||
|
||||
<section className="shell section pt-10 sm:pt-12" aria-label="Placas">
|
||||
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "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" />
|
||||
Filtros
|
||||
{isEn ? "Filters" : "Filtros"}
|
||||
{activeFilterCount > 0 && <span className="chip chip-accent px-2 py-0">{activeFilterCount}</span>}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); clearFilters(); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
clearFilters();
|
||||
}}
|
||||
className="text-sm font-medium text-accent hover:text-accent-strong"
|
||||
>
|
||||
Limpar
|
||||
{isEn ? "Clear" : "Limpar"}
|
||||
</button>
|
||||
)}
|
||||
</summary>
|
||||
@@ -295,16 +333,28 @@ export default function PlacasPage() {
|
||||
<div>
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted">
|
||||
<strong className="font-semibold text-ink">{filteredBoards.length}</strong>{" "}
|
||||
{filteredBoards.length === 1 ? "placa encontrada" : "placas encontradas"}
|
||||
{isEn
|
||||
? filteredBoards.length === 1
|
||||
? "board found"
|
||||
: "boards found"
|
||||
: filteredBoards.length === 1
|
||||
? "placa encontrada"
|
||||
: "placas encontradas"}
|
||||
</p>
|
||||
|
||||
{filteredBoards.length === 0 ? (
|
||||
<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">Nenhuma placa com esses filtros</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted">Remova alguns filtros ou busque por outro nome.</p>
|
||||
<h2 className="mt-4 font-display text-xl font-semibold text-ink">
|
||||
{isEn ? "No boards match these filters" : "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."
|
||||
: "Remova alguns filtros ou busque por outro nome."}
|
||||
</p>
|
||||
<button type="button" onClick={clearFilters} className="btn btn-primary mt-6">
|
||||
Limpar filtros
|
||||
{isEn ? "Clear filters" : "Limpar filtros"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user