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:
1 parent
fc44a69fb3
commit
9e2d65a926
51 files changed
+1730
-420
No files matched your search
+84
-16
@@ -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
@@ -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]">
|
||||
|
||||
Reference in new issue
Block a user