Release / Plano (reaproveitar a imagem?) (push) Successful in 7s
CI / Lint e auditorias (push) Successful in 55s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (linux/arm64) (push) Successful in 4m8s
Release / ci (push) Successful in 5m16s
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
Na página de série a faixa passa a ficar entre o bloco de foto/diagrama de pinos e as especificações. No celular o botão ocupa a largura do cartão e as setas ficam numa linha própria; testado de 320 a 1366 px sem nada ultrapassar a área. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
133 lines
6.1 KiB
React
133 lines
6.1 KiB
React
"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 md:flex-row md:items-center md: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 flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
|
|
{total > 1 && (
|
|
<div className="flex items-center justify-between gap-1 sm:justify-start">
|
|
<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 w-full sm:w-auto">
|
|
{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>
|
|
);
|
|
}
|