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.
464 lines
18 KiB
TypeScript
464 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
|
import type { FormEvent, MouseEvent, ReactNode } from "react";
|
|
import { useEffect, useState } from "react";
|
|
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
|
import { CONTACT_EMAIL } from "@/lib/site-navigation";
|
|
import {
|
|
buildWhatsAppUrl,
|
|
getWhatsAppDisplayNumber,
|
|
OFFER_SUBJECTS,
|
|
} from "@/lib/whatsapp";
|
|
import {
|
|
CONTACT_FORM_ID,
|
|
CONTACT_KIND_EVENT,
|
|
CONTACT_KIND_PARAM,
|
|
CONTACT_KINDS,
|
|
CONTACT_SUBJECT_OPTIONS,
|
|
type ContactKind,
|
|
contactKindForSubject,
|
|
contactSubjectForKind,
|
|
DEFAULT_CONTACT_SUBJECT,
|
|
} from "./contact-subject";
|
|
import "./conversion.css";
|
|
import WhatsAppLink from "./WhatsAppLink";
|
|
import { WhatsAppMark } from "./WhatsAppMark";
|
|
|
|
type ContactField = "name" | "email" | "message";
|
|
type FieldErrors = Partial<Record<ContactField, string>>;
|
|
|
|
const FIELD_ERRORS: Record<ContactField, string> = {
|
|
name: "Informe seu nome para eu saber com quem falo.",
|
|
email: "Confira o e-mail: ele precisa ter o formato [email protected].",
|
|
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)}`;
|
|
}
|
|
|
|
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] font-medium text-white/85";
|
|
const errorClassName =
|
|
"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 =
|
|
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.";
|
|
|
|
/** Abre a URL em outra aba, ou devolve null se não deu (sem `window.open`, bloqueado ou com erro). */
|
|
function tryOpenTab(url: string): Window | null {
|
|
try {
|
|
return window.open(url, "_blank");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Corta o vínculo da aba aberta com esta página; devolve false se o navegador não deixou. */
|
|
function detachOpener(tab: Window): boolean {
|
|
try {
|
|
tab.opener = null;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
|
|
* e não dá para saber se abriu; o vínculo com a página (`opener`) é cortado logo em seguida. A página vai para o
|
|
* WhatsApp na própria aba quando não há aba nova (pop-up bloqueado, navegador interno de aplicativo, sem
|
|
* `window.open`) e também quando o vínculo não pôde ser cortado: nesse caso a aba aberta é fechada, para
|
|
* nenhuma aba do WhatsApp ficar com acesso a esta página.
|
|
*/
|
|
function openWhatsApp(url: string) {
|
|
const tab = tryOpenTab(url);
|
|
if (tab) {
|
|
if (detachOpener(tab)) return;
|
|
tab.close();
|
|
}
|
|
window.location.href = url;
|
|
}
|
|
|
|
/** Atributos que o campo entrega ao controle (input, textarea ou select) para o erro ser anunciado. */
|
|
interface ControlProps {
|
|
id: string;
|
|
"aria-invalid"?: true;
|
|
"aria-describedby"?: string;
|
|
}
|
|
|
|
/** Rótulo, controle e mensagem de erro de um campo; o controle recebe o id e os atributos de acessibilidade. */
|
|
function Field({
|
|
id,
|
|
label,
|
|
error,
|
|
describedBy,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
error?: string;
|
|
/** Outros textos que descrevem o campo, depois do erro. */
|
|
describedBy?: string;
|
|
children: (control: ControlProps) => ReactNode;
|
|
}) {
|
|
const errorId = `${id}-error`;
|
|
const description = [error ? errorId : "", describedBy ?? ""]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className={labelClassName}>
|
|
{label}
|
|
</label>
|
|
{children({
|
|
id,
|
|
"aria-invalid": error ? true : undefined,
|
|
"aria-describedby": description || undefined,
|
|
})}
|
|
{error && (
|
|
<p id={errorId} className={errorClassName}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Contact() {
|
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
|
useEffect(() => {
|
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
|
}, []);
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
email: "",
|
|
subject: DEFAULT_CONTACT_SUBJECT as string,
|
|
message: "",
|
|
});
|
|
const [errors, setErrors] = useState<FieldErrors>({});
|
|
// A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário.
|
|
const [chosenOffer, setChosenOffer] = useState<ContactKind | null>(null);
|
|
|
|
// 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) return;
|
|
setFormData((current) => ({ ...current, subject }));
|
|
setChosenOffer(contactKindForSubject(subject) ?? null);
|
|
};
|
|
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
|
|
const onChoose = (event: Event) =>
|
|
choose((event as CustomEvent<string | undefined>).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") setChosenOffer(null);
|
|
if (field !== "subject" && errors[field]) {
|
|
setErrors((current) => ({ ...current, [field]: undefined }));
|
|
}
|
|
};
|
|
|
|
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
|
|
const hasInvalidField = (form: HTMLFormElement | null) => {
|
|
const found = validate(formData);
|
|
setErrors(found);
|
|
const firstInvalid = FIELD_ORDER.find((field) => found[field]);
|
|
if (firstInvalid) {
|
|
form?.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
|
|
}
|
|
return firstInvalid !== undefined;
|
|
};
|
|
|
|
// O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto".
|
|
const selectedOffer = contactKindForSubject(formData.subject);
|
|
const directSubject = selectedOffer
|
|
? OFFER_SUBJECTS[selectedOffer]
|
|
: undefined;
|
|
|
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
if (hasInvalidField(e.currentTarget)) return;
|
|
openWhatsApp(buildWhatsAppUrl(buildProjectMessage(formData)));
|
|
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
|
};
|
|
|
|
/** O rascunho por e-mail passa pela mesma validação do WhatsApp: sem ela o e-mail sairia vazio. */
|
|
const handleEmailClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
if (hasInvalidField(e.currentTarget.closest("form"))) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
trackEmailClick({ placement: "contact_form" });
|
|
};
|
|
|
|
return (
|
|
<section
|
|
id="contact"
|
|
aria-labelledby="contact-title"
|
|
className="ds-section ds-section-dark"
|
|
>
|
|
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
|
|
<div className="min-w-0">
|
|
<p className="ds-eyebrow">Contato</p>
|
|
<h2
|
|
id="contact-title"
|
|
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
|
|
>
|
|
Vamos conversar sobre o que sua empresa precisa.
|
|
</h2>
|
|
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
|
|
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.
|
|
</p>
|
|
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
|
<h3 className="font-display text-lg font-semibold text-white">
|
|
Como a conversa começa.
|
|
</h3>
|
|
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] leading-7 text-white/65">
|
|
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
|
escopo, prazo e proposta vêm depois.
|
|
</p>
|
|
</div>
|
|
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
|
<WhatsAppLink
|
|
placement="contact_direct"
|
|
service={directSubject}
|
|
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
|
>
|
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
|
|
<WhatsAppMark className="h-6 w-6" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 font-display">
|
|
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
|
|
Prefere conversar direto?
|
|
</span>
|
|
<span className="mt-1 block text-[15px] text-white/65">
|
|
WhatsApp · {whatsAppDisplay}
|
|
</span>
|
|
</span>
|
|
<ArrowUpRight
|
|
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
|
|
aria-hidden="true"
|
|
/>
|
|
</WhatsAppLink>
|
|
<a
|
|
href={`mailto:${CONTACT_EMAIL}`}
|
|
onClick={() => 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)]"
|
|
>
|
|
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
|
<span className="min-w-0 break-all font-display text-[15px]">
|
|
{CONTACT_EMAIL}
|
|
</span>
|
|
<ArrowUpRight
|
|
className="ml-auto h-4 w-4 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
</a>
|
|
</div>
|
|
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/55">
|
|
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
id={CONTACT_FORM_ID}
|
|
onSubmit={handleSubmit}
|
|
noValidate
|
|
aria-labelledby="contact-form-title"
|
|
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
|
>
|
|
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
|
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
|
|
<WhatsAppMark className="h-7 w-7" />
|
|
</span>
|
|
<div>
|
|
<h3
|
|
id="contact-form-title"
|
|
className="font-display text-xl font-semibold leading-7 text-white"
|
|
>
|
|
Prepare sua mensagem
|
|
</h3>
|
|
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
|
|
Você revisa no WhatsApp antes de enviar.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/* js-only: app/layout.tsx esconde o que depende de JavaScript quando ele está desligado, e o <noscript> abaixo entra no lugar */}
|
|
<div className="js-only space-y-sp-5">
|
|
<div className="grid gap-sp-5 xl:grid-cols-2">
|
|
<Field id="contact-name" label="Seu nome" error={errors.name}>
|
|
{(control) => (
|
|
<input
|
|
{...control}
|
|
name="name"
|
|
required
|
|
type="text"
|
|
autoComplete="name"
|
|
placeholder="Como posso te chamar?"
|
|
value={formData.name}
|
|
onChange={(e) => updateField("name", e.target.value)}
|
|
className={fieldClassName}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Field
|
|
id="contact-email"
|
|
label="E-mail (opcional)"
|
|
error={errors.email}
|
|
>
|
|
{(control) => (
|
|
<input
|
|
{...control}
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
placeholder="[email protected]"
|
|
value={formData.email}
|
|
onChange={(e) => updateField("email", e.target.value)}
|
|
className={fieldClassName}
|
|
/>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
<Field id="contact-subject" label="Tipo de projeto">
|
|
{(control) => (
|
|
<select
|
|
{...control}
|
|
name="subject"
|
|
value={formData.subject}
|
|
onChange={(e) => updateField("subject", e.target.value)}
|
|
className={`${fieldClassName} [color-scheme:dark]`}
|
|
>
|
|
{CONTACT_SUBJECT_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</Field>
|
|
{/* <output> (role status): o texto muda quando uma oferta da home é escolhida, e o leitor de tela o anuncia; vazio, não ocupa espaço */}
|
|
<output className="block !mt-sp-2 font-display text-[15px] leading-6 text-white/85 empty:!mt-0">
|
|
{chosenOffer && (
|
|
<>
|
|
Você escolheu:{" "}
|
|
<strong className="font-semibold text-white">
|
|
{CONTACT_KINDS[chosenOffer]}
|
|
</strong>
|
|
</>
|
|
)}
|
|
</output>
|
|
<Field
|
|
id="contact-message"
|
|
label="Contexto do projeto"
|
|
error={errors.message}
|
|
describedBy="contact-privacy-note"
|
|
>
|
|
{(control) => (
|
|
<textarea
|
|
{...control}
|
|
name="message"
|
|
required
|
|
rows={4}
|
|
autoComplete="off"
|
|
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
|
value={formData.message}
|
|
onChange={(e) => updateField("message", e.target.value)}
|
|
className={`${fieldClassName} min-h-32 resize-y`}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<button
|
|
type="submit"
|
|
className="cv-btn cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] motion-reduce:transition-none"
|
|
>
|
|
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
|
Continuar no WhatsApp
|
|
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
|
|
</button>
|
|
<p className="text-center font-display text-[15px] leading-6 text-white/70">
|
|
<a
|
|
href={buildMailtoUrl(buildProjectMessage(formData))}
|
|
onClick={handleEmailClick}
|
|
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
|
|
</a>
|
|
</p>
|
|
<p
|
|
id="contact-privacy-note"
|
|
className="font-display text-[15px] leading-6 text-white/55"
|
|
>
|
|
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
|
|
para você revisar. Não inclua senhas nem dados pessoais de
|
|
clientes.
|
|
</p>
|
|
</div>
|
|
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
|
|
<noscript>
|
|
<p className="font-display text-base leading-7 text-white/80">
|
|
Para enviar sua mensagem, use um destes contatos:
|
|
</p>
|
|
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
|
<a
|
|
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
|
|
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
|
|
>
|
|
Chamar no WhatsApp
|
|
</a>
|
|
<a
|
|
href={`mailto:${CONTACT_EMAIL}`}
|
|
className="inline-flex min-h-12 items-center justify-center break-all rounded-lg border border-white/25 px-sp-4 font-medium text-white no-underline"
|
|
>
|
|
{CONTACT_EMAIL}
|
|
</a>
|
|
</p>
|
|
</noscript>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|