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
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
Reference in new issue
Block a user