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.
193 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|