feat(home): ofertas que vendem o título, WhatsApp no hero e menu do celular acessível
Três ofertas (site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema existente) com miniatura e formulário com o tipo escolhido; botão principal do hero abre o WhatsApp; moldura da SEV no hero do celular; menu fecha ao navegar, Esc, foco preso incluindo a barra de cookies e espaço para ela; CinematicHome como componente de servidor com ilha cliente; /ia com H1 de oferta e cartão do que você recebe; botão flutuante em aside com transform; AVIF antes de WebP; copy sem repetir contato direto.
This commit is contained in:
1 parent
de6af74a0c
commit
f8545cd442
44 files changed
+2286
-571
No files matched your search
@@ -17,15 +17,18 @@ jest.mock("./Section", () => ({
|
||||
children,
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
aside,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
aside?: React.ReactNode;
|
||||
}) => (
|
||||
<section id={id}>
|
||||
<h2>{title}</h2>
|
||||
<p data-testid="about-description">{description}</p>
|
||||
{aside}
|
||||
{children}
|
||||
</section>
|
||||
@@ -92,7 +95,7 @@ describe("About", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Você fala direto com quem desenvolve",
|
||||
name: "Sem intermediários entre o pedido e o sistema",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -109,11 +112,23 @@ describe("About", () => {
|
||||
expect(container.textContent).not.toMatch(
|
||||
/protocolos de baixo nível|operabilidade|camada física|sinal físico/i,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Desde 2022 desenvolvo sistemas internos, aplicativos e integrações IoT na Apolo Energia.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// a seção abre pela credencial, logo depois do nome
|
||||
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
||||
"Evandro Fonseca Junior",
|
||||
);
|
||||
expect(screen.getByTestId("about-description").textContent).toMatch(
|
||||
/^Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia\./,
|
||||
);
|
||||
// a frase da Apolo aparece uma só vez
|
||||
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
|
||||
const { container } = render(<About />);
|
||||
|
||||
expect(container.textContent).not.toMatch(
|
||||
/contato direto|fala direto|falar direto|conversa comigo/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the location label stays on one line and the columns stretch to the same height", () => {
|
||||
|
||||
@@ -10,9 +10,9 @@ import { Section } from "./Section";
|
||||
const STORY_DATA = [
|
||||
{
|
||||
label: "Como eu trabalho",
|
||||
title: "Você fala direto com quem desenvolve",
|
||||
title: "Sem intermediários entre o pedido e o sistema",
|
||||
content:
|
||||
"Você conversa comigo sobre o negócio e acompanha as decisões ao longo da entrega, sem intermediários entre o seu pedido e o sistema.",
|
||||
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
|
||||
},
|
||||
{
|
||||
label: "Sem começar do zero",
|
||||
@@ -42,7 +42,7 @@ const PROFILE_POINTS = [
|
||||
{
|
||||
title: "Quem responde pelo projeto",
|
||||
description:
|
||||
"Você tem contato direto com quem decide a estrutura e desenvolve o sistema.",
|
||||
"Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,8 +54,8 @@ export default function About() {
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="Quem conduz o projeto"
|
||||
title="Evandro Fonseca Junior. Engenharia com contato direto."
|
||||
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
|
||||
title="Evandro Fonseca Junior"
|
||||
description="Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
||||
@@ -115,10 +115,6 @@ export default function About() {
|
||||
sistema. Ver tudo junto ajuda a decidir o que construir, o que
|
||||
conectar e o que dá para aproveitar.
|
||||
</p>
|
||||
<p className="ds-copy">
|
||||
Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
|
||||
IoT na Apolo Energia.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import Contact from "./Contact";
|
||||
import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact`), complemento de
|
||||
* Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento.
|
||||
*/
|
||||
|
||||
const subjectSelect = () =>
|
||||
screen.getByRole("combobox", {
|
||||
name: /tipo de projeto/i,
|
||||
}) as HTMLSelectElement;
|
||||
|
||||
const choose = (kind: unknown) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("Contact: tipo no endereço", () => {
|
||||
it.each([
|
||||
["/", "sem busca nenhuma (tipo ausente)"],
|
||||
["/?utm_source=instagram#contact", "só outros parâmetros (tipo ausente)"],
|
||||
["/?tipo=#contact", "tipo vazio"],
|
||||
["/?tipo=SITE#contact", "tipo com outra caixa (só vale em minúsculas)"],
|
||||
["/?tipo=constructor#contact", "nome herdado de todo objeto"],
|
||||
["/?tipo=outra-coisa#contact", "tipo desconhecido"],
|
||||
])("path: %s (%s) -> keeps the default option", (address) => {
|
||||
window.history.replaceState(null, "", address);
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
expect(subjectSelect()).toHaveValue(DEFAULT_CONTACT_SUBJECT);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/?tipo=site#contact", "Site para empresa"],
|
||||
["/?tipo=sistema#contact", "Sistemas web sob medida"],
|
||||
[
|
||||
"/?utm_source=x&tipo=revisao#contact",
|
||||
"Revisão ou diagnóstico de sistema",
|
||||
],
|
||||
// com o parâmetro repetido vale o primeiro
|
||||
["/?tipo=site&tipo=revisao#contact", "Site para empresa"],
|
||||
])("path: %s (valid type) -> selects %s", (address, option) => {
|
||||
window.history.replaceState(null, "", address);
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
expect(subjectSelect()).toHaveValue(option);
|
||||
});
|
||||
|
||||
it("path: valid type in the address, then an unknown one from the event -> the address choice stays", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=sistema#contact");
|
||||
render(<Contact />);
|
||||
|
||||
choose("desconhecido");
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
|
||||
});
|
||||
|
||||
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=sistema#contact");
|
||||
render(<Contact />);
|
||||
|
||||
choose("revisao");
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import Contact from "./Contact";
|
||||
import { CONTACT_KIND_EVENT } from "./contact-subject";
|
||||
|
||||
/**
|
||||
* O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara
|
||||
* (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página), mais a opção de revisão de sistema.
|
||||
*/
|
||||
|
||||
const subjectSelect = () =>
|
||||
screen.getByRole("combobox", {
|
||||
name: /tipo de projeto/i,
|
||||
}) as HTMLSelectElement;
|
||||
|
||||
const choose = (kind: unknown) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("Contact: opções do tipo de projeto", () => {
|
||||
it("lists 'Revisão ou diagnóstico de sistema' and keeps 'Ainda preciso definir' first and selected", () => {
|
||||
render(<Contact />);
|
||||
|
||||
const options = within(subjectSelect()).getAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"Ainda preciso definir",
|
||||
"Site para empresa",
|
||||
"Sistemas web sob medida",
|
||||
"Aplicativos",
|
||||
"Integrações e automações",
|
||||
"Evolução de um sistema existente",
|
||||
"Revisão ou diagnóstico de sistema",
|
||||
]);
|
||||
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||
});
|
||||
|
||||
it("puts the review type in the message that goes to WhatsApp and e-mail", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
|
||||
await user.selectOptions(
|
||||
subjectSelect(),
|
||||
"Revisão ou diagnóstico de sistema",
|
||||
);
|
||||
|
||||
const mail = screen.getByRole("link", { name: /e-mail/i });
|
||||
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
|
||||
"body",
|
||||
);
|
||||
expect(body).toContain(
|
||||
"Tipo de projeto: Revisão ou diagnóstico de sistema",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Contact: tipo escolhido pela oferta", () => {
|
||||
it.each([
|
||||
["site", "Site para empresa"],
|
||||
["sistema", "Sistemas web sob medida"],
|
||||
["revisao", "Revisão ou diagnóstico de sistema"],
|
||||
])("the offer event with %s selects %s", (kind, option) => {
|
||||
render(<Contact />);
|
||||
|
||||
choose(kind);
|
||||
|
||||
expect(subjectSelect()).toHaveValue(option);
|
||||
});
|
||||
|
||||
it("a later offer replaces the earlier choice, and a person's own pick is replaced by the offer too", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
|
||||
choose("site");
|
||||
await user.selectOptions(subjectSelect(), "Aplicativos");
|
||||
expect(subjectSelect()).toHaveValue("Aplicativos");
|
||||
choose("revisao");
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"desconhecido",
|
||||
"constructor",
|
||||
"__proto__",
|
||||
"",
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
])("an unknown type %p changes nothing", (kind) => {
|
||||
render(<Contact />);
|
||||
choose("sistema");
|
||||
|
||||
choose(kind);
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
|
||||
});
|
||||
|
||||
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=revisao#contact");
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
|
||||
});
|
||||
|
||||
it("ignores an unknown ?tipo= in the address and keeps the default", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=outra-coisa");
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||
});
|
||||
|
||||
it("stops listening when it unmounts", () => {
|
||||
const add = jest.spyOn(window, "addEventListener");
|
||||
const remove = jest.spyOn(window, "removeEventListener");
|
||||
const { unmount } = render(<Contact />);
|
||||
const registered = add.mock.calls.find(
|
||||
([type]) => type === CONTACT_KIND_EVENT,
|
||||
);
|
||||
expect(registered).toBeDefined();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith(CONTACT_KIND_EVENT, registered?.[1]);
|
||||
add.mockRestore();
|
||||
remove.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Contact: texto da seção", () => {
|
||||
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
|
||||
const { container } = render(<Contact />);
|
||||
|
||||
expect(screen.getByText("Prefere conversar direto?")).toBeInTheDocument();
|
||||
expect(container.textContent).not.toMatch(/fala direto|fale direto/i);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Como a conversa começa.",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+34
-23
@@ -6,6 +6,13 @@ import { useEffect, useState } from "react";
|
||||
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
||||
import { CONTACT_EMAIL } from "@/lib/site-navigation";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import {
|
||||
CONTACT_KIND_EVENT,
|
||||
CONTACT_KIND_PARAM,
|
||||
CONTACT_SUBJECT_OPTIONS,
|
||||
contactSubjectForKind,
|
||||
DEFAULT_CONTACT_SUBJECT,
|
||||
} from "./contact-subject";
|
||||
import "./conversion.css";
|
||||
import WhatsAppLink from "./WhatsAppLink";
|
||||
import { WhatsAppMark } from "./WhatsAppMark";
|
||||
@@ -54,19 +61,9 @@ function buildMailtoUrl(message: string) {
|
||||
const fieldClassName =
|
||||
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]";
|
||||
const labelClassName =
|
||||
"block font-display text-[15px] md:text-sm font-medium text-white/85";
|
||||
"block font-display text-[15px] font-medium text-white/85";
|
||||
const errorClassName =
|
||||
"mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[#ff9d94]";
|
||||
|
||||
/** "Ainda preciso definir" vem primeiro e é o padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
|
||||
const SUBJECT_OPTIONS = [
|
||||
"Ainda preciso definir",
|
||||
"Site para empresa",
|
||||
"Sistemas web sob medida",
|
||||
"Aplicativos",
|
||||
"Integrações e automações",
|
||||
"Evolução de um sistema existente",
|
||||
];
|
||||
"mt-sp-2 font-display text-[15px] leading-6 text-[#ff9d94]";
|
||||
|
||||
/** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */
|
||||
const DIRECT_MESSAGE =
|
||||
@@ -161,11 +158,25 @@ export default function Contact() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "Ainda preciso definir",
|
||||
subject: DEFAULT_CONTACT_SUBJECT as string,
|
||||
message: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
|
||||
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
|
||||
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
|
||||
useEffect(() => {
|
||||
const choose = (kind: string | null | undefined) => {
|
||||
const subject = contactSubjectForKind(kind);
|
||||
if (subject) setFormData((current) => ({ ...current, subject }));
|
||||
};
|
||||
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
|
||||
const onChoose = (event: Event) =>
|
||||
choose((event as CustomEvent<string | undefined>).detail);
|
||||
window.addEventListener(CONTACT_KIND_EVENT, onChoose);
|
||||
return () => window.removeEventListener(CONTACT_KIND_EVENT, onChoose);
|
||||
}, []);
|
||||
|
||||
const updateField = (field: ContactField | "subject", value: string) => {
|
||||
setFormData((current) => ({ ...current, [field]: value }));
|
||||
if (field !== "subject" && errors[field]) {
|
||||
@@ -222,9 +233,9 @@ export default function Contact() {
|
||||
</p>
|
||||
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||
<h3 className="font-display text-lg font-semibold text-white">
|
||||
Você fala direto com quem desenvolve.
|
||||
Como a conversa começa.
|
||||
</h3>
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] md:text-sm leading-7 text-white/65">
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] leading-7 text-white/65">
|
||||
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||
escopo, prazo e proposta vêm depois.
|
||||
</p>
|
||||
@@ -238,10 +249,10 @@ export default function Contact() {
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display">
|
||||
<span className="block text-[15px] md:text-sm font-semibold text-white group-hover:text-[#25d366]">
|
||||
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
|
||||
Prefere conversar direto?
|
||||
</span>
|
||||
<span className="mt-1 block text-[15px] md:text-sm text-white/65">
|
||||
<span className="mt-1 block text-[15px] text-white/65">
|
||||
WhatsApp · {whatsAppDisplay}
|
||||
</span>
|
||||
</span>
|
||||
@@ -256,7 +267,7 @@ export default function Contact() {
|
||||
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-all font-display text-[15px] md:text-sm">
|
||||
<span className="min-w-0 break-all font-display text-[15px]">
|
||||
{CONTACT_EMAIL}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
@@ -265,7 +276,7 @@ export default function Contact() {
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] md:text-sm leading-6 text-white/55">
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/55">
|
||||
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
||||
</p>
|
||||
@@ -288,7 +299,7 @@ export default function Contact() {
|
||||
>
|
||||
Prepare sua mensagem
|
||||
</h3>
|
||||
<p className="mt-1 font-display text-[15px] md:text-sm leading-6 text-white/65">
|
||||
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
|
||||
Você revisa no WhatsApp antes de enviar.
|
||||
</p>
|
||||
</div>
|
||||
@@ -339,7 +350,7 @@ export default function Contact() {
|
||||
onChange={(e) => updateField("subject", e.target.value)}
|
||||
className={`${fieldClassName} [color-scheme:dark]`}
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((option) => (
|
||||
{CONTACT_SUBJECT_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
@@ -386,7 +397,7 @@ export default function Contact() {
|
||||
</p>
|
||||
<p
|
||||
id="contact-privacy-note"
|
||||
className="font-display text-[15px] md:text-xs leading-6 text-white/55"
|
||||
className="font-display text-[15px] leading-6 text-white/55"
|
||||
>
|
||||
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
|
||||
para você revisar. Não inclua senhas nem dados pessoais de
|
||||
@@ -396,7 +407,7 @@ export default function Contact() {
|
||||
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
|
||||
<noscript>
|
||||
<p className="font-display text-base leading-7 text-white/80">
|
||||
Para enviar sua mensagem, fale direto comigo:
|
||||
Para enviar sua mensagem, use um destes contatos:
|
||||
</p>
|
||||
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import ContactOfferLink from "./ContactOfferLink";
|
||||
import { CONTACT_KIND_EVENT } from "./contact-subject";
|
||||
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => (
|
||||
<a data-prefetch={String(prefetch)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ContactOfferLink", () => {
|
||||
it("links to the form with the type in the address, and prefetches nothing until hover or focus", () => {
|
||||
render(
|
||||
<ContactOfferLink kind="sistema" className="botao">
|
||||
Começar pelo sistema
|
||||
</ContactOfferLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Começar pelo sistema" });
|
||||
expect(link).toHaveAttribute("href", "/?tipo=sistema#contact");
|
||||
expect(link).toHaveClass("botao");
|
||||
expect(link).toHaveAttribute("data-prefetch", "false");
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link).toHaveAttribute("data-prefetch", "null");
|
||||
});
|
||||
|
||||
it("tells the form which type was chosen, through a window event, when it is clicked", () => {
|
||||
const heard: unknown[] = [];
|
||||
const listener = (event: Event) =>
|
||||
heard.push((event as CustomEvent).detail);
|
||||
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||
try {
|
||||
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||
expect(heard).toEqual(["revisao"]);
|
||||
} finally {
|
||||
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||
}
|
||||
});
|
||||
|
||||
it("still tells the form when another handler already cancelled the click (the home scroll manager does that to scroll by itself)", () => {
|
||||
const heard: unknown[] = [];
|
||||
const listener = (event: Event) =>
|
||||
heard.push((event as CustomEvent).detail);
|
||||
const cancel = (event: Event) => event.preventDefault();
|
||||
window.addEventListener(CONTACT_KIND_EVENT, listener);
|
||||
document.addEventListener("click", cancel, true);
|
||||
try {
|
||||
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
|
||||
expect(heard).toEqual(["site"]);
|
||||
} finally {
|
||||
window.removeEventListener(CONTACT_KIND_EVENT, listener);
|
||||
document.removeEventListener("click", cancel, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CONTACT_KIND_EVENT,
|
||||
type ContactKind,
|
||||
contactKindHref,
|
||||
} from "./contact-subject";
|
||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||
|
||||
/**
|
||||
* Botão de uma oferta da home: leva ao formulário de contato com o tipo de projeto já escolhido.
|
||||
* Na própria home o clique rola até o formulário (useCinematicScroll) sem trocar a busca do endereço, então o
|
||||
* tipo também segue por evento; o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
|
||||
*/
|
||||
export default function ContactOfferLink({
|
||||
kind,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
kind: ContactKind;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HoverPrefetchLink
|
||||
href={contactKindHref(kind)}
|
||||
className={className}
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</HoverPrefetchLink>
|
||||
);
|
||||
}
|
||||
@@ -61,11 +61,21 @@ describe("message by route", () => {
|
||||
],
|
||||
[
|
||||
"/cases/sev-modaintima",
|
||||
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
|
||||
"Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
|
||||
],
|
||||
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
|
||||
["/", "Quero conversar sobre um projeto."],
|
||||
["/servicos", "Quero conversar sobre um projeto."],
|
||||
// a mesma frase do botão do hero e do cabeçalho de cada página
|
||||
["/", "Quero conversar sobre um site ou sistema para a minha empresa."],
|
||||
["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."],
|
||||
[
|
||||
"/cases",
|
||||
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
|
||||
],
|
||||
[
|
||||
"/curriculo",
|
||||
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
|
||||
],
|
||||
["/privacidade", "Quero conversar sobre um projeto."],
|
||||
["/servicos/inexistente", "Quero conversar sobre um projeto."],
|
||||
])("on %s", (pathname, sentence) => {
|
||||
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||
@@ -112,7 +122,7 @@ describe("page changes", () => {
|
||||
]);
|
||||
expect(floatingLink()).toBeInTheDocument();
|
||||
expect(messageOf(floatingLink())).toContain(
|
||||
"Quero conversar sobre um projeto.",
|
||||
"Quero conversar sobre um site ou sistema para a minha empresa.",
|
||||
);
|
||||
|
||||
// sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço
|
||||
|
||||
@@ -119,6 +119,31 @@ it("lifts itself by the height of the open cookie bar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
|
||||
render(<Page />);
|
||||
setVisibility(false, false);
|
||||
|
||||
const className = floatingLink()?.parentElement?.className ?? "";
|
||||
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
|
||||
expect(className).toContain("transition-transform");
|
||||
expect(className).toContain("motion-reduce:transition-none");
|
||||
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
|
||||
expect(className).not.toContain("transition-[bottom]");
|
||||
expect(className).toContain(
|
||||
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
||||
);
|
||||
});
|
||||
|
||||
it("sits in a named complementary region (the 'region' landmark rule), which only exists while the shortcut shows", () => {
|
||||
render(<Page />);
|
||||
expect(screen.queryByRole("complementary")).toBeNull();
|
||||
|
||||
setVisibility(false, false);
|
||||
const region = screen.getByRole("complementary", { name: "Contato rápido" });
|
||||
expect(region.tagName).toBe("ASIDE");
|
||||
expect(region).toContainElement(floatingLink());
|
||||
});
|
||||
|
||||
it("reports the click as a floating WhatsApp handoff", () => {
|
||||
render(<Page />);
|
||||
setVisibility(false, false);
|
||||
|
||||
@@ -14,6 +14,8 @@ const CONTACT_SECTION_ID = "contact";
|
||||
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
|
||||
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
|
||||
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
|
||||
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
|
||||
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
|
||||
*/
|
||||
export default function FloatingWhatsApp({
|
||||
contexts,
|
||||
@@ -57,7 +59,10 @@ export default function FloatingWhatsApp({
|
||||
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] z-[8900] transition-[bottom] duration-200 motion-reduce:transition-none md:hidden">
|
||||
<aside
|
||||
aria-label="Contato rápido"
|
||||
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
|
||||
>
|
||||
<WhatsAppLink
|
||||
placement="floating"
|
||||
service={subject}
|
||||
@@ -66,6 +71,6 @@ export default function FloatingWhatsApp({
|
||||
<WhatsAppMark className="h-7 w-7" />
|
||||
<span className="sr-only">Conversar no WhatsApp</span>
|
||||
</WhatsAppLink>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export default function Footer({
|
||||
<li>
|
||||
<Link
|
||||
href="/curriculo"
|
||||
className={`${linkClassName} !text-[15px] md:!text-[14px] !text-[var(--meta)]`}
|
||||
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
|
||||
>
|
||||
Currículo
|
||||
</Link>
|
||||
@@ -151,7 +151,7 @@ export default function Footer({
|
||||
para 4:1) e o resto do logo, já quase preto, quase não muda */}
|
||||
<Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
|
||||
</Link>
|
||||
<p className="font-display text-[15px] md:text-[14px] leading-6 text-[var(--meta)]">
|
||||
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
|
||||
© {currentYear} Evandro Fonseca Junior. Todos os direitos
|
||||
reservados.
|
||||
</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type AnalyticsConsent,
|
||||
CONSENT_STORAGE_KEY,
|
||||
COOKIE_BAR_ID,
|
||||
GA_DISABLE_KEY,
|
||||
GA_MEASUREMENT_ID,
|
||||
GOOGLE_ADS_ID,
|
||||
@@ -222,6 +223,7 @@ function GoogleAnalyticsConsent() {
|
||||
{barOpen && (
|
||||
<section
|
||||
ref={barRef}
|
||||
id={COOKIE_BAR_ID}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Cookies e privacidade"
|
||||
@@ -234,7 +236,7 @@ function GoogleAnalyticsConsent() {
|
||||
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
|
||||
<p
|
||||
id="analytics-consent-description"
|
||||
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85 sm:text-sm"
|
||||
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
|
||||
>
|
||||
Usamos métricas de navegação do Google só se você permitir.{" "}
|
||||
<a
|
||||
@@ -273,7 +275,7 @@ function GoogleAnalyticsConsent() {
|
||||
<p
|
||||
role="alert"
|
||||
aria-label="Falha ao salvar o consentimento"
|
||||
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] md:text-[13px] leading-5 text-[#ff9d94]"
|
||||
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] leading-5 text-[#ff9d94]"
|
||||
>
|
||||
{consentError}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||
import { Header } from "./Header";
|
||||
|
||||
/**
|
||||
* O menu do celular aberto (complemento de Header.test.tsx): botão de contato acima da barra de cookies, fecha ao
|
||||
* trocar de rota e ao tocar no logo, deixa o conteúdo e o rodapé inertes, trava a rolagem, prende o foco e fecha
|
||||
* com Esc (ouvido no document), devolvendo o foco ao botão do menu.
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => (
|
||||
<a data-prefetch={String(prefetch)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
/** O cabeçalho com as duas partes da página que o menu torna inertes: o conteúdo e o rodapé (ids do layout). */
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
<button type="button">Botão do conteúdo</button>
|
||||
</div>
|
||||
<div id="site-footer">
|
||||
<a href="#privacidade">Privacidade</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const main = () => document.getElementById("main-content") as HTMLElement;
|
||||
const footer = () => document.getElementById("site-footer") as HTMLElement;
|
||||
const menuButton = () =>
|
||||
screen.getByRole("button", { name: /^(Abrir|Fechar) menu$/ });
|
||||
const openMenu = () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const mobileMenu = () =>
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" });
|
||||
|
||||
/** Os controles que o Tab percorre com o menu aberto: logo, botão do menu, os cinco links e o botão de contato do painel. */
|
||||
function trapOrder() {
|
||||
const panel = within(mobileMenu() as HTMLElement);
|
||||
return [
|
||||
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
|
||||
menuButton(),
|
||||
...panel.getAllByRole("link"),
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(usePathname).mockReturnValue("/");
|
||||
document.documentElement.style.removeProperty("overflow");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.style.removeProperty("overflow");
|
||||
});
|
||||
|
||||
describe("Header: menu do celular e a barra de cookies", () => {
|
||||
it("adds the height of the cookie bar to the bottom spacing, so the contact button stays above it", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
const panel = (mobileMenu() as HTMLElement).parentElement
|
||||
?.parentElement as HTMLElement;
|
||||
expect(panel.className).toContain(
|
||||
"pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: o menu fecha sozinho", () => {
|
||||
it("closes when the route changes", () => {
|
||||
const { rerender } = render(<Page />);
|
||||
openMenu();
|
||||
expect(mobileMenu()).toBeInTheDocument();
|
||||
|
||||
jest.mocked(usePathname).mockReturnValue("/cases");
|
||||
rerender(<Page />);
|
||||
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
|
||||
expect(main()).not.toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("stays open on a re-render that keeps the same route", () => {
|
||||
const { rerender } = render(<Page />);
|
||||
openMenu();
|
||||
|
||||
rerender(<Page />);
|
||||
|
||||
expect(mobileMenu()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes when the logo is tapped", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
|
||||
);
|
||||
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(main()).not.toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("closes when the window grows to the desktop layout, where the menu button no longer exists", () => {
|
||||
const original = window.matchMedia;
|
||||
let onChange: ((event: { matches: boolean }) => void) | undefined;
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: (_type: string, listener: typeof onChange) => {
|
||||
onChange = listener;
|
||||
},
|
||||
removeEventListener: jest.fn(),
|
||||
}));
|
||||
try {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(min-width: 1280px)");
|
||||
|
||||
act(() => onChange?.({ matches: false }));
|
||||
expect(mobileMenu()).toBeInTheDocument();
|
||||
|
||||
act(() => onChange?.({ matches: true }));
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: página inerte e sem rolagem com o menu aberto", () => {
|
||||
it("makes the content and the footer inert and locks the page scroll while the menu is open, and undoes both on close", () => {
|
||||
render(<Page />);
|
||||
expect(main()).not.toHaveAttribute("inert");
|
||||
|
||||
openMenu();
|
||||
expect(main()).toHaveAttribute("inert");
|
||||
expect(footer()).toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||
// o próprio cabeçalho nunca fica inerte
|
||||
expect(screen.getByRole("banner")).not.toHaveAttribute("inert");
|
||||
|
||||
fireEvent.click(menuButton());
|
||||
expect(main()).not.toHaveAttribute("inert");
|
||||
expect(footer()).not.toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("gives back the overflow the page already had", () => {
|
||||
document.documentElement.style.overflow = "scroll";
|
||||
render(<Page />);
|
||||
|
||||
openMenu();
|
||||
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||
fireEvent.click(menuButton());
|
||||
|
||||
expect(document.documentElement.style.overflow).toBe("scroll");
|
||||
});
|
||||
|
||||
it("works on a page without a footer wrapper (the parts that exist are still made inert)", () => {
|
||||
render(<Header />);
|
||||
document.body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<div id="main-content"></div>',
|
||||
);
|
||||
try {
|
||||
openMenu();
|
||||
expect(main()).toHaveAttribute("inert");
|
||||
} finally {
|
||||
main().remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("undoes everything when the header unmounts with the menu open", () => {
|
||||
const { unmount } = render(<Page />);
|
||||
openMenu();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("releases the page before any other click handler of the document runs, so a link to #id finds its target focusable", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
const states: Array<{ inert: boolean; overflow: string }> = [];
|
||||
const record = () =>
|
||||
states.push({
|
||||
inert: main().hasAttribute("inert"),
|
||||
overflow: document.documentElement.style.overflow,
|
||||
});
|
||||
// o gerenciador de âncoras da home escuta o clique no document, na fase de captura
|
||||
document.addEventListener("click", record, true);
|
||||
try {
|
||||
fireEvent.click(
|
||||
within(mobileMenu() as HTMLElement).getByRole("link", {
|
||||
name: "Sobre",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
document.removeEventListener("click", record, true);
|
||||
}
|
||||
|
||||
expect(states).toEqual([{ inert: false, overflow: "" }]);
|
||||
expect(mobileMenu()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not release the page for a click outside the header", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
// com o conteúdo inerte o clique real nem chega; o teste dispara direto para provar que o ouvinte só reage ao cabeçalho
|
||||
fireEvent.click(footer().querySelector("a") as HTMLElement);
|
||||
|
||||
expect(main()).toHaveAttribute("inert");
|
||||
expect(mobileMenu()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: Esc e foco com o menu aberto", () => {
|
||||
it("closes with Escape heard on the document, wherever the focus is, and puts the focus back on the menu button", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
act(() => document.body.focus());
|
||||
|
||||
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false);
|
||||
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(main()).not.toHaveAttribute("inert");
|
||||
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
|
||||
expect(menuButton()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("ignores Escape while the menu is closed", () => {
|
||||
render(<Page />);
|
||||
|
||||
// sem menu aberto o ouvinte nem existe: o evento segue sem ser cancelado
|
||||
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(true);
|
||||
expect(mobileMenu()).toBeNull();
|
||||
});
|
||||
|
||||
it("traps Tab: from the last control it goes to the first, and Shift+Tab from the first goes to the last", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
const order = trapOrder();
|
||||
const first = order[0];
|
||||
const last = order[order.length - 1];
|
||||
// o botão de contato do painel é o último; o da barra de desktop e a navegação de desktop ficam fora do ciclo
|
||||
expect(last).toHaveAccessibleName("Falar sobre meu projeto");
|
||||
expect(order).toHaveLength(1 + 1 + 5 + 1);
|
||||
|
||||
act(() => last.focus());
|
||||
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
|
||||
expect(first).toHaveFocus();
|
||||
|
||||
expect(fireEvent.keyDown(first, { key: "Tab", shiftKey: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(last).toHaveFocus();
|
||||
});
|
||||
|
||||
it("lets Tab move normally between two controls of the cycle", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
const order = trapOrder();
|
||||
act(() => order[2].focus());
|
||||
|
||||
expect(fireEvent.keyDown(order[2], { key: "Tab" })).toBe(true);
|
||||
expect(fireEvent.keyDown(order[2], { key: "Tab", shiftKey: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("brings the focus back into the cycle when it is outside it, in the direction of the key", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
const order = trapOrder();
|
||||
act(() => document.body.focus());
|
||||
|
||||
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(false);
|
||||
expect(order[0]).toHaveFocus();
|
||||
|
||||
act(() => document.body.focus());
|
||||
expect(
|
||||
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
|
||||
).toBe(false);
|
||||
expect(order[order.length - 1]).toHaveFocus();
|
||||
});
|
||||
|
||||
it("does not trap Tab while the menu is closed", () => {
|
||||
render(<Page />);
|
||||
act(() => document.body.focus());
|
||||
|
||||
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
|
||||
});
|
||||
|
||||
it("closes with the menu button and keeps the focus on it", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
act(() => menuButton().focus());
|
||||
|
||||
fireEvent.click(menuButton());
|
||||
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(menuButton()).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
|
||||
/** Como no layout: a barra vem antes do cabeçalho e não fica inerte. */
|
||||
function PageWithCookieBar() {
|
||||
return (
|
||||
<>
|
||||
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
|
||||
<button type="button">Aceitar</button>
|
||||
<button type="button">Recusar</button>
|
||||
</section>
|
||||
<Page />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it("cycles through Aceitar and Recusar before the header, and back from the contact button", () => {
|
||||
render(<PageWithCookieBar />);
|
||||
openMenu();
|
||||
const accept = screen.getByRole("button", { name: "Aceitar" });
|
||||
const decline = screen.getByRole("button", { name: "Recusar" });
|
||||
const order = trapOrder();
|
||||
const last = order[order.length - 1];
|
||||
expect(accept).not.toHaveAttribute("inert");
|
||||
|
||||
// do botão de contato (o último do cabeçalho) o Tab vai para Aceitar, em vez de dar a volta no cabeçalho
|
||||
act(() => last.focus());
|
||||
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
|
||||
expect(accept).toHaveFocus();
|
||||
|
||||
// entre Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
|
||||
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
|
||||
act(() => decline.focus());
|
||||
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
|
||||
|
||||
// Shift+Tab de Aceitar (o primeiro do ciclo) volta ao botão de contato
|
||||
act(() => accept.focus());
|
||||
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(last).toHaveFocus();
|
||||
});
|
||||
|
||||
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
|
||||
render(<PageWithCookieBar />);
|
||||
openMenu();
|
||||
act(() => screen.getByRole("button", { name: "Recusar" }).focus());
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mobileMenu()).toBeNull();
|
||||
expect(menuButton()).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Header } from "./Header";
|
||||
|
||||
@@ -33,7 +33,8 @@ function setScrollY(value: number) {
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole("banner");
|
||||
const isDark = () => header().className.includes("backdrop-blur-md");
|
||||
/** Rolada: borda visível e fundo a 95% (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
|
||||
const isDark = () => header().className.includes("border-white/12");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(usePathname).mockReturnValue("/");
|
||||
@@ -53,7 +54,7 @@ describe("Header: rolagem", () => {
|
||||
expect(header()).toHaveClass("border-transparent");
|
||||
});
|
||||
|
||||
it("path: scrolling down and back to the top -> blurred bar, then the plain one again", () => {
|
||||
it("path: scrolling down and back to the top -> bordered bar, then the plain one again, never with a backdrop blur", () => {
|
||||
render(<Header />);
|
||||
|
||||
setScrollY(120);
|
||||
@@ -62,6 +63,7 @@ describe("Header: rolagem", () => {
|
||||
});
|
||||
expect(isDark()).toBe(true);
|
||||
expect(header()).not.toHaveClass("border-transparent");
|
||||
expect(header().className).not.toContain("backdrop-blur");
|
||||
|
||||
// outro evento de rolagem sem mudar de lado: nada muda
|
||||
setScrollY(300);
|
||||
@@ -77,7 +79,7 @@ describe("Header: rolagem", () => {
|
||||
expect(isDark()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: page already scrolled when the header mounts -> starts with the blurred bar", () => {
|
||||
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
|
||||
setScrollY(500);
|
||||
render(<Header />);
|
||||
|
||||
@@ -99,9 +101,12 @@ describe("Header: teclado no menu do celular", () => {
|
||||
render(<Header />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
|
||||
// Tab entre dois controles do meio da lista: segue o caminho normal (o foco só é preso nas pontas)
|
||||
const middle = within(menu).getByRole("link", { name: "Serviços" });
|
||||
act(() => middle.focus());
|
||||
|
||||
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
|
||||
expect(fireEvent.keyDown(menu, { key: "Tab" })).toBe(true);
|
||||
expect(fireEvent.keyDown(middle, { key: "Tab" })).toBe(true);
|
||||
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||
@@ -142,6 +147,125 @@ describe("Header: teclado no menu do celular", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: menu aberto, caminhos de borda do foco, do clique e da janela", () => {
|
||||
/** O cabeçalho com o conteúdo da página que o menu aberto torna inerte (id do layout). */
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
<button type="button">Botão do conteúdo</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const openMenu = () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const content = () => document.getElementById("main-content") as HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.style.removeProperty("overflow");
|
||||
});
|
||||
|
||||
it("path: Tab with no control left to focus in the header -> the key is not blocked and the focus does not move", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
const banner = screen.getByRole("banner");
|
||||
for (const button of banner.querySelectorAll("button"))
|
||||
button.setAttribute("disabled", "");
|
||||
for (const link of banner.querySelectorAll("a"))
|
||||
link.removeAttribute("href");
|
||||
act(() => document.body.focus());
|
||||
|
||||
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
|
||||
expect(
|
||||
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
|
||||
).toBe(true);
|
||||
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: click whose target is not an element (the document itself) -> the page stays locked", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
fireEvent.click(document);
|
||||
|
||||
expect(content()).toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||
});
|
||||
|
||||
it("path: click on something inside the header that is not a link -> the page stays locked", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||
);
|
||||
|
||||
expect(content()).toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("hidden");
|
||||
});
|
||||
|
||||
it("path: click on a link inside the header -> the page is released at once", () => {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||
).getByRole("link", { name: "Cases" }),
|
||||
);
|
||||
|
||||
expect(content()).not.toHaveAttribute("inert");
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("path: no matchMedia at all in the browser -> the menu still opens and closes", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
try {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||
).toBeNull();
|
||||
expect(content()).not.toHaveAttribute("inert");
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("path: matchMedia returns an object with no change listener (older browsers) -> the menu still opens and closes", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = jest.fn().mockReturnValue({
|
||||
matches: false,
|
||||
}) as unknown as typeof window.matchMedia;
|
||||
try {
|
||||
render(<Page />);
|
||||
openMenu();
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||
).toBeNull();
|
||||
expect(content()).not.toHaveAttribute("inert");
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header: rota ausente", () => {
|
||||
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
|
||||
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
|
||||
|
||||
@@ -213,8 +213,8 @@ describe("Header", () => {
|
||||
expect(headerLinks[3]).toHaveAttribute("data-prefetch", "null");
|
||||
});
|
||||
|
||||
it("sends the header button to the home form on the home and on other pages", () => {
|
||||
for (const pathname of ["/", "/cases", "/ia", "/servicos"]) {
|
||||
it("sends the header button to the home form on the home and on pages without a subject", () => {
|
||||
for (const pathname of ["/", "/privacidade"]) {
|
||||
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||
const { unmount } = render(<Header whatsAppContexts={contexts} />);
|
||||
|
||||
@@ -227,6 +227,32 @@ describe("Header", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
|
||||
[
|
||||
"/cases",
|
||||
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
|
||||
],
|
||||
["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."],
|
||||
[
|
||||
"/curriculo",
|
||||
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
|
||||
],
|
||||
])(
|
||||
"opens WhatsApp with the page subject in the header button on %s",
|
||||
(pathname, sentence) => {
|
||||
jest.mocked(usePathname).mockReturnValue(pathname);
|
||||
render(<Header whatsAppContexts={contexts} />);
|
||||
|
||||
const contact = screen.getByRole("link", {
|
||||
name: "Falar sobre meu projeto",
|
||||
});
|
||||
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||
expect(contact).toHaveAttribute("target", "_blank");
|
||||
expect(whatsAppHref(contact)).toContain(sentence);
|
||||
},
|
||||
);
|
||||
|
||||
it("opens WhatsApp with the service in the header button on a service page", () => {
|
||||
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
|
||||
render(<Header whatsAppContexts={contexts} />);
|
||||
@@ -252,7 +278,7 @@ describe("Header", () => {
|
||||
expect(buttons).toHaveLength(2);
|
||||
for (const button of buttons) {
|
||||
expect(whatsAppHref(button)).toContain(
|
||||
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
|
||||
"Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+122
-21
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
import {
|
||||
isServiceOrCasePath,
|
||||
headerOpensWhatsApp,
|
||||
type WhatsAppContexts,
|
||||
whatsAppSubjectForPath,
|
||||
} from "@/lib/whatsapp";
|
||||
@@ -15,10 +15,46 @@ import WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
const MENU_ID = "site-navigation-menu";
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
|
||||
const INERT_IDS = ["main-content", "site-footer"];
|
||||
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
|
||||
const DESKTOP_QUERY = "(min-width: 1280px)";
|
||||
|
||||
/**
|
||||
* Botão de contato do cabeçalho. Nas páginas de serviço e de case ele abre o WhatsApp já com o assunto da página;
|
||||
* nas demais leva ao formulário da home.
|
||||
* Controles que o Tab percorre com o menu aberto: Aceitar e Recusar da barra de cookies, quando ela está aberta (ela
|
||||
* vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do menu, links e botão de contato do painel.
|
||||
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
|
||||
*/
|
||||
function menuFocusables(header: HTMLElement) {
|
||||
const cookieBar = document.getElementById(COOKIE_BAR_ID);
|
||||
const roots = cookieBar ? [cookieBar, header] : [header];
|
||||
return roots
|
||||
.flatMap((root) =>
|
||||
Array.from(
|
||||
root.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
|
||||
),
|
||||
)
|
||||
.filter((element) => !element.closest("[data-menu-trap-skip]"));
|
||||
}
|
||||
|
||||
/** Tab (e Shift+Tab) com o menu aberto: o foco dá a volta entre o cabeçalho e a barra de cookies, sem sair para a página inerte. */
|
||||
function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
||||
const focusables = menuFocusables(header);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
|
||||
const active = document.activeElement;
|
||||
const leaving =
|
||||
active === edge || !focusables.some((element) => element === active);
|
||||
if (!leaving || !opposite) return;
|
||||
|
||||
event.preventDefault();
|
||||
opposite.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
||||
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
|
||||
*/
|
||||
function ContactCta({
|
||||
className,
|
||||
@@ -46,9 +82,13 @@ function ContactCta({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href="/#contact" className={className} onClick={onNavigate}>
|
||||
<HoverPrefetchLink
|
||||
href="/#contact"
|
||||
className={className}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{CTA_LABEL}
|
||||
</Link>
|
||||
</HoverPrefetchLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,12 +99,19 @@ export function Header({
|
||||
whatsAppContexts?: WhatsAppContexts;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const viaWhatsApp = isServiceOrCasePath(pathname);
|
||||
const viaWhatsApp = headerOpensWhatsApp(pathname);
|
||||
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Trocou a rota: o menu fecha (o cabeçalho vive no layout e não remonta entre as páginas).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só dispara o fechamento a cada página
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasScrolled = false;
|
||||
const updateScrolled = () => {
|
||||
@@ -81,19 +128,72 @@ export function Header({
|
||||
|
||||
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
// Menu aberto: o conteúdo e o rodapé ficam inertes, a página não rola, o Tab não sai do cabeçalho e o Esc fecha.
|
||||
useEffect(() => {
|
||||
const header = headerRef.current;
|
||||
const menuButton = menuButtonRef.current;
|
||||
if (!mobileMenuOpen || !header || !menuButton) return;
|
||||
|
||||
event.preventDefault();
|
||||
closeMobileMenu();
|
||||
menuButtonRef.current?.focus();
|
||||
};
|
||||
const root = document.documentElement;
|
||||
const inertTargets = INERT_IDS.flatMap((id) => {
|
||||
const element = document.getElementById(id);
|
||||
return element ? [element] : [];
|
||||
});
|
||||
for (const element of inertTargets) element.setAttribute("inert", "");
|
||||
const previousOverflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
|
||||
// Devolve a página ao normal na hora, sem esperar o React: um link do menu para #id precisa achar o destino
|
||||
// focável quando o gerenciador de âncoras da home (useHomeFragmentNavigation) tenta focá-lo no clique.
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const element of inertTargets) element.removeAttribute("inert");
|
||||
root.style.overflow = previousOverflow;
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab") return trapTab(event, header);
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
event.preventDefault();
|
||||
release();
|
||||
setMobileMenuOpen(false);
|
||||
menuButton.focus();
|
||||
};
|
||||
// Na fase de captura da janela, antes do document: roda antes do gerenciador de âncoras da home.
|
||||
const onLinkClick = (event: MouseEvent) => {
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (link && header.contains(link)) release();
|
||||
};
|
||||
const desktop = window.matchMedia?.(DESKTOP_QUERY);
|
||||
const onViewportChange = (event: MediaQueryListEvent) => {
|
||||
if (!event.matches) return;
|
||||
release();
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("click", onLinkClick, true);
|
||||
desktop?.addEventListener?.("change", onViewportChange);
|
||||
return () => {
|
||||
release();
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("click", onLinkClick, true);
|
||||
desktop?.removeEventListener?.("change", onViewportChange);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
scrolled
|
||||
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||
? "border-white/12 bg-[var(--ink)]/95"
|
||||
: "border-transparent bg-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
@@ -102,12 +202,14 @@ export function Header({
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||
</HoverPrefetchLink>
|
||||
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
data-menu-trap-skip=""
|
||||
className="hidden items-center xl:flex"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
@@ -115,7 +217,7 @@ export function Header({
|
||||
<li key={item.href}>
|
||||
<HoverPrefetchLink
|
||||
href={item.href}
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</HoverPrefetchLink>
|
||||
@@ -125,9 +227,9 @@ export function Header({
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="hidden xl:block">
|
||||
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||
<ContactCta
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
placement="header"
|
||||
viaWhatsApp={viaWhatsApp}
|
||||
subject={subject}
|
||||
@@ -141,7 +243,6 @@ export function Header({
|
||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<span
|
||||
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
@@ -158,13 +259,13 @@ export function Header({
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom))] lg:px-sp-8 xl:hidden">
|
||||
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
|
||||
<div className="ds-section-shell flex flex-1 flex-col">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
aria-label="Navegação mobile"
|
||||
className="flex flex-1 flex-col"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<ul className="flex list-none flex-col">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
@@ -18,20 +18,30 @@ describe("Hero", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes project conversations to contact without opening an external service", () => {
|
||||
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
|
||||
render(<Hero />);
|
||||
|
||||
const contactLink = screen.getByRole("link", {
|
||||
name: /conversar/i,
|
||||
});
|
||||
expect(contactLink).toHaveAttribute("href", "/#contact");
|
||||
expect(contactLink).not.toHaveAttribute("target", "_blank");
|
||||
fireEvent.click(contactLink);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases",
|
||||
expect(contactLink).toHaveAccessibleName("Conversar sobre meu projeto");
|
||||
expect(contactLink.getAttribute("href")).toMatch(
|
||||
/^https:\/\/wa\.me\/\d+\?/,
|
||||
);
|
||||
expect(contactLink).toHaveAttribute("target", "_blank");
|
||||
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
const text = new URL(
|
||||
contactLink.getAttribute("href") ?? "",
|
||||
).searchParams.get("text");
|
||||
expect(text).toBe(
|
||||
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
|
||||
);
|
||||
// o botão primário é o único com o fundo limão; o secundário é contorno
|
||||
expect(contactLink.className).toContain("bg-[var(--slash)]");
|
||||
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
||||
expect(projects).toHaveAttribute("href", "/cases");
|
||||
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
||||
expect(projects).not.toHaveAttribute("target");
|
||||
});
|
||||
|
||||
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||
@@ -119,13 +129,31 @@ describe("Hero", () => {
|
||||
for (const image of images) {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
}
|
||||
expect(images[0]).toHaveAttribute(
|
||||
"src",
|
||||
"/videos/sev-modaintima/desktop.webp",
|
||||
);
|
||||
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
|
||||
const original = (image: Element) =>
|
||||
new URL(
|
||||
image.getAttribute("src") ?? "",
|
||||
"http://localhost",
|
||||
).searchParams.get("url");
|
||||
expect(original(images[0])).toBe("/videos/sev-modaintima/desktop.webp");
|
||||
expect(images[0]).toHaveAccessibleName(
|
||||
"Site da SEV Moda Íntima aberto no computador",
|
||||
);
|
||||
expect(images[1]).toHaveAttribute("src", "/videos/as-servicos/mobile.webp");
|
||||
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
|
||||
});
|
||||
|
||||
it("shows a small phone frame with the mobile poster of SEV below the buttons on small screens", () => {
|
||||
render(<Hero clients={clients} />);
|
||||
|
||||
const small = screen.getByTestId("hero-work-mobile");
|
||||
expect(small).toHaveClass("lg:hidden");
|
||||
const image = small.querySelector("img") as HTMLImageElement;
|
||||
expect(image).toHaveAccessibleName(
|
||||
"Site da SEV Moda Íntima aberto no celular",
|
||||
);
|
||||
const buttons = screen.getByRole("link", { name: /ver projetos/i });
|
||||
expect(
|
||||
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,12 @@ import Link from "next/link";
|
||||
import { type CSSProperties, Fragment } from "react";
|
||||
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
||||
import HeroField from "./cinematic/HeroField";
|
||||
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
||||
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
||||
import WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
@@ -108,9 +110,11 @@ export default function Hero({
|
||||
</p>
|
||||
|
||||
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Link
|
||||
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
|
||||
<WhatsAppLink
|
||||
id={HERO_CTA_ID}
|
||||
href="/#contact"
|
||||
service={HOME_SUBJECT}
|
||||
placement="hero"
|
||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||
>
|
||||
Conversar sobre meu projeto
|
||||
@@ -124,7 +128,7 @@ export default function Hero({
|
||||
>
|
||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
</WhatsAppLink>
|
||||
<Link
|
||||
href="/cases"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import HeroWork from "./HeroWork";
|
||||
|
||||
/**
|
||||
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
|
||||
* (o título é o LCP), e a moldura de celular pequena para as telas abaixo de 1024 px.
|
||||
*/
|
||||
|
||||
const original = (image: Element) =>
|
||||
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||
"url",
|
||||
);
|
||||
|
||||
describe("HeroWork: pôsteres no tamanho certo", () => {
|
||||
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
|
||||
render(<HeroWork />);
|
||||
|
||||
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
|
||||
expect(desktop).toHaveLength(2);
|
||||
expect(original(desktop[0])).toBe("/videos/sev-modaintima/desktop.webp");
|
||||
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
|
||||
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
|
||||
expect(desktop[0]).toHaveAttribute(
|
||||
"sizes",
|
||||
"(min-width: 1400px) 500px, 36vw",
|
||||
);
|
||||
expect(desktop[1]).toHaveAttribute(
|
||||
"sizes",
|
||||
"(min-width: 1400px) 136px, 10vw",
|
||||
);
|
||||
|
||||
const small = within(screen.getByTestId("hero-work-mobile")).getByRole(
|
||||
"img",
|
||||
);
|
||||
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
|
||||
expect(small).toHaveAttribute("sizes", "84px");
|
||||
});
|
||||
|
||||
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
|
||||
render(<HeroWork />);
|
||||
|
||||
const [browser, phone] = within(
|
||||
screen.getByTestId("hero-work"),
|
||||
).getAllByRole("img");
|
||||
expect(browser).toHaveAttribute("width", "1440");
|
||||
expect(browser).toHaveAttribute("height", "900");
|
||||
expect(phone).toHaveAttribute("width", "390");
|
||||
expect(phone).toHaveAttribute("height", "844");
|
||||
});
|
||||
|
||||
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
|
||||
const { container } = render(<HeroWork />);
|
||||
|
||||
const images = container.querySelectorAll("img");
|
||||
expect(images).toHaveLength(3);
|
||||
for (const image of images) {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(image).toHaveAttribute("fetchpriority", "low");
|
||||
}
|
||||
// sem <link rel="preload">: o next/image só o insere com `preload` (ou fetchPriority="high")
|
||||
expect(document.head.querySelector('link[rel="preload"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroWork: moldura de celular pequena", () => {
|
||||
it("shows a 180px phone with the SEV mobile poster only below 1024px, while the column stays desktop-only", () => {
|
||||
render(<HeroWork />);
|
||||
|
||||
const column = screen.getByTestId("hero-work");
|
||||
expect(column).toHaveClass("hidden", "lg:block");
|
||||
|
||||
const small = screen.getByTestId("hero-work-mobile");
|
||||
expect(small).toHaveClass("lg:hidden");
|
||||
const phone = small.querySelector(".ds-phone") as HTMLElement;
|
||||
expect(phone.style.height).toBe("180px");
|
||||
expect(phone).toContainElement(within(small).getByRole("img"));
|
||||
expect(within(small).getByRole("img")).toHaveAccessibleName(
|
||||
"Site da SEV Moda Íntima aberto no celular",
|
||||
);
|
||||
expect(
|
||||
within(small).getByText(/Site da SEV Moda Íntima, como aparece/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not interactive: the frames only show the work", () => {
|
||||
render(<HeroWork />);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByTestId("hero-work-mobile")).toHaveClass(
|
||||
"pointer-events-none",
|
||||
);
|
||||
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
|
||||
});
|
||||
|
||||
it("passes the address to the browser bar of the desktop frame", () => {
|
||||
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+69
-38
@@ -1,3 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { BrowserFrame } from "./showcase/BrowserFrame";
|
||||
import { PhoneFrame } from "./showcase/PhoneFrame";
|
||||
@@ -7,52 +8,82 @@ export type HeroWorkUrl = { text: string; secure: boolean } | null;
|
||||
|
||||
const DESKTOP_PROJECT = "sev-modaintima";
|
||||
const MOBILE_PROJECT = "as-servicos";
|
||||
/** No celular a moldura mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador. */
|
||||
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
||||
/** Altura da moldura de celular abaixo dos botões (px); a largura sai da proporção 390x844 do aparelho. */
|
||||
const SMALL_PHONE_HEIGHT = 180;
|
||||
|
||||
/**
|
||||
* Trabalho real ao lado do título, só em telas de 1024 px ou mais: o pôster do site da SEV Moda Íntima no navegador
|
||||
* (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo da AS Serviços no celular.
|
||||
* A coluna ocupa cerca de 40% da largura. Ela fica escondida (display: none) abaixo de 1024 px,
|
||||
* então as imagens (lazy) nem são baixadas no celular.
|
||||
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da SEV
|
||||
* Moda Íntima no navegador (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo
|
||||
* da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, uma moldura de celular
|
||||
* pequena (180 px de altura) com o pôster mobile da SEV, abaixo dos botões.
|
||||
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
|
||||
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: o título segue como o maior elemento da dobra (LCP)
|
||||
* e a imagem que fica escondida pelo breakpoint (display: none) nem é baixada.
|
||||
*/
|
||||
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
||||
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
||||
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
||||
const smallPhone = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="hero-work"
|
||||
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
|
||||
>
|
||||
<BrowserFrame url={url} className="w-[88%]">
|
||||
{/* biome-ignore lint/performance/noImgElement: pôster de 1440 px já otimizado em /public/videos; next/image pediria o otimizador para um arquivo menor que o necessário */}
|
||||
<img
|
||||
src={desktop.poster}
|
||||
alt="Site da SEV Moda Íntima aberto no computador"
|
||||
width={desktop.width}
|
||||
height={desktop.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</BrowserFrame>
|
||||
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
|
||||
<PhoneFrame
|
||||
className="right-0 bottom-[-12%] w-[26%]"
|
||||
style={{ position: "absolute" }}
|
||||
<>
|
||||
<div
|
||||
data-testid="hero-work"
|
||||
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: mesmo motivo do pôster desktop */}
|
||||
<img
|
||||
src={phone.poster}
|
||||
alt="Site da AS Serviços aberto no celular"
|
||||
width={phone.width}
|
||||
height={phone.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
className="absolute inset-0 block h-full w-full object-cover object-top"
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
<BrowserFrame url={url} className="w-[88%]">
|
||||
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
|
||||
<Image
|
||||
src={desktop.poster}
|
||||
alt="Site da SEV Moda Íntima aberto no computador"
|
||||
width={desktop.width}
|
||||
height={desktop.height}
|
||||
sizes="(min-width: 1400px) 500px, 36vw"
|
||||
loading="lazy"
|
||||
fetchPriority="low"
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</BrowserFrame>
|
||||
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
|
||||
<PhoneFrame
|
||||
className="right-0 bottom-[-12%] w-[26%]"
|
||||
style={{ position: "absolute" }}
|
||||
>
|
||||
<Image
|
||||
src={phone.poster}
|
||||
alt="Site da AS Serviços aberto no celular"
|
||||
width={phone.width}
|
||||
height={phone.height}
|
||||
sizes="(min-width: 1400px) 136px, 10vw"
|
||||
loading="lazy"
|
||||
fetchPriority="low"
|
||||
className="absolute inset-0 block h-full w-full object-cover object-top"
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="hero-work-mobile"
|
||||
className="hero-in pointer-events-none mt-sp-5 flex items-center gap-sp-4 [--d:.8s] lg:hidden"
|
||||
>
|
||||
<PhoneFrame style={{ height: SMALL_PHONE_HEIGHT }}>
|
||||
<Image
|
||||
src={smallPhone.poster}
|
||||
alt="Site da SEV Moda Íntima aberto no celular"
|
||||
width={smallPhone.width}
|
||||
height={smallPhone.height}
|
||||
sizes="84px"
|
||||
loading="lazy"
|
||||
fetchPriority="low"
|
||||
className="absolute inset-0 block h-full w-full object-cover object-top"
|
||||
/>
|
||||
</PhoneFrame>
|
||||
<p className="max-w-[16em] font-display text-[15px] leading-6 text-[rgba(244,242,237,.72)]">
|
||||
Site da SEV Moda Íntima, como aparece no celular.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { render, screen, within } from "@testing-library/react";
|
||||
import HomeFaq, { FAQ_ITEMS } from "./HomeFaq";
|
||||
|
||||
describe("HomeFaq", () => {
|
||||
it("shows the four questions the site already backs up, closed by default", () => {
|
||||
it("shows the five questions the site already backs up, closed by default", () => {
|
||||
const { container } = render(<HomeFaq />);
|
||||
|
||||
expect(
|
||||
@@ -13,6 +13,7 @@ describe("HomeFaq", () => {
|
||||
);
|
||||
expect(summaries).toEqual([
|
||||
"Como começa um projeto?",
|
||||
"Quanto custa?",
|
||||
"Você atende fora de Santa Catarina?",
|
||||
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
|
||||
"O que preciso ter para começar?",
|
||||
@@ -54,7 +55,23 @@ describe("HomeFaq", () => {
|
||||
// ...e fora da página
|
||||
expect(screen.queryByText(unanswered)).toBeNull();
|
||||
}
|
||||
expect(container.querySelectorAll("details")).toHaveLength(4);
|
||||
expect(container.querySelectorAll("details")).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("answers 'Quanto custa?' with the owner's sentence only: no value, no deadline, and no promise that the first talk is free", () => {
|
||||
render(<HomeFaq />);
|
||||
|
||||
const details = screen
|
||||
.getByText("Quanto custa?")
|
||||
.closest("details") as HTMLElement;
|
||||
expect(
|
||||
within(details).getByText(
|
||||
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(details.textContent).not.toMatch(
|
||||
/R\$|\d|grátis|gratuit|sem custo|sem compromisso/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("starts showing a prepared question as soon as it gets an answer", () => {
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface FaqItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perguntas da home. As quatro primeiras só dizem o que o site já sustenta (contato, rodapé, /ia).
|
||||
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
|
||||
* já autorizou ("Quanto custa?": escopo, valor e prazo por escrito depois da primeira conversa, sem número nenhum).
|
||||
* As três últimas estão preparadas, sem resposta: o dono as responde depois e elas passam a aparecer.
|
||||
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que ele não tenha dito.
|
||||
*/
|
||||
@@ -19,6 +20,11 @@ export const FAQ_ITEMS: FaqItem[] = [
|
||||
answer:
|
||||
"Com uma conversa para entender o problema, sem precisar chegar com a solução definida. Depois dela, você recebe o escopo e a proposta por escrito.",
|
||||
},
|
||||
{
|
||||
question: "Quanto custa?",
|
||||
answer:
|
||||
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
|
||||
},
|
||||
{
|
||||
question: "Você atende fora de Santa Catarina?",
|
||||
answer:
|
||||
|
||||
@@ -41,6 +41,23 @@ it("talks about the service when one is given", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("takes an id, so the floating shortcut can find the invitation it replaces", () => {
|
||||
render(
|
||||
<WhatsAppLink id="hero-contact-cta" placement="hero">
|
||||
Conversar
|
||||
</WhatsAppLink>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Conversar" })).toHaveAttribute(
|
||||
"id",
|
||||
"hero-contact-cta",
|
||||
);
|
||||
|
||||
render(<WhatsAppLink placement="hero">Sem id</WhatsAppLink>);
|
||||
expect(screen.getByRole("link", { name: "Sem id" })).not.toHaveAttribute(
|
||||
"id",
|
||||
);
|
||||
});
|
||||
|
||||
it("talks about 'um projeto' when no service is given", () => {
|
||||
render(<WhatsAppLink placement="contact_direct">Conversar</WhatsAppLink>);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
interface WhatsAppLinkProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Para o botão flutuante achar o convite (HERO_CTA_ID). */
|
||||
id?: string;
|
||||
/** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */
|
||||
service?: string;
|
||||
placement: string;
|
||||
@@ -20,6 +22,7 @@ const DEFAULT_ANALYTICS_SERVICE = "Contato";
|
||||
export default function WhatsAppLink({
|
||||
children,
|
||||
className = "",
|
||||
id,
|
||||
service,
|
||||
placement,
|
||||
onClick,
|
||||
@@ -29,6 +32,7 @@ export default function WhatsAppLink({
|
||||
const analyticsService = subject ?? DEFAULT_ANALYTICS_SERVICE;
|
||||
return (
|
||||
<a
|
||||
id={id}
|
||||
href={buildWhatsAppUrl(
|
||||
`Olá, Evandro! Quero conversar sobre ${subject ?? "um projeto"}. Contato iniciado pelo site evandro.dev.br.`,
|
||||
)}
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { primaryButton, secondaryButtonLight } from "./button-styles";
|
||||
import { primaryButton } from "./button-styles";
|
||||
import { WorkModalities } from "./WorkModalities";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de WorkModalities e TierCard: a entrada dos planos (Reveal, CSS), o toque e o passe do
|
||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), plano em destaque (botão e borda) x demais planos,
|
||||
* primeiro passo de cada plano, e serviços do hub (só os três principais).
|
||||
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
|
||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (miniatura do case, botão que abre o
|
||||
* formulário com o tipo escolhido), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
|
||||
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
|
||||
*/
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => (
|
||||
<section id={id}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) => (
|
||||
<section id={id}>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
||||
|
||||
/** O hub inteiro, fora da ordem da home: a home mostra só sites, sistemas web e integrações, nesta ordem. */
|
||||
const serviceLinks = [
|
||||
{
|
||||
href: "/servicos/tauri-rust",
|
||||
label: "Aplicativos de desktop",
|
||||
description: "Programas rápidos para o computador.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/integracao-hardware-software-iot",
|
||||
label: "Integração de equipamentos e sistemas",
|
||||
description: "Máquinas e sistemas trocando dados.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/consultoria-nextjs",
|
||||
label: "Melhorias em sites e sistemas",
|
||||
description: "Evolução do que já existe.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/criacao-de-sites",
|
||||
label: "Sites profissionais",
|
||||
description: "Presença digital para a empresa.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/engenharia-de-software",
|
||||
label: "Sistemas web e aplicativos mobile",
|
||||
description: "Sistemas sob medida.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/arquitetura-de-software",
|
||||
label: "Diagnóstico e arquitetura",
|
||||
description: "Avaliação técnica.",
|
||||
},
|
||||
];
|
||||
const original = (image: Element) =>
|
||||
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||
"url",
|
||||
);
|
||||
|
||||
describe("WorkModalities: movimento", () => {
|
||||
it("path: tiers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
|
||||
it("path: offers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||
@@ -89,7 +74,7 @@ describe("WorkModalities: movimento", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("path: each tier sits alone in its own reveal wrapper, which can shrink in the grid", () => {
|
||||
it("path: each offer sits alone in its own reveal wrapper, which can shrink in the grid", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
for (const card of container.querySelectorAll("article")) {
|
||||
@@ -100,112 +85,199 @@ describe("WorkModalities: movimento", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: plano em destaque", () => {
|
||||
it("path: the featured tier has the filled button and the thick border; the others have outline buttons", () => {
|
||||
describe("WorkModalities: as três ofertas", () => {
|
||||
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const featured = screen.getByRole("link", {
|
||||
name: "Conversar sobre meu sistema",
|
||||
});
|
||||
expect(featured.className).toContain(primaryButton);
|
||||
expect(featured.closest("article")).toHaveStyle({ borderWidth: "2px" });
|
||||
expect(featured.closest("article")).toHaveClass("border-[var(--slash)]");
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Escolha por onde começar",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(articles).toHaveLength(3);
|
||||
expect(
|
||||
articles.map(
|
||||
(article) =>
|
||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||
),
|
||||
).toEqual([
|
||||
"Site que traz pedidos pelo WhatsApp",
|
||||
"Sistema para tirar a operação da planilha",
|
||||
"Revisão de sistema que já existe, inclusive feito com IA",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const name of ["Avaliar meu projeto", "Planejar a próxima etapa"]) {
|
||||
const other = screen.getByRole("link", { name });
|
||||
expect(other.className).toContain(secondaryButtonLight);
|
||||
expect(other.className).not.toContain(primaryButton);
|
||||
expect(other.closest("article")).toHaveStyle({ borderWidth: "1px" });
|
||||
expect(other.closest("article")).not.toHaveClass("border-[var(--slash)]");
|
||||
it("path: the site offer cites the Lavação 047 (catalog or price table, ready message, panel to edit alone)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const [site] = Array.from(container.querySelectorAll("article"));
|
||||
expect(site.textContent).toMatch(/Catálogo ou tabela de preços/);
|
||||
expect(site.textContent).toMatch(/mensagem pronta/);
|
||||
expect(site.textContent).toMatch(/painel para você editar/);
|
||||
expect(site.textContent).toMatch(/Lavação 047/);
|
||||
});
|
||||
|
||||
it("path: the first two offers show the poster of their case, sized for a third of the row", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const [site, system] = Array.from(container.querySelectorAll("article"));
|
||||
const siteImage = within(site).getByRole("img");
|
||||
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
|
||||
expect(siteImage).toHaveAccessibleName(
|
||||
"Site Lavação 047 aberto no computador",
|
||||
);
|
||||
expect(siteImage).toHaveAttribute(
|
||||
"sizes",
|
||||
"(min-width: 1024px) 33vw, 100vw",
|
||||
);
|
||||
expect(siteImage).toHaveAttribute("loading", "lazy");
|
||||
expect(original(within(system).getByRole("img"))).toBe(
|
||||
"/videos/sev-modaintima/desktop.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the review offer has a checklist card instead of a poster, with what the review delivers", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const review = Array.from(container.querySelectorAll("article"))[2];
|
||||
expect(within(review).queryByRole("img")).toBeNull();
|
||||
expect(within(review).getByText("O que você recebe")).toBeInTheDocument();
|
||||
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(review).getByText(item)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("path: each button opens the contact form with the project type already chosen, all three with the same filled style", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
const buttons = [
|
||||
["Começar pelo site", "/?tipo=site#contact"],
|
||||
["Começar pelo sistema", "/?tipo=sistema#contact"],
|
||||
["Começar pela revisão", "/?tipo=revisao#contact"],
|
||||
] as const;
|
||||
buttons.forEach(([name, href], index) => {
|
||||
const button = within(articles[index]).getByRole("link", { name });
|
||||
expect(button).toHaveAttribute("href", href);
|
||||
expect(button.className).toContain(primaryButton);
|
||||
});
|
||||
});
|
||||
|
||||
it("path: each offer links to the case or the page that backs it", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(
|
||||
within(articles[0]).getByRole("link", { name: "Ver o case Lavação 047" }),
|
||||
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||
expect(
|
||||
within(articles[1]).getByRole("link", {
|
||||
name: "Ver o case SEV Moda Íntima",
|
||||
}),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
expect(
|
||||
within(articles[2]).getByRole("link", {
|
||||
name: "Ver como funciona a revisão",
|
||||
}),
|
||||
).toHaveAttribute("href", "/ia");
|
||||
});
|
||||
|
||||
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull();
|
||||
expect(screen.queryByText(/Planilhas demais/)).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toBeNull();
|
||||
for (const link of screen.getAllByRole("link")) {
|
||||
expect(link.getAttribute("href")).not.toMatch(/^\/servicos/);
|
||||
}
|
||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: serviços do hub", () => {
|
||||
it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => {
|
||||
render(<WorkModalities />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toHaveAttribute("href", "/servicos");
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Sites profissionais/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: serviceLinks given -> only the three main services (sites, sistemas web, integrações), in that order", () => {
|
||||
const { container } = render(
|
||||
<WorkModalities serviceLinks={serviceLinks} />,
|
||||
);
|
||||
|
||||
const grid = container.querySelector(
|
||||
"[data-reel-stagger='left']",
|
||||
) as HTMLElement;
|
||||
const links = within(grid).getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/engenharia-de-software",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
]);
|
||||
expect(
|
||||
within(links[0]).getByText("Sites profissionais"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(links[1]).getByText("Sistemas sob medida."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toHaveAttribute("href", "/servicos");
|
||||
});
|
||||
|
||||
it("path: a main service missing from the hub -> it is skipped, the others stay", () => {
|
||||
const { container } = render(
|
||||
<WorkModalities
|
||||
serviceLinks={serviceLinks.filter(
|
||||
({ href }) => !href.endsWith("/engenharia-de-software"),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = container.querySelector(
|
||||
"[data-reel-stagger='left']",
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(grid)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: primeiro passo", () => {
|
||||
it("path: each tier states a concrete first step, with no price, deadline or contract promise", () => {
|
||||
describe("WorkModalities: um só estilo de cartão", () => {
|
||||
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(articles).toHaveLength(3);
|
||||
const classNames = articles.map((article) => article.className);
|
||||
expect(new Set(classNames).size).toBe(1);
|
||||
for (const article of articles) {
|
||||
expect(within(article).getByText("Primeiro passo")).toBeInTheDocument();
|
||||
expect(article).not.toHaveAttribute("style");
|
||||
expect(article.className).not.toContain("border-[var(--slash)]");
|
||||
expect(article.className).not.toContain("shadow");
|
||||
}
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
/Primeira conversa para entender o problema\. Depois, escopo e proposta por escrito\./,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByText(/Você me mostra o que já existe\./),
|
||||
).toBeInTheDocument();
|
||||
for (const gone of [
|
||||
"Proposta por projeto",
|
||||
"Escopo de análise definido",
|
||||
"Etapas acordadas",
|
||||
]) {
|
||||
expect(screen.queryByText(gone)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: every title reserves the same minimum height, so the texts below line up", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
for (const article of container.querySelectorAll("article")) {
|
||||
const title = within(article as HTMLElement).getByRole("heading", {
|
||||
level: 3,
|
||||
});
|
||||
expect(title.className).toContain("lg:min-h-[3.3em]");
|
||||
expect(title.className).toContain("xl:min-h-[2.2em]");
|
||||
}
|
||||
expect(container.textContent).not.toMatch(/R\$|em contrato|prazo de \d/);
|
||||
});
|
||||
|
||||
it("path: no text under 15px on the desktop either (the old 12 and 13px classes are gone)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(container.innerHTML).not.toMatch(
|
||||
/(?:^|[\s:!"'])(?:md:|lg:)?!?text-\[(?:[0-9]|1[0-4])px\]|text-(?:xs|sm)\b/,
|
||||
);
|
||||
expect(container.innerHTML).not.toContain("ds-kicker");
|
||||
expect(container.innerHTML).not.toContain("md:!text-[13px]");
|
||||
expect(container.innerHTML).not.toContain("md:!text-[12px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: como eu trabalho", () => {
|
||||
it("path: the process is a compact list of four steps, with the title and the description in one line each", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const process = container.querySelector("#process") as HTMLElement;
|
||||
expect(
|
||||
within(process).getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Como eu trabalho",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const steps = within(process).getAllByRole("listitem");
|
||||
expect(steps).toHaveLength(4);
|
||||
expect(
|
||||
steps.map((step) => step.querySelector("strong")?.textContent),
|
||||
).toEqual([
|
||||
"Entender a operação.",
|
||||
"Definir a entrega.",
|
||||
"Construir e validar.",
|
||||
"Publicar e dar continuidade.",
|
||||
]);
|
||||
// sem a grade de quatro cartões com subtítulo por etapa
|
||||
expect(process.querySelector("h4")).toBeNull();
|
||||
expect(process.textContent).toMatch(
|
||||
/Prazo e investimento dependem do escopo/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: sem prometer o que o dono não disse", () => {
|
||||
it("path: no price, deadline or contract promise anywhere in the section", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(container.textContent).not.toMatch(
|
||||
/R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i,
|
||||
);
|
||||
expect(screen.queryByText("Primeiro passo")).toBeNull();
|
||||
});
|
||||
});
|
||||
+147
-188
@@ -1,83 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import type { ServiceLink } from "@/lib/site-navigation";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { primaryButton, secondaryButtonLight } from "./button-styles";
|
||||
import Image from "next/image";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { primaryButton, textLink } from "./button-styles";
|
||||
import ContactOfferLink from "./ContactOfferLink";
|
||||
import type { ContactKind } from "./contact-subject";
|
||||
import "./conversion.css";
|
||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface Tier {
|
||||
interface Offer {
|
||||
id: string;
|
||||
badge: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
deliverables: string[];
|
||||
/** Primeiro passo concreto, sem prazo nem preço. */
|
||||
firstStep: string;
|
||||
featured?: boolean;
|
||||
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
|
||||
kind: ContactKind;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
/** Case cujo pôster ilustra a oferta; sem ele, a miniatura é o cartão de checklist. */
|
||||
project?: { id: string; title: string };
|
||||
moreLabel: string;
|
||||
moreHref: string;
|
||||
}
|
||||
|
||||
const TIERS: Tier[] = [
|
||||
/**
|
||||
* As três ofertas da home, na ordem do H1: site que traz pedidos, sistema que tira a operação da planilha e revisão
|
||||
* de sistema que já existe. Cada texto só diz o que o case ou a página de /ia citados já sustentam; sem prazo nem preço.
|
||||
*/
|
||||
const OFFERS: Offer[] = [
|
||||
{
|
||||
id: "build",
|
||||
badge: "Do zero à produção",
|
||||
title: "Um sistema para a sua operação",
|
||||
tagline: "Quando as ferramentas prontas já não atendem.",
|
||||
id: "site",
|
||||
title: "Site que traz pedidos pelo WhatsApp",
|
||||
description:
|
||||
"Transforme regras de negócio, planilhas e processos dispersos em um sistema próprio, com os fluxos e as integrações que a empresa precisa.",
|
||||
deliverables: [
|
||||
"Definição de escopo e arquitetura",
|
||||
"Interface, backend e banco de dados",
|
||||
"Integrações e permissões de acesso",
|
||||
"Validação, publicação e documentação",
|
||||
],
|
||||
firstStep:
|
||||
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
|
||||
featured: true,
|
||||
ctaLabel: "Conversar sobre meu sistema",
|
||||
ctaHref: "/#contact",
|
||||
"Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho, como na Lavação 047.",
|
||||
kind: "site",
|
||||
ctaLabel: "Começar pelo site",
|
||||
project: { id: "lavacao-047", title: "Lavação 047" },
|
||||
moreLabel: "Ver o case Lavação 047",
|
||||
moreHref: "/cases/lavacao-047",
|
||||
},
|
||||
{
|
||||
id: "discovery",
|
||||
badge: "Antes de investir",
|
||||
title: "Diagnóstico de um sistema existente",
|
||||
tagline: "Entenda o que aproveitar e o que precisa mudar.",
|
||||
id: "sistema",
|
||||
title: "Sistema para tirar a operação da planilha",
|
||||
description:
|
||||
"Seu sistema ficou lento, frágil ou difícil de manter? Avalio o código e a operação para orientar a próxima decisão, inclusive em projetos iniciados com IA.",
|
||||
deliverables: [
|
||||
"Levantamento de riscos e dependências",
|
||||
"Revisão de acesso aos dados",
|
||||
"Análise dos gargalos da aplicação",
|
||||
"Plano de correção por prioridade",
|
||||
],
|
||||
firstStep:
|
||||
"Você me mostra o que já existe. Depois, recebe o levantamento de riscos e o plano de correção por prioridade.",
|
||||
ctaLabel: "Avaliar meu projeto",
|
||||
ctaHref: "/#contact",
|
||||
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.",
|
||||
kind: "sistema",
|
||||
ctaLabel: "Começar pelo sistema",
|
||||
project: { id: "sev-modaintima", title: "SEV Moda Íntima" },
|
||||
moreLabel: "Ver o case SEV Moda Íntima",
|
||||
moreHref: "/cases/sev-modaintima",
|
||||
},
|
||||
{
|
||||
id: "evolution",
|
||||
badge: "Continuidade",
|
||||
title: "Integração e evolução",
|
||||
tagline: "Faça o que já existe trabalhar melhor.",
|
||||
id: "revisao",
|
||||
title: "Revisão de sistema que já existe, inclusive feito com IA",
|
||||
description:
|
||||
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
|
||||
deliverables: [
|
||||
"Integração entre sistemas e equipamentos",
|
||||
"Novos fluxos e automações",
|
||||
"Testes das partes afetadas",
|
||||
"Planejamento de manutenção e operação",
|
||||
],
|
||||
firstStep:
|
||||
"Primeira conversa sobre o sistema e as ferramentas que você já usa. Depois, escopo e proposta por escrito.",
|
||||
ctaLabel: "Planejar a próxima etapa",
|
||||
ctaHref: "/#contact",
|
||||
"Avalio o que já existe, testo as falhas de acesso a dados e separo o que dá para aproveitar do que é melhor refazer, antes de o sistema receber mais clientes.",
|
||||
kind: "revisao",
|
||||
ctaLabel: "Começar pela revisão",
|
||||
moreLabel: "Ver como funciona a revisão",
|
||||
moreHref: "/ia",
|
||||
},
|
||||
];
|
||||
|
||||
/** O que a revisão entrega: os mesmos itens da página /ia, em versão curta para a miniatura da oferta. */
|
||||
const REVIEW_CHECKLIST = [
|
||||
"As falhas de acesso a dados",
|
||||
"O que dá para aproveitar",
|
||||
"O que é melhor refazer",
|
||||
"Um orçamento para pôr em produção",
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{
|
||||
title: "Entender a operação",
|
||||
@@ -101,167 +91,136 @@ const PROCESS = [
|
||||
},
|
||||
];
|
||||
|
||||
function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
/** Miniatura da revisão, sem case para mostrar: o que o relatório traz, em um cartão escuro do mesmo tamanho dos pôsteres. */
|
||||
function ReviewChecklist() {
|
||||
return (
|
||||
<article
|
||||
className={`ds-card cv-press cv-lift relative flex h-full min-w-0 flex-col overflow-hidden max-md:w-full max-md:max-w-none max-md:px-sp-5 max-md:py-sp-5 ${
|
||||
tier.featured ? "border-[var(--slash)] shadow-sm" : ""
|
||||
}`}
|
||||
style={{
|
||||
borderWidth: tier.featured ? "2px" : "1px",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-sp-4">
|
||||
<span className="ds-kicker">{tier.badge}</span>
|
||||
</div>
|
||||
|
||||
<DrawLine delay={0.04} />
|
||||
|
||||
<div className="mt-sp-4 min-w-0">
|
||||
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
|
||||
{tier.title}
|
||||
</h3>
|
||||
<p className="mt-sp-2 text-[15px] md:text-[13px] leading-[1.7] text-[var(--meta)]">
|
||||
{tier.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="ds-copy !mt-sp-4 !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[13px]"
|
||||
data-reel="right"
|
||||
>
|
||||
{tier.description}
|
||||
<div className="flex h-full flex-col justify-center gap-sp-3 px-sp-5 py-sp-4 text-white">
|
||||
<p className="font-display text-[15px] font-semibold leading-6 text-white">
|
||||
O que você recebe
|
||||
</p>
|
||||
|
||||
<ul
|
||||
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
data-reel-stagger="fade"
|
||||
>
|
||||
{tier.deliverables.map((deliverable) => (
|
||||
<li key={deliverable} className="pl-sp-1">
|
||||
{deliverable}
|
||||
<ul className="flex flex-col gap-sp-2">
|
||||
{REVIEW_CHECKLIST.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/85"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
className="mt-1 h-4 w-4 shrink-0 text-white/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="m3 8.5 3.2 3L13 4.5" />
|
||||
</svg>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="mt-sp-6 space-y-sp-2 border-t border-[var(--border)] pt-sp-5">
|
||||
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
|
||||
Primeiro passo
|
||||
</div>
|
||||
<div className="font-display text-[15px] md:text-[13px] leading-[1.7] text-[var(--ink-80)]">
|
||||
{tier.firstStep}
|
||||
</div>
|
||||
/** Um único estilo de cartão para as três ofertas: miniatura, título com altura mínima, texto, botão e link do case. */
|
||||
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
|
||||
const poster = offer.project
|
||||
? getShowcaseAssets(offer.project.id, "desktop")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
||||
<div className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]">
|
||||
{poster && offer.project ? (
|
||||
<Image
|
||||
src={poster.poster}
|
||||
alt={`Site ${offer.project.title} aberto no computador`}
|
||||
width={poster.width}
|
||||
height={poster.height}
|
||||
sizes="(min-width: 1024px) 33vw, 100vw"
|
||||
loading="lazy"
|
||||
className="block h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<ReviewChecklist />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={tier.ctaHref}
|
||||
className={`mt-sp-6 w-full ${
|
||||
tier.featured ? primaryButton : secondaryButtonLight
|
||||
}`}
|
||||
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
|
||||
<h3 className="ds-card-title !mt-sp-5 text-balance !text-[clamp(20px,5vw,26px)] !leading-[1.1] !tracking-[-0.03em] [overflow-wrap:break-word] [word-break:normal] lg:min-h-[3.3em] lg:!text-[22px] xl:min-h-[2.2em] xl:!text-[24px]">
|
||||
{offer.title}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="ds-copy !mt-sp-3 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
|
||||
data-reel="right"
|
||||
>
|
||||
{tier.ctaLabel}
|
||||
</Link>
|
||||
{offer.description}
|
||||
</p>
|
||||
|
||||
<ContactOfferLink
|
||||
kind={offer.kind}
|
||||
className={`mt-sp-6 w-full ${primaryButton}`}
|
||||
>
|
||||
{offer.ctaLabel}
|
||||
</ContactOfferLink>
|
||||
<HoverPrefetchLink
|
||||
href={offer.moreHref}
|
||||
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
|
||||
>
|
||||
{offer.moreLabel}
|
||||
</HoverPrefetchLink>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** A home mostra só os três serviços principais, nesta ordem: sites, sistemas web e integrações. O hub tem os demais. */
|
||||
const HOME_SERVICE_SLUGS = [
|
||||
"criacao-de-sites",
|
||||
"engenharia-de-software",
|
||||
"integracao-hardware-software-iot",
|
||||
];
|
||||
|
||||
export function WorkModalities({
|
||||
serviceLinks = [],
|
||||
}: {
|
||||
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
|
||||
serviceLinks?: ServiceLink[];
|
||||
}) {
|
||||
const mainServices = HOME_SERVICE_SLUGS.flatMap((slug) => {
|
||||
const service = serviceLinks.find(({ href }) => href.endsWith(`/${slug}`));
|
||||
return service ? [service] : [];
|
||||
});
|
||||
|
||||
export function WorkModalities() {
|
||||
return (
|
||||
<Section
|
||||
id="modalities"
|
||||
eyebrow="Soluções"
|
||||
title="Software feito a partir do seu negócio."
|
||||
description="Para empresas que precisam de um sistema próprio, de ferramentas conectadas ou de uma avaliação técnica antes de dar o próximo passo."
|
||||
title="Escolha por onde começar"
|
||||
description="Três pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
|
||||
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-[760px]">
|
||||
<div className="ds-kicker">Reconhece algum destes cenários?</div>
|
||||
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||
Planilhas demais. Sistemas isolados. Trabalho repetido.
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
|
||||
A operação cresceu, mas as ferramentas não acompanharam. Ou a
|
||||
ideia precisa sair do protótipo para atender clientes. O projeto
|
||||
começa pelo problema, sem exigir que você já saiba qual tecnologia
|
||||
usar.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}>
|
||||
Ver todos os serviços
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-sp-6 grid gap-sp-3 md:grid-cols-3"
|
||||
data-reel-stagger="left"
|
||||
>
|
||||
{mainServices.map((service) => (
|
||||
<Link
|
||||
key={service.href}
|
||||
href={service.href}
|
||||
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
|
||||
>
|
||||
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
|
||||
{service.label}
|
||||
</div>
|
||||
<div className="mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[var(--ink-80)]">
|
||||
{service.description}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-sp-4 md:grid-cols-2 md:gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
|
||||
{TIERS.map((tier, index) => (
|
||||
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
|
||||
{OFFERS.map((offer, index) => (
|
||||
<Reveal
|
||||
key={tier.id}
|
||||
key={offer.id}
|
||||
y={20}
|
||||
duration={0.75}
|
||||
delay={0.08 * (index + 1)}
|
||||
className="min-w-0"
|
||||
>
|
||||
<TierCard tier={tier} />
|
||||
<OfferCard offer={offer} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="process" className="mt-sp-12 scroll-mt-24">
|
||||
<h3 className="ds-card-title">Como o projeto acontece</h3>
|
||||
<p className="ds-copy max-w-[65ch]">
|
||||
Você acompanha as decisões e valida cada etapa. Prazo e investimento
|
||||
dependem do escopo, das integrações e dos riscos identificados.
|
||||
</p>
|
||||
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{/* Lista compacta: o título e a descrição de cada etapa numa linha só, sem a grade de quatro cartões. */}
|
||||
<div
|
||||
id="process"
|
||||
className="mt-sp-12 grid scroll-mt-24 gap-sp-6 border-t border-[var(--border)] pt-sp-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-sp-12"
|
||||
>
|
||||
<div>
|
||||
<h3 className="ds-card-title !mt-0">Como eu trabalho</h3>
|
||||
<p className="ds-copy max-w-[40ch] !text-[15px]">
|
||||
Você acompanha as decisões e valida cada etapa. Prazo e investimento
|
||||
dependem do escopo, das integrações e dos riscos identificados.
|
||||
</p>
|
||||
</div>
|
||||
<ol className="divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||
{PROCESS.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="border-t border-[var(--border)] pt-sp-4"
|
||||
>
|
||||
<span className="ds-kicker">
|
||||
<li key={step.title} className="flex gap-sp-4 py-sp-4">
|
||||
<span className="w-7 shrink-0 font-mono text-[15px] leading-6 text-[var(--meta)]">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
|
||||
<p className="ds-copy">{step.description}</p>
|
||||
<p className="min-w-0 font-display text-[15px] leading-6 text-[var(--ink-80)]">
|
||||
<strong className="font-semibold text-[var(--ink)]">
|
||||
{step.title}.
|
||||
</strong>{" "}
|
||||
{step.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import CinematicHome from "./CinematicHome";
|
||||
@@ -137,16 +139,12 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
const testimonials = [
|
||||
{ quote: "Ótimo.", name: "Ana", role: "Sócia", company: "Empresa" },
|
||||
];
|
||||
const serviceLinks = [
|
||||
{ href: "/servicos/x", label: "X", description: "Serviço X" },
|
||||
];
|
||||
|
||||
render(
|
||||
<CinematicHome
|
||||
projects={projects}
|
||||
clients={clients}
|
||||
testimonials={testimonials}
|
||||
serviceLinks={serviceLinks}
|
||||
heroStats={{ years: 5 }}
|
||||
/>,
|
||||
);
|
||||
@@ -154,7 +152,8 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
expect(lastHeroProps()).toMatchObject({ stats: { years: 5 }, clients });
|
||||
expect(mockProjectsProps).toHaveBeenCalledWith({ projects });
|
||||
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials });
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks });
|
||||
// as ofertas não recebem mais os serviços do hub: a grade duplicada saiu da seção
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => {
|
||||
@@ -165,9 +164,7 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
clients: undefined,
|
||||
});
|
||||
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: undefined });
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({
|
||||
serviceLinks: undefined,
|
||||
});
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("path: scenes sit in order inside <main> (FAQ right before the contact); the home has no footer of its own (it comes from the layout)", () => {
|
||||
@@ -201,3 +198,25 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
expect(onLayoutReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CinematicHome: componente de servidor", () => {
|
||||
const source = readFileSync(join(__dirname, "CinematicHome.tsx"), "utf8");
|
||||
|
||||
it("path: the file is a server component: no 'use client' and no React hooks, which live in the CinematicMain island", () => {
|
||||
expect(source).not.toMatch(/^["']use client["']/m);
|
||||
expect(source).not.toMatch(
|
||||
/\buse(?:State|Effect|LayoutEffect|Ref|Callback|Memo)\b/,
|
||||
);
|
||||
expect(source).not.toMatch(/useCinematicScroll|useHomeFragmentNavigation/);
|
||||
expect(source).toContain('import CinematicMain from "./CinematicMain"');
|
||||
});
|
||||
|
||||
it("path: the island is the only client file of the pair, and it wraps the scenes as children", () => {
|
||||
const island = readFileSync(join(__dirname, "CinematicMain.tsx"), "utf8");
|
||||
|
||||
expect(island).toMatch(/^"use client";/);
|
||||
expect(island).toMatch(/children/);
|
||||
expect(island).toContain("useCinematicScroll");
|
||||
expect(island).toContain("useHomeFragmentNavigation");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import type { Testimonial } from "@/lib/proof";
|
||||
import type { ServiceLink } from "@/lib/site-navigation";
|
||||
import About from "../About";
|
||||
import type { ClientLink } from "../ClientStrip";
|
||||
import Contact from "../Contact";
|
||||
@@ -14,17 +10,19 @@ import Projects from "../Projects";
|
||||
import { displayUrl } from "../showcase/BrowserFrame";
|
||||
import Testimonials from "../Testimonials";
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
import CinematicMain from "./CinematicMain";
|
||||
|
||||
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
|
||||
const HERO_WORK_PROJECT = "sev-modaintima";
|
||||
|
||||
/**
|
||||
* Cenas da home, em componente de servidor: sem hooks aqui. Os hooks de rolagem e de chegada por fragmento ficam
|
||||
* na ilha de cliente CinematicMain, que recebe as cenas como filhos.
|
||||
*/
|
||||
export default function CinematicHome({
|
||||
projects,
|
||||
clients,
|
||||
testimonials,
|
||||
serviceLinks,
|
||||
heroStats,
|
||||
}: {
|
||||
heroStats?: HeroStats;
|
||||
@@ -32,8 +30,6 @@ export default function CinematicHome({
|
||||
projects: PortfolioProjectView[];
|
||||
clients?: ClientLink[];
|
||||
testimonials?: Testimonial[];
|
||||
/** Serviços do hub; lib/site-content.ts só pode ser lido no servidor, então a página os entrega prontos. */
|
||||
serviceLinks?: ServiceLink[];
|
||||
}) {
|
||||
const shownUrl = displayUrl({
|
||||
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
|
||||
@@ -42,32 +38,24 @@ export default function CinematicHome({
|
||||
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
|
||||
const heroWorkUrl =
|
||||
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(mainRef);
|
||||
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
|
||||
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. O rodapé vem do layout. */}
|
||||
<main ref={mainRef} className="ds-main relative z-0">
|
||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
||||
<CinematicMain>
|
||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
||||
|
||||
<Projects projects={projects} />
|
||||
<Projects projects={projects} />
|
||||
|
||||
<About />
|
||||
<About />
|
||||
|
||||
<WorkModalities serviceLinks={serviceLinks} />
|
||||
<WorkModalities />
|
||||
|
||||
<EngineeringWithAI />
|
||||
<EngineeringWithAI />
|
||||
|
||||
<Testimonials items={testimonials} />
|
||||
<Testimonials items={testimonials} />
|
||||
|
||||
<HomeFaq />
|
||||
<HomeFaq />
|
||||
|
||||
<Contact />
|
||||
</main>
|
||||
</div>
|
||||
<Contact />
|
||||
</CinematicMain>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CinematicMain from "./CinematicMain";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
/**
|
||||
* A ilha de cliente da home: tem os hooks de rolagem e de chegada por fragmento e entrega as cenas (filhos) dentro
|
||||
* de <main>. Os hooks são trocados por dublês que registram o que recebem.
|
||||
*/
|
||||
|
||||
jest.mock("./useCinematicScroll", () => ({ useCinematicScroll: jest.fn() }));
|
||||
jest.mock("./useHomeFragmentNavigation", () => ({
|
||||
useHomeFragmentNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
const onLayoutReady = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
|
||||
});
|
||||
|
||||
describe("CinematicMain", () => {
|
||||
it("renders the scenes as direct children of <main>, inside the clipping wrapper", () => {
|
||||
render(
|
||||
<CinematicMain>
|
||||
<section data-testid="a" />
|
||||
<section data-testid="b" />
|
||||
</CinematicMain>,
|
||||
);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main).toHaveClass("ds-main", "relative", "z-0");
|
||||
expect(
|
||||
Array.from(main.children).map((child) =>
|
||||
child.getAttribute("data-testid"),
|
||||
),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(main.parentElement).toHaveClass("overflow-x-clip");
|
||||
});
|
||||
|
||||
it("gives the scroll hook the <main> ref and runs the layout-ready callback once on mount", () => {
|
||||
render(
|
||||
<CinematicMain>
|
||||
<section />
|
||||
</CinematicMain>,
|
||||
);
|
||||
|
||||
const ref = jest.mocked(useCinematicScroll).mock.calls[0][0];
|
||||
expect(ref.current).toBe(screen.getByRole("main"));
|
||||
expect(onLayoutReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not run the layout-ready callback again on a re-render", () => {
|
||||
const { rerender } = render(
|
||||
<CinematicMain>
|
||||
<section />
|
||||
</CinematicMain>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CinematicMain>
|
||||
<section />
|
||||
<section />
|
||||
</CinematicMain>,
|
||||
);
|
||||
|
||||
expect(onLayoutReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
/**
|
||||
* A ilha de cliente da home: só ela precisa de hooks (rolagem suave e passagens entre cenas, chegada por fragmento).
|
||||
* Ela recebe as cenas como `children`, então CinematicHome e as seções continuam no servidor e só o que já era
|
||||
* de cliente (Hero, Contact, Section...) vai para o pacote do navegador.
|
||||
* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. O rodapé vem do layout.
|
||||
*/
|
||||
export default function CinematicMain({ children }: { children: ReactNode }) {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(mainRef);
|
||||
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
|
||||
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<main ref={mainRef} className="ds-main relative z-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
CONTACT_KIND_PARAM,
|
||||
CONTACT_KINDS,
|
||||
CONTACT_SUBJECT_OPTIONS,
|
||||
contactKindHref,
|
||||
contactSubjectForKind,
|
||||
DEFAULT_CONTACT_SUBJECT,
|
||||
} from "./contact-subject";
|
||||
|
||||
describe("contact-subject", () => {
|
||||
it("starts the options with the default, 'Ainda preciso definir', so nobody is pushed into a type", () => {
|
||||
expect(CONTACT_SUBJECT_OPTIONS[0]).toBe(DEFAULT_CONTACT_SUBJECT);
|
||||
expect(DEFAULT_CONTACT_SUBJECT).toBe("Ainda preciso definir");
|
||||
});
|
||||
|
||||
it("maps every type an offer asks for to an option of the form", () => {
|
||||
for (const subject of Object.values(CONTACT_KINDS)) {
|
||||
expect(CONTACT_SUBJECT_OPTIONS).toContain(subject);
|
||||
}
|
||||
expect(CONTACT_KINDS).toEqual({
|
||||
site: "Site para empresa",
|
||||
sistema: "Sistemas web sob medida",
|
||||
revisao: "Revisão ou diagnóstico de sistema",
|
||||
});
|
||||
});
|
||||
|
||||
it("looks a type up by its own keys only", () => {
|
||||
expect(contactSubjectForKind("site")).toBe("Site para empresa");
|
||||
expect(contactSubjectForKind("revisao")).toBe(
|
||||
"Revisão ou diagnóstico de sistema",
|
||||
);
|
||||
for (const unknown of [
|
||||
"",
|
||||
"Site",
|
||||
"constructor",
|
||||
"toString",
|
||||
"__proto__",
|
||||
]) {
|
||||
expect(contactSubjectForKind(unknown)).toBeUndefined();
|
||||
}
|
||||
expect(contactSubjectForKind(null)).toBeUndefined();
|
||||
expect(contactSubjectForKind(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the address with the type in the search, before the fragment, so #contact still finds the section", () => {
|
||||
expect(contactKindHref("site")).toBe(
|
||||
`/?${CONTACT_KIND_PARAM}=site#contact`,
|
||||
);
|
||||
for (const kind of Object.keys(CONTACT_KINDS) as Array<
|
||||
keyof typeof CONTACT_KINDS
|
||||
>) {
|
||||
const url = new URL(contactKindHref(kind), "https://example.com");
|
||||
expect(url.hash).toBe("#contact");
|
||||
expect(url.searchParams.get("tipo")).toBe(kind);
|
||||
expect(url.pathname).toBe("/");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Tipos de projeto do formulário de contato (Contact.tsx) e o jeito de as ofertas da home escolherem um deles.
|
||||
* Uma oferta abre o formulário com o tipo já marcado de dois jeitos: o endereço `/?tipo=<tipo>#contact` (vale ao
|
||||
* chegar de outra página ou em um link copiado) e o evento CONTACT_KIND_EVENT (vale na própria home, onde o
|
||||
* gerenciador de âncoras rola até o formulário sem recarregar a página nem mudar a busca do endereço).
|
||||
* O tipo vai na busca, antes do `#`: um `/#contact?tipo=...` faria o fragmento virar "contact?tipo=...",
|
||||
* que não é o id de nenhum elemento, e a página não rolaria até o formulário.
|
||||
*/
|
||||
|
||||
/** Primeiro e padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
|
||||
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
|
||||
|
||||
export const CONTACT_SUBJECT_OPTIONS = [
|
||||
DEFAULT_CONTACT_SUBJECT,
|
||||
"Site para empresa",
|
||||
"Sistemas web sob medida",
|
||||
"Aplicativos",
|
||||
"Integrações e automações",
|
||||
"Evolução de um sistema existente",
|
||||
"Revisão ou diagnóstico de sistema",
|
||||
] as const;
|
||||
|
||||
/** Tipo que uma oferta pede -> opção do formulário. */
|
||||
export const CONTACT_KINDS = {
|
||||
site: "Site para empresa",
|
||||
sistema: "Sistemas web sob medida",
|
||||
revisao: "Revisão ou diagnóstico de sistema",
|
||||
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
|
||||
|
||||
export type ContactKind = keyof typeof CONTACT_KINDS;
|
||||
|
||||
/** Parâmetro de busca do endereço: `/?tipo=site#contact`. */
|
||||
export const CONTACT_KIND_PARAM = "tipo";
|
||||
/** Evento da janela com o tipo escolhido em `detail`. */
|
||||
export const CONTACT_KIND_EVENT = "contact:kind";
|
||||
|
||||
/** Opção do formulário para um tipo; `undefined` para um tipo desconhecido (inclusive nomes herdados, como "constructor"). */
|
||||
export function contactSubjectForKind(kind: string | null | undefined) {
|
||||
return kind && Object.hasOwn(CONTACT_KINDS, kind)
|
||||
? CONTACT_KINDS[kind as ContactKind]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function contactKindHref(kind: ContactKind) {
|
||||
return `/?${CONTACT_KIND_PARAM}=${kind}#contact`;
|
||||
}
|
||||
+57
-5
@@ -10,17 +10,24 @@ it("uses a short search title: with the layout suffix it fits in 44 characters",
|
||||
description: expect.any(String),
|
||||
alternates: { canonical: "https://evandro.dev.br/ia" },
|
||||
});
|
||||
// as prévias de compartilhamento não passam pelo template: o título delas já leva o sufixo
|
||||
expect(metadata.openGraph?.title).toBe(
|
||||
`Revisão de app feito com IA${suffix}`,
|
||||
);
|
||||
// como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo
|
||||
expect(metadata.openGraph?.title).toBe("Revisão de app feito com IA");
|
||||
expect(metadata.twitter?.title).toBe("Revisão de app feito com IA");
|
||||
});
|
||||
|
||||
it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => {
|
||||
const { container } = render(<AiPage />);
|
||||
|
||||
expect(
|
||||
container.querySelector('[class*="border-[var(--slash)]"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the full AI content, with the cited incidents summarized only here", () => {
|
||||
render(<AiPage />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"Revisão de app feito com IA (Lovable, Bolt, Replit)",
|
||||
"Seu app feito com IA está pronto para receber clientes? Eu testo antes deles.",
|
||||
);
|
||||
for (const heading of [
|
||||
"Se a IA cria um sistema, por que contratar um desenvolvedor?",
|
||||
@@ -91,6 +98,51 @@ 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", () => {
|
||||
render(<AiPage />);
|
||||
|
||||
const card = screen.getByTestId("ia-review-card");
|
||||
expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
|
||||
expect(
|
||||
within(card)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent?.replace(/^\d+/, "")),
|
||||
).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",
|
||||
]);
|
||||
// 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);
|
||||
expect(card.parentElement).toHaveClass(
|
||||
"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
|
||||
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", () => {
|
||||
render(<AiPage />);
|
||||
|
||||
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
|
||||
const secondary = within(hero as HTMLElement).getByRole("link", {
|
||||
name: "Ver como a revisão funciona",
|
||||
});
|
||||
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/);
|
||||
}
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Ver projetos entregues" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the incident news collapsed in a <details>, closed by default", () => {
|
||||
const { container } = render(<AiPage />);
|
||||
|
||||
|
||||
+64
-27
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import {
|
||||
pageTop,
|
||||
@@ -25,8 +24,7 @@ export const metadata: Metadata = {
|
||||
description: pageDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url: pageUrl,
|
||||
// as prévias de compartilhamento não passam pelo template do título
|
||||
title: `${pageTitle} | Evandro.dev.br`,
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
type: "website",
|
||||
}),
|
||||
@@ -140,32 +138,71 @@ export default function AiPage() {
|
||||
{ label: "Revisão de app feito com IA", href: "/ia" },
|
||||
]}
|
||||
/>
|
||||
<p className="ds-eyebrow">IA no desenvolvimento</p>
|
||||
<h1 className="ds-section-title max-w-[22ch] text-balance">
|
||||
Revisão de app feito com IA (Lovable, Bolt, Replit)
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
A IA permite testar ideias e construir protótipos mesmo sem
|
||||
experiência em programação. Quando o sistema passa a cuidar de
|
||||
clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que
|
||||
foi gerado e validar como ele se comporta fora da demonstração.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3">
|
||||
<WhatsAppLink
|
||||
className={primaryButton}
|
||||
service={WHATSAPP_SERVICE}
|
||||
placement="ia_hero"
|
||||
<div className="grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-sp-12">
|
||||
<div className="min-w-0">
|
||||
<p className="ds-eyebrow">IA no desenvolvimento</p>
|
||||
<h1 className="ds-section-title max-w-[24ch] text-balance">
|
||||
Seu app feito com IA está pronto para receber clientes? Eu testo
|
||||
antes deles.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
A IA permite testar ideias e construir protótipos mesmo sem
|
||||
experiência em programação. Quando o sistema passa a cuidar de
|
||||
clientes, dinheiro ou dados pessoais, alguém precisa avaliar o
|
||||
que foi gerado e validar como ele se comporta fora da
|
||||
demonstração. Revisão para apps criados com Lovable, Bolt,
|
||||
Replit e ferramentas parecidas.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3">
|
||||
<WhatsAppLink
|
||||
className={primaryButton}
|
||||
service={WHATSAPP_SERVICE}
|
||||
placement="ia_hero"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="ia-review-card"
|
||||
className="min-w-0 border border-white/15 bg-white/[0.04] p-sp-6 sm:p-sp-8"
|
||||
>
|
||||
Pedir a revisão do meu app
|
||||
</WhatsAppLink>
|
||||
<Link href="/cases" className={textLinkDark}>
|
||||
Ver projetos entregues
|
||||
</Link>
|
||||
<p className="font-display text-xl font-semibold leading-7 text-white">
|
||||
O que você recebe
|
||||
</p>
|
||||
<ol className="mt-sp-5 flex flex-col">
|
||||
{DELIVERABLES.map((item, index) => (
|
||||
<li
|
||||
key={item.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
|
||||
aria-hidden="true"
|
||||
className="w-7 shrink-0 font-mono text-[15px] leading-6 text-white/60"
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="min-w-0 font-display text-base leading-6 text-white/90">
|
||||
{item.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section" aria-labelledby="ia-receber-title">
|
||||
<section
|
||||
id="ia-receber"
|
||||
className="ds-section"
|
||||
aria-labelledby="ia-receber-title"
|
||||
>
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="ia-receber-title" className="ds-section-title max-w-[26ch]">
|
||||
O que você recebe na revisão
|
||||
@@ -198,7 +235,7 @@ export default function AiPage() {
|
||||
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
|
||||
Se a IA cria um sistema, por que contratar um desenvolvedor?
|
||||
</h2>
|
||||
<div className="mt-sp-6 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||
<div className="mt-sp-6 border-l-2 border-[var(--ink)] pl-sp-5">
|
||||
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
|
||||
Você contrata engenharia para transformar as regras do seu negócio
|
||||
em um sistema que possa ser testado, operado e mantido. A IA pode
|
||||
@@ -259,7 +296,7 @@ export default function AiPage() {
|
||||
{incident.links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
className="inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
|
||||
className="inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -298,7 +335,7 @@ export default function AiPage() {
|
||||
adequação.
|
||||
</p>
|
||||
<a
|
||||
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
|
||||
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
|
||||
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -106,6 +106,19 @@ describe("what the layout mounts", () => {
|
||||
expect(serviceHubLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives the footer wrapper and the content the ids the mobile menu makes inert", () => {
|
||||
const html = markup();
|
||||
|
||||
expect(html).toMatch(
|
||||
/<div id="main-content"[^>]*>(?:(?!<\/div>).)*conteúdo/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<div id="site-footer" class="relative z-10"><footer data-testid="footer"/,
|
||||
);
|
||||
// o botão flutuante fica fora dos dois: ele tem a própria região (<aside>)
|
||||
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gives the header and the button the service and case names, and only names", () => {
|
||||
markup();
|
||||
|
||||
@@ -131,6 +144,15 @@ describe("what the layout mounts", () => {
|
||||
});
|
||||
|
||||
describe("fonts", () => {
|
||||
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
|
||||
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
|
||||
|
||||
const mono = source.match(/Geist_Mono\(\{[^}]*\}\)/)?.[0] ?? "";
|
||||
expect(mono).toMatch(/preload:\s*false/);
|
||||
const display = source.match(/Space_Grotesk\(\{[^}]*\}\)/)?.[0] ?? "";
|
||||
expect(display).not.toMatch(/preload:\s*false/);
|
||||
});
|
||||
|
||||
it("no longer loads the unused Monument Extended font", () => {
|
||||
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
|
||||
|
||||
|
||||
+8
-17
@@ -11,14 +11,14 @@ import {
|
||||
} from "@/lib/site-content";
|
||||
import type { WhatsAppContexts } from "@/lib/whatsapp";
|
||||
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
||||
import { FRAGMENT_NAV_BOOT_SCRIPT } from "./components/cinematic/fragment-nav";
|
||||
import FloatingWhatsApp from "./components/FloatingWhatsApp";
|
||||
import Footer from "./components/Footer";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import "./globals.css";
|
||||
// O CSS da vitrine (molduras de navegador e de celular, vitrine animada) vem uma vez só, aqui, depois do global.
|
||||
// Importado por cada componente que o usa, ele era pré-carregado pelos links do menu em páginas que não o usam
|
||||
// (aviso de CSS pré-carregado e não usado).
|
||||
// As molduras de navegador e de celular (o hero e os cases também as usam) vêm uma vez só, aqui, depois do global.
|
||||
// O CSS só da vitrine e da janela ampliada (reel-list, pinned-reel e lightbox) viaja com os componentes deles.
|
||||
import "./components/showcase/showcase.css";
|
||||
|
||||
const titleSuffix = " | Evandro.dev.br";
|
||||
@@ -48,6 +48,8 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
display: "swap",
|
||||
// só rótulos pequenos e kickers: não disputa a rede com a fonte do título, o arquivo é baixado quando o texto usa
|
||||
preload: false,
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
@@ -143,18 +145,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
|
||||
if (sheet) sheet.disabled = true;
|
||||
}, { once: true });
|
||||
if (location.pathname === "/") {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
if (navigation?.type !== "back_forward") {
|
||||
history.scrollRestoration = "manual";
|
||||
if (navigation?.type === "reload" && location.hash) {
|
||||
history.replaceState(history.state, "", location.pathname + location.search);
|
||||
}
|
||||
addEventListener("pagehide", () => {
|
||||
history.scrollRestoration = "auto";
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
${FRAGMENT_NAV_BOOT_SCRIPT}
|
||||
`}</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -169,8 +160,8 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
{/* Rodapé de todas as páginas; z-10 o mantém acima das cenas da home (relative z-0). */}
|
||||
<div className="relative z-10">
|
||||
{/* Rodapé de todas as páginas; z-10 o mantém acima das cenas da home (relative z-0). O id deixa o menu do celular torná-lo inerte. */}
|
||||
<div id="site-footer" className="relative z-10">
|
||||
<Footer serviceLinks={serviceHubLinks} />
|
||||
</div>
|
||||
<FloatingWhatsApp contexts={whatsAppContexts} />
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ReactElement } from "react";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
|
||||
import { clientNames, testimonials } from "@/lib/proof";
|
||||
import { serviceHubLinks } from "@/lib/site-content";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
import Home from "./page";
|
||||
|
||||
@@ -90,11 +89,11 @@ describe("Home: projetos e provas", () => {
|
||||
expect((await renderHome()).projects).toEqual([]);
|
||||
});
|
||||
|
||||
it("path: the proof and the service links come straight from their modules", async () => {
|
||||
it("path: the proof comes straight from its module, and the home no longer carries the service links (the offers do not list them)", async () => {
|
||||
const props = await renderHome();
|
||||
|
||||
expect(props.clients).toBe(clientNames);
|
||||
expect(props.testimonials).toBe(testimonials);
|
||||
expect(props.serviceLinks).toBe(serviceHubLinks);
|
||||
expect(props).not.toHaveProperty("serviceLinks");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
|
||||
import { clientNames, isClientProject, testimonials } from "@/lib/proof";
|
||||
import { serviceHubLinks } from "@/lib/site-content";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
/**
|
||||
@@ -29,7 +28,6 @@ export default async function Home() {
|
||||
clients={clientNames}
|
||||
projects={projects}
|
||||
testimonials={testimonials}
|
||||
serviceLinks={serviceHubLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user