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:
Evandro Fonseca Junior committed 2026-10-14 17:01:36 -03:00
1 parent de6af74a0c
commit f8545cd442
44 files changed
+2286 -571

No files matched your search

+3
View File
@@ -121,6 +121,9 @@ export function trackCvDownload({ placement }: { placement: string }): void {
/** O rodapé pede a reabertura da barra de cookies; quem a monta (GoogleAnalytics) escuta este evento. */
export const OPEN_COOKIE_PREFERENCES_EVENT = "evandro:open-cookie-preferences";
/** Id da barra de cookies: o menu do celular a inclui na armadilha de foco enquanto ela está aberta (Header). */
export const COOKIE_BAR_ID = "cookie-bar";
export function openCookiePreferences(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(OPEN_COOKIE_PREFERENCES_EVENT));
+8
View File
@@ -5,6 +5,14 @@ describe("next.config", () => {
expect(nextConfig.poweredByHeader).toBe(false);
});
it("serves AVIF before WebP, because the browser takes the first format of the list it accepts", () => {
expect(nextConfig.images?.formats).toEqual(["image/avif", "image/webp"]);
});
it("leaves the image cache time to the framework default instead of revalidating every minute", () => {
expect(nextConfig.images).not.toHaveProperty("minimumCacheTTL");
});
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 === "/(.*)");
+36 -2
View File
@@ -1,6 +1,11 @@
import {
AI_REVIEW_SUBJECT,
CASES_SUBJECT,
HOME_SUBJECT,
headerOpensWhatsApp,
isServiceOrCasePath,
RESUME_SUBJECT,
SERVICES_SUBJECT,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
@@ -43,14 +48,27 @@ describe("whatsAppSubjectForPath", () => {
it.each([
"",
"/servicos/",
"/cases/",
"/ia/extra",
"/IA",
"/cases/sev-modaintima/extra",
"/curriculo/extra",
"/servicos/a/b",
])("path: %j -> no subject", (pathname) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBeUndefined();
});
it.each([
["/servicos/", SERVICES_SUBJECT],
["/cases/", CASES_SUBJECT],
["/curriculo/", RESUME_SUBJECT],
["/", HOME_SUBJECT],
])(
"path: the index page %j (with or without the slash) -> its own subject",
(pathname, subject) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBe(subject);
expect(whatsAppSubjectForPath(pathname)).toBe(subject);
},
);
});
describe("isServiceOrCasePath", () => {
@@ -61,3 +79,19 @@ describe("isServiceOrCasePath", () => {
},
);
});
describe("headerOpensWhatsApp", () => {
it.each(["", "/servicos/a/b", "/cases/a/b", "/IA", "/curriculo/extra"])(
"path: %j -> false (the header button goes to the home form)",
(pathname) => {
expect(headerOpensWhatsApp(pathname)).toBe(false);
},
);
it.each(["/ia/", "/cases/", "/servicos/", "/curriculo/"])(
"path: the index page %j with the trailing slash -> true",
(pathname) => {
expect(headerOpensWhatsApp(pathname)).toBe(true);
},
);
});
+49 -6
View File
@@ -1,7 +1,13 @@
import {
AI_REVIEW_SUBJECT,
buildWhatsAppUrl,
CASES_SUBJECT,
HOME_SUBJECT,
headerOpensWhatsApp,
isServiceOrCasePath,
RESUME_SUBJECT,
SERVICES_SUBJECT,
similarProjectSubject,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
@@ -18,10 +24,27 @@ describe("whatsAppSubjectForPath", () => {
);
});
it("cites the case on /cases/<slug>", () => {
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
"um projeto parecido com o da SEV Moda Íntima",
"um projeto parecido com o SEV Moda Íntima",
);
expect(similarProjectSubject("Lavação 047")).toBe(
"um projeto parecido com o Lavação 047",
);
});
it("speaks of the page itself on the home and the index pages, the same as the body buttons of /cases and /servicos", () => {
expect(whatsAppSubjectForPath("/", contexts)).toBe(HOME_SUBJECT);
expect(whatsAppSubjectForPath("/cases", contexts)).toBe(CASES_SUBJECT);
expect(CASES_SUBJECT).toBe("um projeto parecido com os cases do portfólio");
expect(whatsAppSubjectForPath("/servicos", contexts)).toBe(
SERVICES_SUBJECT,
);
expect(SERVICES_SUBJECT).toBe("Sites, sistemas e aplicativos");
expect(whatsAppSubjectForPath("/curriculo", contexts)).toBe(RESUME_SUBJECT);
// sem contextos, a página-índice continua falando de si
expect(whatsAppSubjectForPath("/cases/")).toBe(CASES_SUBJECT);
expect(whatsAppSubjectForPath("/curriculo/")).toBe(RESUME_SUBJECT);
});
it("cites the AI review on /ia", () => {
@@ -36,11 +59,8 @@ describe("whatsAppSubjectForPath", () => {
});
it.each([
"/",
"/servicos",
"/cases",
"/curriculo",
"/privacidade",
"/notebook-smoke",
"/servicos/inexistente",
"/cases/inexistente",
"/servicos/criacao-de-sites/extra",
@@ -93,6 +113,29 @@ describe("isServiceOrCasePath", () => {
});
});
describe("headerOpensWhatsApp", () => {
it.each([
["/servicos/criacao-de-sites", true],
["/servicos/inexistente", true],
["/cases/sev-modaintima", true],
["/ia", true],
["/ia/", true],
["/cases", true],
["/servicos", true],
["/curriculo", true],
["/curriculo/", true],
// a home já tem o formulário na própria página; as demais rotas não têm assunto
["/", false],
["/privacidade", false],
["/cases/a/b", false],
["/ia/extra", false],
[null, false],
[undefined, false],
])("%s -> %s", (pathname, expected) => {
expect(headerOpensWhatsApp(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"));
+71 -14
View File
@@ -30,32 +30,89 @@ export interface WhatsAppContexts {
/** 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() : "";
/**
* Assuntos das páginas sem slug. Cabeçalho, botão flutuante e corpo da mesma página usam a mesma frase;
* /cases e /servicos repetem a que os botões do corpo dessas páginas já usam.
*/
export const HOME_SUBJECT = "um site ou sistema para a minha empresa";
export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). */
export function similarProjectSubject(title: string) {
return `um projeto parecido com o ${title}`;
}
/** Assunto de cada página sem parâmetro, pelo caminho sem a barra final (só a home mantém a barra). */
const INDEX_SUBJECTS: Readonly<Record<string, string>> = {
"/": HOME_SUBJECT,
"/ia": AI_REVIEW_SUBJECT,
"/cases": CASES_SUBJECT,
"/servicos": SERVICES_SUBJECT,
"/curriculo": RESUME_SUBJECT,
};
/** Páginas com um parâmetro no caminho: em que tabela de nomes ele se procura e como o nome vira assunto. */
const DETAIL_ROUTES = [
{
pattern: /^\/servicos\/([^/]+)$/,
table: "services",
subject: (name: string) => name,
},
{
pattern: /^\/cases\/([^/]+)$/,
table: "cases",
subject: similarProjectSubject,
},
] as const;
/**
* Nome pela chave; chave ausente, nome vazio ou só com espaços valem como ausente, para a mensagem cair no assunto
* padrão em vez de "sobre .".
*/
function lookup(
table: Record<string, string> | undefined,
key: string | undefined,
) {
const name =
table && key !== undefined && 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".
* Assunto da conversa de WhatsApp para a rota atual. Em /servicos/<slug> cita o serviço, em /cases/<slug> o case,
* em /ia a revisão de aplicativo e nas páginas-índice (home, /cases, /servicos, /curriculo) o assunto da própria
* página; 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 path = pathname?.replace(/(.)\/$/, "$1") ?? "";
if (Object.hasOwn(INDEX_SUBJECTS, path)) return INDEX_SUBJECTS[path];
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;
for (const { pattern, table, subject } of DETAIL_ROUTES) {
const name = lookup(contexts?.[table], pattern.exec(path)?.[1]);
if (name) return subject(name);
}
return 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. */
/** Páginas de serviço e de case. */
export function isServiceOrCasePath(pathname: string | null | undefined) {
return /^\/(servicos|cases)\/[^/]+\/?$/.test(pathname ?? "");
}
/**
* Páginas em que o botão do cabeçalho abre o WhatsApp com o assunto da página, em vez de ir ao formulário da home:
* serviço e case, e também /ia, /cases, /servicos e /curriculo. Na home e nas demais rotas ele leva ao formulário.
*/
export function headerOpensWhatsApp(pathname: string | null | undefined) {
return (
isServiceOrCasePath(pathname) ||
/^\/(ia|cases|servicos|curriculo)\/?$/.test(pathname ?? "")
);
}