diff --git a/README.md b/README.md index bea3325..c430e9b 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,6 @@ corepack pnpm@9.15.9 start | `pnpm test:watch` | Executa Jest em modo watch. | | `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. | -## Contagem de projetos do GitHub - -O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados. - -Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece. - ## Vitrine de vídeos dos projetos Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`. diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx new file mode 100644 index 0000000..783deae --- /dev/null +++ b/app/components/About.paths.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from "@testing-library/react"; +import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import About from "./About"; + +/** + * Testes de caminhos-base de About: todas as decisões dependem de `shouldReduceMotion` + * (variantes dos cards, transição, hover e toque), então há dois caminhos: movimento normal e reduzido. + * O `motion/react` é trocado por elementos simples que registram as props de animação. + */ + +const mockSeen = jest.fn(); + +jest.mock("motion/react", () => { + const React = require("react"); + const cache = new Map(); + const motion = new Proxy( + {}, + { + get: (_target, tag: string) => { + if (!cache.has(tag)) { + cache.set( + tag, + ({ + children, + variants, + whileHover, + whileTap, + transition, + initial: _initial, + animate: _animate, + whileInView: _whileInView, + viewport: _viewport, + ...props + }: Record & { children?: unknown }) => { + mockSeen(tag, { variants, whileHover, whileTap, transition }); + return React.createElement(tag, props, children); + }, + ); + } + return cache.get(tag); + }, + }, + ); + return { motion }; +}); + +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: jest.fn(), +})); + +jest.mock("@/public/edev.jpg", () => ({ + __esModule: true, + default: { src: "/edev.jpg", width: 10, height: 10 }, +})); + +jest.mock("./Section", () => ({ + Section: ({ + children, + id, + title, + }: { + children?: React.ReactNode; + id?: string; + title?: string; + }) => ( +
+

{title}

+ {children} +
+ ), +})); + +jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null })); +jest.mock("./animations/LazyRevealImage", () => ({ + LazyRevealImage: ({ alt }: { alt: string }) => ( + + ), +})); + +type Seen = { + variants?: { + hidden: Record; + visible: { transition: Record }; + }; + whileHover?: unknown; + whileTap?: unknown; +}; + +function renderedCards(): Seen[] { + return mockSeen.mock.calls + .filter(([tag]) => tag === "article") + .map(([, seen]) => seen as Seen); +} + +beforeEach(() => { + mockSeen.mockClear(); +}); + +describe("About", () => { + it("path: normal motion -> cards rise and clip in, with hover lift and press feedback", () => { + jest.mocked(useReducedMotion).mockReturnValue(false); + + render(); + + const cards = renderedCards(); + expect(cards).toHaveLength(4); // 3 etapas da história + o card do perfil + for (const card of cards) { + expect(card.variants?.hidden).toEqual({ + opacity: 0, + y: 20, + clipPath: "inset(100% 0 0 0)", + }); + expect(card.variants?.visible.transition).toEqual({ + duration: 0.8, + ease: [0.16, 1, 0.3, 1], + }); + expect(card.whileHover).toEqual({ + y: -4, + boxShadow: "0 18px 40px rgba(10,10,10,0.16)", + }); + expect(card.whileTap).toEqual({ scale: 0.99 }); + } + }); + + it("path: reduced motion -> cards only fade, with no hover lift and no press feedback", () => { + jest.mocked(useReducedMotion).mockReturnValue(true); + + render(); + + const cards = renderedCards(); + expect(cards).toHaveLength(4); + for (const card of cards) { + expect(card.variants?.hidden).toEqual({ opacity: 0 }); + expect(card.variants?.visible.transition).toEqual({ duration: 0.25 }); + expect(card.whileHover).toBeUndefined(); + expect(card.whileTap).toBeUndefined(); + } + }); + + it("path: content does not depend on motion -> same story, profile points and résumé link", () => { + for (const reduced of [false, true]) { + jest.mocked(useReducedMotion).mockReturnValue(reduced); + const { unmount } = render(); + + expect( + screen.getByRole("heading", { + level: 3, + name: "Do sinal físico ao produto digital", + }), + ).toBeInTheDocument(); + expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Ver trajetória completa" }), + ).toHaveAttribute("href", "/curriculo"); + unmount(); + } + }); +}); diff --git a/app/components/About.tsx b/app/components/About.tsx index 08b9127..429cddc 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -1,10 +1,12 @@ "use client"; import { motion, type Variants } from "motion/react"; +import Link from "next/link"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import profilePhoto from "@/public/edev.jpg"; import { DrawLine } from "./animations/DrawLine"; import { LazyRevealImage } from "./animations/LazyRevealImage"; +import { textLink } from "./button-styles"; import { Section } from "./Section"; const STORY_DATA = [ @@ -162,6 +164,12 @@ export default function About() { ))} + + Ver trajetória completa + diff --git a/app/components/ClientStrip.paths.test.tsx b/app/components/ClientStrip.paths.test.tsx new file mode 100644 index 0000000..f0071c7 --- /dev/null +++ b/app/components/ClientStrip.paths.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import ClientStrip from "./ClientStrip"; + +/** + * Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes + * (um cliente, vários) e o className opcional. + */ + +const clients = [ + { id: "sev-modaintima", name: "SEV Moda Íntima" }, + { id: "lavacao-047", name: "Lavação 047" }, + { id: "sc-plus", name: "SC Plus Aquicultura" }, +]; + +describe("ClientStrip", () => { + it("path: a single client -> no separator dot", () => { + const { container } = render(); + + expect(screen.getAllByRole("link")).toHaveLength(1); + expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0); + }); + + it("path: several clients -> a separator after every client except the last", () => { + render(); + + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(3); + expect( + items.map((item) => item.querySelector("[aria-hidden='true']") !== null), + ).toEqual([true, true, false]); + expect(items[0].querySelector("[aria-hidden='true']")).toHaveTextContent( + "·", + ); + }); + + it("path: className given -> appended to the wrapper without a stray space", () => { + const { container } = render( + , + ); + + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.className.endsWith("hero-in mt-sp-4")).toBe(true); + }); + + it("path: className omitted -> the wrapper class has no trailing space", () => { + const { container } = render(); + + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.className).toBe(wrapper.className.trim()); + }); +}); diff --git a/app/components/ClientStrip.test.tsx b/app/components/ClientStrip.test.tsx new file mode 100644 index 0000000..c0cc706 --- /dev/null +++ b/app/components/ClientStrip.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, within } from "@testing-library/react"; +import ClientStrip from "./ClientStrip"; + +const clients = [ + { id: "sev-modaintima", name: "SEV Moda Íntima" }, + { id: "lavacao-047", name: "Lavação 047" }, +]; + +it("lists each client as a link to its case", () => { + render(); + + expect(screen.getByText("Já entregue para:")).toBeInTheDocument(); + const list = screen.getByRole("list"); + const links = within(list).getAllByRole("link"); + expect(links.map((link) => link.textContent)).toEqual([ + "SEV Moda Íntima", + "Lavação 047", + ]); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/cases/sev-modaintima", + "/cases/lavacao-047", + ]); +}); + +it("renders nothing without clients", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + + const withoutProp = render(); + expect(withoutProp.container).toBeEmptyDOMElement(); +}); diff --git a/app/components/ClientStrip.tsx b/app/components/ClientStrip.tsx new file mode 100644 index 0000000..738c584 --- /dev/null +++ b/app/components/ClientStrip.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +export type ClientLink = { id: string; name: string }; + +/** + * Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case. + * Sem clientes, nada é desenhado. + */ +export default function ClientStrip({ + clients = [], + className = "", +}: { + clients?: ClientLink[]; + className?: string; +}) { + if (clients.length === 0) return null; + + return ( +
+

Já entregue para:

+
    + {clients.map((client, index) => ( +
  • + + {client.name} + + {/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */} + {index < clients.length - 1 && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/app/components/Contact.paths.test.tsx b/app/components/Contact.paths.test.tsx new file mode 100644 index 0000000..f0e6a67 --- /dev/null +++ b/app/components/Contact.paths.test.tsx @@ -0,0 +1,279 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Contact from "./Contact"; + +/** + * Testes de caminhos-base de Contact (complemento de Contact.test.tsx): validate() campo a campo, + * foco no primeiro campo inválido, limpeza independente dos erros e montagem da mensagem. + */ + +beforeEach(() => { + jest.replaceProperty(process.env, "NODE_ENV", "production"); + localStorage.setItem("evandro.analytics-consent.v1", "granted"); + delete window["ga-disable-G-QTT9QT0T3S"]; + window.gtag = jest.fn(); + jest.spyOn(window, "open").mockImplementation(() => null); +}); + +afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + delete window.gtag; +}); + +const NAME_ERROR = "Informe seu nome para eu saber com quem falo."; +const EMAIL_ERROR = + "Confira o e-mail: ele precisa ter o formato nome@empresa.com.br."; +const MESSAGE_ERROR = "Conte em poucas palavras o que você precisa resolver."; + +const nameField = () => screen.getByRole("textbox", { name: /nome/i }); +const emailField = () => screen.getByRole("textbox", { name: /e-mail/i }); +const messageField = () => screen.getByRole("textbox", { name: /contexto/i }); +const submitButton = () => screen.getByRole("button", { name: /whatsapp/i }); + +async function fill( + user: ReturnType, + values: { name?: string; email?: string; message?: string }, +) { + if (values.name !== undefined) await user.type(nameField(), values.name); + if (values.email !== undefined) await user.type(emailField(), values.email); + if (values.message !== undefined) + await user.type(messageField(), values.message); +} + +function sentMessage() { + const url = new URL(String(jest.mocked(window.open).mock.calls[0][0])); + return url.searchParams.get("text") ?? ""; +} + +describe("Contact: validate()", () => { + it("path: only the name is missing -> only the name is flagged and gets the focus", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { + email: "ana@example.com", + message: "Organizar pedidos", + }); + + await user.click(submitButton()); + + expect(nameField()).toHaveAttribute("aria-invalid", "true"); + expect(emailField()).not.toHaveAttribute("aria-invalid"); + expect(messageField()).not.toHaveAttribute("aria-invalid"); + expect(screen.getByText(NAME_ERROR)).toBeVisible(); + expect(screen.queryByText(EMAIL_ERROR)).toBeNull(); + expect(screen.queryByText(MESSAGE_ERROR)).toBeNull(); + expect(nameField()).toHaveFocus(); + expect(window.open).not.toHaveBeenCalled(); + expect(window.gtag).not.toHaveBeenCalled(); + }); + + it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { + name: "Ana", + email: "ana@semponto", + message: "Organizar pedidos", + }); + + await user.click(submitButton()); + + expect(emailField()).toHaveAttribute("aria-invalid", "true"); + expect(emailField()).toHaveAccessibleDescription(EMAIL_ERROR); + expect(nameField()).not.toHaveAttribute("aria-invalid"); + expect(messageField()).not.toHaveAttribute("aria-invalid"); + expect(emailField()).toHaveFocus(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("path: only the message is missing -> only the message is flagged and gets the focus", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { name: "Ana" }); + + await user.click(submitButton()); + + expect(messageField()).toHaveAttribute("aria-invalid", "true"); + expect(messageField()).toHaveAccessibleDescription( + new RegExp(MESSAGE_ERROR), + ); + expect(nameField()).not.toHaveAttribute("aria-invalid"); + expect(emailField()).not.toHaveAttribute("aria-invalid"); + expect(messageField()).toHaveFocus(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("path: name and message made only of spaces -> both rejected", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { name: " ", message: " " }); + + await user.click(submitButton()); + + expect(screen.getByText(NAME_ERROR)).toBeVisible(); + expect(screen.getByText(MESSAGE_ERROR)).toBeVisible(); + expect(nameField()).toHaveFocus(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { + name: "Ana", + email: " ", + message: "Organizar pedidos", + }); + + await user.click(submitButton()); + + expect(screen.queryByText(EMAIL_ERROR)).toBeNull(); + expect(window.open).toHaveBeenCalledTimes(1); + expect(sentMessage()).not.toContain("E-mail:"); + }); + + it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a b@c.com", "sem-arroba.com"])( + "path: e-mail %j -> invalid format is blocked", + async (email) => { + const user = userEvent.setup(); + render(); + await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); + + await user.click(submitButton()); + + expect(screen.getByText(EMAIL_ERROR)).toBeVisible(); + expect(window.open).not.toHaveBeenCalled(); + }, + ); + + it.each(["a@b.co", "ana+projeto@sub.empresa.com.br"])( + "path: e-mail %j -> valid format is accepted and sent", + async (email) => { + const user = userEvent.setup(); + render(); + await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); + + await user.click(submitButton()); + + expect(screen.queryByText(EMAIL_ERROR)).toBeNull(); + expect(window.open).toHaveBeenCalledTimes(1); + expect(sentMessage()).toContain(`E-mail: ${email}\n`); + }, + ); +}); + +describe("Contact: updateField() and the error state", () => { + it("path: typing in one field clears only that field's error", async () => { + const user = userEvent.setup(); + render(); + await user.click(submitButton()); + expect(screen.getByText(NAME_ERROR)).toBeVisible(); + expect(screen.getByText(MESSAGE_ERROR)).toBeVisible(); + + await user.type(messageField(), "Organizar pedidos"); + + expect(screen.queryByText(MESSAGE_ERROR)).toBeNull(); + expect(messageField()).not.toHaveAttribute("aria-invalid"); + expect(screen.getByText(NAME_ERROR)).toBeVisible(); + expect(nameField()).toHaveAttribute("aria-invalid", "true"); + }); + + it("path: changing the project type keeps the other fields' errors untouched", async () => { + const user = userEvent.setup(); + render(); + await user.click(submitButton()); + + await user.selectOptions( + screen.getByRole("combobox", { name: /tipo de projeto/i }), + "Aplicativos", + ); + + expect(screen.getByText(NAME_ERROR)).toBeVisible(); + expect(screen.getByText(MESSAGE_ERROR)).toBeVisible(); + expect( + screen.getByRole("combobox", { name: /tipo de projeto/i }), + ).toHaveValue("Aplicativos"); + }); + + it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => { + const user = userEvent.setup(); + render(); + await user.click(submitButton()); + expect(window.open).not.toHaveBeenCalled(); + + await fill(user, { name: "Ana", message: "Organizar pedidos" }); + await user.click(submitButton()); + + expect(screen.queryByText(NAME_ERROR)).toBeNull(); + expect(screen.queryByText(MESSAGE_ERROR)).toBeNull(); + expect(window.open).toHaveBeenCalledTimes(1); + expect(window.gtag).toHaveBeenCalledTimes(1); + }); +}); + +describe("Contact: mensagem montada", () => { + it("path: surrounding spaces are trimmed in name, e-mail and message", async () => { + const user = userEvent.setup(); + render(); + await fill(user, { + name: " Ana ", + email: " ana@example.com ", + message: " Organizar pedidos ", + }); + + await user.click(submitButton()); + + const message = sentMessage(); + expect(message).toContain("Nome: Ana\n"); + expect(message).toContain("E-mail: ana@example.com\n"); + expect(message).toContain( + "Contexto, objetivo e prazo:\nOrganizar pedidos\n\n", + ); + }); + + it("path: the chosen project type and the e-mail line reach the mailto draft, and the e-mail line is omitted when blank", async () => { + const user = userEvent.setup(); + render(); + await user.selectOptions( + screen.getByRole("combobox", { name: /tipo de projeto/i }), + "Aplicativos", + ); + await fill(user, { name: "Ana", message: "Organizar pedidos" }); + + const mail = screen.getByRole("link", { + name: "Prefere e-mail? Enviar por e-mail", + }); + const body = new URL(mail.getAttribute("href") ?? "").searchParams.get( + "body", + ); + expect(body).toContain("Tipo de projeto: Aplicativos"); + expect(body).not.toContain("E-mail:"); + + await user.type(emailField(), "ana@example.com"); + const withEmail = new URL( + screen + .getByRole("link", { name: "Prefere e-mail? Enviar por e-mail" }) + .getAttribute("href") ?? "", + ).searchParams.get("body"); + expect(withEmail).toContain("E-mail: ana@example.com\n"); + }); + + it("path: the direct e-mail row reports its own placement", async () => { + const user = userEvent.setup(); + render(); + + const direct = screen.getByRole("link", { + name: /evandrofonsecajunior@gmail\.com/, + }); + // o jsdom não navega para mailto:; o clique só precisa chegar ao rastreio + direct.addEventListener("click", (event) => event.preventDefault()); + await user.click(direct); + + expect(window.gtag).toHaveBeenCalledWith( + "event", + "email_click", + expect.objectContaining({ placement: "contact_direct" }), + ); + }); +}); diff --git a/app/components/Contact.test.tsx b/app/components/Contact.test.tsx index bfdbfb1..bd75390 100644 --- a/app/components/Contact.test.tsx +++ b/app/components/Contact.test.tsx @@ -103,3 +103,114 @@ it("opens the project draft with analytics unavailable and consent denied", asyn expect(destination.origin).toBe("https://wa.me"); expect(destination.searchParams.get("text")).toContain("Organizar pedidos"); }); + +it("lets the visitor skip the e-mail and leaves that line out of the message", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + + expect(window.open).toHaveBeenCalledTimes(1); + const url = new URL(String(jest.mocked(window.open).mock.calls[0][0])); + const message = url.searchParams.get("text") ?? ""; + expect(message).toContain("Nome: Ana"); + expect(message).toContain("Tipo de projeto: Site para empresa"); + expect(message).not.toContain("E-mail:"); +}); + +it("offers 'Site para empresa' as the first and default project type", () => { + render(); + + const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); + const options = Array.from(subject.querySelectorAll("option")).map( + (option) => option.value, + ); + expect(options[0]).toBe("Site para empresa"); + expect(subject).toHaveValue("Site para empresa"); +}); + +it("explains each invalid field in Portuguese and marks it for assistive technology", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByRole("textbox", { name: /e-mail/i }), + "email-invalido", + ); + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + + const name = screen.getByRole("textbox", { name: /nome/i }); + const email = screen.getByRole("textbox", { name: /e-mail/i }); + const message = screen.getByRole("textbox", { name: /contexto/i }); + for (const field of [name, email, message]) { + expect(field).toHaveAttribute("aria-invalid", "true"); + } + expect(name).toHaveAccessibleDescription( + "Informe seu nome para eu saber com quem falo.", + ); + expect(email).toHaveAccessibleDescription( + "Confira o e-mail: ele precisa ter o formato nome@empresa.com.br.", + ); + expect(message).toHaveAccessibleDescription( + /Conte em poucas palavras o que você precisa resolver\./, + ); + expect(name).toHaveFocus(); + expect(window.open).not.toHaveBeenCalled(); + + await user.type(name, "Ana"); + expect(name).not.toHaveAttribute("aria-invalid"); + expect( + screen.queryByText("Informe seu nome para eu saber com quem falo."), + ).toBeNull(); +}); + +it("offers the same text by e-mail, below the WhatsApp button", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos & estoque", + ); + const submit = screen.getByRole("button", { name: /whatsapp/i }); + const mail = screen.getByRole("link", { + name: "Prefere e-mail? Enviar por e-mail", + }); + expect( + submit.compareDocumentPosition(mail) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + const href = mail.getAttribute("href") ?? ""; + expect(href.startsWith("mailto:evandrofonsecajunior@gmail.com?")).toBe(true); + const url = new URL(href); + expect(url.searchParams.get("subject")).toBe("Projeto pelo site"); + const body = url.searchParams.get("body") ?? ""; + expect(body).toContain("Nome: Ana"); + expect(body).toContain("Organizar pedidos & estoque"); + expect(body).toContain("Tipo de projeto: Site para empresa"); + + await user.click(mail); + expect(window.gtag).toHaveBeenCalledWith( + "event", + "email_click", + expect.objectContaining({ placement: "contact_form" }), + ); +}); + +it("does not call the direct WhatsApp row 'Contato'", () => { + render(); + + const direct = screen.getByRole("link", { name: /Prefere conversar direto/ }); + const text = new URL(direct.getAttribute("href") ?? "").searchParams.get( + "text", + ); + expect(text).toContain("Quero conversar sobre um projeto."); + expect(text).not.toMatch(/sobre Contato/); +}); diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 3559cae..d0a00bb 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -3,23 +3,52 @@ import { ArrowUpRight, Mail, MapPin } from "lucide-react"; import type { FormEvent } from "react"; import { useEffect, useState } from "react"; -import { trackWhatsAppOpen } from "@/lib/analytics"; +import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics"; +import { CONTACT_EMAIL } from "@/lib/site-navigation"; import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp"; +import "./conversion.css"; import WhatsAppLink from "./WhatsAppLink"; +import { WhatsAppMark } from "./WhatsAppMark"; -// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons). -function WhatsAppMark({ className }: { className: string }) { - return ( - - ); +type ContactField = "name" | "email" | "message"; +type FieldErrors = Partial>; + +const FIELD_ERRORS: Record = { + name: "Informe seu nome para eu saber com quem falo.", + email: "Confira o e-mail: ele precisa ter o formato nome@empresa.com.br.", + message: "Conte em poucas palavras o que você precisa resolver.", +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const FIELD_ORDER: ContactField[] = ["name", "email", "message"]; + +/** E-mail é opcional: só é erro se vier preenchido e fora do formato. */ +function validate(data: { + name: string; + email: string; + message: string; +}): FieldErrors { + const errors: FieldErrors = {}; + if (!data.name.trim()) errors.name = FIELD_ERRORS.name; + const email = data.email.trim(); + if (email && !EMAIL_PATTERN.test(email)) errors.email = FIELD_ERRORS.email; + if (!data.message.trim()) errors.message = FIELD_ERRORS.message; + return errors; +} + +/** Mesmo texto para o WhatsApp e para o e-mail; a linha do e-mail só entra se a pessoa informou um. */ +function buildProjectMessage(data: { + name: string; + email: string; + subject: string; + message: string; +}) { + const email = data.email.trim(); + return `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${data.name.trim()}\n${email ? `E-mail: ${email}\n` : ""}Tipo de projeto: ${data.subject}\n\nContexto, objetivo e prazo:\n${data.message.trim()}\n\nContato iniciado pelo site evandro.dev.br.`; +} + +function buildMailtoUrl(message: string) { + return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`; } export default function Contact() { @@ -29,21 +58,37 @@ export default function Contact() { }, []); 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)]"; + "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-sm font-medium text-white/85"; + const errorClassName = + "mt-sp-2 font-display text-sm leading-6 text-[#ff9d94]"; const [formData, setFormData] = useState({ name: "", email: "", - subject: "Sistemas web sob medida", + subject: "Site para empresa", message: "", }); + const [errors, setErrors] = useState({}); + + const updateField = (field: ContactField | "subject", value: string) => { + setFormData((current) => ({ ...current, [field]: value })); + if (field !== "subject" && errors[field]) { + setErrors((current) => ({ ...current, [field]: undefined })); + } + }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); - if (!e.currentTarget.reportValidity()) return; - const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`; + const form = e.currentTarget; + const found = validate(formData); + setErrors(found); + const firstInvalid = FIELD_ORDER.find((field) => found[field]); + if (firstInvalid) { + form.querySelector(`[name="${firstInvalid}"]`)?.focus(); + return; + } window.open( - buildWhatsAppUrl(messageTemplate), + buildWhatsAppUrl(buildProjectMessage(formData)), "_blank", "noopener,noreferrer", ); @@ -81,7 +126,6 @@ export default function Contact() {
@@ -102,12 +146,13 @@ export default function Contact() { /> trackEmailClick({ placement: "contact_direct" })} 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)]" >
- setFormData({ ...formData, email: e.target.value }) + aria-invalid={errors.email ? true : undefined} + aria-describedby={ + errors.email ? "contact-email-error" : undefined } + value={formData.email} + onChange={(e) => updateField("email", e.target.value)} className={fieldClassName} /> + {errors.email && ( +

+ {errors.email} +

+ )}
@@ -189,11 +248,10 @@ export default function Contact() { id="contact-subject" name="subject" value={formData.subject} - onChange={(e) => - setFormData({ ...formData, subject: e.target.value }) - } + onChange={(e) => updateField("subject", e.target.value)} className={`${fieldClassName} [color-scheme:dark]`} > + @@ -220,22 +278,39 @@ export default function Contact() { rows={4} autoComplete="off" placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?" - aria-describedby="contact-privacy-note" - value={formData.message} - onChange={(e) => - setFormData({ ...formData, message: e.target.value }) + aria-invalid={errors.message ? true : undefined} + aria-describedby={ + errors.message + ? "contact-message-error contact-privacy-note" + : "contact-privacy-note" } + value={formData.message} + onChange={(e) => updateField("message", e.target.value)} className={`${fieldClassName} min-h-32 resize-y`} /> + {errors.message && ( +

+ {errors.message} +

+ )}
+

+ trackEmailClick({ placement: "contact_form" })} + className="inline-flex min-h-11 items-center underline underline-offset-4 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]" + > + Prefere e-mail? Enviar por e-mail + +

({ + Section: ({ + children, + title, + description, + }: { + children: ReactNode; + title: string; + description?: string; + }) => ( +

+

{title}

+

{description}

+ {children} +
+ ), +})); + +it("is the short home version and points to /ia for the full content", () => { + render(); + + expect( + screen.getByRole("heading", { + name: "Já começou com IA? Dá para partir daí.", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Entender como funciona" }), + ).toHaveAttribute("href", "/ia"); + // os incidentes e a LGPD ficam só em /ia + expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull(); +}); diff --git a/app/components/EngineeringWithAI.tsx b/app/components/EngineeringWithAI.tsx index 8bbeae9..78117f3 100644 --- a/app/components/EngineeringWithAI.tsx +++ b/app/components/EngineeringWithAI.tsx @@ -1,213 +1,20 @@ +import Link from "next/link"; +import { secondaryButtonLight } from "./button-styles"; import { Section } from "./Section"; -const RESPONSIBILITIES = [ - { - title: "Quem pode acessar cada dado?", - description: - "Login não basta. Permissões precisam ser verificadas no servidor e testadas entre usuários, empresas e perfis diferentes.", - }, - { - title: "Como recuperar a operação?", - description: - "Ambientes separados, acesso restrito à produção e backups com restauração testada reduzem o impacto de uma alteração errada.", - }, - { - title: "Quem valida antes de publicar?", - description: - "Código gerado também passa por revisão, testes e avaliação de dependências. O escopo inclui manutenção, custos e riscos de operação.", - }, -]; - +/** Versão curta da home. O conteúdo completo (e os incidentes citados) fica em /ia. */ export default function EngineeringWithAI() { return (
-
-

- Você contrata engenharia para transformar as regras do seu negócio em - um sistema que possa ser testado, operado e mantido. A IA pode fazer - parte desse trabalho. Ela não dispensa decisões sobre arquitetura, - segurança e responsabilidade pelos dados. -

-
- -
- {RESPONSIBILITIES.map((item) => ( -
-

{item.title}

-

{item.description}

-
- ))} -
- -
-

O que casos documentados ensinam

-

- São episódios de 2025, não avaliações atuais das plataformas. Eles - mostram riscos de configuração, permissões e operação; não provam que - todo sistema feito com IA seja inseguro ou que um profissional elimine - qualquer risco. -

-
-
- - Dados expostos em aplicações criadas com Lovable - -
-

- Em 29 de maio de 2025, a Semafor noticiou uma análise de Matt - Palmer e Kody Low: 170 de 1.645 aplicações examinadas permitiam - acesso indevido a informações, incluindo dados de usuários e - chaves de API. A reportagem relacionou a exposição à - configuração de acesso dos bancos conectados às aplicações. -

-

- O registro CVE-2025-48757 descreve políticas insuficientes de - acesso por registro no banco. O fornecedor contesta a atribuição - da vulnerabilidade à plataforma e ressalta a responsabilidade - dos clientes por seus dados. O relato de exposição não comprova - roubo por terceiros em todas essas aplicações. -

-

- O cuidado técnico: testar se uma pessoa - consegue ler ou alterar dados de outra, em vez de considerar a - tela de login uma prova de segurança. -

- -
-
- -
- - Replit: um agente apagou dados de uma aplicação - -
-

- Em julho de 2025, Jason Lemkin, cofundador da SaaStr, relatou - que um agente apagou dados durante a construção de uma - aplicação. A Replit reconheceu o episódio e informou que o banco - foi integralmente recuperado por rollback. Naquele projeto, - alterações de desenvolvimento podiam afetar a produção. -

-

- A plataforma anunciou separação entre bancos de desenvolvimento - e produção por padrão. O cuidado técnico:{" "} - limitar permissões de agentes, separar ambientes e testar a - recuperação antes de depender dela. Neste caso houve exclusão e - recuperação de dados, não roubo de informações. -

- - Relato e medidas publicadas pela Replit (29/07/2025) - -
-
- -
- - Base44: aplicações privadas com falha de acesso - -
-

- Em julho de 2025, pesquisadores da Wiz demonstraram uma falha - que permitia acesso não autorizado a aplicações privadas do - Base44. A falha estava na camada compartilhada de autenticação - da plataforma, não em um erro comprovado de um usuário - iniciante. -

-

- A Wix corrigiu o problema em menos de 24 horas após a - notificação e informou não ter encontrado evidência de - exploração maliciosa. O cuidado técnico:{" "} - avaliar também o fornecedor e as permissões efetivas das - integrações, além do código da aplicação. -

- - Pesquisa da Wiz e resposta da Wix (29/07/2025) - -
-
-
-
- -
-
-

E a LGPD?

-

- Usar IA não afasta as obrigações de proteção de dados. A ANPD exige - medidas técnicas e administrativas também de agentes de pequeno - porte e esclarece que as flexibilizações não os isentam das demais - obrigações da LGPD, como bases legais e direitos dos titulares. -

-

- O trabalho técnico inclui controles de acesso, coleta limitada ao - necessário e rotinas de retenção e exclusão. Conformidade depende - também dos processos da empresa e de orientação jurídica; contratar - desenvolvimento não equivale a uma certificação de adequação. -

- - ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13 - -
-
-

- Já começou com IA? Podemos partir daí. -

-

- Seu protótipo ajuda a explicar a ideia. Antes de propor uma - reconstrução, avalio o que pode ser aproveitado, quais riscos - precisam de correção e o que falta para atender usuários em - produção. -

-

- Você não precisa saber programar para conduzir o negócio. Precisa - entender o que está contratando, acompanhar a entrega e ter clareza - sobre quem cuida de cada parte do sistema. -

- - Avaliar meu projeto - -
-
+ + Entender como funciona +
); } diff --git a/app/components/Experience.test.tsx b/app/components/Experience.test.tsx deleted file mode 100644 index 7fe66d0..0000000 --- a/app/components/Experience.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { render, screen, within } from "@testing-library/react"; -import type { HTMLAttributes, ReactNode } from "react"; -import type { PortfolioExperience } from "@/lib/portfolio-data"; -import Experience from "./Experience"; - -type MotionProps = HTMLAttributes & { - initial?: unknown; - whileInView?: unknown; - viewport?: unknown; - variants?: unknown; - whileHover?: unknown; - whileTap?: unknown; - transition?: unknown; -}; - -jest.mock("motion/react", () => { - const createMotionElement = - (Tag: "div" | "article") => - ({ - children, - initial: _initial, - whileInView: _whileInView, - viewport: _viewport, - variants: _variants, - whileHover: _whileHover, - whileTap: _whileTap, - transition: _transition, - ...props - }: MotionProps & { children?: ReactNode }) => ( - {children} - ); - - return { - motion: { - div: createMotionElement("div"), - article: createMotionElement("article"), - }, - useReducedMotion: () => false, - }; -}); - -jest.mock("./Section", () => ({ - Section: ({ - children, - id, - title, - }: { - children?: ReactNode; - id?: string; - title?: string; - }) => ( -
-

{title}

- {children} -
- ), -})); - -jest.mock("./animations/DrawLine", () => ({ - DrawLine: () => null, -})); - -function experience({ - id, - title, - period, - featured, -}: { - id: string; - title: string; - period: string; - featured: boolean; -}): PortfolioExperience { - return { - id, - title, - company: "Empresa", - period, - location: "Remoto", - type: "Software", - description: "Descrição", - achievements: [], - technologies: [], - featured, - }; -} - -describe("Experience", () => { - it("renders only featured experiences from newest to oldest", () => { - render( - , - ); - - expect( - screen.queryByRole("heading", { name: "Oculta" }), - ).not.toBeInTheDocument(); - expect( - screen - .getAllByRole("article") - .map( - (article) => - within(article).getByRole("heading", { level: 3 }).textContent, - ), - ).toEqual(["Mais recente", "Mais antiga"]); - }); -}); diff --git a/app/components/Experience.tsx b/app/components/Experience.tsx deleted file mode 100644 index 9204fac..0000000 --- a/app/components/Experience.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { motion, type Variants } from "motion/react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; -import type { PortfolioExperience } from "@/lib/portfolio-data"; -import { DrawLine } from "./animations/DrawLine"; -import { Section } from "./Section"; - -export default function Experience({ - experiences = [], -}: { - experiences?: PortfolioExperience[]; -}) { - const shouldReduceMotion = useReducedMotion(); - const sortedExperiences = [...experiences] - .filter((experience) => experience.featured) - .sort((a, b) => { - const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10); - const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10); - return yearB - yearA; - }); - - const listVariants: Variants = { - hidden: {}, - visible: { - transition: { - delayChildren: 0.08, - staggerChildren: 0.08, - }, - }, - }; - - const itemVariants: Variants = { - hidden: shouldReduceMotion - ? { opacity: 0 } - : { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" }, - visible: { - opacity: 1, - y: 0, - clipPath: "inset(0% 0 0 0)", - transition: shouldReduceMotion - ? { duration: 0.25 } - : { duration: 0.8, ease: [0.16, 1, 0.3, 1] }, - }, - }; - - return ( -
- - {sortedExperiences.map((exp) => ( - -
-
-
{exp.period}
- -

- {exp.title} -

-

- {exp.company} • {exp.type} -

-
-
{exp.location}
-
- -

- {exp.description} -

- -
    - {exp.achievements?.map((achievement) => ( -
  • {achievement}
  • - ))} -
- -
    - {exp.technologies?.slice(0, 6).map((tech) => ( -
  • - {tech} -
  • - ))} -
-
- ))} -
-
- ); -} diff --git a/app/components/FloatingWhatsApp.paths.test.tsx b/app/components/FloatingWhatsApp.paths.test.tsx new file mode 100644 index 0000000..1c94d73 --- /dev/null +++ b/app/components/FloatingWhatsApp.paths.test.tsx @@ -0,0 +1,242 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import FloatingWhatsApp from "./FloatingWhatsApp"; +import GoogleAnalytics from "./GoogleAnalytics"; + +/** + * Testes de caminhos-base de FloatingWhatsApp (complemento de FloatingWhatsApp.test.tsx): + * cada convite (botão do hero, seção de contato) sozinho na página, relatórios parciais do observador, + * desconexão ao desmontar e a convivência com a barra de cookies. + * A regra "só abaixo de 768 px" é CSS (md:hidden); o jsdom não avalia media queries, então só a classe é conferida. + */ + +jest.mock("next/script", () => ({ + __esModule: true, + default: ({ + strategy: _strategy, + ...props + }: ComponentProps<"script"> & { strategy?: string }) => + +
+
+ +

IA no desenvolvimento

+

+ Começou um sistema com IA? Dá para partir daí. +

+

+ A IA permite testar ideias e construir protótipos mesmo sem + experiência em programação. Quando o sistema passa a cuidar de + clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que + foi gerado e validar como ele se comporta fora da demonstração. +

+
+ + Falar sobre meu sistema + + + Ver projetos entregues + +
+
+
+ +
+
+

+ Se a IA cria um sistema, por que contratar um desenvolvedor? +

+
+

+ Você contrata engenharia para transformar as regras do seu negócio + em um sistema que possa ser testado, operado e mantido. A IA pode + fazer parte desse trabalho. Ela não dispensa decisões sobre + arquitetura, segurança e responsabilidade pelos dados. +

+
+
+ {RESPONSIBILITIES.map((item) => ( +
+

+ {item.title} +

+

{item.description}

+
+ ))} +
+
+
+ +
+
+

+ O que casos documentados ensinam +

+

+ São episódios de 2025, não avaliações atuais das plataformas. Eles + mostram riscos de configuração, permissões e operação; não provam + que todo sistema feito com IA seja inseguro ou que um profissional + elimine qualquer risco. +

+
+ {INCIDENTS.map((incident) => ( +
+

{incident.title}

+

{incident.summary}

+

+ O cuidado técnico: {incident.care} +

+ +
+ ))} +
+
+
+ +
+
+
+

+ E a LGPD? +

+

+ Usar IA não afasta as obrigações de proteção de dados. A ANPD + exige medidas técnicas e administrativas também de agentes de + pequeno porte e esclarece que as flexibilizações não os isentam + das demais obrigações da LGPD, como bases legais e direitos dos + titulares. +

+

+ O trabalho técnico inclui controles de acesso, coleta limitada ao + necessário e rotinas de retenção e exclusão. Conformidade depende + também dos processos da empresa e de orientação jurídica; + contratar desenvolvimento não equivale a uma certificação de + adequação. +

+ + ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13 + +
+
+

+ Já começou com IA? Podemos partir daí. +

+

+ Seu protótipo ajuda a explicar a ideia. Antes de propor uma + reconstrução, avalio o que pode ser aproveitado, quais riscos + precisam de correção e o que falta para atender usuários em + produção. +

+

+ Você não precisa saber programar para conduzir o negócio. Precisa + entender o que está contratando, acompanhar a entrega e ter + clareza sobre quem cuida de cada parte do sistema. +

+
+ + Avaliar meu projeto + +
+
+
+
+ + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 6fe63a7..0bc9c89 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -131,6 +131,10 @@ export default function RootLayout({ children }: Readonly) { {/* Set arrival policy before native restoration and client-side layout changes. */} + {/* Sem JavaScript as animações de entrada (opacity:0 inline vindo do servidor e palavras de título em CSS) não rodam: tudo precisa aparecer. */} +