fix(home): oferta leva ao formulário, ofertas logo depois da vitrine

O botão das ofertas rola até o contato com o tipo marcado e não quebra mais as âncoras; ofertas com o que você recebe e primeiro passo, logo depois da vitrine; bloco de IA fora da home; cartão largo da SEV no hero do celular; separador da faixa de clientes só entre itens; favicon de 13 para 1,8 KB; pré-carregamento só no hover e nunca de /#... fora da home; X do menu e cabeçalho opaco; WhatsApp com "o case"; /ia com 3 passos e modelo de relatório ilustrativo; Sobre alinhado ao currículo; CSP Report-Only com os domínios do Ads.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:47 -03:00
1 parent fc44a69fb3
commit 9e2d65a926
51 files changed
+1730 -420

No files matched your search

+84 -16
View File
@@ -98,21 +98,26 @@ it("presents the review as an offer: what the person receives, with no price or
expect(container.textContent).not.toMatch(/R\$|\d+ dias|em até \d+/);
});
it("shows the report list in a card on the right half of the hero, next to the headline", () => {
it("shows 'Como funciona em 3 passos' in a card on the right half of the hero, next to the headline, instead of repeating the deliverables", () => {
render(<AiPage />);
const card = screen.getByTestId("ia-review-card");
expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
const card = screen.getByTestId("ia-steps-card");
expect(
within(card)
.getAllByRole("listitem")
.map((item) => item.textContent?.replace(/^\d+/, "")),
within(card).getByText("Como funciona em 3 passos"),
).toBeInTheDocument();
const steps = within(card).getAllByRole("listitem");
expect(steps).toHaveLength(3);
expect(
steps.map((item) => item.querySelector("strong")?.textContent),
).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
"Conversa pelo WhatsApp.",
"Teste e avaliação.",
"Relatório e orçamento.",
]);
// a conversa vem primeiro e repete o aviso da página: nada de senhas nem acessos por mensagem
expect(steps[0]).toHaveTextContent(
"Não envie senhas nem acessos por mensagem.",
);
// o cartão divide a grade do hero com o título (metade direita no desktop) e não cria uma região de página
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
expect(hero).toContainElement(card);
@@ -120,13 +125,22 @@ it("shows the report list in a card on the right half of the hero, next to the h
"lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]",
);
expect(screen.queryByRole("complementary")).toBeNull();
// o título do cartão não repete o h2 "O que você recebe na revisão" da seção seguinte
// os quatro entregáveis ficam só na seção "O que você recebe na revisão": o hero não os repete
expect(within(card).queryByText("O que você recebe")).toBeNull();
for (const item of [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]) {
expect(within(card).queryByText(item)).toBeNull();
}
expect(
screen.getAllByRole("heading", { name: /O que você recebe/ }),
).toHaveLength(1);
});
it("keeps the secondary hero button inside the page: the portfolio cases are not reviews of AI-built apps", () => {
it("keeps the in-page secondary link and brings back a secondary link to the cases in the hero, both as text links", () => {
render(<AiPage />);
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
@@ -135,12 +149,66 @@ it("keeps the secondary hero button inside the page: the portfolio cases are not
});
expect(secondary).toHaveAttribute("href", "#ia-receber");
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
for (const link of within(hero as HTMLElement).getAllByRole("link")) {
expect(link.getAttribute("href")).not.toMatch(/^\/cases/);
const cases = within(hero as HTMLElement).getByRole("link", {
name: "Ver projetos entregues",
});
expect(cases).toHaveAttribute("href", "/cases");
// os dois são links de texto; o único botão cheio do hero é o do WhatsApp
expect(cases.className).toBe(secondary.className);
const hrefs = within(hero as HTMLElement)
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(hrefs.filter((href) => href?.startsWith("/cases"))).toEqual([
"/cases",
]);
});
it("shows the report model with three illustrative findings (flaw, severity, suggested fix), labelled as examples and with no client name", () => {
const { container } = render(<AiPage />);
const heading = screen.getByRole("heading", {
level: 2,
name: "Modelo do relatório",
});
const section = heading.closest("section") as HTMLElement;
expect(section).toHaveAttribute("id", "ia-modelo");
expect(section).toHaveTextContent("exemplos ilustrativos");
expect(section).toHaveTextContent("não são de nenhum cliente");
const findings = within(section).getAllByRole("article");
expect(findings).toHaveLength(3);
for (const [index, finding] of findings.entries()) {
expect(finding).toHaveTextContent(`Exemplo ilustrativo 0${index + 1}`);
expect(
Array.from(finding.querySelectorAll("dt")).map(
(term) => term.textContent,
),
).toEqual(["Falha", "Gravidade", "Correção sugerida"]);
for (const definition of finding.querySelectorAll("dd")) {
expect(definition.textContent?.trim()).not.toBe("");
}
}
// a gravidade é uma palavra só, de uma escala simples
expect(
screen.queryByRole("link", { name: "Ver projetos entregues" }),
).toBeNull();
findings.map((finding) => finding.querySelectorAll("dd")[1].textContent),
).toEqual(["Alta", "Alta", "Média"]);
// sem nome de cliente do portfólio, sem preço nem prazo
expect(section.textContent).not.toMatch(
/SEV|Lavação|SC Plus|AS Serviços|Apolo|R\$|\d+ dias|em até \d+/,
);
// o modelo vem depois do que se recebe e antes de "por que contratar"
const receive = container.querySelector("#ia-receber") as HTMLElement;
expect(
receive.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const hire = screen
.getByRole("heading", {
name: "Se a IA cria um sistema, por que contratar um desenvolvedor?",
})
.closest("section") as HTMLElement;
expect(
section.compareDocumentPosition(hire) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the incident news collapsed in a <details>, closed by default", () => {
+113 -6
View File
@@ -5,6 +5,7 @@ import {
primaryButton,
textLinkDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -53,6 +54,51 @@ const DELIVERABLES = [
},
];
/**
* Como a revisão anda, em três passos, no cartão do hero. Só o que o resto da página já diz: a conversa pelo WhatsApp
* (sem enviar senhas), o teste e a separação do que aproveitar do que refazer, e o orçamento por escrito. Sem prazo e
* sem preço: isso se combina na conversa.
*/
const STEPS = [
{
title: "Conversa pelo WhatsApp",
description:
"Você conta o que o app faz e o que ele precisa fazer. Não envie senhas nem acessos por mensagem.",
},
{
title: "Teste e avaliação",
description:
"Testo se uma pessoa consegue ver ou alterar os dados de outra e separo o que dá para aproveitar do que é melhor refazer.",
},
{
title: "Relatório e orçamento",
description:
"Você recebe o que precisa de correção e o orçamento por escrito para pôr o app em produção.",
},
];
/**
* Três achados de EXEMPLO do relatório, para mostrar como ele é organizado: a falha, a gravidade e a correção sugerida.
* São ilustrativos: não são de nenhum cliente nem de um app real, e a página os rotula assim.
*/
const REPORT_EXAMPLES = [
{
flaw: "Um usuário consegue abrir os dados de outro trocando o número no endereço da página.",
severity: "Alta",
fix: "Conferir no servidor se cada registro pertence a quem está logado e testar com contas de empresas diferentes.",
},
{
flaw: "A chave de acesso ao banco de dados está no código que o navegador baixa.",
severity: "Alta",
fix: "Levar a chave para o servidor, trocar a que ficou exposta e liberar no banco só o que cada perfil precisa.",
},
{
flaw: "Não há cópia de segurança com restauração testada.",
severity: "Média",
fix: "Ligar as cópias automáticas, separar o banco de testes do de produção e ensaiar a restauração.",
},
];
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
@@ -161,24 +207,27 @@ export default function AiPage() {
>
Pedir a revisão do meu app
</WhatsAppLink>
{/* Segue na própria página: os cases do portfólio não são revisões de app feito com IA. */}
<a href="#ia-receber" className={textLinkDark}>
Ver como a revisão funciona
</a>
{/* Link secundário para os cases: mostram como eu entrego, não são revisões de app feito com IA. */}
<HoverPrefetchLink href="/cases" className={textLinkDark}>
Ver projetos entregues
</HoverPrefetchLink>
</div>
</div>
<div
data-testid="ia-review-card"
data-testid="ia-steps-card"
className="min-w-0 border border-white/15 bg-white/[0.04] p-sp-6 sm:p-sp-8"
>
<p className="font-display text-xl font-semibold leading-7 text-white">
O que você recebe
Como funciona em 3 passos
</p>
<ol className="mt-sp-5 flex flex-col">
{DELIVERABLES.map((item, index) => (
{STEPS.map((step, index) => (
<li
key={item.title}
key={step.title}
className="flex items-start gap-sp-4 border-t border-white/12 py-sp-4 first:border-t-0 first:pt-0 last:pb-0"
>
<span
@@ -188,7 +237,10 @@ export default function AiPage() {
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 font-display text-base leading-6 text-white/90">
{item.title}
<strong className="font-semibold text-white">
{step.title}.
</strong>{" "}
{step.description}
</span>
</li>
))}
@@ -230,6 +282,61 @@ export default function AiPage() {
</div>
</section>
<section
id="ia-modelo"
className="ds-section"
aria-labelledby="ia-modelo-title"
>
<div className="ds-section-shell">
<h2 id="ia-modelo-title" className="ds-section-title max-w-[26ch]">
Modelo do relatório
</h2>
<p className="ds-copy max-w-[65ch]">
Cada falha encontrada entra no relatório com a gravidade e a
correção sugerida. Os três achados abaixo são{" "}
<strong>exemplos ilustrativos</strong> de como ele é organizado: não
são de nenhum cliente nem de um app real.
</p>
<ol className="mt-sp-8 grid gap-sp-6 lg:grid-cols-3">
{REPORT_EXAMPLES.map((example, index) => (
<li key={example.flaw} className="flex">
<article className="ds-card w-full">
<p className="ds-kicker">
Exemplo ilustrativo {String(index + 1).padStart(2, "0")}
</p>
<dl className="mt-sp-4 flex flex-col gap-sp-4">
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Falha
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.flaw}
</dd>
</div>
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Gravidade
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.severity}
</dd>
</div>
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Correção sugerida
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.fix}
</dd>
</div>
</dl>
</article>
</li>
))}
</ol>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-contratar-title">
<div className="ds-section-shell">
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">