feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s

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.
This commit is contained in:
evandro committed 2026-11-10 03:48:25 -03:00
1 parent 4671837c44
commit c7d09d63d4
165 files changed
+7940 -3790

No files matched your search

@@ -12,10 +12,11 @@ import { ServiceHero } from "./ServiceHero";
import type { ServiceHeroData } from "./service-page";
/**
* Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX):
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide (grade de duas colunas), hasCaseCards (botão "Ver projetos
* parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa).
* Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam.
* Testes de caminhos-base de ServiceHero (CC 7, contado à mão: as seis escolhas do JSX):
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide ou heroLoop (grade de duas colunas), hasCaseCards (botão
* "Ver projetos parecidos"), diagram (esquema ou o resto), heroLoop sem coluna ao lado (peça em loop ou capa) e
* heroCase (a frase da capa).
* Os filhos que desenham a coluna ao lado (esquema, peça e capa) são trocados por dublês que mostram o que receberam.
*/
jest.mock("./ServiceDiagram", () => ({
@@ -23,6 +24,11 @@ jest.mock("./ServiceDiagram", () => ({
<div data-testid="diagram">{kind}</div>
),
}));
jest.mock("@/app/components/PagePiece", () => ({
PagePiece: ({ loop }: { loop: string }) => (
<div data-testid="piece">{loop}</div>
),
}));
jest.mock("./ServiceHeroCover", () => ({
ServiceHeroCover: (props: {
caseStudy?: { id: string };
@@ -123,6 +129,36 @@ describe("ServiceHero", () => {
expect(screen.queryByTestId("cover")).toBeNull();
});
it("path: a loop and no side column -> the low strip stays, the grid comes in and the loop piece takes the side column", () => {
const { container } = render(
<ServiceHero
service={service("atendimento-whatsapp-ia")}
hero={noSide}
/>,
);
for (const token of compactHero.split(/\s+/).filter(Boolean))
expect(section(container)).toHaveClass(token);
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
expect(shell(container)).toHaveClass(token);
expect(screen.getByTestId("piece")).toHaveTextContent("whatsapp");
expect(screen.queryByTestId("cover")).toBeNull();
// sem rótulo sobre o título: a trilha já diz o nome do serviço
expect(section(container).querySelector(".ds-eyebrow")).toBeNull();
});
it("path: a loop and a side column -> the side column wins and the loop is not mounted", () => {
render(
<ServiceHero
service={service("nota-fiscal-reforma-tributaria")}
hero={{ ...noSide, diagram: "desktop", hasHeroSide: true }}
/>,
);
expect(screen.getByTestId("diagram")).toBeInTheDocument();
expect(screen.queryByTestId("piece")).toBeNull();
});
it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => {
const lavacao = service();
render(
@@ -174,7 +210,7 @@ describe("ServiceHero", () => {
render(<ServiceHero service={sistemas} hero={noSide} />);
const button = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
name: /chamar no whatsapp/i,
});
const message = new URL(button.getAttribute("href") ?? "").searchParams.get(
"text",
+9 -5
View File
@@ -8,6 +8,7 @@ import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import { PagePiece } from "@/app/components/PagePiece";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
import { ServiceDiagram } from "./ServiceDiagram";
@@ -16,7 +17,8 @@ import type { ServiceHeroData } from "./service-page";
/**
* Topo da página do serviço: trilha, título, resposta, botão de contato e, na coluna ao lado, a capa do case escolhido
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo.
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo; com `heroLoop`, a peça de blocos
* ocupa a coluna ao lado de 1024 px em diante, sem mudar a altura do topo.
*/
export function ServiceHero({
service,
@@ -26,6 +28,7 @@ export function ServiceHero({
hero: ServiceHeroData;
}) {
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
const loop = hasHeroSide ? undefined : service.heroLoop;
return (
<section
@@ -34,9 +37,9 @@ export function ServiceHero({
}`}
>
<div
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
className={`ds-section-shell ${hasHeroSide || loop ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<div className="min-w-0 max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
@@ -44,7 +47,6 @@ export function ServiceHero({
{ label: service.navLabel, href: `/servicos/${service.slug}` },
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[22ch] text-balance text-white">
{service.headline}
</h1>
@@ -57,7 +59,7 @@ export function ServiceHero({
placement="service_detail_hero"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
{hasCaseCards ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
@@ -72,6 +74,8 @@ export function ServiceHero({
</div>
{diagram ? (
<ServiceDiagram kind={diagram} />
) : loop ? (
<PagePiece loop={loop} />
) : (
<ServiceHeroCover
caseStudy={heroCase}
@@ -113,4 +113,35 @@ describe("ServiceOfferCards", () => {
for (const { proof } of serviceOffers)
for (const id of proof.caseIds) expect(known.has(id)).toBe(true);
});
it.each([
// cinco ofertas (as de hoje): 3 + 2, os dois da última linha dividem a linha
[5, ["2", "2", "2", "3", "3"]],
// quatro: 3 + 1, o que sobra ocupa a linha inteira
[4, ["2", "2", "2", "6"]],
// três: uma linha cheia
[3, ["2", "2", "2"]],
])(
"path: %i offers -> no empty cell on the last row of the 6-column grid",
(count, spans) => {
const ids: ServiceOffer["id"][] = [
"site",
"sistema",
"whatsapp",
"revisao",
"fiscal",
];
const offers = ids
.slice(0, count)
.map((id): ServiceOffer => ({ ...offer({}), id }));
const { container } = render(<ServiceOfferCards offers={offers} />);
expect(container.firstElementChild).toHaveClass("lg:grid-cols-6");
expect(
Array.from(container.querySelectorAll("article")).map(
(card) => card.className.match(/lg:col-span-(\d)/)?.[1],
),
).toEqual(spans);
},
);
});
+26 -6
View File
@@ -6,14 +6,20 @@ import {
type ServiceOffer,
} from "@/lib/site-content";
function OfferCard({ offer }: { offer: ServiceOffer }) {
function OfferCard({
offer,
className,
}: {
offer: ServiceOffer;
className: string;
}) {
const proofCases = offer.proof.caseIds.flatMap((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
return caseStudy ? [caseStudy] : [];
});
return (
<article className="ds-card">
<article className={`ds-card ${className}`}>
<h3 className="ds-card-title !mt-0 text-balance">{offer.name}</h3>
<p className="ds-copy !flex-grow-0">{offer.summary}</p>
<ul className="ds-list space-y-sp-2">
@@ -55,16 +61,30 @@ function OfferCard({ offer }: { offer: ServiceOffer }) {
);
}
/** As três ofertas, lado a lado no computador: nome, o que a pessoa recebe, onde ver funcionando e o link da página certa. */
/**
* Largura de cada cartão na grade de 6 colunas: três por linha (2 colunas cada), e a última linha incompleta se divide
* por inteiro, sem célula vazia (dois cartões: 3 colunas cada; um: a linha toda).
*/
function offerSpan(index: number, total: number) {
const rest = total % 3;
if (index < total - rest) return "lg:col-span-2";
return rest === 2 ? "lg:col-span-3" : "lg:col-span-6";
}
/** As ofertas lado a lado no computador, três por linha: nome, o que a pessoa recebe, onde ver funcionando e o link da página certa. */
export function ServiceOfferCards({
offers,
}: {
offers: readonly ServiceOffer[];
}) {
return (
<div className="mt-sp-8 grid items-stretch gap-sp-6 lg:grid-cols-3">
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
<div className="mt-sp-8 grid items-stretch gap-sp-6 lg:grid-cols-6">
{offers.map((offer, index) => (
<OfferCard
key={offer.id}
offer={offer}
className={offerSpan(index, offers.length)}
/>
))}
</div>
);
@@ -0,0 +1,42 @@
import type { ServicePageContent } from "@/lib/site-content";
/**
* Regras ou datas de fonte oficial (`official`), com o link de cada fonte para o visitante conferir. O link abre fora
* do site, em outra aba; sem `official`, nada aparece.
*/
export function ServiceOfficial({
official,
}: {
official: ServicePageContent["official"];
}) {
if (!official) return null;
return (
<section className="ds-section" aria-labelledby="fonte-oficial">
<div className="ds-section-shell">
<h2 id="fonte-oficial" className="ds-card-title !mt-0">
{official.title}
</h2>
<ul className="ds-list max-w-[80ch] space-y-sp-2">
{official.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<ul className="mt-sp-4 flex flex-col">
{official.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</section>
);
}
@@ -43,7 +43,7 @@ function ServiceCaseCard({
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>