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.
This commit is contained in:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("ServicesIndexPage: topo sem captura gravada", () => {
|
||||
"/cases",
|
||||
);
|
||||
const contactLinks = screen.getAllByRole("link", {
|
||||
name: "Falar sobre meu projeto",
|
||||
name: "Chamar no WhatsApp",
|
||||
});
|
||||
expect(contactLinks).toHaveLength(2);
|
||||
for (const link of contactLinks) {
|
||||
@@ -49,11 +49,11 @@ describe("ServicesIndexPage: topo sem captura gravada", () => {
|
||||
expect.stringContaining("https://wa.me/"),
|
||||
);
|
||||
}
|
||||
// as três ofertas seguem no topo da página, mesmo sem nenhuma captura
|
||||
// as ofertas seguem no topo da página, mesmo sem nenhuma captura
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Três jeitos de começar.",
|
||||
name: "Cinco jeitos de começar.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ const pageDescription =
|
||||
/** Cliente que não entra no mosaico do topo: a lista de /cases abre com a SEV (ordem da vitrine), mas o hub mostra os outros três. */
|
||||
const MOSAIC_EXCLUDED_ID = "sev-modaintima";
|
||||
|
||||
/** Páginas que as três ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */
|
||||
/** Páginas que as ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */
|
||||
const offerHrefs = new Set(serviceOffers.map(({ href }) => href));
|
||||
const otherServices = serviceHubLinks.filter(
|
||||
({ href }) => !offerHrefs.has(href),
|
||||
@@ -139,7 +139,7 @@ export default function ServicesIndexPage() {
|
||||
service={SERVICES_SUBJECT}
|
||||
placement="service_hub_hero"
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
{buttonLabels.whatsapp}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink
|
||||
href="/cases"
|
||||
@@ -165,7 +165,7 @@ export default function ServicesIndexPage() {
|
||||
id="ofertas-title"
|
||||
className="ds-section-title max-w-[26ch] text-balance"
|
||||
>
|
||||
Três jeitos de começar.
|
||||
Cinco jeitos de começar.
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[64ch]">
|
||||
Não precisa chegar com um projeto técnico pronto. Escolha o que mais
|
||||
@@ -244,7 +244,7 @@ export default function ServicesIndexPage() {
|
||||
service={SERVICES_SUBJECT}
|
||||
placement="service_hub_footer"
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
{buttonLabels.whatsapp}
|
||||
</WhatsAppLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user