diff --git a/.gitignore b/.gitignore index a0d7728..faad045 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ next-env.d.ts .superpowers/ /.worktrees/ /.impeccable/ +/.serena/ # generated analysis artifacts /graphify-out/ diff --git a/CREDITS.md b/CREDITS.md index a72b3c3..91ddc85 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -4,3 +4,8 @@ - `public/assets/prints/*.png` are captures taken with headless Chrome from production deployments owned by the site author (evandro.dev.br), used as on-screen content inside the notebook browser surface. + +## WhatsApp icon +- The WhatsApp silhouette in `app/components/Contact.tsx` comes from + [Simple Icons](https://github.com/simple-icons/simple-icons/blob/develop/icons/whatsapp.svg). + It identifies the contact channel; it does not imply endorsement by WhatsApp. diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..8d81af7 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index b683ae0..be3a76f 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -58,16 +58,12 @@ function readJsonLdEntities(container: HTMLElement): Record[] { } function expectCaseCtas() { - expect( - screen.getByRole("link", { - name: "Avaliar perfil para contratação", - }), - ).toHaveAttribute("href", "/curriculo"); - expect( - screen.getByRole("link", { - name: "Solicitar consultoria técnica", - }), - ).toHaveAttribute("href", "/#contact"); + const destinations = screen + .getAllByRole("link") + .map((link) => link.getAttribute("href")); + expect(destinations).toContain("/#contact"); + expect(destinations).toContain("/servicos"); + expect(destinations).not.toContain("/curriculo"); } it("keeps the six public params and factual case metadata", async () => { @@ -213,7 +209,7 @@ it("renders the authorized detailed case in order with traceable sources", async }); it("keeps the fallback concise and links its declared services", async () => { - const caseStudy = getCase("gcc-installer"); + const caseStudy = getCase("sc-plus"); expect(caseStudy.detail).toBeUndefined(); const ui = await CasePage({ params: Promise.resolve({ slug: caseStudy.id }), @@ -226,7 +222,9 @@ it("keeps the fallback concise and links its declared services", async () => { for (const technology of caseStudy.stack) { expect(screen.getByText(technology)).toBeVisible(); } - expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull(); + expect( + screen.getByRole("link", { name: "Abrir demonstração" }), + ).toHaveAttribute("href", caseStudy.demoUrl); expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute( "href", caseStudy.githubUrl, diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index 409310a..16a28c0 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -35,10 +35,11 @@ export async function generateMetadata({ const url = `${siteUrl}/cases/${caseStudy.id}`; return { - title: `${caseStudy.title} | Case de Engenharia de Software`, + title: `${caseStudy.title} | Case de Engenharia`, description: caseStudy.summary, alternates: { canonical: url }, openGraph: { + locale: "pt_BR", title: caseStudy.title, description: caseStudy.summary, url, @@ -109,6 +110,14 @@ export default async function CasePage({ params }: CasePageProps) {

{caseStudy.detail?.answer.text ?? caseStudy.summary}

