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

352 lines
14 KiB
TypeScript

import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import {
generalFirstStep,
type ServiceOffer,
serviceOffers,
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { OfferVideo } from "./OfferVideo";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
import { WhatsAppMark } from "./WhatsAppMark";
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
interface OfferExtras {
description: string;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz um vídeo ou uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string };
moreLabel: string;
moreHref: string;
}
/** Ofertas com cartão na home; a da nota fiscal entra na faixa datada acima dos cartões (FiscalNotice). */
type HomeOfferId = Exclude<ServiceOffer["id"], "fiscal">;
/**
* Os textos da home para as quatro ofertas, na ordem de `serviceOffers`. Cada texto só diz o que o case, a página de /ia
* ou a página da oferta citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de
* catálogo e tabela de preços, o que o texto descreve. O sistema, o atendimento no WhatsApp e a revisão trazem uma peça
* 3D em vídeo (OfferVideo); por cima, o atendimento tem o selo do aplicativo e a revisão, um recorte do relatório.
*/
const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
site: {
description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
ctaLabel: "Começar pelo site",
poster: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047",
},
sistema: {
description:
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.",
ctaLabel: "Começar pelo sistema",
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
whatsapp: {
description:
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
ctaLabel: "Começar pelo atendimento",
moreLabel: "Ver como funciona o atendimento",
moreHref: "/servicos/atendimento-whatsapp-ia",
},
revisao: {
description:
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
ctaLabel: "Começar pela revisão",
moreLabel: "Ver como funciona a revisão",
moreHref: "/ia",
},
};
const homeOffers = serviceOffers.flatMap((offer) =>
offer.id === "fiscal" ? [] : [{ offer, extras: OFFER_EXTRAS[offer.id] }],
);
const PROCESS = [
{
title: "Entender a operação",
description:
"Quem usa, qual problema precisa resolver e quais dados e ferramentas já existem.",
},
{
title: "Definir a entrega",
description:
"Escopo, arquitetura, riscos, investimento e critérios de aceite antes de desenvolver.",
},
{
title: "Construir e validar",
description:
"Entregas por etapa, demonstrações e testes dos fluxos, das permissões e das integrações.",
},
{
title: "Publicar e dar continuidade",
description:
"Implantação, documentação e condições de manutenção combinadas para a operação.",
},
];
/**
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
*/
const REPORT_EXCERPT = [
{ finding: "Dados expostos", severity: "Alta" },
{ finding: "Chave exposta", severity: "Alta" },
{ finding: "Sem backup", severity: "Média" },
] as const;
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
const SEVERITY_BADGE =
"inline-flex min-w-14 shrink-0 items-center justify-center border border-[var(--ink)] px-2 font-display text-[18px] @[29rem]:text-[15px] leading-6";
/**
* Recorte do "Modelo do relatório" de /ia junto do vídeo da revisão: três linhas de achado, cada uma com o selo da
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
* quadro, então numa caixa larga (528 px ou mais) o recorte fica por cima da metade direita, centrado na vertical. Numa
* caixa mais estreita (celular, e as duas colunas logo acima de 1024 px) ele não cabe ao lado da janela sem cobri-la:
* desce para baixo do quadro, no mesmo fundo escuro. A largura vem da caixa (container query), não da tela.
*/
/*
* Onde o trecho do relatório fica: ao lado da janela quando o quadro tem 29rem ou mais (a janela do vídeo acaba em 51%
* da largura); embaixo do vídeo quando o cartão ocupa a linha inteira e é estreito (celular); fora da tela entre 1024
* e 1279 px, em que os cartões já estão lado a lado mas o quadro é estreito demais e empilhar desalinharia os títulos.
*/
function ReviewArt() {
return (
<div
role="img"
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
className="pointer-events-none flex flex-col items-center gap-sp-2 px-sp-3 pt-sp-1 pb-sp-4 lg:max-xl:hidden @[29rem]:absolute @[29rem]:inset-y-0 @[29rem]:right-0 @[29rem]:items-end @[29rem]:justify-center @[29rem]:p-sp-4"
>
<ul
aria-hidden="true"
className="w-max max-w-full divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-3 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-4 py-1 font-display text-[18px] @[29rem]:text-[15px] leading-7"
>
<span className="min-w-0">{finding}</span>
<span
className={`${SEVERITY_BADGE} ${severity === "Alta" ? "bg-[var(--ink)] text-[var(--paper)]" : "text-[var(--ink)]"}`}
>
{severity}
</span>
</li>
))}
</ul>
<p
aria-hidden="true"
className="font-display text-[18px] @[29rem]:text-[15px] leading-6 text-white/80"
>
Exemplo ilustrativo
</p>
</div>
);
}
/** Classes do quadro da mídia, iguais nos quatro cartões: a foto ou o vídeo ocupam o quadro inteiro. */
const MEDIA_FRAME =
"relative aspect-[16/10] md:aspect-[21/9] lg:aspect-[16/10]";
/** Foto do case, quando há um; senão o vídeo da oferta, com o que vai junto dele. O quadro tem o mesmo tamanho em todos. */
function OfferArt({
offer,
extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
const poster = extras.poster
? getShowcaseAssets(extras.poster.id, "desktop")
: null;
if (poster && extras.poster) {
return (
<div className={MEDIA_FRAME}>
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 50vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
</div>
);
}
return (
<>
<div className={MEDIA_FRAME}>
<OfferVideo name={offer.id} />
{offer.id === "whatsapp" ? (
// de que aplicativo se trata; decorativo como o vídeo, porque o título do cartão já diz WhatsApp
<span
aria-hidden="true"
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-sp-2 bg-black/70 px-sp-2 py-1 font-display text-[15px] leading-5 text-white @[33rem]:top-3 @[33rem]:left-3"
>
<WhatsAppMark className="h-4 w-4 text-[var(--whatsapp)]" />
WhatsApp
</span>
) : null}
</div>
{offer.id === "revisao" ? <ReviewArt /> : null}
</>
);
}
/** Um único estilo de cartão para as quatro ofertas: foto ou vídeo, título com altura mínima, texto, o que você recebe, botão e link. */
function OfferCard({
offer,
extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
return (
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div
data-testid={`offer-art-${offer.id}`}
className="@container relative overflow-hidden border border-[var(--border)] bg-[var(--ink)]"
>
<OfferArt offer={offer} extras={extras} />
</div>
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
<h3 className="ds-card-title !mt-sp-5 text-balance !text-[clamp(20px,5vw,26px)] !leading-[1.1] !tracking-[-0.03em] [overflow-wrap:break-word] [word-break:normal] lg:min-h-[3.3em] lg:!text-[22px] xl:min-h-[2.2em] xl:!text-[24px]">
{offer.name}
</h3>
<p
className="ds-copy !mt-sp-3 !grow-0 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
data-reel="right"
>
{extras.description}
</p>
<div className="my-sp-5">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
O que você recebe
</p>
<ul className="mt-sp-2 flex flex-col gap-sp-2">
{offer.deliverables.map((item) => (
<li
key={item}
className="flex items-start gap-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="mt-1 h-4 w-4 shrink-0 text-[var(--ink)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m3 8.5 3.2 3L13 4.5" />
</svg>
{item}
</li>
))}
</ul>
</div>
{/* mt-auto: com cartões de alturas diferentes, o botão e o link descem juntos até o pé do cartão */}
<ContactOfferLink
kind={offer.id}
className={`mt-auto w-full ${primaryButton}`}
>
{extras.ctaLabel}
</ContactOfferLink>
<HoverPrefetchLink
href={extras.moreHref}
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
>
{extras.moreLabel}
</HoverPrefetchLink>
</article>
);
}
export function WorkModalities() {
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
return (
<Section
id="modalities"
eyebrow="Soluções"
title="Escolha por onde começar"
description="Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
disableChildrenAnimation
>
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
{fiscal && isFiscalNoticeActive(new Date()) ? (
<FiscalNotice href={fiscal.href} />
) : null}
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-2 lg:gap-sp-6">
{homeOffers.map(({ offer, extras }, index) => (
<Reveal
key={offer.id}
y={20}
duration={0.75}
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} extras={extras} />
</Reveal>
))}
</div>
{/* Uma vez só, depois dos cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
</p>
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{generalFirstStep}
</p>
</div>
<HoverPrefetchLink
href="/servicos"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver todos os serviços
</HoverPrefetchLink>
{/* Lista compacta: o título e a descrição de cada etapa numa linha só, sem a grade de quatro cartões. */}
<div
id="process"
className="mt-sp-12 grid scroll-mt-24 gap-sp-6 border-t border-[var(--border)] pt-sp-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-sp-12"
>
<div>
<h3 className="ds-card-title !mt-0">Como eu trabalho</h3>
<p className="ds-copy max-w-[40ch] !text-[15px]">
Você acompanha as decisões e valida cada etapa. Prazo e investimento
dependem do escopo, das integrações e dos riscos identificados.
</p>
</div>
<ol className="divide-y divide-[var(--border)] border-y border-[var(--border)]">
{PROCESS.map((step, index) => (
<li key={step.title} className="flex gap-sp-4 py-sp-4">
<span className="w-7 shrink-0 font-mono text-[15px] leading-6 text-[var(--meta)]">
{String(index + 1).padStart(2, "0")}
</span>
<p className="min-w-0 font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
{step.title}.
</strong>{" "}
{step.description}
</p>
</li>
))}
</ol>
</div>
</Section>
);
}