{aside}
{children}
@@ -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();
+
+ 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", () => {
diff --git a/app/components/About.tsx b/app/components/About.tsx
index 83fa022..39ff2e9 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -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() {
@@ -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.
-
- Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
- IoT na Apolo Energia.
-
{PROFILE_POINTS.map((point) => (
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ choose("revisao");
+
+ expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
+ });
+});
diff --git a/app/components/Contact.offer.test.tsx b/app/components/Contact.offer.test.tsx
new file mode 100644
index 0000000..c7cd055
--- /dev/null
+++ b/app/components/Contact.offer.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+ });
+});
diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx
index 6037974..690caf5 100644
--- a/app/components/Contact.tsx
+++ b/app/components/Contact.tsx
@@ -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({});
+ // 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).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() {
- Você fala direto com quem desenvolve.
+ Como a conversa começa.
-
+
Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois.
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. */}