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:
Evandro Fonseca Junior committed 2026-10-14 14:15:25 -03:00
1 parent e80f73d075
commit 10f170abf4
56 files changed
+3099 -906

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import nextConfig from "../next.config";
/**
* Testes de caminhos-base de headers() em next.config.ts: o que muda entre produção e desenvolvimento.
* Produção ganha o HSTS e perde o 'unsafe-eval' da política de conteúdo (só o hot reload do dev precisa dele).
*/
async function siteHeaders() {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
if (!site) throw new Error("Missing site-wide header rule");
return site.headers;
}
const header = (headers: { key: string; value: string }[], key: string) =>
headers.find((item) => item.key === key)?.value;
afterEach(() => {
jest.restoreAllMocks();
});
describe("next.config headers()", () => {
it("path: production -> HSTS is sent and the policy has no 'unsafe-eval'", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
const headers = await siteHeaders();
expect(header(headers, "Strict-Transport-Security")).toBe(
"max-age=31536000; includeSubDomains; preload",
);
expect(
header(headers, "Content-Security-Policy-Report-Only"),
).not.toContain("'unsafe-eval'");
});
it("path: development or test -> no HSTS, and the policy allows 'unsafe-eval' for the dev server", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
const headers = await siteHeaders();
expect(header(headers, "Strict-Transport-Security")).toBeUndefined();
expect(header(headers, "Content-Security-Policy-Report-Only")).toContain(
"'unsafe-eval'",
);
});
it("path: any environment -> the video rule keeps the long cache", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const videos = rules.find(({ source }) => source === "/videos/:path*");
expect(videos?.headers).toEqual([
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import nextConfig from "../next.config";
describe("next.config", () => {
it("does not announce the framework in the X-Powered-By header", () => {
expect(nextConfig.poweredByHeader).toBe(false);
});
it("keeps the CSP in Report-Only until Google Ads is allowed in it, never enforcing it by accident", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
const names = site?.headers.map(({ key }) => key) ?? [];
expect(names).toContain("Content-Security-Policy-Report-Only");
expect(names).not.toContain("Content-Security-Policy");
});
});
+4 -3
View File
@@ -1,12 +1,13 @@
export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
/** "Projetos" saiu: duplicava "Cases". A ordem é a da conversa de venda: prova, oferta, método, quem sou, IA. */
export const NAVIGATION_ITEMS = [
{ href: "/#projects", label: "Projetos" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/cases", label: "Cases" },
{ href: "/servicos", label: "Serviços" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/ia", label: "IA" },
] as const;
export const CONTACT_EMAIL = "[email protected]";
+63
View File
@@ -0,0 +1,63 @@
import {
AI_REVIEW_SUBJECT,
isServiceOrCasePath,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
/**
* Testes de caminhos-base de whatsAppSubjectForPath e isServiceOrCasePath (complemento de whatsapp.test.ts):
* o case sem tabela de nomes, as duas tabelas independentes, nome vazio e rotas sem slug.
*/
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" },
};
describe("whatsAppSubjectForPath", () => {
it("path: /cases/<id> without contexts -> undefined", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima")).toBeUndefined();
expect(
whatsAppSubjectForPath("/cases/sev-modaintima", undefined),
).toBeUndefined();
});
it("path: /ia without contexts and with a trailing slash -> the AI review", () => {
expect(whatsAppSubjectForPath("/ia")).toBe(AI_REVIEW_SUBJECT);
expect(whatsAppSubjectForPath("/ia/", contexts)).toBe(AI_REVIEW_SUBJECT);
});
it("path: a service slug is only looked up in the services, a case id only in the cases", () => {
expect(
whatsAppSubjectForPath("/servicos/sev-modaintima", contexts),
).toBeUndefined();
expect(
whatsAppSubjectForPath("/cases/criacao-de-sites", contexts),
).toBeUndefined();
});
it("path: a case with an empty name -> no subject (the message stays generic)", () => {
expect(whatsAppSubjectForPath("/cases/vazio", contexts)).toBeUndefined();
});
it.each([
"",
"/servicos/",
"/cases/",
"/ia/extra",
"/IA",
"/cases/sev-modaintima/extra",
])("path: %j -> no subject", (pathname) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBeUndefined();
});
});
describe("isServiceOrCasePath", () => {
it.each(["", "/servicos/", "/cases/", "/cases/a/b", "/curriculo", "/ia/"])(
"path: %j -> false",
(pathname) => {
expect(isServiceOrCasePath(pathname)).toBe(false);
},
);
});
+104
View File
@@ -0,0 +1,104 @@
import {
AI_REVIEW_SUBJECT,
buildWhatsAppUrl,
isServiceOrCasePath,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
describe("whatsAppSubjectForPath", () => {
it("cites the service on /servicos/<slug>", () => {
expect(whatsAppSubjectForPath("/servicos/criacao-de-sites", contexts)).toBe(
"Sites profissionais",
);
});
it("cites the case on /cases/<slug>", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
"um projeto parecido com o da SEV Moda Íntima",
);
});
it("cites the AI review on /ia", () => {
expect(whatsAppSubjectForPath("/ia", contexts)).toBe(AI_REVIEW_SUBJECT);
expect(whatsAppSubjectForPath("/ia/", undefined)).toBe(AI_REVIEW_SUBJECT);
});
it("ignores a trailing slash", () => {
expect(
whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts),
).toBe("Sites profissionais");
});
it.each([
"/",
"/servicos",
"/cases",
"/curriculo",
"/privacidade",
"/servicos/inexistente",
"/cases/inexistente",
"/servicos/criacao-de-sites/extra",
// nomes que existem em todo objeto não podem virar assunto
"/servicos/constructor",
"/cases/__proto__",
"/cases/toString",
])("falls back to a generic message on %s", (pathname) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBeUndefined();
});
it("treats an empty or blank name as missing, so the message never reads 'sobre .'", () => {
const blank: WhatsAppContexts = {
services: { vazio: "", espacos: " " },
cases: { vazio: "", espacos: " " },
};
for (const pathname of [
"/servicos/vazio",
"/servicos/espacos",
"/cases/vazio",
"/cases/espacos",
]) {
expect(whatsAppSubjectForPath(pathname, blank)).toBeUndefined();
}
});
it("is generic without contexts, without a pathname or outside the router", () => {
expect(
whatsAppSubjectForPath("/servicos/criacao-de-sites"),
).toBeUndefined();
expect(whatsAppSubjectForPath(null, contexts)).toBeUndefined();
expect(whatsAppSubjectForPath(undefined, contexts)).toBeUndefined();
});
});
describe("isServiceOrCasePath", () => {
it.each([
["/servicos/criacao-de-sites", true],
["/servicos/qualquer-coisa/", true],
["/cases/sev-modaintima", true],
["/", false],
["/servicos", false],
["/cases", false],
["/ia", false],
["/servicos/a/b", false],
[null, false],
[undefined, false],
])("%s -> %s", (pathname, expected) => {
expect(isServiceOrCasePath(pathname)).toBe(expected);
});
});
describe("buildWhatsAppUrl", () => {
it("encodes the message in the text parameter of a wa.me link", () => {
const url = new URL(buildWhatsAppUrl("Olá & tudo? #1"));
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d{12,13}$/);
expect(url.searchParams.get("text")).toBe("Olá & tudo? #1");
});
});
+44
View File
@@ -15,3 +15,47 @@ export function getWhatsAppDisplayNumber() {
export function buildWhatsAppUrl(message: string) {
return `https://wa.me/${getWhatsAppNumber()}?text=${encodeURIComponent(message)}`;
}
/**
* Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
* esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador.
*/
export interface WhatsAppContexts {
/** slug do serviço -> nome do serviço */
services: Record<string, string>;
/** id do case -> nome do projeto ou do cliente */
cases: Record<string, string>;
}
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem. */
export const AI_REVIEW_SUBJECT = "a revisão de um aplicativo feito com IA";
/** Nome pela chave; nome vazio ou só com espaços vale como ausente, para a mensagem cair no assunto padrão em vez de "sobre .". */
function lookup(table: Record<string, string> | undefined, key: string) {
const name = table && Object.hasOwn(table, key) ? table[key].trim() : "";
return name || undefined;
}
/**
* Assunto da conversa de WhatsApp para a rota atual. Em /servicos/<slug> cita o serviço, em /cases/<slug> o case
* e em /ia a revisão de aplicativo; nas demais rotas devolve undefined e a mensagem fala de "um projeto".
*/
export function whatsAppSubjectForPath(
pathname: string | null | undefined,
contexts?: WhatsAppContexts,
): string | undefined {
const path = pathname?.replace(/\/$/, "") ?? "";
if (path === "/ia") return AI_REVIEW_SUBJECT;
const slug = path.match(/^\/servicos\/([^/]+)$/)?.[1];
if (slug) return lookup(contexts?.services, slug);
const caseId = path.match(/^\/cases\/([^/]+)$/)?.[1];
const title = caseId ? lookup(contexts?.cases, caseId) : undefined;
return title ? `um projeto parecido com o da ${title}` : undefined;
}
/** Páginas de serviço e de case: nelas o botão do cabeçalho abre o WhatsApp em vez de ir ao formulário da home. */
export function isServiceOrCasePath(pathname: string | null | undefined) {
return /^\/(servicos|cases)\/[^/]+\/?$/.test(pathname ?? "");
}