feat(home): seção Sobre com prova por ponto, contato por oferta e hero com a Lavação 047 na tela larga

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:26 -03:00
1 parent d356c364e0
commit 9fd1570426
33 files changed
+1128 -369

No files matched your search

+239 -102
View File
@@ -1,99 +1,57 @@
import { LayoutDashboard, type LucideIcon, ShieldCheck } from "lucide-react";
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 type { ContactKind } from "./contact-subject";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
interface Offer {
id: string;
title: string;
/** 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;
/** O que a pessoa recebe: de três a quatro itens, só o que o case ou a página /ia citados já sustentam. */
deliverables: string[];
/** Primeira etapa: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). */
firstStep: string;
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
kind: ContactKind;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão não tem foto e mostra só o ícone. */
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string };
/** Ícone do cartão sem foto. */
icon?: LucideIcon;
moreLabel: string;
moreHref: string;
}
/**
* As três ofertas da home, na ordem do H1: site que traz pedidos, sistema que tira a operação da planilha e revisão
* de sistema que já existe. Cada texto só diz o que o case ou a página de /ia 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 e a
* revisão não têm um case que mostre o que descrevem (a vitrine da SEV é uma loja, não um painel), então o cartão
* traz só um ícone, e os entregáveis dizem o que a pessoa recebe.
* Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
* página de /ia 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 e a revisão não têm um case que mostre o que descrevem (a vitrine
* da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
*/
const OFFERS: Offer[] = [
{
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
site: {
description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
deliverables: [
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
],
firstStep:
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
kind: "site",
ctaLabel: "Começar pelo site",
poster: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047",
},
{
id: "sistema",
title: "Sistema para tirar a operação da planilha",
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.",
deliverables: [
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
],
firstStep:
"Primeira conversa para entender a operação. Depois, escopo e proposta por escrito.",
kind: "sistema",
ctaLabel: "Começar pelo sistema",
icon: LayoutDashboard,
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
{
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com 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.",
deliverables: [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
],
firstStep:
"Primeira conversa para entender o app e o que ele precisa fazer. Depois, escopo e proposta por escrito.",
kind: "revisao",
ctaLabel: "Começar pela revisão",
icon: ShieldCheck,
moreLabel: "Ver como funciona a revisão",
moreHref: "/ia",
},
];
};
const PROCESS = [
{
@@ -118,52 +76,230 @@ const PROCESS = [
},
];
/** Miniatura sem foto: um painel escuro do mesmo tamanho dos pôsteres, só com o ícone da oferta. */
function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) {
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
const SHEET_COLUMNS = [0, 1, 2];
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
const SHEET_DASH_WIDTHS = [18, 12, 15];
/** Alturas das cinco barras do painel do esquema. */
const PANEL_BARS = [14, 26, 20, 34, 28];
/**
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
*/
function SystemArt() {
return (
<svg
role="img"
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
viewBox="0 0 240 150"
className="block h-full w-full text-white"
fill="none"
stroke="currentColor"
>
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
<rect x="14" y="30" width="80" height="92" />
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
</g>
<rect
x="15"
y="31"
width="78"
height="14"
fill="currentColor"
fillOpacity="0.16"
stroke="none"
/>
<g fill="currentColor" fillOpacity="0.5" stroke="none">
{SHEET_DATA_ROWS.flatMap((row) =>
SHEET_COLUMNS.map((column) => (
<rect
key={`${row}-${column}`}
x={19 + column * 26.6}
y={52 + row * 14.6}
width={
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
}
height="4"
rx="1"
/>
)),
)}
</g>
<path
data-art="seta"
d="M104 76h24M119 66l11 10-11 10"
strokeOpacity="0.85"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g data-art="painel">
<rect
x="140"
y="22"
width="92"
height="108"
rx="3"
fill="currentColor"
fillOpacity="0.06"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{[147, 175, 203].map((x) => (
<g key={x}>
<rect
x={x}
y="30"
width="25"
height="32"
strokeOpacity="0.35"
strokeWidth="1"
/>
<rect
x={x + 4}
y="35"
width="12"
height="3"
fill="currentColor"
fillOpacity="0.5"
stroke="none"
/>
<rect
x={x + 4}
y="43"
width="16"
height="9"
fill="currentColor"
fillOpacity="0.92"
stroke="none"
/>
</g>
))}
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
{PANEL_BARS.map((height, index) => (
<rect
key={height}
x={151 + index * 15}
y={118 - height}
width="10"
height={height}
fill="currentColor"
fillOpacity="0.6"
stroke="none"
/>
))}
</g>
</svg>
);
}
/**
* 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-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
/**
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
* numa linha só no cartão mais estreito (três colunas em 1024 px).
*/
function ReviewArt() {
return (
<div
data-testid="offer-icon"
className="flex h-full items-center justify-center text-white/85"
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="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
>
<Icon aria-hidden="true" className="h-14 w-14" strokeWidth={1.25} />
<ul
aria-hidden="true"
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
>
<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-[15px] leading-5 text-white/75"
>
Exemplo ilustrativo
</p>
</div>
);
}
/** Um único estilo de cartão para as três ofertas: foto ou ícone, título com altura mínima, texto, o que você recebe, primeiro passo, botão e link do case. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
const poster = offer.poster
? getShowcaseAssets(offer.poster.id, "desktop")
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
function OfferArt({
offer,
extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
const poster = extras.poster
? getShowcaseAssets(extras.poster.id, "desktop")
: null;
if (poster && extras.poster) {
return (
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
);
}
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
}
/** Um único estilo de cartão para as três ofertas: foto ou ilustração, título com altura mínima, texto, o que você recebe, botão e link do case. */
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 className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]">
{poster && offer.poster ? (
<Image
src={poster.poster}
alt={`Site ${offer.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
) : offer.icon ? (
<OfferIcon icon={offer.icon} />
) : null}
<div
data-testid={`offer-art-${offer.id}`}
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
>
<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.title}
{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"
>
{offer.description}
{extras.description}
</p>
<div className="my-sp-5">
@@ -192,27 +328,18 @@ function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
</ul>
</div>
{/* mt-auto: com cartões de alturas diferentes, o primeiro passo e o botão descem juntos até o pé do cartão */}
<div className="mt-auto border-t border-[var(--border)] pt-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
</p>
<p className="mt-sp-1 font-display text-[15px] leading-6 text-[var(--ink-80)]">
{offer.firstStep}
</p>
</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.kind}
className={`mt-sp-5 w-full ${primaryButton}`}
kind={offer.id}
className={`mt-auto w-full ${primaryButton}`}
>
{offer.ctaLabel}
{extras.ctaLabel}
</ContactOfferLink>
<HoverPrefetchLink
href={offer.moreHref}
href={extras.moreHref}
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
>
{offer.moreLabel}
{extras.moreLabel}
</HoverPrefetchLink>
</article>
);
@@ -228,7 +355,7 @@ export function WorkModalities() {
disableChildrenAnimation
>
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{OFFERS.map((offer, index) => (
{serviceOffers.map((offer, index) => (
<Reveal
key={offer.id}
y={20}
@@ -236,14 +363,24 @@ export function WorkModalities() {
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} />
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
</Reveal>
))}
</div>
{/* Uma vez só, depois dos três 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-6 text-[var(--ink-80)]`}
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver todos os serviços
</HoverPrefetchLink>