Files
portifolio-evandrodevbr/app/components/GoogleAnalytics.tsx
T
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
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.
2026-11-10 03:48:25 -03:00

298 lines
9.8 KiB
TypeScript

"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type AnalyticsConsent,
CONSENT_STORAGE_KEY,
COOKIE_BAR_ID,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
GOOGLE_ADS_ID,
type Gtag,
OPEN_COOKIE_PREFERENCES_EVENT,
readConsent,
} from "@/lib/analytics";
import "./conversion.css";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
const deniedConsent = {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
} as const;
function persistConsent(value: AnalyticsConsent): boolean {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
return true;
} catch {
return false;
}
}
function getGtag(): Gtag {
window.dataLayer ??= [];
const dataLayer = window.dataLayer;
if (!window.gtag) {
window.gtag = function gtag() {
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
dataLayer.push(arguments);
};
}
return window.gtag;
}
function removeAnalyticsLoader() {
document.getElementById("google-analytics-script")?.remove();
}
function writeCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
document.cookie = value;
}
function expireAnalyticsCookies() {
const cookieNames = new Set(
document.cookie
.split(";")
.map((cookie) => cookie.split("=")[0]?.trim())
.filter(
(name): name is string =>
name === "_ga" || Boolean(name?.startsWith("_ga_")),
),
);
const { hostname } = window.location;
const domains =
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
? new Set([hostname, SITE_DOMAIN])
: new Set<string>();
for (const name of cookieNames) {
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
writeCookie(expiration);
for (const domain of domains) {
writeCookie(`${expiration}; Domain=${domain}`);
}
}
}
const barButtonClassName =
"cv-btn inline-flex min-h-10 min-w-11 items-center justify-center border px-[10px] py-sp-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] motion-reduce:transition-none";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const configuredRef = useRef(false);
const barRef = useRef<HTMLElement>(null);
const acceptRef = useRef<HTMLButtonElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("set", "ads_data_redaction", true);
gtag("js", new Date());
configuredRef.current = true;
}
gtag("consent", "update", {
...deniedConsent,
analytics_storage: "granted",
});
gtag("config", GA_MEASUREMENT_ID, {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
// One loader serves both destinations; GT IDs are aliases, not extra installs.
gtag("config", GOOGLE_ADS_ID, {
allow_ad_personalization_signals: false,
});
}, []);
useEffect(() => {
const storedConsent = readConsent();
if (storedConsent === "granted") enableAnalytics();
setConsent(storedConsent);
}, [enableAnalytics]);
useEffect(() => {
if (consent !== "granted" && consent !== undefined) {
removeAnalyticsLoader();
}
}, [consent]);
// O link "Preferências de cookies" do rodapé reabre a barra; quem pediu recebe o foco de volta ao fechar.
useEffect(() => {
const reopen = () => {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setPreferencesOpen(true);
};
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
return () =>
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
}, []);
const barOpen = consent === null || preferencesOpen;
// Enquanto a barra está na tela, publica a altura dela para o botão flutuante do WhatsApp subir.
useEffect(() => {
const bar = barRef.current;
if (!barOpen || !bar) return;
const root = document.documentElement;
const publish = () =>
root.style.setProperty("--cookie-bar-height", `${bar.offsetHeight}px`);
publish();
root.setAttribute("data-cookie-bar", "open");
const observer = new ResizeObserver(publish);
observer.observe(bar);
return () => {
observer.disconnect();
root.style.removeProperty("--cookie-bar-height");
root.removeAttribute("data-cookie-bar");
};
}, [barOpen]);
// Reaberta pelo rodapé, a barra está no começo do documento: leva o foco para ela.
useEffect(() => {
if (preferencesOpen) acceptRef.current?.focus();
}, [preferencesOpen]);
function closePreferences() {
setPreferencesOpen(false);
openerRef.current?.focus();
openerRef.current = null;
}
function acceptAnalytics() {
if (consent === "granted") {
closePreferences();
return;
}
if (!persistConsent("granted")) {
setConsentError(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
enableAnalytics();
setConsent("granted");
closePreferences();
}
function denyAnalytics() {
const revoking = consent === "granted";
if (!persistConsent("denied")) {
setConsentError(
revoking
? "Não foi possível salvar a revogação. Tente novamente para concluir."
: "Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
if (revoking) {
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
}
setConsent("denied");
closePreferences();
}
if (consent === undefined) return null;
return (
<>
{consent === "granted" && process.env.NODE_ENV === "production" && (
<Script
id="google-analytics-script"
data-testid="google-analytics-script"
src={GA_SCRIPT_SRC}
strategy="afterInteractive"
/>
)}
{barOpen && (
<section
ref={barRef}
id={COOKIE_BAR_ID}
role="dialog"
aria-modal="false"
aria-label="Cookies e privacidade"
aria-describedby="analytics-consent-description"
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
className="ds-enter fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] [--enter-y:16px] sm:px-sp-6"
>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
>
Métricas de navegação do Google, só se você permitir.{" "}
{/* o preenchimento vertical dá ao link 24 px de alvo de toque sem engordar a linha */}
<a
className="whitespace-nowrap py-1 underline underline-offset-2 hover:text-white hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
</a>
{preferencesOpen && consent && (
<span className="sr-only">
{" "}
Escolha atual:{" "}
{consent === "granted" ? "permitido" : "recusado"}.
</span>
)}
</p>
<div className="flex shrink-0 gap-sp-2">
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar
</button>
</div>
</div>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] leading-5 text-[#ff9d94]"
>
{consentError}
</p>
)}
</section>
)}
</>
);
}
/**
* A barra de cookies e o consentimento aparecem em qualquer ambiente: no desenvolvimento a página fica igual à da
* produção (ordem do Tab, altura da barra, rodapé). Só o carregamento do gtag (o <Script> acima) fica preso à produção.
*/
export default function GoogleAnalytics() {
return <GoogleAnalyticsConsent />;
}