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
+122
-21
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { COOKIE_BAR_ID } from "@/lib/analytics";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
import {
|
||||
isServiceOrCasePath,
|
||||
headerOpensWhatsApp,
|
||||
type WhatsAppContexts,
|
||||
whatsAppSubjectForPath,
|
||||
} from "@/lib/whatsapp";
|
||||
@@ -15,10 +15,46 @@ import WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
const MENU_ID = "site-navigation-menu";
|
||||
const CTA_LABEL = "Falar sobre meu projeto";
|
||||
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
|
||||
const INERT_IDS = ["main-content", "site-footer"];
|
||||
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
|
||||
const DESKTOP_QUERY = "(min-width: 1280px)";
|
||||
|
||||
/**
|
||||
* Botão de contato do cabeçalho. Nas páginas de serviço e de case ele abre o WhatsApp já com o assunto da página;
|
||||
* nas demais leva ao formulário da home.
|
||||
* Controles que o Tab percorre com o menu aberto: Aceitar e Recusar da barra de cookies, quando ela está aberta (ela
|
||||
* vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do menu, links e botão de contato do painel.
|
||||
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
|
||||
*/
|
||||
function menuFocusables(header: HTMLElement) {
|
||||
const cookieBar = document.getElementById(COOKIE_BAR_ID);
|
||||
const roots = cookieBar ? [cookieBar, header] : [header];
|
||||
return roots
|
||||
.flatMap((root) =>
|
||||
Array.from(
|
||||
root.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
|
||||
),
|
||||
)
|
||||
.filter((element) => !element.closest("[data-menu-trap-skip]"));
|
||||
}
|
||||
|
||||
/** Tab (e Shift+Tab) com o menu aberto: o foco dá a volta entre o cabeçalho e a barra de cookies, sem sair para a página inerte. */
|
||||
function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
||||
const focusables = menuFocusables(header);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
|
||||
const active = document.activeElement;
|
||||
const leaving =
|
||||
active === edge || !focusables.some((element) => element === active);
|
||||
if (!leaving || !opposite) return;
|
||||
|
||||
event.preventDefault();
|
||||
opposite.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
||||
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
|
||||
*/
|
||||
function ContactCta({
|
||||
className,
|
||||
@@ -46,9 +82,13 @@ function ContactCta({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href="/#contact" className={className} onClick={onNavigate}>
|
||||
<HoverPrefetchLink
|
||||
href="/#contact"
|
||||
className={className}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{CTA_LABEL}
|
||||
</Link>
|
||||
</HoverPrefetchLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,12 +99,19 @@ export function Header({
|
||||
whatsAppContexts?: WhatsAppContexts;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const viaWhatsApp = isServiceOrCasePath(pathname);
|
||||
const viaWhatsApp = headerOpensWhatsApp(pathname);
|
||||
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Trocou a rota: o menu fecha (o cabeçalho vive no layout e não remonta entre as páginas).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só dispara o fechamento a cada página
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasScrolled = false;
|
||||
const updateScrolled = () => {
|
||||
@@ -81,19 +128,72 @@ export function Header({
|
||||
|
||||
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
// Menu aberto: o conteúdo e o rodapé ficam inertes, a página não rola, o Tab não sai do cabeçalho e o Esc fecha.
|
||||
useEffect(() => {
|
||||
const header = headerRef.current;
|
||||
const menuButton = menuButtonRef.current;
|
||||
if (!mobileMenuOpen || !header || !menuButton) return;
|
||||
|
||||
event.preventDefault();
|
||||
closeMobileMenu();
|
||||
menuButtonRef.current?.focus();
|
||||
};
|
||||
const root = document.documentElement;
|
||||
const inertTargets = INERT_IDS.flatMap((id) => {
|
||||
const element = document.getElementById(id);
|
||||
return element ? [element] : [];
|
||||
});
|
||||
for (const element of inertTargets) element.setAttribute("inert", "");
|
||||
const previousOverflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
|
||||
// Devolve a página ao normal na hora, sem esperar o React: um link do menu para #id precisa achar o destino
|
||||
// focável quando o gerenciador de âncoras da home (useHomeFragmentNavigation) tenta focá-lo no clique.
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const element of inertTargets) element.removeAttribute("inert");
|
||||
root.style.overflow = previousOverflow;
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab") return trapTab(event, header);
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
event.preventDefault();
|
||||
release();
|
||||
setMobileMenuOpen(false);
|
||||
menuButton.focus();
|
||||
};
|
||||
// Na fase de captura da janela, antes do document: roda antes do gerenciador de âncoras da home.
|
||||
const onLinkClick = (event: MouseEvent) => {
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (link && header.contains(link)) release();
|
||||
};
|
||||
const desktop = window.matchMedia?.(DESKTOP_QUERY);
|
||||
const onViewportChange = (event: MediaQueryListEvent) => {
|
||||
if (!event.matches) return;
|
||||
release();
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("click", onLinkClick, true);
|
||||
desktop?.addEventListener?.("change", onViewportChange);
|
||||
return () => {
|
||||
release();
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("click", onLinkClick, true);
|
||||
desktop?.removeEventListener?.("change", onViewportChange);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
scrolled
|
||||
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||
? "border-white/12 bg-[var(--ink)]/95"
|
||||
: "border-transparent bg-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
@@ -102,12 +202,14 @@ export function Header({
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||
</HoverPrefetchLink>
|
||||
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
data-menu-trap-skip=""
|
||||
className="hidden items-center xl:flex"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
@@ -115,7 +217,7 @@ export function Header({
|
||||
<li key={item.href}>
|
||||
<HoverPrefetchLink
|
||||
href={item.href}
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</HoverPrefetchLink>
|
||||
@@ -125,9 +227,9 @@ export function Header({
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="hidden xl:block">
|
||||
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||
<ContactCta
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
||||
placement="header"
|
||||
viaWhatsApp={viaWhatsApp}
|
||||
subject={subject}
|
||||
@@ -141,7 +243,6 @@ export function Header({
|
||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<span
|
||||
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
@@ -158,13 +259,13 @@ export function Header({
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom))] lg:px-sp-8 xl:hidden">
|
||||
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
|
||||
<div className="ds-section-shell flex flex-1 flex-col">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
aria-label="Navegação mobile"
|
||||
className="flex flex-1 flex-col"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<ul className="flex list-none flex-col">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
|
||||
Reference in new issue
Block a user