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:
1 parent
de6af74a0c
commit
f8545cd442
44 files changed
+2286
-571
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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 === "/(.*)");
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ?? "")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user