feat(placas): faixa de placas sugeridas nas séries, comparação e seletor
Release / Plano (reaproveitar a imagem?) (push) Successful in 12s
CI / Lint e auditorias (push) Successful in 1m39s
CI / Trivy (dependências e segredos) (push) Successful in 13s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m35s
Release / ci (push) Successful in 4m30s
Release / Publica a imagem (linux/arm64) (push) Successful in 19s
Release / Cria a release no Gitea (push) Skipped
Release / Deploy no Coolify (push) Successful in 22s

Sorteia uma placa dos chips da página, com preferência pelas de loja oficial, e
troca só pelas setas, com transição suave. Usa o link de afiliado do idioma
(rel=sponsored) e some quando o chip não tem placa. Vai nas páginas de série, na
comparação e nos dois modos de resultado do seletor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-11-02 18:31:01 -03:00
1 parent 6ab83c112d
commit 1f84f1f661
8 files changed
+256

No files matched your search

+3
View File
@@ -49,3 +49,6 @@ next-env.d.ts
# Configuração local do Superset
.superset/
# mockups do brainstorming
.superpowers/
+6
View File
@@ -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.
+5
View File
@@ -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() {
</div>
</section>
<div className="shell mt-8">
<PlacasSugeridas chips={selectedSeries} />
</div>
<section className="shell section" aria-labelledby="docs-titulo">
<h2 id="docs-titulo" className="section-title">
{isEs ? "Documentación oficial" : isEn ? "Official documentation" : isFr ? "Documentation officielle" : isZh ? "官方文档" : isDe ? "Offizielle Dokumentation" : "Documentação oficial"}
+4
View File
@@ -4,6 +4,7 @@ import { useState, useMemo } from "react";
import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import PlacasSugeridas from "@/components/PlacasSugeridas";
import Link from "next/link";
import { getLocalizedHref } from "@/i18n/links";
import seriesDataPt from "@/public/series.json";
@@ -2554,6 +2555,8 @@ export default function Seletor() {
</div>
</div>
<PlacasSugeridas chips={topThree.map((rec) => rec.key)} className="mt-10" />
{otherRecommendations.length > 0 && (
<div className="mt-10">
<h3 className="font-display text-xl font-semibold tracking-[-0.03em] text-ink">
@@ -2714,6 +2717,7 @@ export default function Seletor() {
</button>
</div>
)}
{filteredChips.length > 0 && <PlacasSugeridas chips={filteredChips.map(([chave]) => chave)} className="mt-6" />}
</section>
)}
+2
View File
@@ -13,6 +13,7 @@ import seriesDataDe from "@/data/de/series.json";
import seriesDataZh from "@/data/zh-CN/series.json";
import ConnectionsDiagram from "@/components/ConnectionsDiagram";
import SeriesTabMenu from "@/components/SeriesTabMenu";
import PlacasSugeridas from "@/components/PlacasSugeridas";
import PinRestrictions from "@/components/PinRestrictions";
import { getLocalizedHref } from "@/i18n/links";
import {
@@ -844,6 +845,7 @@ export default async function SerieDetail({ params }) {
<div className="shell mt-10">
<SeriesTabMenu tabs={tabs} isEn={isEn} isEs={isEs} isFr={isFr} isDe={isDe} isZh={isZh} />
<PinRestrictions conexoes={conexoes} serieKey={key} isEn={isEn} isEs={isEs} isFr={isFr} isDe={isDe} isZh={isZh} />
<PlacasSugeridas chips={[key]} className="mt-5" />
</div>
<div className="shell mt-12">
+26
View File
@@ -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;
}
+132
View File
@@ -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 <div aria-hidden="true" className={`min-h-[5.5rem] ${className}`} />;
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 (
<section aria-label={t.regiao} className={`sugestao-faixa card-quiet flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:gap-5 ${className}`}>
<div
key={`${placa.Nome}-${indice}`}
className={`flex min-w-0 flex-1 items-center gap-4 ${sentido === "proxima" ? "sugestao-proxima" : sentido === "anterior" ? "sugestao-anterior" : ""}`}
>
{imagem && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imagem} alt="" width={56} height={56} loading="lazy" className="h-14 w-14 shrink-0 rounded-lg bg-white object-contain" />
)}
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-x-2 text-xs font-semibold text-accent">
<span>{mostrarChip ? placa["Série"] : t.titulo}</span>
<span className="font-medium text-muted">· {t.afiliado}</span>
</p>
<p className="mt-0.5 truncate font-display text-base font-semibold tracking-[-0.02em] text-ink">{placa.Nome}</p>
<p className="truncate text-sm text-muted">{motivo}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{total > 1 && (
<div className="flex items-center gap-1">
<button type="button" onClick={() => ir(-1)} className="btn btn-ghost btn-icon" aria-label={t.anterior}>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<span className="min-w-[3.25rem] text-center text-xs tabular-nums text-muted">
{Math.min(indice, total - 1) + 1} {t.de} {total}
</span>
<button type="button" onClick={() => ir(1)} className="btn btn-ghost btn-icon" aria-label={t.proxima}>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
)}
<a href={linkForLocale(placa, locale)} target="_blank" rel="sponsored noopener noreferrer" className="btn btn-primary btn-sm">
{t.ver}
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"> {t.aba}</span>
</a>
</div>
<p className="sr-only" aria-live="polite">
{`${placa.Nome}, ${Math.min(indice, total - 1) + 1} ${t.de} ${total}`}
</p>
</section>
);
}
+78
View File
@@ -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(/<img[^>]*\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]}`;
}