feat(home): posicionamento com prova, menu sem recarregar e WhatsApp em todo o site
H1 'Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.'; HeroWork maior com a SEV; menu Cases, Serviços, Como trabalho, Sobre e IA com next/link e pré-carregamento no hover; menu do celular em tela cheia; WhatsApp flutuante e no rodapé com a mensagem da rota; rodapé em todas as páginas; FAQ da home; /ia como oferta de revisão de app feito com IA; formulário com noscript, validação do mailto e plano B do pop-up; cartões sem foco indevido; sem a fonte Monument.
This commit is contained in:
1 parent
e80f73d075
commit
10f170abf4
56 files changed
+3099
-906
No files matched your search
+219
-126
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||
import type { FormEvent } from "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";
|
||||
@@ -51,21 +51,117 @@ 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] md:text-sm 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",
|
||||
];
|
||||
|
||||
/** 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 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-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: "Site para empresa",
|
||||
subject: "Ainda preciso definir",
|
||||
message: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
@@ -77,22 +173,31 @@ export default function Contact() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
/** 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();
|
||||
form?.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
|
||||
}
|
||||
return firstInvalid !== 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;
|
||||
}
|
||||
window.open(
|
||||
buildWhatsAppUrl(buildProjectMessage(formData)),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||
trackEmailClick({ placement: "contact_form" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,7 +224,7 @@ export default function Contact() {
|
||||
<h3 className="font-display text-lg font-semibold text-white">
|
||||
Você fala direto com quem desenvolve.
|
||||
</h3>
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] md:text-sm leading-7 text-white/65">
|
||||
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||
escopo, prazo e proposta vêm depois.
|
||||
</p>
|
||||
@@ -133,10 +238,10 @@ export default function Contact() {
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display">
|
||||
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]">
|
||||
<span className="block text-[15px] md:text-sm font-semibold text-white group-hover:text-[#25d366]">
|
||||
Prefere conversar direto?
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-white/65">
|
||||
<span className="mt-1 block text-[15px] md:text-sm text-white/65">
|
||||
WhatsApp · {whatsAppDisplay}
|
||||
</span>
|
||||
</span>
|
||||
@@ -151,7 +256,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)]"
|
||||
>
|
||||
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-all font-display text-sm">
|
||||
<span className="min-w-0 break-all font-display text-[15px] md:text-sm">
|
||||
{CONTACT_EMAIL}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
@@ -160,7 +265,7 @@ export default function Contact() {
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-sm leading-6 text-white/55">
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] md:text-sm 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>
|
||||
@@ -183,117 +288,85 @@ export default function Contact() {
|
||||
>
|
||||
Prepare sua mensagem
|
||||
</h3>
|
||||
<p className="mt-1 font-display text-sm leading-6 text-white/65">
|
||||
<p className="mt-1 font-display text-[15px] md:text-sm leading-6 text-white/65">
|
||||
Você revisa no WhatsApp antes de enviar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-sp-5">
|
||||
{/* 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">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className={labelClassName}>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Como posso te chamar?"
|
||||
aria-invalid={errors.name ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.name ? "contact-name-error" : undefined
|
||||
}
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="contact-name-error" className={errorClassName}>
|
||||
{errors.name}
|
||||
</p>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-email" className={labelClassName}>
|
||||
E-mail (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="[email protected]"
|
||||
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 && (
|
||||
<p id="contact-email-error" className={errorClassName}>
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-subject" className={labelClassName}>
|
||||
Tipo de projeto
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) => updateField("subject", e.target.value)}
|
||||
className={`${fieldClassName} [color-scheme:dark]`}
|
||||
</Field>
|
||||
<Field
|
||||
id="contact-email"
|
||||
label="E-mail (opcional)"
|
||||
error={errors.email}
|
||||
>
|
||||
<option value="Site para empresa">Site para empresa</option>
|
||||
<option value="Sistemas web sob medida">
|
||||
Sistemas web sob medida
|
||||
</option>
|
||||
<option value="Aplicativos">Aplicativos</option>
|
||||
<option value="Integrações e automações">
|
||||
Integrações e automações
|
||||
</option>
|
||||
<option value="Evolução de um sistema existente">
|
||||
Evolução de um sistema existente
|
||||
</option>
|
||||
<option value="Ainda preciso definir">
|
||||
Ainda preciso definir
|
||||
</option>
|
||||
</select>
|
||||
{(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>
|
||||
<div>
|
||||
<label htmlFor="contact-message" className={labelClassName}>
|
||||
Contexto do projeto
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||
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 && (
|
||||
<p id="contact-message-error" className={errorClassName}>
|
||||
{errors.message}
|
||||
</p>
|
||||
<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]`}
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<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 flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
|
||||
@@ -305,7 +378,7 @@ export default function Contact() {
|
||||
<p className="text-center font-display text-[15px] leading-6 text-white/70">
|
||||
<a
|
||||
href={buildMailtoUrl(buildProjectMessage(formData))}
|
||||
onClick={() => trackEmailClick({ placement: "contact_form" })}
|
||||
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
|
||||
@@ -313,13 +386,33 @@ export default function Contact() {
|
||||
</p>
|
||||
<p
|
||||
id="contact-privacy-note"
|
||||
className="font-display text-xs leading-6 text-white/55"
|
||||
className="font-display text-[15px] md:text-xs 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, fale direto comigo:
|
||||
</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-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
|
||||
>
|
||||
Conversar 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>
|
||||
|
||||
Reference in new issue
Block a user