feat(home): posicionamento com prova, menu sem recarregar e WhatsApp em todo o site
H1 'Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.'; HeroWork maior com a SEV; menu Cases, Serviços, Como trabalho, Sobre e IA com next/link e pré-carregamento no hover; menu do celular em tela cheia; WhatsApp flutuante e no rodapé com a mensagem da rota; rodapé em todas as páginas; FAQ da home; /ia como oferta de revisão de app feito com IA; formulário com noscript, validação do mailto e plano B do pop-up; cartões sem foco indevido; sem a fonte Monument.
This commit is contained in:
1 parent
e80f73d075
commit
10f170abf4
56 files changed
+3099
-906
No files matched your search
+242
-17
@@ -1,13 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import Contact from "./Contact";
|
||||
|
||||
/**
|
||||
* Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava
|
||||
* mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem.
|
||||
*/
|
||||
const setup = () => userEvent.setup({ delay: null });
|
||||
|
||||
/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */
|
||||
let openedWindow: { opener: unknown };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||
window.gtag = jest.fn();
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
openedWindow = { opener: {} };
|
||||
jest
|
||||
.spyOn(window, "open")
|
||||
.mockImplementation(() => openedWindow as unknown as Window);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -17,7 +30,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
const name = "Ana & João";
|
||||
@@ -28,15 +41,15 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
|
||||
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
|
||||
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||
await user.selectOptions(subject, "Integrações e automações");
|
||||
await user.type(screen.getByRole("textbox", { name: /contexto/i }), context);
|
||||
await user.click(screen.getByRole("textbox", { name: /contexto/i }));
|
||||
await user.paste(context);
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(window.gtag).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
const [destination, target, features] = jest.mocked(window.open).mock
|
||||
.calls[0];
|
||||
const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
|
||||
const url = new URL(String(destination));
|
||||
expect(url.origin).toBe("https://wa.me");
|
||||
expect(url.pathname).toMatch(/^\/\d+$/);
|
||||
@@ -47,7 +60,9 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
|
||||
expect(message).toContain("Integrações e automações");
|
||||
expect(message).toContain(context);
|
||||
expect(target).toBe("_blank");
|
||||
expect(features).toBe("noopener,noreferrer");
|
||||
// sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois
|
||||
expect(rest).toEqual([]);
|
||||
expect(openedWindow.opener).toBeNull();
|
||||
expect(window.gtag).toHaveBeenCalledTimes(1);
|
||||
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
|
||||
send_to: "G-QTT9QT0T3S",
|
||||
@@ -60,7 +75,7 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
|
||||
});
|
||||
|
||||
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
const submit = screen.getByRole("button", { name: /whatsapp/i });
|
||||
@@ -84,7 +99,7 @@ it("does not open WhatsApp while required fields are empty or the email is inval
|
||||
it("opens the project draft with analytics unavailable and consent denied", async () => {
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "denied");
|
||||
delete window.gtag;
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
@@ -105,7 +120,7 @@ it("opens the project draft with analytics unavailable and consent denied", asyn
|
||||
});
|
||||
|
||||
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
|
||||
@@ -120,23 +135,31 @@ it("lets the visitor skip the e-mail and leaves that line out of the message", a
|
||||
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
|
||||
const message = url.searchParams.get("text") ?? "";
|
||||
expect(message).toContain("Nome: Ana");
|
||||
expect(message).toContain("Tipo de projeto: Site para empresa");
|
||||
expect(message).toContain("Tipo de projeto: Ainda preciso definir");
|
||||
expect(message).not.toContain("E-mail:");
|
||||
});
|
||||
|
||||
it("offers 'Site para empresa' as the first and default project type", () => {
|
||||
it("offers 'Ainda preciso definir' as the first and default project type", () => {
|
||||
render(<Contact />);
|
||||
|
||||
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||
const options = Array.from(subject.querySelectorAll("option")).map(
|
||||
(option) => option.value,
|
||||
);
|
||||
expect(options[0]).toBe("Site para empresa");
|
||||
expect(subject).toHaveValue("Site para empresa");
|
||||
expect(options[0]).toBe("Ainda preciso definir");
|
||||
expect(options).toContain("Site para empresa");
|
||||
expect(subject).toHaveValue("Ainda preciso definir");
|
||||
});
|
||||
|
||||
it("labels the first field 'Seu nome'", () => {
|
||||
render(<Contact />);
|
||||
|
||||
expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name");
|
||||
expect(screen.queryByLabelText("Nome completo")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
await user.type(
|
||||
@@ -171,7 +194,7 @@ it("explains each invalid field in Portuguese and marks it for assistive technol
|
||||
});
|
||||
|
||||
it("offers the same text by e-mail, below the WhatsApp button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
@@ -194,7 +217,7 @@ it("offers the same text by e-mail, below the WhatsApp button", async () => {
|
||||
const body = url.searchParams.get("body") ?? "";
|
||||
expect(body).toContain("Nome: Ana");
|
||||
expect(body).toContain("Organizar pedidos & estoque");
|
||||
expect(body).toContain("Tipo de projeto: Site para empresa");
|
||||
expect(body).toContain("Tipo de projeto: Ainda preciso definir");
|
||||
|
||||
await user.click(mail);
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
@@ -214,3 +237,205 @@ it("does not call the direct WhatsApp row 'Contato'", () => {
|
||||
expect(text).toContain("Quero conversar sobre um projeto.");
|
||||
expect(text).not.toMatch(/sobre Contato/);
|
||||
});
|
||||
|
||||
describe("e-mail draft validation", () => {
|
||||
it("does not open the e-mail client while the form is empty, and explains what is missing", () => {
|
||||
render(<Contact />);
|
||||
|
||||
const mail = screen.getByRole("link", {
|
||||
name: "Prefere e-mail? Enviar por e-mail",
|
||||
});
|
||||
// fireEvent.click devolve false quando o clique foi cancelado (preventDefault)
|
||||
expect(fireEvent.click(mail)).toBe(false);
|
||||
|
||||
expect(
|
||||
screen.getByText("Informe seu nome para eu saber com quem falo."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("Conte em poucas palavras o que você precisa resolver."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus();
|
||||
expect(window.gtag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a malformed e-mail address the same way", async () => {
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /e-mail/i }),
|
||||
"email-invalido",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /contexto/i }),
|
||||
"Organizar pedidos",
|
||||
);
|
||||
|
||||
const mail = screen.getByRole("link", {
|
||||
name: "Prefere e-mail? Enviar por e-mail",
|
||||
});
|
||||
expect(fireEvent.click(mail)).toBe(false);
|
||||
expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("lets the click through, and measures it, once the form is valid", async () => {
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /contexto/i }),
|
||||
"Organizar pedidos",
|
||||
);
|
||||
|
||||
const mail = screen.getByRole("link", {
|
||||
name: "Prefere e-mail? Enviar por e-mail",
|
||||
});
|
||||
// o clique não é cancelado pela validação: o navegador segue para o mailto:
|
||||
expect(fireEvent.click(mail)).toBe(true);
|
||||
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
"event",
|
||||
"email_click",
|
||||
expect.objectContaining({ placement: "contact_form" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhatsApp fallback", () => {
|
||||
it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => {
|
||||
// o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href
|
||||
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
jest.mocked(window.open).mockImplementation(() => null);
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /contexto/i }),
|
||||
"Organizar pedidos",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
errors.mock.calls.some(([error]) =>
|
||||
String(error).includes("Not implemented: navigation"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
"event",
|
||||
"whatsapp_open",
|
||||
expect.objectContaining({ placement: "contact_form" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not navigate the page when the new tab opened", async () => {
|
||||
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /contexto/i }),
|
||||
"Organizar pedidos",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */
|
||||
async function submitWithMinimumFields() {
|
||||
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /contexto/i }),
|
||||
"Organizar pedidos",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||
return errors;
|
||||
}
|
||||
|
||||
const navigated = (errors: jest.SpyInstance) =>
|
||||
errors.mock.calls.some(([error]) =>
|
||||
String(error).includes("Not implemented: navigation"),
|
||||
);
|
||||
|
||||
it("goes to WhatsApp in the same page when there is no window.open", async () => {
|
||||
const original = window.open;
|
||||
Object.defineProperty(window, "open", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const errors = await submitWithMinimumFields();
|
||||
|
||||
expect(navigated(errors)).toBe(true);
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
"event",
|
||||
"whatsapp_open",
|
||||
expect.objectContaining({ placement: "contact_form" }),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(window, "open", {
|
||||
configurable: true,
|
||||
value: original,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("goes to WhatsApp in the same page when window.open throws", async () => {
|
||||
jest.mocked(window.open).mockImplementation(() => {
|
||||
throw new DOMException("bloqueado", "SecurityError");
|
||||
});
|
||||
|
||||
const errors = await submitWithMinimumFields();
|
||||
|
||||
expect(navigated(errors)).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => {
|
||||
const close = jest.fn();
|
||||
const tab = {
|
||||
close,
|
||||
get opener() {
|
||||
return {};
|
||||
},
|
||||
set opener(_value: unknown) {
|
||||
throw new DOMException("sem acesso", "SecurityError");
|
||||
},
|
||||
};
|
||||
jest.mocked(window.open).mockImplementation(() => tab as unknown as Window);
|
||||
|
||||
const errors = await submitWithMinimumFields();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(navigated(errors)).toBe(true);
|
||||
expect(window.gtag).toHaveBeenCalledWith(
|
||||
"event",
|
||||
"whatsapp_open",
|
||||
expect.objectContaining({ placement: "contact_form" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without JavaScript", () => {
|
||||
it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a <noscript>", () => {
|
||||
const html = renderToStaticMarkup(<Contact />);
|
||||
|
||||
const noscript = html.match(/<noscript>([\s\S]*?)<\/noscript>/)?.[1] ?? "";
|
||||
expect(noscript).toMatch(/href="https:\/\/wa\.me\/\d+\?text=/);
|
||||
expect(noscript).toContain('href="mailto:[email protected]"');
|
||||
// o botão de envio e os campos ficam na área "js-only", que app/layout.tsx esconde sem JavaScript
|
||||
const jsOnly = html.match(/class="js-only[^"]*"/);
|
||||
expect(jsOnly).not.toBeNull();
|
||||
expect(html.indexOf("js-only")).toBeLessThan(
|
||||
html.indexOf("Continuar no WhatsApp"),
|
||||
);
|
||||
expect(html.indexOf("Continuar no WhatsApp")).toBeLessThan(
|
||||
html.indexOf("<noscript>"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user