diff --git a/.gitignore b/.gitignore
index d66aaed..8776167 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,6 @@ next-env.d.ts
# Configuração local do Superset
.superset/
+
+# mockups do brainstorming
+.superpowers/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3c4ba2..c890596 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ Tudo o que muda de relevante no ESPDocs está aqui. O formato segue o
### Adicionado
+- Faixa discreta de placas sugeridas nas páginas de série, na comparação e nos resultados do
+ seletor (`PlacasSugeridas`). Sorteia uma placa dos chips da tela, com preferência (peso 3)
+ pelas de loja oficial, e fica parada até o leitor usar as setas, com transição suave (respeita
+ "reduzir movimento"). Cada placa traz o chip, uma linha de motivo traduzida nos 6 idiomas e o
+ link de afiliado do idioma da página, marcado como tal. Não aparece quando o chip não tem
+ placa. Sem rastreamento nem cookies.
- Catálogo de placas com link de afiliado por país: a planilha ganha as colunas `Link_en`,
`Link_es`, `Link_fr`, `Link_de` e `Link_zh-CN`, e o botão do card usa a do idioma da página,
caindo no `Link` (Brasil) quando a coluna está vazia. As colunas de link nunca viram filtro.
diff --git a/app/[locale]/comparacao/page.jsx b/app/[locale]/comparacao/page.jsx
index a9b6c61..1ffd29e 100644
--- a/app/[locale]/comparacao/page.jsx
+++ b/app/[locale]/comparacao/page.jsx
@@ -2,6 +2,7 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
+import PlacasSugeridas from "@/components/PlacasSugeridas";
import PageHero from "@/components/PageHero";
import { useEffect, useState, useMemo, Fragment } from "react";
import { usePathname } from "next/navigation";
@@ -740,6 +741,10 @@ export default function Comparacao() {
+
diff --git a/app/globals.css b/app/globals.css
index a79106d..4dde2b7 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -752,6 +752,32 @@
from { opacity: 0; transform: translateY(16px); }
}
+ /* Faixa de placas sugeridas: entra suave e desliza na direção da seta usada.
+ "Reduzir movimento" já zera as durações na regra global acima. */
+ .sugestao-faixa {
+ animation: sugestao-entrada 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+
+ .sugestao-proxima {
+ animation: sugestao-proxima 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+
+ .sugestao-anterior {
+ animation: sugestao-anterior 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+
+ @keyframes sugestao-entrada {
+ from { opacity: 0; transform: translateY(10px); }
+ }
+
+ @keyframes sugestao-proxima {
+ from { opacity: 0; transform: translateX(20px); }
+ }
+
+ @keyframes sugestao-anterior {
+ from { opacity: 0; transform: translateX(-20px); }
+ }
+
.no-scrollbar {
scrollbar-width: none;
}
diff --git a/components/PlacasSugeridas.jsx b/components/PlacasSugeridas.jsx
new file mode 100644
index 0000000..f6069ca
--- /dev/null
+++ b/components/PlacasSugeridas.jsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { usePathname } from "next/navigation";
+import { ArrowUpRight, ChevronLeft, ChevronRight } from "lucide-react";
+import { boardsForChips, imageOf, reasonFor, weightedOrder } from "@/lib/boardSuggestions";
+import { linkForLocale } from "@/lib/boardLinks";
+
+const TEXTOS = {
+ pt: { titulo: "Placa sugerida", afiliado: "Link de afiliado", ver: "Ver no AliExpress", anterior: "Placa anterior", proxima: "Próxima placa", de: "de", aba: "(abre em nova aba)", regiao: "Placas sugeridas com estes chips" },
+ en: { titulo: "Suggested board", afiliado: "Affiliate link", ver: "View on AliExpress", anterior: "Previous board", proxima: "Next board", de: "of", aba: "(opens in new tab)", regiao: "Suggested boards with these chips" },
+ es: { titulo: "Placa sugerida", afiliado: "Enlace de afiliado", ver: "Ver en AliExpress", anterior: "Placa anterior", proxima: "Siguiente placa", de: "de", aba: "(abre en una pestaña nueva)", regiao: "Placas sugeridas con estos chips" },
+ fr: { titulo: "Carte suggérée", afiliado: "Lien d'affiliation", ver: "Voir sur AliExpress", anterior: "Carte précédente", proxima: "Carte suivante", de: "sur", aba: "(ouvre un nouvel onglet)", regiao: "Cartes suggérées avec ces puces" },
+ de: { titulo: "Board-Vorschlag", afiliado: "Affiliate-Link", ver: "Auf AliExpress ansehen", anterior: "Vorheriges Board", proxima: "Nächstes Board", de: "von", aba: "(öffnet in neuem Tab)", regiao: "Board-Vorschläge mit diesen Chips" },
+ "zh-CN": { titulo: "推荐开发板", afiliado: "推广链接", ver: "在 AliExpress 查看", anterior: "上一块开发板", proxima: "下一块开发板", de: "/", aba: "(在新标签页打开)", regiao: "搭载这些芯片的推荐开发板" },
+};
+
+function localeFrom(pathname) {
+ for (const loc of ["en", "es", "fr", "de", "zh-CN"]) {
+ if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) return loc;
+ }
+ return "pt";
+}
+
+// Uma só busca por visita, compartilhada entre as instâncias; falha vira "sem placas".
+let promessaDePlacas;
+function carregarPlacas() {
+ if (!promessaDePlacas) {
+ promessaDePlacas = fetch("/api/placas")
+ .then((resposta) => (resposta.ok ? resposta.json() : Promise.reject(new Error(String(resposta.status)))))
+ .then((json) => json.data || [])
+ .catch(() => {
+ promessaDePlacas = undefined;
+ return [];
+ });
+ }
+ return promessaDePlacas;
+}
+
+// Faixa discreta com uma placa sorteada dos chips da página. Complementa a documentação:
+// fica parada, só troca quando o leitor usa as setas, e some se não houver placa.
+export default function PlacasSugeridas({ chips, className = "" }) {
+ const pathname = usePathname() || "";
+ const locale = localeFrom(pathname);
+ const t = TEXTOS[locale];
+ const chipsKey = [...chips].sort().join("|");
+
+ const [placas, setPlacas] = useState(null);
+ const [indice, setIndice] = useState(0);
+ const [sentido, setSentido] = useState("entrada");
+
+ useEffect(() => {
+ let ativo = true;
+ carregarPlacas().then((dados) => ativo && setPlacas(dados));
+ return () => {
+ ativo = false;
+ };
+ }, []);
+
+ // O sorteio refaz só quando os dados ou o conjunto de chips mudam, não a cada render.
+ const ordem = useMemo(() => {
+ if (!placas) return null;
+ return weightedOrder(boardsForChips(placas, chipsKey ? chipsKey.split("|") : [], locale));
+ }, [placas, chipsKey, locale]);
+
+ useEffect(() => {
+ setIndice(0);
+ setSentido("entrada");
+ }, [chipsKey]);
+
+ // Enquanto carrega, reserva a altura da faixa para a página não pular quando ela aparece.
+ if (ordem === null) return
;
+ if (ordem.length === 0) return null;
+
+ const placa = ordem[Math.min(indice, ordem.length - 1)];
+ const total = ordem.length;
+ const imagem = imageOf(placa);
+ const motivo = reasonFor(placa, locale);
+ const mostrarChip = chips.length > 1;
+
+ const ir = (passo) => {
+ setSentido(passo > 0 ? "proxima" : "anterior");
+ setIndice((atual) => (atual + passo + total) % total);
+ };
+
+ return (
+
+
+ {imagem && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ {mostrarChip ? placa["Série"] : t.titulo}
+ · {t.afiliado}
+
+
{placa.Nome}
+
{motivo}
+
+
+
+
+ {total > 1 && (
+
+ ir(-1)} className="btn btn-ghost btn-icon" aria-label={t.anterior}>
+
+
+
+ {Math.min(indice, total - 1) + 1} {t.de} {total}
+
+ ir(1)} className="btn btn-ghost btn-icon" aria-label={t.proxima}>
+
+
+
+ )}
+
+ {t.ver}
+
+ {t.aba}
+
+
+
+
+ {`${placa.Nome}, ${Math.min(indice, total - 1) + 1} ${t.de} ${total}`}
+
+
+ );
+}
diff --git a/lib/boardSuggestions.js b/lib/boardSuggestions.js
new file mode 100644
index 0000000..602be1f
--- /dev/null
+++ b/lib/boardSuggestions.js
@@ -0,0 +1,78 @@
+// Lógica da faixa de placas sugeridas (components/PlacasSugeridas.jsx): quais placas entram,
+// em que ordem sorteada e o texto curto de motivo. Sem React, para ficar fácil de testar.
+import { linkForLocale } from "@/lib/boardLinks";
+
+// Placa de loja oficial entra no sorteio com este peso; as demais valem 1.
+export const PESO_OFICIAL = 3;
+
+export function boardsForChips(boards, chips, locale) {
+ const alvo = new Set(chips);
+ return boards.filter((board) => alvo.has(board["Série"]) && linkForLocale(board, locale));
+}
+
+// Sorteio ponderado sem reposição (Efraimidis-Spirakis): quanto maior o peso, maior a chance
+// de ficar no começo, e todas as placas aparecem uma vez na ordem final.
+export function weightedOrder(boards, random = Math.random) {
+ return boards
+ .map((board) => {
+ const peso = board["Loja Oficial"]?.trim() === "Sim" ? PESO_OFICIAL : 1;
+ return { board, chave: Math.pow(random(), 1 / peso) };
+ })
+ .sort((a, b) => b.chave - a.chave)
+ .map(({ board }) => board);
+}
+
+export function imageOf(board) {
+ const src = board.Imagem?.match(/
]*\ssrc="([^"]+)"/i)?.[1];
+ if (!src) return null;
+ return src.startsWith("//") ? `https:${src}` : src;
+}
+
+// Recursos que viram o texto de motivo, do mais característico para o mais comum.
+const FEATURES = [
+ { columns: ["Display Touch"], key: "touch" },
+ { columns: ["Display"], key: "display" },
+ { columns: ["Câmera"], key: "camera" },
+ { columns: ["LoRa"], key: "lora" },
+ { columns: ["GPS"], key: "gps" },
+ { columns: ["GSM (3G/4G)"], key: "gsm" },
+ { columns: ["Conector Ethernet"], key: "ethernet" },
+ { columns: ["RS485"], key: "rs485" },
+ { columns: ["Radiofrequência"], key: "rf" },
+ { columns: ["Infravermelho"], key: "ir" },
+ { columns: ["Bateria Interna"], key: "battery" },
+ { columns: ["Conector p/ Bateria"], key: "batteryConnector" },
+ { columns: ["Conector p/ Painel Solar"], key: "solar" },
+ { columns: ["Wi-Fi 5GHz"], key: "wifi5" },
+ { columns: ["Suporte a Matter"], key: "matter" },
+];
+
+const LABELS = {
+ pt: { touch: "tela touch", display: "tela", camera: "câmera", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "Ethernet", rs485: "RS485", rf: "rádio sub-GHz", ir: "infravermelho", battery: "bateria", batteryConnector: "conector de bateria", solar: "entrada solar", wifi5: "Wi-Fi 5 GHz", matter: "Matter", optional: "opcional", basic: "placa de desenvolvimento essencial" },
+ en: { touch: "touchscreen", display: "display", camera: "camera", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "Ethernet", rs485: "RS485", rf: "sub-GHz radio", ir: "infrared", battery: "battery", batteryConnector: "battery connector", solar: "solar input", wifi5: "5 GHz Wi-Fi", matter: "Matter", optional: "optional", basic: "essential development board" },
+ es: { touch: "pantalla táctil", display: "pantalla", camera: "cámara", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "Ethernet", rs485: "RS485", rf: "radio sub-GHz", ir: "infrarrojos", battery: "batería", batteryConnector: "conector de batería", solar: "entrada solar", wifi5: "Wi-Fi de 5 GHz", matter: "Matter", optional: "opcional", basic: "placa de desarrollo esencial" },
+ fr: { touch: "écran tactile", display: "écran", camera: "caméra", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "Ethernet", rs485: "RS485", rf: "radio sub-GHz", ir: "infrarouge", battery: "batterie", batteryConnector: "connecteur de batterie", solar: "entrée solaire", wifi5: "Wi-Fi 5 GHz", matter: "Matter", optional: "optionnel", basic: "carte de développement essentielle" },
+ de: { touch: "Touchscreen", display: "Display", camera: "Kamera", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "Ethernet", rs485: "RS485", rf: "Sub-GHz-Funk", ir: "Infrarot", battery: "Akku", batteryConnector: "Akkuanschluss", solar: "Solareingang", wifi5: "5-GHz-WLAN", matter: "Matter", optional: "optional", basic: "Entwicklungsboard für den Einstieg" },
+ "zh-CN": { touch: "触摸屏", display: "显示屏", camera: "摄像头", lora: "LoRa", gps: "GPS", gsm: "4G/GSM", ethernet: "以太网", rs485: "RS485", rf: "Sub-GHz 无线电", ir: "红外", battery: "内置电池", batteryConnector: "电池接口", solar: "太阳能输入", wifi5: "5 GHz Wi-Fi", matter: "Matter", optional: "可选", basic: "基础开发板" },
+};
+
+// "tela touch, câmera e LoRa": até três recursos, do mais característico para o mais comum.
+// Quando há touch, a tela comum não entra à parte (já está implícita).
+export function reasonFor(board, locale) {
+ const rotulos = LABELS[locale] || LABELS.pt;
+ const temTouch = ["Sim", "Opcional"].includes(board["Display Touch"]?.trim());
+ const itens = [];
+ for (const { columns, key } of FEATURES) {
+ if (key === "display" && temTouch) continue;
+ const valor = board[columns[0]]?.trim();
+ if (valor !== "Sim" && valor !== "Opcional") continue;
+ const texto = valor === "Opcional" ? `${rotulos[key]} (${rotulos.optional})` : rotulos[key];
+ itens.push(texto);
+ if (itens.length === 3) break;
+ }
+ if (itens.length === 0) return rotulos.basic;
+ if (locale === "zh-CN") return itens.join("、");
+ if (itens.length === 1) return itens[0];
+ const conector = { pt: "e", en: "and", es: "y", fr: "et", de: "und" }[locale] || "e";
+ return `${itens.slice(0, -1).join(", ")} ${conector} ${itens[itens.length - 1]}`;
+}