419 lines
15 KiB
TypeScript
419 lines
15 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 "./conversion.css";
|
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
|
import { Reveal } from "./Reveal";
|
|
import { Section } from "./Section";
|
|
|
|
/** 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 uma ilustração própria (ver OfferArt). */
|
|
poster?: { id: string; title: string };
|
|
moreLabel: string;
|
|
moreHref: string;
|
|
}
|
|
|
|
/**
|
|
* 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 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.",
|
|
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",
|
|
},
|
|
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 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.",
|
|
},
|
|
];
|
|
|
|
/** 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
|
|
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"
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/** 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
|
|
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.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() {
|
|
return (
|
|
<Section
|
|
id="modalities"
|
|
eyebrow="Soluções"
|
|
title="Escolha por onde começar"
|
|
description="Três 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
|
|
>
|
|
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
|
|
{serviceOffers.map((offer, index) => (
|
|
<Reveal
|
|
key={offer.id}
|
|
y={20}
|
|
duration={0.75}
|
|
delay={0.08 * (index + 1)}
|
|
className="min-w-0"
|
|
>
|
|
<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-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>
|
|
);
|
|
}
|