Files
ESPDocs/app/[locale]/catalogo/page.jsx
T
Lucas RayanandAntigravity 0d5f8f9c5f
CI / Lint e auditorias (push) Successful in 45s
CI / Trivy (dependências e segredos) (push) Successful in 11s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m23s
fix(i18n): localize json-ld metadata and eliminate breadcrumb leaks
- Pass currentLocale to jsonLd.serie and jsonLd.framework to ensure English headline, name, and properties
- Localize JSON-LD @id anchors to #article, #about, #glossary, #organization, and #author for English routes
- Pass isEn prop to PageHero across all internal pages to prevent breadcrumbs from defaulting to Portuguese
- Add scripts/verifica-vazamentos.py to smoke-imagem.sh to permanently test all 36 English URLs (visible text and ld+json)

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

377 lines
12 KiB
React

"use client";
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 [activeFilters, setActiveFilters] = useState({});
const [filterOptions, setFilterOptions] = useState({});
const [filtrosAbertos, setFiltrosAbertos] = useState(false);
const excludedColumns = ["Nome", "Descrição", "Link", "Imagem", "Loja Oficial"];
useEffect(() => {
fetchBoards();
setFiltrosAbertos(window.matchMedia("(min-width: 1024px)").matches);
}, []);
useEffect(() => {
applyFilters();
}, [boards, searchTerm, activeFilters]);
const fetchBoards = async () => {
try {
setLoading(true);
const response = await fetch("/api/placas");
if (!response.ok) {
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);
} finally {
setLoading(false);
}
};
const generateFilterOptions = (data, headers) => {
const options = {};
const filterableColumns = headers.filter((h) => !excludedColumns.includes(h));
filterableColumns.forEach((column) => {
const uniqueValues = new Set();
data.forEach((item) => {
const value = item[column];
if (value && value.toString().trim()) {
uniqueValues.add(value.toString().trim());
}
});
const values = Array.from(uniqueValues).sort();
const binaryValues = ["Sim", "Não", "Opcional"];
const isBinaryFilter = values.every((v) => binaryValues.includes(v));
options[column] = {
values: values,
isBinary: isBinaryFilter,
};
});
setFilterOptions(options);
};
const applyFilters = () => {
let filtered = [...boards];
if (searchTerm) {
filtered = filtered.filter((board) => {
const searchLower = searchTerm.toLowerCase();
return (
board.Nome?.toLowerCase().includes(searchLower) ||
board.Descrição?.toLowerCase().includes(searchLower)
);
});
}
Object.keys(activeFilters).forEach((column) => {
if (activeFilters[column] && activeFilters[column].length > 0) {
filtered = filtered.filter((board) => {
const boardValue = board[column]?.toString().trim();
if (activeFilters[column].includes("binary-enabled")) {
return boardValue === "Sim" || boardValue === "Opcional";
}
return activeFilters[column].includes(boardValue);
});
}
});
setFilteredBoards(filtered);
};
const handleFilterToggle = (column, value) => {
setActiveFilters((prev) => {
const newFilters = { ...prev };
if (!newFilters[column]) {
newFilters[column] = [value];
} else {
const index = newFilters[column].indexOf(value);
if (index > -1) {
newFilters[column] = newFilters[column].filter((v) => v !== value);
if (newFilters[column].length === 0) {
delete newFilters[column];
}
} else {
newFilters[column] = [...newFilters[column], value];
}
}
return newFilters;
});
};
const clearFilters = () => {
setActiveFilters({});
setSearchTerm("");
};
const activeFilterCount = Object.keys(activeFilters).length + (searchTerm ? 1 : 0);
const filterableColumns = headers.filter((h) => !excludedColumns.includes(h));
const hero = (
<PageHero
isEn={isEn}
trail={[{ label: isEn ? "Board Catalog" : "Catálogo de placas" }]}
icon={ShoppingCart}
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."
}
/>
);
if (loading) {
return (
<div className="site-canvas min-h-screen">
<Header />
<main id="conteudo">
{hero}
<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">
{isEn ? "Querying boards database…" : "Consultando o banco de placas…"}
</p>
</section>
</main>
<Footer />
</div>
);
}
if (error) {
return (
<div className="site-canvas min-h-screen">
<Header />
<main id="conteudo">
{hero}
<section className="shell section pt-10">
<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">
{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" />
{isEn ? "Try again" : "Tentar novamente"}
</button>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
const filtros = (
<div className="space-y-6">
<div>
<label htmlFor="busca-placa" className="text-sm font-semibold text-ink">
{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={isEn ? "Name or description" : "Nome ou descrição"}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="field pl-10 text-sm"
/>
</div>
</div>
{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">
{colLabel}
<input
type="checkbox"
checked={activeFilters[column]?.includes("binary-enabled") || false}
onChange={() => handleFilterToggle(column, "binary-enabled")}
className="h-5 w-5 shrink-0 cursor-pointer accent-[var(--accent)]"
/>
</label>
);
}
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">
{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) => {
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>
);
})}
</div>
);
return (
<div className="site-canvas min-h-screen">
<Header />
<main id="conteudo">
{hero}
<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" />
{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();
}}
className="text-sm font-medium text-accent hover:text-accent-strong"
>
{isEn ? "Clear" : "Limpar"}
</button>
)}
</summary>
<div className="border-t border-line p-5 lg:max-h-[calc(100vh-11rem)] lg:overflow-y-auto">{filtros}</div>
</details>
</aside>
<div>
<p role="status" aria-live="polite" className="text-sm text-muted">
<strong className="font-semibold text-ink">{filteredBoards.length}</strong>{" "}
{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">
{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">
{isEn ? "Clear filters" : "Limpar filtros"}
</button>
</div>
) : (
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredBoards.map((board, index) => (
<BoardCard key={index} board={board} />
))}
</div>
)}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}