+ {caseStudy.role ? ( +

+ + Minha participação: + {" "} + {caseStudy.role} +

+ ) : null} {!caseStudy.detail ? (

{caseStudy.impact} @@ -236,9 +245,9 @@ export default async function CasePage({ params }: CasePageProps) {

- {intentCtas.hiring.label} + {intentCtas.project.label} - {intentCtas.hiring.label} + {intentCtas.project.label} - Garuva, SC | Remote worldwide + Base em Garuva, SC | Atendimento remoto
} > @@ -140,14 +140,14 @@ export default function About() { className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]" imageClassName="object-contain" /> -
Technical profile
+
Da arquitetura à implementação
-

Base técnica e postura de entrega

+

Uma visão do sistema inteiro

- Sou engenheiro da computação e desenvolvedor de software full-stack, - atuando ha 5 anos com foco em integrar camadas que normalmente ficam - separadas: hardware, servicos, operacao e interface. Meu trabalho e - dar forma clara ao que costuma nascer fragmentado. + Meu trabalho conecta interface, serviços, dados e, quando + necessário, equipamentos. Entender essas partes em conjunto ajuda a + definir o que construir, o que integrar e o que pode ser + aproveitado.

{PROFILE_POINTS.map((point) => ( diff --git a/app/components/Contact.test.tsx b/app/components/Contact.test.tsx new file mode 100644 index 0000000..bfdbfb1 --- /dev/null +++ b/app/components/Contact.test.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Contact from "./Contact"; + +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; +}); + +it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => { + const user = userEvent.setup(); + render(); + + const name = "Ana & João"; + const email = "ana+projeto@example.com"; + const context = + "Integrar pedidos & estoque? Objetivo: evitar retrabalho.\nPrazo: outubro #2026"; + await user.type(screen.getByRole("textbox", { name: /nome/i }), name); + await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email); + const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); + await user.selectOptions(subject, "Integrações e automações"); + await user.type(screen.getByRole("textbox", { name: /contexto/i }), context); + + expect(window.open).not.toHaveBeenCalled(); + expect(window.gtag).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + + expect(window.open).toHaveBeenCalledTimes(1); + const [destination, target, features] = jest.mocked(window.open).mock + .calls[0]; + const url = new URL(String(destination)); + expect(url.origin).toBe("https://wa.me"); + expect(url.pathname).toMatch(/^\/\d+$/); + expect([...url.searchParams.keys()]).toEqual(["text"]); + const message = url.searchParams.get("text"); + expect(message).toContain(name); + expect(message).toContain(email); + expect(message).toContain("Integrações e automações"); + expect(message).toContain(context); + expect(target).toBe("_blank"); + expect(features).toBe("noopener,noreferrer"); + expect(window.gtag).toHaveBeenCalledTimes(1); + expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", { + send_to: "G-QTT9QT0T3S", + service: "Contato", + placement: "contact_form", + page_path: "/", + page_location: "https://evandro.dev.br/", + page_referrer: "", + }); +}); + +it("does not open WhatsApp while required fields are empty or the email is invalid", async () => { + const user = userEvent.setup(); + render(); + + const submit = screen.getByRole("button", { name: /whatsapp/i }); + await user.click(submit); + expect(window.open).not.toHaveBeenCalled(); + + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /e-mail/i }), + "email-invalido", + ); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + await user.click(submit); + expect(window.open).not.toHaveBeenCalled(); + expect(window.gtag).not.toHaveBeenCalled(); +}); + +it("opens the project draft with analytics unavailable and consent denied", async () => { + localStorage.setItem("evandro.analytics-consent.v1", "denied"); + delete window.gtag; + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /e-mail/i }), + "ana@example.com", + ); + 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 destination = new URL( + String(jest.mocked(window.open).mock.calls[0][0]), + ); + expect(destination.origin).toBe("https://wa.me"); + expect(destination.searchParams.get("text")).toContain("Organizar pedidos"); +}); diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 2d34959..3559cae 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -1,231 +1,252 @@ "use client"; -import { motion, type Variants } from "motion/react"; -import type React from "react"; +import { ArrowUpRight, Mail, MapPin } from "lucide-react"; +import type { FormEvent } from "react"; import { useEffect, useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { trackWhatsAppOpen } from "@/lib/analytics"; import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp"; -import { DrawLine } from "./animations/DrawLine"; -import MagneticButton from "./cinematic/MagneticButton"; -import { Section } from "./Section"; +import WhatsAppLink from "./WhatsAppLink"; + +// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons). +function WhatsAppMark({ className }: { className: string }) { + return ( + + ); +} export default function Contact() { - const shouldReduceMotion = useReducedMotion(); const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp"); - useEffect(() => { setWhatsAppDisplay(getWhatsAppDisplayNumber()); }, []); const fieldClassName = - "mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] 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)]"; + const labelClassName = "block font-display text-sm font-medium text-white/85"; const [formData, setFormData] = useState({ name: "", email: "", - subject: "full-stack", + subject: "Sistemas web sob medida", message: "", }); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); - - const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`; - - const whatsappUrl = buildWhatsAppUrl(messageTemplate); - - window.open(whatsappUrl, "_blank", "noopener,noreferrer"); - }; - - const gridVariants: Variants = { - hidden: {}, - visible: { - transition: { - delayChildren: 0.05, - staggerChildren: 0.08, - }, - }, - }; - - const cardVariants: Variants = { - hidden: shouldReduceMotion - ? { opacity: 0 } - : { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" }, - visible: { - opacity: 1, - y: 0, - clipPath: "inset(0% 0 0 0)", - transition: shouldReduceMotion - ? { duration: 0.25 } - : { duration: 0.75, ease: [0.16, 1, 0.3, 1] }, - }, + 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.`; + window.open( + buildWhatsAppUrl(messageTemplate), + "_blank", + "noopener,noreferrer", + ); + trackWhatsAppOpen({ service: "Contato", placement: "contact_form" }); }; return ( -
- - -
- Availability -
- -

- Resposta inicial em ate 24h -

-

- Ideal para discovery tecnico, revisao de arquitetura, rescue de - projeto, performance ou construcao de produto com ownership real. +

+
+

Contato

+

+ Vamos conversar sobre o que sua empresa precisa. +

+

+ Conte o que você quer resolver. Pode ser um projeto novo, uma + melhoria ou uma integração. Não precisa chegar com a solução + definida.

-
-
WhatsApp: {whatsAppDisplay}
-
E-mail: evandrofonsecajunior@gmail.com
-
Base: Garuva, Santa Catarina | atuacao remota
+
+

+ Você fala direto com quem desenvolve. +

+

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

-
- Envie contexto, prazo e objetivo. Eu cuido de transformar isso em - conversa tecnica objetiva. +
+ + + + + + + Prefere conversar direto? + + + WhatsApp · {whatsAppDisplay} + + + + +
- +

+

+
- -
- - - setFormData({ ...formData, name: e.target.value }) - } - className={fieldClassName} - /> +
+ + + +
+

+ Prepare sua mensagem +

+

+ Você revisa no WhatsApp antes de enviar. +

+
-
-