From f8545cd442eb7b6d0e1ff4aa7a8972a8464e33e3 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 17:01:36 -0300 Subject: [PATCH] =?UTF-8?q?feat(home):=20ofertas=20que=20vendem=20o=20t?= =?UTF-8?q?=C3=ADtulo,=20WhatsApp=20no=20hero=20e=20menu=20do=20celular=20?= =?UTF-8?q?acess=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/components/About.paths.test.tsx | 27 +- app/components/About.tsx | 14 +- app/components/Contact.kind.paths.test.tsx | 74 ++++ app/components/Contact.offer.test.tsx | 149 +++++++ app/components/Contact.tsx | 57 +-- app/components/ContactOfferLink.test.tsx | 69 ++++ app/components/ContactOfferLink.tsx | 38 ++ .../FloatingWhatsApp.route.test.tsx | 18 +- app/components/FloatingWhatsApp.test.tsx | 25 ++ app/components/FloatingWhatsApp.tsx | 9 +- app/components/Footer.tsx | 4 +- app/components/GoogleAnalytics.tsx | 6 +- app/components/Header.menu.test.tsx | 378 ++++++++++++++++++ app/components/Header.paths.test.tsx | 134 ++++++- app/components/Header.test.tsx | 32 +- app/components/Header.tsx | 143 ++++++- app/components/Hero.test.tsx | 56 ++- app/components/Hero.tsx | 10 +- app/components/HeroWork.test.tsx | 102 +++++ app/components/HeroWork.tsx | 107 +++-- app/components/HomeFaq.test.tsx | 21 +- app/components/HomeFaq.tsx | 8 +- app/components/WhatsAppLink.test.tsx | 17 + app/components/WhatsAppLink.tsx | 4 + app/components/WorkModalities.paths.test.tsx | 340 +++++++++------- app/components/WorkModalities.tsx | 335 +++++++--------- .../cinematic/CinematicHome.paths.test.tsx | 35 +- app/components/cinematic/CinematicHome.tsx | 42 +- .../cinematic/CinematicMain.test.tsx | 70 ++++ app/components/cinematic/CinematicMain.tsx | 27 ++ app/components/contact-subject.test.ts | 58 +++ app/components/contact-subject.ts | 46 +++ app/ia/page.test.tsx | 62 ++- app/ia/page.tsx | 91 +++-- app/layout.test.tsx | 22 + app/layout.tsx | 25 +- app/page.paths.test.tsx | 5 +- app/page.tsx | 2 - lib/analytics.ts | 3 + lib/next-config.test.ts | 8 + lib/whatsapp.paths.test.ts | 38 +- lib/whatsapp.test.ts | 55 ++- lib/whatsapp.ts | 85 +++- next.config.ts | 6 +- 44 files changed, 2286 insertions(+), 571 deletions(-) create mode 100644 app/components/Contact.kind.paths.test.tsx create mode 100644 app/components/Contact.offer.test.tsx create mode 100644 app/components/ContactOfferLink.test.tsx create mode 100644 app/components/ContactOfferLink.tsx create mode 100644 app/components/Header.menu.test.tsx create mode 100644 app/components/HeroWork.test.tsx create mode 100644 app/components/cinematic/CinematicMain.test.tsx create mode 100644 app/components/cinematic/CinematicMain.tsx create mode 100644 app/components/contact-subject.test.ts create mode 100644 app/components/contact-subject.ts diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx index 6343968..7bc4903 100644 --- a/app/components/About.paths.test.tsx +++ b/app/components/About.paths.test.tsx @@ -17,15 +17,18 @@ jest.mock("./Section", () => ({ children, id, title, + description, aside, }: { children?: React.ReactNode; id?: string; title?: string; + description?: string; aside?: React.ReactNode; }) => (

{title}

+

{description}

{aside} {children}
@@ -92,7 +95,7 @@ describe("About", () => { expect( screen.getByRole("heading", { level: 3, - name: "Você fala direto com quem desenvolve", + name: "Sem intermediários entre o pedido e o sistema", }), ).toBeInTheDocument(); expect( @@ -109,11 +112,23 @@ describe("About", () => { expect(container.textContent).not.toMatch( /protocolos de baixo nível|operabilidade|camada física|sinal físico/i, ); - expect( - screen.getByText( - "Desde 2022 desenvolvo sistemas internos, aplicativos e integrações IoT na Apolo Energia.", - ), - ).toBeInTheDocument(); + // a seção abre pela credencial, logo depois do nome + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + "Evandro Fonseca Junior", + ); + expect(screen.getByTestId("about-description").textContent).toMatch( + /^Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia\./, + ); + // a frase da Apolo aparece uma só vez + expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1); + }); + + it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => { + const { container } = render(); + + expect(container.textContent).not.toMatch( + /contato direto|fala direto|falar direto|conversa comigo/i, + ); }); it("path: the location label stays on one line and the columns stretch to the same height", () => { diff --git a/app/components/About.tsx b/app/components/About.tsx index 83fa022..39ff2e9 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -10,9 +10,9 @@ import { Section } from "./Section"; const STORY_DATA = [ { label: "Como eu trabalho", - title: "Você fala direto com quem desenvolve", + title: "Sem intermediários entre o pedido e o sistema", content: - "Você conversa comigo sobre o negócio e acompanha as decisões ao longo da entrega, sem intermediários entre o seu pedido e o sistema.", + "Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.", }, { label: "Sem começar do zero", @@ -42,7 +42,7 @@ const PROFILE_POINTS = [ { title: "Quem responde pelo projeto", description: - "Você tem contato direto com quem decide a estrutura e desenvolve o sistema.", + "Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.", }, ]; @@ -54,8 +54,8 @@ export default function About() {
@@ -115,10 +115,6 @@ export default function About() { sistema. Ver tudo junto ajuda a decidir o que construir, o que conectar e o que dá para aproveitar.

-

- Desde 2022 desenvolvo sistemas internos, aplicativos e integrações - IoT na Apolo Energia. -

{PROFILE_POINTS.map((point) => (
+ screen.getByRole("combobox", { + name: /tipo de projeto/i, + }) as HTMLSelectElement; + +const choose = (kind: unknown) => + act(() => { + window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind })); + }); + +afterEach(() => { + window.history.replaceState(null, "", "/"); +}); + +describe("Contact: tipo no endereço", () => { + it.each([ + ["/", "sem busca nenhuma (tipo ausente)"], + ["/?utm_source=instagram#contact", "só outros parâmetros (tipo ausente)"], + ["/?tipo=#contact", "tipo vazio"], + ["/?tipo=SITE#contact", "tipo com outra caixa (só vale em minúsculas)"], + ["/?tipo=constructor#contact", "nome herdado de todo objeto"], + ["/?tipo=outra-coisa#contact", "tipo desconhecido"], + ])("path: %s (%s) -> keeps the default option", (address) => { + window.history.replaceState(null, "", address); + + render(); + + expect(subjectSelect()).toHaveValue(DEFAULT_CONTACT_SUBJECT); + }); + + it.each([ + ["/?tipo=site#contact", "Site para empresa"], + ["/?tipo=sistema#contact", "Sistemas web sob medida"], + [ + "/?utm_source=x&tipo=revisao#contact", + "Revisão ou diagnóstico de sistema", + ], + // com o parâmetro repetido vale o primeiro + ["/?tipo=site&tipo=revisao#contact", "Site para empresa"], + ])("path: %s (valid type) -> selects %s", (address, option) => { + window.history.replaceState(null, "", address); + + render(); + + expect(subjectSelect()).toHaveValue(option); + }); + + it("path: valid type in the address, then an unknown one from the event -> the address choice stays", () => { + window.history.replaceState(null, "", "/?tipo=sistema#contact"); + render(); + + choose("desconhecido"); + + expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); + }); + + it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => { + window.history.replaceState(null, "", "/?tipo=sistema#contact"); + render(); + + choose("revisao"); + + expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + }); +}); diff --git a/app/components/Contact.offer.test.tsx b/app/components/Contact.offer.test.tsx new file mode 100644 index 0000000..c7cd055 --- /dev/null +++ b/app/components/Contact.offer.test.tsx @@ -0,0 +1,149 @@ +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Contact from "./Contact"; +import { CONTACT_KIND_EVENT } from "./contact-subject"; + +/** + * O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara + * (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página), mais a opção de revisão de sistema. + */ + +const subjectSelect = () => + screen.getByRole("combobox", { + name: /tipo de projeto/i, + }) as HTMLSelectElement; + +const choose = (kind: unknown) => + act(() => { + window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind })); + }); + +afterEach(() => { + window.history.replaceState(null, "", "/"); +}); + +describe("Contact: opções do tipo de projeto", () => { + it("lists 'Revisão ou diagnóstico de sistema' and keeps 'Ainda preciso definir' first and selected", () => { + render(); + + const options = within(subjectSelect()).getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Ainda preciso definir", + "Site para empresa", + "Sistemas web sob medida", + "Aplicativos", + "Integrações e automações", + "Evolução de um sistema existente", + "Revisão ou diagnóstico de sistema", + ]); + expect(subjectSelect()).toHaveValue("Ainda preciso definir"); + }); + + it("puts the review type in the message that goes to WhatsApp and e-mail", async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.selectOptions( + subjectSelect(), + "Revisão ou diagnóstico de sistema", + ); + + const mail = screen.getByRole("link", { name: /e-mail/i }); + const body = new URL(mail.getAttribute("href") ?? "").searchParams.get( + "body", + ); + expect(body).toContain( + "Tipo de projeto: Revisão ou diagnóstico de sistema", + ); + }); +}); + +describe("Contact: tipo escolhido pela oferta", () => { + it.each([ + ["site", "Site para empresa"], + ["sistema", "Sistemas web sob medida"], + ["revisao", "Revisão ou diagnóstico de sistema"], + ])("the offer event with %s selects %s", (kind, option) => { + render(); + + choose(kind); + + expect(subjectSelect()).toHaveValue(option); + }); + + it("a later offer replaces the earlier choice, and a person's own pick is replaced by the offer too", async () => { + const user = userEvent.setup({ delay: null }); + render(); + + choose("site"); + await user.selectOptions(subjectSelect(), "Aplicativos"); + expect(subjectSelect()).toHaveValue("Aplicativos"); + choose("revisao"); + + expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + }); + + it.each([ + "desconhecido", + "constructor", + "__proto__", + "", + undefined, + null, + 42, + ])("an unknown type %p changes nothing", (kind) => { + render(); + choose("sistema"); + + choose(kind); + + expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); + }); + + it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => { + window.history.replaceState(null, "", "/?tipo=revisao#contact"); + + render(); + + expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + }); + + it("ignores an unknown ?tipo= in the address and keeps the default", () => { + window.history.replaceState(null, "", "/?tipo=outra-coisa"); + + render(); + + expect(subjectSelect()).toHaveValue("Ainda preciso definir"); + }); + + it("stops listening when it unmounts", () => { + const add = jest.spyOn(window, "addEventListener"); + const remove = jest.spyOn(window, "removeEventListener"); + const { unmount } = render(); + const registered = add.mock.calls.find( + ([type]) => type === CONTACT_KIND_EVENT, + ); + expect(registered).toBeDefined(); + + unmount(); + + expect(remove).toHaveBeenCalledWith(CONTACT_KIND_EVENT, registered?.[1]); + add.mockRestore(); + remove.mockRestore(); + }); +}); + +describe("Contact: texto da seção", () => { + it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => { + const { container } = render(); + + expect(screen.getByText("Prefere conversar direto?")).toBeInTheDocument(); + expect(container.textContent).not.toMatch(/fala direto|fale direto/i); + expect( + screen.getByRole("heading", { + level: 3, + name: "Como a conversa começa.", + }), + ).toBeInTheDocument(); + }); +}); diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 6037974..690caf5 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -6,6 +6,13 @@ import { useEffect, useState } from "react"; import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics"; import { CONTACT_EMAIL } from "@/lib/site-navigation"; import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp"; +import { + CONTACT_KIND_EVENT, + CONTACT_KIND_PARAM, + CONTACT_SUBJECT_OPTIONS, + contactSubjectForKind, + DEFAULT_CONTACT_SUBJECT, +} from "./contact-subject"; import "./conversion.css"; import WhatsAppLink from "./WhatsAppLink"; import { WhatsAppMark } from "./WhatsAppMark"; @@ -54,19 +61,9 @@ function buildMailtoUrl(message: string) { const fieldClassName = "mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]"; const labelClassName = - "block font-display text-[15px] md:text-sm font-medium text-white/85"; + "block font-display text-[15px] font-medium text-white/85"; const errorClassName = - "mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[#ff9d94]"; - -/** "Ainda preciso definir" vem primeiro e é o padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */ -const SUBJECT_OPTIONS = [ - "Ainda preciso definir", - "Site para empresa", - "Sistemas web sob medida", - "Aplicativos", - "Integrações e automações", - "Evolução de um sistema existente", -]; + "mt-sp-2 font-display text-[15px] leading-6 text-[#ff9d94]"; /** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */ const DIRECT_MESSAGE = @@ -161,11 +158,25 @@ export default function Contact() { const [formData, setFormData] = useState({ name: "", email: "", - subject: "Ainda preciso definir", + subject: DEFAULT_CONTACT_SUBJECT as string, message: "", }); const [errors, setErrors] = useState({}); + // O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra + // página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada. + useEffect(() => { + const choose = (kind: string | null | undefined) => { + const subject = contactSubjectForKind(kind); + if (subject) setFormData((current) => ({ ...current, subject })); + }; + choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM)); + const onChoose = (event: Event) => + choose((event as CustomEvent).detail); + window.addEventListener(CONTACT_KIND_EVENT, onChoose); + return () => window.removeEventListener(CONTACT_KIND_EVENT, onChoose); + }, []); + const updateField = (field: ContactField | "subject", value: string) => { setFormData((current) => ({ ...current, [field]: value })); if (field !== "subject" && errors[field]) { @@ -222,9 +233,9 @@ export default function Contact() {

- Você fala direto com quem desenvolve. + Como a conversa começa.

-

+

Sou Evandro. A primeira conversa é para entender sua necessidade; escopo, prazo e proposta vêm depois.

@@ -238,10 +249,10 @@ export default function Contact() { - + Prefere conversar direto? - + WhatsApp · {whatsAppDisplay} @@ -256,7 +267,7 @@ export default function Contact() { className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]" >
-

+

@@ -288,7 +299,7 @@ export default function Contact() { > Prepare sua mensagem -

+

Você revisa no WhatsApp antes de enviar.

@@ -339,7 +350,7 @@ export default function Contact() { onChange={(e) => updateField("subject", e.target.value)} className={`${fieldClassName} [color-scheme:dark]`} > - {SUBJECT_OPTIONS.map((option) => ( + {CONTACT_SUBJECT_OPTIONS.map((option) => ( @@ -386,7 +397,7 @@ export default function Contact() {

Nada é enviado automaticamente. O WhatsApp abre com a mensagem para você revisar. Não inclua senhas nem dados pessoais de @@ -396,7 +407,7 @@ export default function Contact() { {/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}

- Para enviar sua mensagem, fale direto comigo: + Para enviar sua mensagem, use um destes contatos:

({ + __esModule: true, + default: ({ + children, + prefetch, + ...props + }: { children?: React.ReactNode; prefetch?: boolean | null } & Record< + string, + unknown + >) => ( + + {children} + + ), +})); + +describe("ContactOfferLink", () => { + it("links to the form with the type in the address, and prefetches nothing until hover or focus", () => { + render( + + Começar pelo sistema + , + ); + + const link = screen.getByRole("link", { name: "Começar pelo sistema" }); + expect(link).toHaveAttribute("href", "/?tipo=sistema#contact"); + expect(link).toHaveClass("botao"); + expect(link).toHaveAttribute("data-prefetch", "false"); + fireEvent.mouseEnter(link); + expect(link).toHaveAttribute("data-prefetch", "null"); + }); + + it("tells the form which type was chosen, through a window event, when it is clicked", () => { + const heard: unknown[] = []; + const listener = (event: Event) => + heard.push((event as CustomEvent).detail); + window.addEventListener(CONTACT_KIND_EVENT, listener); + try { + render(Começar); + + fireEvent.click(screen.getByRole("link", { name: "Começar" })); + expect(heard).toEqual(["revisao"]); + } finally { + window.removeEventListener(CONTACT_KIND_EVENT, listener); + } + }); + + it("still tells the form when another handler already cancelled the click (the home scroll manager does that to scroll by itself)", () => { + const heard: unknown[] = []; + const listener = (event: Event) => + heard.push((event as CustomEvent).detail); + const cancel = (event: Event) => event.preventDefault(); + window.addEventListener(CONTACT_KIND_EVENT, listener); + document.addEventListener("click", cancel, true); + try { + render(Começar); + + fireEvent.click(screen.getByRole("link", { name: "Começar" })); + expect(heard).toEqual(["site"]); + } finally { + window.removeEventListener(CONTACT_KIND_EVENT, listener); + document.removeEventListener("click", cancel, true); + } + }); +}); diff --git a/app/components/ContactOfferLink.tsx b/app/components/ContactOfferLink.tsx new file mode 100644 index 0000000..0a8aecd --- /dev/null +++ b/app/components/ContactOfferLink.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + CONTACT_KIND_EVENT, + type ContactKind, + contactKindHref, +} from "./contact-subject"; +import HoverPrefetchLink from "./HoverPrefetchLink"; + +/** + * Botão de uma oferta da home: leva ao formulário de contato com o tipo de projeto já escolhido. + * Na própria home o clique rola até o formulário (useCinematicScroll) sem trocar a busca do endereço, então o + * tipo também segue por evento; o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba. + */ +export default function ContactOfferLink({ + kind, + className, + children, +}: { + kind: ContactKind; + className?: string; + children: ReactNode; +}) { + return ( + + window.dispatchEvent( + new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }), + ) + } + > + {children} + + ); +} diff --git a/app/components/FloatingWhatsApp.route.test.tsx b/app/components/FloatingWhatsApp.route.test.tsx index 55ae492..d34d40b 100644 --- a/app/components/FloatingWhatsApp.route.test.tsx +++ b/app/components/FloatingWhatsApp.route.test.tsx @@ -61,11 +61,21 @@ describe("message by route", () => { ], [ "/cases/sev-modaintima", - "Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.", + "Quero conversar sobre um projeto parecido com o SEV Moda Íntima.", ], ["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."], - ["/", "Quero conversar sobre um projeto."], - ["/servicos", "Quero conversar sobre um projeto."], + // a mesma frase do botão do hero e do cabeçalho de cada página + ["/", "Quero conversar sobre um site ou sistema para a minha empresa."], + ["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."], + [ + "/cases", + "Quero conversar sobre um projeto parecido com os cases do portfólio.", + ], + [ + "/curriculo", + "Quero conversar sobre um projeto, depois de ver o seu currículo.", + ], + ["/privacidade", "Quero conversar sobre um projeto."], ["/servicos/inexistente", "Quero conversar sobre um projeto."], ])("on %s", (pathname, sentence) => { jest.mocked(usePathname).mockReturnValue(pathname); @@ -112,7 +122,7 @@ describe("page changes", () => { ]); expect(floatingLink()).toBeInTheDocument(); expect(messageOf(floatingLink())).toContain( - "Quero conversar sobre um projeto.", + "Quero conversar sobre um site ou sistema para a minha empresa.", ); // sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço diff --git a/app/components/FloatingWhatsApp.test.tsx b/app/components/FloatingWhatsApp.test.tsx index 5a20c69..26b093b 100644 --- a/app/components/FloatingWhatsApp.test.tsx +++ b/app/components/FloatingWhatsApp.test.tsx @@ -119,6 +119,31 @@ it("lifts itself by the height of the open cookie bar", () => { ); }); +it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => { + render(); + setVisibility(false, false); + + const className = floatingLink()?.parentElement?.className ?? ""; + expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]"); + expect(className).toContain("transition-transform"); + expect(className).toContain("motion-reduce:transition-none"); + // o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele + expect(className).not.toContain("transition-[bottom]"); + expect(className).toContain( + "bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]", + ); +}); + +it("sits in a named complementary region (the 'region' landmark rule), which only exists while the shortcut shows", () => { + render(); + expect(screen.queryByRole("complementary")).toBeNull(); + + setVisibility(false, false); + const region = screen.getByRole("complementary", { name: "Contato rápido" }); + expect(region.tagName).toBe("ASIDE"); + expect(region).toContainElement(floatingLink()); +}); + it("reports the click as a floating WhatsApp handoff", () => { render(); setVisibility(false, false); diff --git a/app/components/FloatingWhatsApp.tsx b/app/components/FloatingWhatsApp.tsx index 0d6abcc..5c5e191 100644 --- a/app/components/FloatingWhatsApp.tsx +++ b/app/components/FloatingWhatsApp.tsx @@ -14,6 +14,8 @@ const CONTACT_SECTION_ID = "contact"; * Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem * cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e * contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta. + * Fica num

); } diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index b89e7d6..6296585 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -132,7 +132,7 @@ export default function Footer({
  • Currículo @@ -151,7 +151,7 @@ export default function Footer({ para 4:1) e o resto do logo, já quase preto, quase não muda */} -

    +

    © {currentYear} Evandro Fonseca Junior. Todos os direitos reservados.

    diff --git a/app/components/GoogleAnalytics.tsx b/app/components/GoogleAnalytics.tsx index ea3e3c6..eff4310 100644 --- a/app/components/GoogleAnalytics.tsx +++ b/app/components/GoogleAnalytics.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { type AnalyticsConsent, CONSENT_STORAGE_KEY, + COOKIE_BAR_ID, GA_DISABLE_KEY, GA_MEASUREMENT_ID, GOOGLE_ADS_ID, @@ -222,6 +223,7 @@ function GoogleAnalyticsConsent() { {barOpen && (