Files
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00

193 lines
6.4 KiB
TypeScript

"use client";
import Image from "next/image";
import {
type CSSProperties,
type RefObject,
useEffect,
useId,
useRef,
useState,
ViewTransition,
} from "react";
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
import {
type Device,
getShowcaseAssets,
projectKicker,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { ProjectActions } from "./ProjectActions";
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
import { useNearViewport } from "./useShowcaseMedia";
/**
* Qual cartão do carrossel está à frente: o índice do cartão cujo início está mais perto do começo da faixa (no fim da
* rolagem, o último, que talvez não alcance o começo). Só lê a posição da rolagem; sem rolagem horizontal fica em 0.
*/
export function carouselIndex(list: HTMLElement) {
const cards = Array.from(list.children) as HTMLElement[];
if (cards.length < 2 || list.scrollWidth <= list.clientWidth + 1) return 0;
if (list.scrollLeft + list.clientWidth >= list.scrollWidth - 2)
return cards.length - 1;
const stride =
cards[1].getBoundingClientRect().left -
cards[0].getBoundingClientRect().left;
if (stride <= 0) return 0;
return Math.min(
cards.length - 1,
Math.max(0, Math.round(list.scrollLeft / stride)),
);
}
/** Acompanha a rolagem horizontal da faixa e devolve o índice do cartão à frente (em um quadro de animação por vez). */
function useCarouselIndex(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
) {
const [index, setIndex] = useState(0);
useEffect(() => {
const list = ref.current;
if (!enabled || !list) return;
let frame = 0;
const read = () => {
frame = 0;
setIndex(carouselIndex(list));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
read();
list.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(frame);
list.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [ref, enabled]);
return index;
}
function ListItem({
item,
device,
onExpand,
}: {
item: ShowcaseItem;
/** `null` no servidor e até montar: o item mostra só o texto e os links. */
device: Device | null;
onExpand?: () => void;
}) {
const { config, project } = item;
const ref = useRef<HTMLLIElement>(null);
const near = useNearViewport(ref);
const assets = device ? getShowcaseAssets(config.id, device) : null;
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela. É a mesma capa do topo do
// case: viaja até lá na troca de página (o item tem uma moldura só, então o nome não se repete)
const poster =
assets && near ? (
<ViewTransition {...coverTransitionProps(config.id)}>
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
sizes={device === "mobile" ? "160px" : "50vw"}
/>
</ViewTransition>
) : null;
return (
<li
ref={ref}
className="ds-reel-item"
data-device={device ?? undefined}
style={{ "--accent": config.accent } as CSSProperties}
>
{device && (
<div className="ds-reel-item-media">
{device === "mobile" ? (
<PhoneFrame className="ds-reel-item-phone">{poster}</PhoneFrame>
) : (
<BrowserFrame url={displayUrl(project)}>
<div className="ds-reel-item-screen">{poster}</div>
</BrowserFrame>
)}
</div>
)}
<div className="ds-reel-caption">
<p className="ds-kicker">
{projectKicker(project.category, project.status)}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
<ProjectActions item={item} onExpand={onExpand} />
</div>
</li>
);
}
/**
* Projetos em lista simples: sem rolagem presa, sem vídeo que acompanha a rolagem. No celular (abaixo de 768 px)
* é um carrossel por toque (rolagem horizontal com encaixe, o próximo projeto aparece pela borda); em telas
* maiores, uma grade. Serve também a quem pede menos movimento e a janelas baixas demais para a vitrine fixa.
*
* É o que o servidor entrega (texto e links, sem mídia): sem JavaScript os projetos continuam todos ali, e
* `pending` esconde a lista por CSS só onde a vitrine fixa vai assumir o lugar (largura e altura de desktop).
* Pôster e "Ampliar vídeo" entram depois que o navegador monta; vídeo, só dentro da janela ampliada.
*/
export function ProjectList({
items,
device,
pending = false,
}: {
items: ShowcaseItem[];
device: Device | null;
pending?: boolean;
}) {
const open = useShowcaseOpen();
const headingId = useId();
const listRef = useRef<HTMLUListElement>(null);
// a contagem só existe no celular (o carrossel por toque); a grade das telas maiores não rola para o lado
const carousel = device === "mobile";
const current = useCarouselIndex(listRef, carousel);
return (
<section
className="ds-reel-list"
aria-labelledby={headingId}
data-pending={pending ? "" : undefined}
>
<h2 id={headingId} className="sr-only">
Projetos de clientes
</h2>
<ul className="ds-reel-items" ref={listRef}>
{items.map((item) => (
<ListItem
key={item.config.id}
item={item}
device={device}
onExpand={device ? () => open(item.config.id) : undefined}
/>
))}
</ul>
{carousel && items.length > 1 && (
// quem lê com leitor de tela já tem a lista (com o número de itens): a contagem é só visual
<p className="ds-reel-count" aria-hidden="true">
<span className="ds-reel-dots">
{items.map((item, index) => (
<i
key={item.config.id}
data-on={index === current ? "" : undefined}
/>
))}
</span>
{current + 1}/{items.length}
</p>
)}
</section>
);
}