diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx
index 52ee1cd..094c3bc 100644
--- a/app/components/About.paths.test.tsx
+++ b/app/components/About.paths.test.tsx
@@ -1,6 +1,23 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
import About from "./About";
+// o Link do Next, trocado por um que mostra o prefetch recebido: HoverPrefetchLink começa em false
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
/**
* Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
* feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
@@ -89,24 +106,69 @@ describe("About", () => {
expect(cards[3]).toHaveClass("cv-press");
});
- it("path: the story, the profile points and the résumé link are all there", () => {
- render();
+ it("path: the section has three points, each with the proof that backs it, and the résumé link", () => {
+ const { container } = render();
+ const titles = [
+ "Quem conversa com você escreve o código",
+ "Aproveito o que você já tem",
+ "Do código ao sistema no ar",
+ ];
+ for (const name of titles) {
+ expect(
+ screen.getByRole("heading", { level: 3, name }),
+ ).toBeInTheDocument();
+ }
+ // no máximo três pontos: os três cartões da história; o cartão do perfil não é um quarto ponto, só diz do que cuido
+ const story = [...container.querySelectorAll("article")].slice(0, 3);
+ expect(story).toHaveLength(3);
+ for (const card of story) {
+ expect(within(card).getAllByText("Na prática:")).toHaveLength(1);
+ }
+ // a prova de cada ponto é concreta: a Apolo desde 2022, a revisão e os cases entregues
+ expect(story[0]).toHaveTextContent(
+ /Na prática: Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT/,
+ );
+ expect(story[1]).toHaveTextContent(
+ /Na prática: Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer/,
+ );
+ expect(story[2]).toHaveTextContent(
+ /Na prática: Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços\./,
+ );
expect(
- screen.getByRole("heading", {
- level: 3,
- name: "Sem intermediários entre o pedido e o sistema",
+ within(story[1]).getByRole("link", {
+ name: "Ver como funciona a revisão",
}),
- ).toBeInTheDocument();
- expect(
- screen.getByText("Solução do tamanho do seu problema"),
- ).toBeInTheDocument();
+ ).toHaveAttribute("href", "/ia");
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo");
});
- it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work", () => {
+ it("path: the idea of talking to whoever develops shows up once, not in five texts", () => {
+ const { container } = render();
+
+ expect(container.textContent).not.toMatch(
+ /Sem intermediários|Uma pessoa cuida|Quem responde pelo projeto|acompanha as decisões|fala com quem desenvolve|Solução do tamanho|Tudo funcionando junto/,
+ );
+ expect(container.textContent?.match(/mesma pessoa/g)).toHaveLength(1);
+ });
+
+ it("path: the résumé and the review links pre-fetch only when the visitor reaches them (HoverPrefetchLink), as in the rest of the home", () => {
+ render();
+
+ for (const name of [
+ "Ver trajetória completa",
+ "Ver como funciona a revisão",
+ ]) {
+ const link = screen.getByRole("link", { name });
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link);
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ }
+ });
+
+ it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work once, as the proof of the first point", () => {
const { container } = render();
expect(container.textContent).not.toMatch(
@@ -116,14 +178,14 @@ describe("About", () => {
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
"Evandro Fonseca Junior",
);
- expect(screen.getByTestId("about-description").textContent).toMatch(
- /^Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia\./,
+ expect(screen.getByTestId("about-description").textContent).toBe(
+ "Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas.",
);
// só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
expect(container.textContent).not.toMatch(
/os sistemas internos e a IoT da Apolo/,
);
- // a frase da Apolo aparece uma só vez
+ // a frase da Apolo aparece uma só vez (na prova do primeiro ponto)
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
});
@@ -131,7 +193,7 @@ describe("About", () => {
const { container } = render();
expect(container.textContent).not.toMatch(/Como eu trabalho/);
- expect(screen.getByText("Acompanhamento")).toBeInTheDocument();
+ expect(screen.getByText("Quem desenvolve")).toBeInTheDocument();
});
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
@@ -142,13 +204,14 @@ describe("About", () => {
);
});
- it("path: the location label stays on one line and the columns stretch to the same height", () => {
+ it("path: the location label stays on one line and the portrait card keeps its own height next to the text cards", () => {
const { container } = render();
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
"whitespace-nowrap",
);
- expect(container.querySelector(".ds-grid-2")).toHaveClass("items-stretch");
+ // items-start: o retrato tem a altura do próprio conteúdo e acompanha a rolagem; só os cartões de texto se esticam entre si
+ expect(container.querySelector(".ds-grid-2")).toHaveClass("items-start");
const story = container.querySelectorAll("article");
for (const card of Array.from(story).slice(0, 3)) {
expect(card).toHaveClass("flex-1");
diff --git a/app/components/About.tsx b/app/components/About.tsx
index 2b523ce..eb2350c 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -1,48 +1,42 @@
-import Link from "next/link";
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { textLink } from "./button-styles";
import "./conversion.css";
+import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
+/**
+ * Os três pontos da seção, cada um com a prova que o sustenta. A ideia de que quem conversa com você é quem desenvolve
+ * aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
+ * "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
+ */
const STORY_DATA = [
{
- label: "Acompanhamento",
- title: "Sem intermediários entre o pedido e o sistema",
+ label: "Quem desenvolve",
+ title: "Quem conversa com você escreve o código",
content:
- "Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
+ "A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
+ proof:
+ "Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.",
},
{
label: "Sem começar do zero",
title: "Aproveito o que você já tem",
content:
"Antes de mexer em um sistema que já está em uso, vejo o que existe e combino com você o que vem primeiro. Nem todo problema exige começar de novo.",
+ proof:
+ "Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer, com o motivo de cada parte.",
+ link: { href: "/ia", label: "Ver como funciona a revisão" },
},
{
label: "Depois da entrega",
title: "Do código ao sistema no ar",
content:
"Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.",
- },
-];
-
-const PROFILE_POINTS = [
- {
- title: "Solução do tamanho do seu problema",
- description:
- "Escolho o que resolve o seu caso, sem complicar além do necessário.",
- },
- {
- title: "Tudo funcionando junto",
- description:
- "A tela, os dados e os equipamentos são ligados para trabalhar como uma coisa só.",
- },
- {
- title: "Quem responde pelo projeto",
- description:
- "Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.",
+ proof:
+ "Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.",
},
];
@@ -55,7 +49,7 @@ export default function About() {
id="about"
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior"
- description="Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega."
+ description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
disableChildrenAnimation
aside={
@@ -64,7 +58,8 @@ export default function About() {
}
>
-
+ {/* items-start: o cartão do retrato tem a altura do próprio conteúdo, e não a das três colunas de texto; no desktop ele acompanha a rolagem */}
+
Cuido da tela que sua equipe usa, dos dados por trás dela e,
@@ -115,25 +123,12 @@ export default function About() {
sistema. Ver tudo junto ajuda a decidir o que construir, o que
conectar e o que dá para aproveitar.
-
- {PROFILE_POINTS.map((point) => (
-
-
- {point.title}
-
-
{point.description}
-
- ))}
-
-
Ver trajetória completa
-
+
diff --git a/app/components/Contact.kind.paths.test.tsx b/app/components/Contact.kind.paths.test.tsx
index b15e97b..a3a2338 100644
--- a/app/components/Contact.kind.paths.test.tsx
+++ b/app/components/Contact.kind.paths.test.tsx
@@ -3,7 +3,7 @@ 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
+ * Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact-form`), 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.
*/
@@ -38,14 +38,22 @@ describe("Contact: tipo no endereço", () => {
});
it.each([
- ["/?tipo=site#contact", "Site para empresa"],
- ["/?tipo=sistema#contact", "Sistemas web sob medida"],
+ ["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"],
[
- "/?utm_source=x&tipo=revisao#contact",
- "Revisão ou diagnóstico de sistema",
+ "/?tipo=sistema#contact-form",
+ "Sistema para tirar a operação da planilha",
],
+ [
+ "/?utm_source=x&tipo=revisao#contact-form",
+ "Revisão de sistema que já existe",
+ ],
+ // o endereço antigo, com #contact, continua valendo: o tipo vem da busca, não do fragmento
+ ["/?tipo=site#contact", "Site que traz pedidos pelo WhatsApp"],
// com o parâmetro repetido vale o primeiro
- ["/?tipo=site&tipo=revisao#contact", "Site para empresa"],
+ [
+ "/?tipo=site&tipo=revisao#contact-form",
+ "Site que traz pedidos pelo WhatsApp",
+ ],
])("path: %s (valid type) -> selects %s", (address, option) => {
window.history.replaceState(null, "", address);
@@ -60,7 +68,9 @@ describe("Contact: tipo no endereço", () => {
choose("desconhecido");
- expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
+ expect(subjectSelect()).toHaveValue(
+ "Sistema para tirar a operação da planilha",
+ );
});
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
@@ -69,6 +79,6 @@ describe("Contact: tipo no endereço", () => {
choose("revisao");
- expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
+ expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
});
});
diff --git a/app/components/Contact.offer.test.tsx b/app/components/Contact.offer.test.tsx
index c7cd055..ab67227 100644
--- a/app/components/Contact.offer.test.tsx
+++ b/app/components/Contact.offer.test.tsx
@@ -5,7 +5,8 @@ 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.
+ * (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página). As três ofertas entram no formulário
+ * com o nome que têm na home, o formulário avisa "Você escolheu: " e o link direto do WhatsApp leva o tipo.
*/
const subjectSelect = () =>
@@ -23,18 +24,18 @@ afterEach(() => {
});
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", () => {
+ it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render();
const options = within(subjectSelect()).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Ainda preciso definir",
- "Site para empresa",
- "Sistemas web sob medida",
+ "Site que traz pedidos pelo WhatsApp",
+ "Sistema para tirar a operação da planilha",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
- "Revisão ou diagnóstico de sistema",
+ "Revisão de sistema que já existe",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
@@ -45,24 +46,22 @@ describe("Contact: opções do tipo de projeto", () => {
await user.selectOptions(
subjectSelect(),
- "Revisão ou diagnóstico de sistema",
+ "Revisão de sistema que já existe",
);
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",
- );
+ expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe");
});
});
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"],
+ ["site", "Site que traz pedidos pelo WhatsApp"],
+ ["sistema", "Sistema para tirar a operação da planilha"],
+ ["revisao", "Revisão de sistema que já existe"],
])("the offer event with %s selects %s", (kind, option) => {
render();
@@ -80,7 +79,7 @@ describe("Contact: tipo escolhido pela oferta", () => {
expect(subjectSelect()).toHaveValue("Aplicativos");
choose("revisao");
- expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
+ expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
});
it.each([
@@ -97,15 +96,20 @@ describe("Contact: tipo escolhido pela oferta", () => {
choose(kind);
- expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
+ expect(subjectSelect()).toHaveValue(
+ "Sistema para tirar a operação da planilha",
+ );
});
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
- window.history.replaceState(null, "", "/?tipo=revisao#contact");
+ window.history.replaceState(null, "", "/?tipo=revisao#contact-form");
render();
- expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
+ expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Você escolheu: Revisão de sistema que já existe",
+ );
});
it("ignores an unknown ?tipo= in the address and keeps the default", () => {
@@ -133,6 +137,86 @@ describe("Contact: tipo escolhido pela oferta", () => {
});
});
+describe("Contact: você escolheu e o WhatsApp direto", () => {
+ const directLink = () =>
+ screen.getByRole("link", { name: /Prefere conversar direto/ });
+ const directMessage = () =>
+ new URL(directLink().getAttribute("href") ?? "").searchParams.get("text");
+
+ it("renders the form card with the id the offers scroll to, and an empty status until an offer is chosen", () => {
+ const { container } = render();
+
+ expect(container.querySelector("form")).toHaveAttribute(
+ "id",
+ "contact-form",
+ );
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it.each([
+ ["site", "Site que traz pedidos pelo WhatsApp"],
+ ["sistema", "Sistema para tirar a operação da planilha"],
+ ["revisao", "Revisão de sistema que já existe"],
+ ])(
+ "after the offer %s the form says 'Você escolheu: '",
+ (kind, name) => {
+ render();
+
+ choose(kind);
+
+ expect(screen.getByRole("status")).toHaveTextContent(
+ `Você escolheu: ${name}`,
+ );
+ expect(within(screen.getByRole("status")).getByText(name).tagName).toBe(
+ "STRONG",
+ );
+ },
+ );
+
+ it("drops the message when the person picks another type in the form, and keeps it for an unknown offer", async () => {
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ choose("desconhecido");
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ choose("sistema");
+ expect(screen.getByRole("status")).not.toBeEmptyDOMElement();
+ await user.selectOptions(subjectSelect(), "Aplicativos");
+
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it("takes the chosen type to the direct WhatsApp link of the contact column, as a phrase, and back to 'um projeto'", async () => {
+ const user = userEvent.setup({ delay: null });
+ render();
+ expect(directMessage()).toBe(
+ "Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
+ );
+
+ choose("sistema");
+ expect(directMessage()).toBe(
+ "Olá, Evandro! Quero conversar sobre um sistema para tirar a operação da planilha. Contato iniciado pelo site evandro.dev.br.",
+ );
+ choose("revisao");
+ expect(directMessage()).toContain(
+ "Quero conversar sobre a revisão de um sistema que já existe.",
+ );
+ // quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
+ await user.selectOptions(
+ subjectSelect(),
+ "Site que traz pedidos pelo WhatsApp",
+ );
+ expect(directMessage()).toContain(
+ "Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
+ );
+ // um tipo sem oferta volta para a frase de "um projeto"
+ await user.selectOptions(subjectSelect(), "Aplicativos");
+ expect(directMessage()).toContain("Quero conversar sobre um projeto.");
+ // nada de nome de oferta com maiúscula na frase
+ expect(directMessage()).not.toMatch(/sobre [A-Z]/);
+ });
+});
+
describe("Contact: texto da seção", () => {
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
const { container } = render();
diff --git a/app/components/Contact.test.tsx b/app/components/Contact.test.tsx
index 798e5ce..beb4c60 100644
--- a/app/components/Contact.test.tsx
+++ b/app/components/Contact.test.tsx
@@ -147,7 +147,7 @@ it("offers 'Ainda preciso definir' as the first and default project type", () =>
(option) => option.value,
);
expect(options[0]).toBe("Ainda preciso definir");
- expect(options).toContain("Site para empresa");
+ expect(options).toContain("Site que traz pedidos pelo WhatsApp");
expect(subject).toHaveValue("Ainda preciso definir");
});
diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx
index 690caf5..2c7fccb 100644
--- a/app/components/Contact.tsx
+++ b/app/components/Contact.tsx
@@ -5,11 +5,19 @@ import type { FormEvent, MouseEvent, ReactNode } from "react";
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 {
+ buildWhatsAppUrl,
+ getWhatsAppDisplayNumber,
+ OFFER_SUBJECTS,
+} from "@/lib/whatsapp";
+import {
+ CONTACT_FORM_ID,
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
+ CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS,
+ type ContactKind,
+ contactKindForSubject,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject";
@@ -162,13 +170,17 @@ export default function Contact() {
message: "",
});
const [errors, setErrors] = useState({});
+ // A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário.
+ const [chosenOffer, setChosenOffer] = useState(null);
// 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 }));
+ if (!subject) return;
+ setFormData((current) => ({ ...current, subject }));
+ setChosenOffer(contactKindForSubject(subject) ?? null);
};
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
const onChoose = (event: Event) =>
@@ -179,6 +191,7 @@ export default function Contact() {
const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
+ if (field === "subject") setChosenOffer(null);
if (field !== "subject" && errors[field]) {
setErrors((current) => ({ ...current, [field]: undefined }));
}
@@ -195,6 +208,12 @@ export default function Contact() {
return firstInvalid !== undefined;
};
+ // O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto".
+ const selectedOffer = contactKindForSubject(formData.subject);
+ const directSubject = selectedOffer
+ ? OFFER_SUBJECTS[selectedOffer]
+ : undefined;
+
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (hasInvalidField(e.currentTarget)) return;
@@ -243,6 +262,7 @@ export default function Contact() {
@@ -283,6 +303,7 @@ export default function Contact() {