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

+72 -21
View File
@@ -17,6 +17,13 @@ import ServicePage, { generateMetadata, generateStaticParams } from "./page";
beforeEach(() => {
setNavigation({ pathname: "/servicos" });
// a peça em loop do topo (OfferVideo) toca e pausa o vídeo, que o jsdom não implementa
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function renderService(slug: string) {
@@ -43,6 +50,8 @@ if (!service) throw new Error("Missing representative service");
const publicServiceSlugs = [
"criacao-de-sites",
"engenharia-de-software",
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
@@ -67,8 +76,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
it("keeps static params and route metadata canonical", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect(staticSlugs).toHaveLength(8);
expect(new Set(staticSlugs).size).toBe(8);
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
const metadata = await generateMetadata({
@@ -110,7 +119,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
}
const primaryCtas = screen.getAllByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
});
expect(primaryCtas).toHaveLength(2);
for (const cta of primaryCtas) {
@@ -285,22 +294,22 @@ it("keeps hub cards and structured items aligned", () => {
itemListElement: Array<{ position: number; url: string; name: string }>;
};
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
1, 2, 3, 4, 5, 6,
1, 2, 3, 4, 5, 6, 7, 8,
]);
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
expect(structuredUrls).toHaveLength(6);
expect(new Set(structuredUrls).size).toBe(6);
expect(structuredUrls).toHaveLength(8);
expect(new Set(structuredUrls).size).toBe(8);
expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
);
// as duas ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
// as quatro ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
const offerLinks = serviceOffers
.filter(({ href }) => publicServiceHrefs.includes(href))
.map(({ name, href }) => ({
href,
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
}));
expect(offerLinks).toHaveLength(2);
expect(offerLinks).toHaveLength(4);
for (const { href, link } of offerLinks) {
expect(link).toHaveAttribute("href", href);
}
@@ -312,8 +321,8 @@ it("keeps hub cards and structured items aligned", () => {
...offerLinks.map(({ link }) => link.getAttribute("href")),
...otherLinks.map((link) => link.getAttribute("href")),
];
expect(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6);
expect(visibleHrefs).toHaveLength(8);
expect(new Set(visibleHrefs).size).toBe(8);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
expect(graph[2]).toMatchObject({
"@type": "BreadcrumbList",
@@ -324,12 +333,12 @@ it("keeps hub cards and structured items aligned", () => {
});
});
it("puts the three offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
it("puts the five offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
const { container } = render(<ServicesIndexPage />);
const offersHeading = screen.getByRole("heading", {
level: 2,
name: "Três jeitos de começar.",
name: "Cinco jeitos de começar.",
});
const othersHeading = screen.getByRole("heading", {
level: 2,
@@ -342,8 +351,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
const cards = Array.from(
container.querySelectorAll<HTMLElement>("section article"),
).slice(0, 3);
expect(cards).toHaveLength(3);
).slice(0, 5);
expect(cards).toHaveLength(5);
for (const [index, offer] of serviceOffers.entries()) {
const card = cards[index];
expect(
@@ -357,7 +366,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
).toHaveAttribute("href", offer.href);
expect(within(card).getByText(offer.proof.label)).toBeVisible();
}
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão; o atendimento
// e a nota fiscal não têm case e só levam à página do serviço
expect(
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
@@ -368,10 +378,18 @@ it("puts the three offers right after the hero, each with its name, deliverables
within(cards[1]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(within(cards[1]).getByText(/Apolo Energia/)).toBeVisible();
expect(within(cards[2]).queryAllByRole("link")).toHaveLength(1);
for (const card of [cards[2], cards[3], cards[4]]) {
expect(within(card).queryAllByRole("link")).toHaveLength(1);
}
expect(
within(cards[2]).getByRole("link", { name: /^Ver o serviço/ }),
within(cards[3]).getByRole("link", { name: /^Ver o serviço/ }),
).toHaveAttribute("href", "/ia");
expect(
within(cards[2]).getByText(/^Ainda não há case público/),
).toBeVisible();
expect(
within(cards[4]).getByText(/^Ainda não há case público/),
).toBeVisible();
});
it("keeps the hub free of case covers, with the SEV only as a proof link in two offers, and without disclaimers", () => {
@@ -606,7 +624,7 @@ it("says the offer name, not the old label, in the breadcrumb, the title and the
).getAllByRole("link");
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
for (const cta of screen.getAllByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
})) {
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
"text",
@@ -914,7 +932,7 @@ it("puts the FAQ before the next step and ends every service page with the same
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
@@ -926,8 +944,8 @@ it("puts the FAQ before the next step and ends every service page with the same
// um rótulo só para o contato: sem "no WhatsApp" colado nem texto por serviço
expect(
screen
.getAllByRole("link", { name: buttonLabels.contact })
.every((link) => link.textContent === buttonLabels.contact),
.getAllByRole("link", { name: buttonLabels.whatsapp })
.every((link) => link.textContent === buttonLabels.whatsapp),
).toBe(true);
expect(container.textContent).not.toMatch(/diretamente/i);
unmount();
@@ -963,3 +981,36 @@ it("keeps related case ids resolving to real cases", () => {
expect(caseStudies.some((item) => item.id === id)).toBe(true);
}
});
it("shows the official dates or rules with a link to each source, after 'O que você recebe', only where the service has them", async () => {
for (const slug of [
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
]) {
const page = getServicePage(slug);
if (!page?.official) throw new Error(`Missing official block of ${slug}`);
const { unmount } = await renderService(slug);
const heading = screen.getByRole("heading", {
level: 2,
name: page.official.title,
});
expect(
isBefore(
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
heading,
),
).toBe(true);
for (const item of page.official.items) {
expect(screen.getByText(item)).toBeVisible();
}
for (const { label, href } of page.official.links) {
const link = screen.getByRole("link", { name: label });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
unmount();
}
await renderService("consultoria-nextjs");
expect(document.getElementById("fonte-oficial")).toBeNull();
});
+5 -2
View File
@@ -24,6 +24,7 @@ import { ServiceAlsoCases } from "../_components/ServiceAlsoCases";
import { ServiceDeliverables } from "../_components/ServiceDeliverables";
import { ServiceHero } from "../_components/ServiceHero";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceOfficial } from "../_components/ServiceOfficial";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
import {
resolveServiceHero,
@@ -76,7 +77,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
const hero = resolveServiceHero(service);
const { cards, also } = splitServiceCases(service);
// as duas primeiras ofertas têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
// as ofertas, menos a revisão (que abre /ia), têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
const offer = getServiceOffer(service.slug);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
const audienceCard = (
@@ -125,6 +126,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
{offer ? <ServiceDeliverables offer={offer} /> : null}
<ServiceOfficial official={service.official} />
<section className="ds-section">
<div className="ds-section-shell">
{offer ? (
@@ -226,7 +229,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
placement="service_detail_footer"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink
href="/servicos"