feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas

/servicos abre com as 3 ofertas e depois outros serviços; H1 de criação de sites e de engenharia com o nome da oferta; capa de cada serviço pelo assunto (SEV fora do próprio case cai de ~11 para 2 imagens); diagrama de fluxo em SVG nos serviços sem case; cases com o que o cliente passou a poder fazer e contato logo depois de Como ficou; FAQ num componente só; lastmod por rota; ProfessionalService com areaServed.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:46 -03:00
1 parent e63f3c2ebd
commit fc44a69fb3
41 files changed
+2486 -550

No files matched your search

+13 -9
View File
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
/**
* Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador,
* os seguintes têm, e só o último é a página atual (inclusive quando é o único).
* Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha
* no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único).
*/
describe("Breadcrumb", () => {
it("path: several items -> a separator before every item but the first", () => {
it("path: several items -> a separator after every item but the last, so a wrapped line starts with a name", () => {
const { container } = render(
<Breadcrumb
items={[
@@ -20,15 +20,19 @@ describe("Breadcrumb", () => {
const entries = Array.from(container.querySelectorAll("li"));
expect(entries).toHaveLength(3);
expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull();
expect(
entries[0].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[1].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[2].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(entries[0].className).toBe("");
expect(entries[1].className).toContain("flex");
expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull();
// o nome vem antes da barra em todos os itens, e nenhum item abre por "/"
for (const entry of entries) {
expect(entry.firstElementChild?.tagName).toBe("A");
expect(entry.textContent?.trimStart().startsWith("/")).toBe(false);
}
expect(entries[0].className).toContain("flex");
});
it("path: a single item -> no separator, and it is the current page", () => {
+7 -6
View File
@@ -3,6 +3,8 @@ import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual.
* Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink).
* A barra vem depois de cada item, menos do último: quando a trilha não cabe numa linha (celular), a quebra cai
* depois da barra e a linha de baixo começa pelo nome, nunca por "/".
*/
export function Breadcrumb({
items,
@@ -11,13 +13,9 @@ export function Breadcrumb({
}) {
return (
<nav aria-label="Trilha de navegação" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-[15px]">
<ol className="flex flex-wrap items-center gap-x-sp-2 gap-y-sp-1 text-[15px]">
{items.map(({ label, href }, index) => (
<li
key={href}
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? <span aria-hidden="true">/</span> : null}
<li key={href} className="flex items-center gap-sp-2">
<HoverPrefetchLink
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
@@ -25,6 +23,9 @@ export function Breadcrumb({
>
{label}
</HoverPrefetchLink>
{index < items.length - 1 ? (
<span aria-hidden="true">/</span>
) : null}
</li>
))}
</ol>
+90
View File
@@ -0,0 +1,90 @@
import { render, screen, within } from "@testing-library/react";
import { Faq } from "./Faq";
import HomeFaq from "./HomeFaq";
const items = [
{ question: "Primeira pergunta?", answer: "Primeira resposta." },
{
question: "Segunda pergunta?",
answer: "Segunda resposta.",
link: { href: "/ia", label: "Ver a revisão" },
},
{ question: "Sem resposta ainda?", answer: " " },
];
describe("Faq", () => {
it("path: answered questions -> one closed accordion item per question, under a level 2 heading", () => {
const { container } = render(<Faq items={items} />);
expect(
screen.getByRole("heading", { level: 2, name: "Perguntas frequentes" }),
).toBeInTheDocument();
const summaries = Array.from(container.querySelectorAll("summary")).map(
(summary) => summary.textContent?.replace("+", "").trim(),
);
expect(summaries).toEqual(["Primeira pergunta?", "Segunda pergunta?"]);
for (const details of container.querySelectorAll("details")) {
expect(details.open).toBe(false);
}
});
it("path: a question with no answer -> stays out of the page, like on the home", () => {
render(<Faq items={items} />);
expect(screen.queryByText("Sem resposta ainda?")).toBeNull();
});
it("path: a question with a link -> the link sits inside its answer", () => {
render(<Faq items={items} />);
const details = screen
.getByText("Segunda pergunta?")
.closest("details") as HTMLElement;
expect(within(details).getByText("Segunda resposta.")).toBeInTheDocument();
expect(
within(details).getByRole("link", { name: "Ver a revisão" }),
).toHaveAttribute("href", "/ia");
});
it("path: custom title and anchor -> the heading and the section follow them", () => {
const { container } = render(
<Faq items={items} id="antes" title="Antes de contratar" />,
);
const heading = screen.getByRole("heading", { name: "Antes de contratar" });
expect(heading).toHaveAttribute("id", "antes-title");
expect(container.querySelector("section")).toHaveAttribute("id", "antes");
expect(container.querySelector("section")).toHaveAttribute(
"aria-labelledby",
"antes-title",
);
});
it("path: no items, or none with an answer -> renders nothing, not even the heading", () => {
const empty = render(<Faq />);
expect(empty.container).toBeEmptyDOMElement();
empty.unmount();
const unanswered = render(
<Faq items={[{ question: "Pergunta?", answer: "" }]} />,
);
expect(unanswered.container).toBeEmptyDOMElement();
});
it("path: same pattern as the home FAQ -> same summary and details classes", () => {
const shared = render(<Faq items={items} />);
const home = render(
<HomeFaq
items={[{ question: "Primeira pergunta?", answer: "Resposta." }]}
/>,
);
const classOf = (container: HTMLElement, selector: string) =>
container.querySelector(selector)?.className;
for (const selector of ["details", "summary", "summary > span"]) {
expect(classOf(shared.container, selector)).toBe(
classOf(home.container, selector),
);
}
});
});
+73
View File
@@ -0,0 +1,73 @@
import { textLink } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
export interface FaqEntry {
question: string;
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
answer: string;
link?: { href: string; label: string };
}
/**
* Perguntas frequentes em acordeão, um formato só no site: pergunta na linha, resposta ao abrir, "+" que gira.
* A home (HomeFaq), as páginas de serviço e a de serviços usam este componente. Só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que o dono não tenha dito.
*/
export function Faq({
items,
id = "faq",
title = "Perguntas frequentes",
eyebrow = "Dúvidas comuns",
}: {
items?: readonly FaqEntry[];
/** Âncora da seção; o título leva `${id}-title`. */
id?: string;
title?: string;
eyebrow?: string;
}) {
const answered = (items ?? []).filter((item) => item.answer.trim() !== "");
if (answered.length === 0) return null;
return (
<section id={id} aria-labelledby={`${id}-title`} className="ds-section">
<div className="ds-section-shell">
<p className="ds-eyebrow">{eyebrow}</p>
<h2
id={`${id}-title`}
className="ds-section-title max-w-[22ch] text-balance"
>
{title}
</h2>
<div className="mt-sp-8 max-w-[860px]">
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
<span
aria-hidden="true"
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-5">
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
{item.link ? (
<HoverPrefetchLink
href={item.link.href}
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
>
{item.link.label}
</HoverPrefetchLink>
) : null}
</div>
</details>
))}
</div>
</div>
</section>
);
}