Files
portifolio-evandrodevbr/app/components/Header.tsx
T
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00

304 lines
12 KiB
TypeScript

"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import {
headerOpensWhatsApp,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "@/lib/whatsapp";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
const WHATSAPP_LABEL = "Chamar no WhatsApp";
/** 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)";
/**
* Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
* de privacidade", Aceitar e Recusar; 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, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
* ("Falar sobre meu projeto").
*/
function ContactCta({
className,
placement,
viaWhatsApp,
subject,
onNavigate,
}: {
className: string;
placement: string;
viaWhatsApp: boolean;
subject?: string;
onNavigate?: () => void;
}) {
if (viaWhatsApp) {
return (
<WhatsAppLink
className={className}
service={subject}
placement={placement}
onClick={onNavigate}
>
{WHATSAPP_LABEL}
</WhatsAppLink>
);
}
return (
<HoverPrefetchLink
href="/#contact"
className={className}
onClick={onNavigate}
>
{CTA_LABEL}
</HoverPrefetchLink>
);
}
export function Header({
whatsAppContexts,
}: {
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx), para o WhatsApp falar da página aberta. */
whatsAppContexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
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 = () => {
const nextScrolled = window.scrollY > 0;
if (nextScrolled === wasScrolled) return;
wasScrolled = nextScrolled;
setScrolled(nextScrolled);
};
updateScrolled();
window.addEventListener("scroll", updateScrolled, { passive: true });
return () => window.removeEventListener("scroll", updateScrolled);
}, []);
const closeMobileMenu = () => setMobileMenuOpen(false);
// 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;
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)]"
: "border-transparent bg-[var(--ink)]"
}`}
>
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
<HoverPrefetchLink
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">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
className="cv-bar 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 [--bar-x:var(--sp-3)] hover:text-white"
>
{item.label}
</HoverPrefetchLink>
</li>
))}
</ul>
</nav>
<div className="flex shrink-0 items-center">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
className="cv-fill 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"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
/>
</div>
<button
ref={menuButtonRef}
type="button"
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
// as linhas têm 2 px (h-0.5) e 8 px de espaço (gap-sp-2): de uma a outra são 10 px, e as de cima e de baixo andam 10 px até o meio, para o X fechar sem torcer
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[10px] rotate-45" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "-translate-y-[10px] -rotate-45" : ""}`}
/>
</button>
</div>
</div>
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
// 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="ds-enter 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))] [--enter-y:-8px] 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"
>
<ul className="flex list-none flex-col">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href} className="border-b border-white/10">
<HoverPrefetchLink
href={item.href}
className="cv-bar flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline [--bar-y:100%] hover:text-white"
onClick={closeMobileMenu}
>
{item.label}
</HoverPrefetchLink>
</li>
))}
</ul>
<div className="mt-auto pt-sp-8">
<ContactCta
className="cv-fill flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
onNavigate={closeMobileMenu}
/>
</div>
</nav>
</div>
</div>
) : null}
</header>
);
}