feat: publish privacy and consent links
This commit is contained in:
1 parent
a9a35cf936
commit
f83c5e1ada
9 files changed
+254
-1
No files matched your search
@@ -28,6 +28,10 @@ describe("Footer contact and navigation", () => {
|
|||||||
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
|
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
|
||||||
expect(servicesLink).toHaveAttribute("href", "/servicos");
|
expect(servicesLink).toHaveAttribute("href", "/servicos");
|
||||||
expect(servicesLink).not.toHaveAttribute("target", "_blank");
|
expect(servicesLink).not.toHaveAttribute("target", "_blank");
|
||||||
|
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/privacidade",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const socialLinks: SocialLink[] = [
|
|||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ label: "Hub de serviços", href: "/servicos" },
|
{ label: "Hub de serviços", href: "/servicos" },
|
||||||
{ label: "Cases", href: "/cases" },
|
{ label: "Cases", href: "/cases" },
|
||||||
|
{ label: "Privacidade", href: "/privacidade" },
|
||||||
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
||||||
{
|
{
|
||||||
label: "Integração hardware + software",
|
label: "Integração hardware + software",
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ describe("GoogleAnalytics consent", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByTestId("google-analytics-script"),
|
screen.queryByTestId("google-analytics-script"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ler política de privacidade" }),
|
||||||
|
).toHaveAttribute("href", "/privacidade");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists acceptance and enables analytics without advertising consent", async () => {
|
it("persists acceptance and enables analytics without advertising consent", async () => {
|
||||||
@@ -187,6 +190,9 @@ describe("GoogleAnalytics consent", () => {
|
|||||||
name: "Preferências de analytics",
|
name: "Preferências de analytics",
|
||||||
});
|
});
|
||||||
await user.click(preferencesButton);
|
await user.click(preferencesButton);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ler política de privacidade" }),
|
||||||
|
).toHaveAttribute("href", "/privacidade");
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Fechar preferências" }),
|
screen.getByRole("button", { name: "Fechar preferências" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,6 +198,12 @@ function GoogleAnalyticsConsent() {
|
|||||||
Recusar analytics
|
Recusar analytics
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-accent mt-sp-3 inline-block"
|
||||||
|
href="/privacidade"
|
||||||
|
>
|
||||||
|
Ler política de privacidade
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,12 @@ function GoogleAnalyticsConsent() {
|
|||||||
Fechar preferências
|
Fechar preferências
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-accent mt-sp-3 inline-block"
|
||||||
|
href="/privacidade"
|
||||||
|
>
|
||||||
|
Ler política de privacidade
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||||
|
|
||||||
|
export const alt = "Privacidade e consentimento no Evandro.dev.br";
|
||||||
|
export const size = OG_SIZE;
|
||||||
|
export const contentType = OG_CONTENT_TYPE;
|
||||||
|
|
||||||
|
export default function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
<OgCard
|
||||||
|
eyebrow="Privacidade"
|
||||||
|
title="Privacidade e consentimento"
|
||||||
|
description="Informações factuais sobre contato, analytics e preferências no portfólio."
|
||||||
|
/>,
|
||||||
|
OG_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import PrivacyPage, { metadata } from "./page";
|
||||||
|
|
||||||
|
it("publishes factual privacy content and a breadcrumb-only JSON-LD graph", () => {
|
||||||
|
const { container } = render(<PrivacyPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 1, name: /privacidade/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByText(/evandro\.analytics-consent\.v1/)).toBeVisible();
|
||||||
|
expect(screen.getAllByText(/WhatsApp/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/nenhuma tag do Google é carregada/i)).toBeVisible();
|
||||||
|
expect(screen.getByText(/2026-09-04/)).toBeVisible();
|
||||||
|
|
||||||
|
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||||
|
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||||
|
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||||
|
"Início",
|
||||||
|
"Privacidade",
|
||||||
|
]);
|
||||||
|
expect(breadcrumbLinks[0]).toHaveAttribute("href", "/");
|
||||||
|
expect(breadcrumbLinks[1]).toHaveAttribute("href", "/privacidade");
|
||||||
|
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page");
|
||||||
|
|
||||||
|
const scripts = container.querySelectorAll<HTMLScriptElement>(
|
||||||
|
'script[type="application/ld+json"]',
|
||||||
|
);
|
||||||
|
expect(scripts).toHaveLength(1);
|
||||||
|
const jsonLd = JSON.parse(scripts[0]?.textContent ?? "{}");
|
||||||
|
expect(jsonLd["@graph"]).toHaveLength(1);
|
||||||
|
expect(jsonLd["@graph"][0]["@type"]).toBe("BreadcrumbList");
|
||||||
|
expect(JSON.stringify(jsonLd)).not.toMatch(
|
||||||
|
/Person|Organization|LocalBusiness|ProfessionalService|Service/,
|
||||||
|
);
|
||||||
|
expect(jsonLd["@graph"][0].itemListElement).toEqual([
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Início",
|
||||||
|
item: "https://evandro.dev.br",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: "Privacidade",
|
||||||
|
item: "https://evandro.dev.br/privacidade",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses privacy-specific canonical metadata", () => {
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
title: expect.stringMatching(/privacidade/i),
|
||||||
|
description: expect.any(String),
|
||||||
|
alternates: { canonical: "https://evandro.dev.br/privacidade" },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import { buildBreadcrumbList } from "@/lib/seo";
|
||||||
|
import { siteName, siteUrl } from "@/lib/site-content";
|
||||||
|
|
||||||
|
const pageUrl = `${siteUrl}/privacidade`;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Privacidade e consentimento",
|
||||||
|
description:
|
||||||
|
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
|
||||||
|
alternates: { canonical: pageUrl },
|
||||||
|
openGraph: {
|
||||||
|
title: `Privacidade e consentimento | ${siteName}`,
|
||||||
|
description:
|
||||||
|
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
|
||||||
|
url: pageUrl,
|
||||||
|
siteName,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
buildBreadcrumbList(pageUrl, [
|
||||||
|
["Início", siteUrl],
|
||||||
|
["Privacidade", pageUrl],
|
||||||
|
] as const),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
|
|
||||||
|
<section className="ds-section ds-section-dark">
|
||||||
|
<div className="ds-section-shell max-w-[960px]">
|
||||||
|
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||||
|
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link className="ds-link ds-link-muted" href="/">
|
||||||
|
Início
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-sp-2">
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<Link
|
||||||
|
aria-current="page"
|
||||||
|
className="ds-link ds-link-muted"
|
||||||
|
href="/privacidade"
|
||||||
|
>
|
||||||
|
Privacidade
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<p className="ds-eyebrow">Privacidade e consentimento</p>
|
||||||
|
<h1 className="ds-section-title text-white">
|
||||||
|
Privacidade e consentimento
|
||||||
|
</h1>
|
||||||
|
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
||||||
|
Esta página explica, de forma objetiva, como funcionam o contato e
|
||||||
|
as escolhas de analytics neste portfólio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ds-section">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="ds-grid-2">
|
||||||
|
<article className="ds-card">
|
||||||
|
<p className="ds-kicker">Responsável e contato</p>
|
||||||
|
<h2 className="ds-card-title">Evandro Fonseca Junior</h2>
|
||||||
|
<p className="ds-copy">
|
||||||
|
O responsável pelo site é Evandro Fonseca Junior. Para contato
|
||||||
|
ou solicitações relacionadas a esta página, escreva para{" "}
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-accent"
|
||||||
|
href="mailto:[email protected]"
|
||||||
|
>
|
||||||
|
evandrofonsecajunior@gmail.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ds-card">
|
||||||
|
<p className="ds-kicker">Formulário de contato</p>
|
||||||
|
<h2 className="ds-card-title">Mensagem pelo WhatsApp</h2>
|
||||||
|
<p className="ds-copy">
|
||||||
|
Os dados digitados no formulário são transformados em uma
|
||||||
|
mensagem e abrem o WhatsApp. O site não possui endpoint próprio
|
||||||
|
de armazenamento desse formulário.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ds-card">
|
||||||
|
<p className="ds-kicker">Analytics</p>
|
||||||
|
<h2 className="ds-card-title">Escolha antes da medição</h2>
|
||||||
|
<p className="ds-copy">
|
||||||
|
Antes de <code>granted</code>, nenhuma tag do Google é
|
||||||
|
carregada. A escolha fica no <code>localStorage</code> sob a
|
||||||
|
chave <code>evandro.analytics-consent.v1</code>, com os valores{" "}
|
||||||
|
<code>granted</code> ou <code>denied</code>.
|
||||||
|
</p>
|
||||||
|
<p className="ds-copy mt-sp-4">
|
||||||
|
A publicidade permanece negada. Ao conceder analytics, o Google
|
||||||
|
pode criar cookies <code>_ga*</code>. Ao revogar, novas coletas
|
||||||
|
são desativadas e os cookies de analytics acessíveis são
|
||||||
|
expirados.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ds-card">
|
||||||
|
<p className="ds-kicker">Preferências e links</p>
|
||||||
|
<h2 className="ds-card-title">Você pode mudar a escolha</h2>
|
||||||
|
<p className="ds-copy">
|
||||||
|
Para alterar a preferência, use o controle existente{" "}
|
||||||
|
<strong>“Preferências de analytics”</strong> para acessar suas
|
||||||
|
preferências de privacidade. Links externos para GitHub,
|
||||||
|
LinkedIn, Instagram, demos e WhatsApp seguem as políticas
|
||||||
|
próprias de cada serviço.
|
||||||
|
</p>
|
||||||
|
<p className="ds-copy mt-sp-4">
|
||||||
|
Solicitações relacionadas aos seus direitos devem ser
|
||||||
|
encaminhadas ao e-mail publicado acima.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="ds-link ds-link-accent mt-sp-5 inline-block"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Voltar ao início
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-sp-8 border-t border-[var(--border)] pt-sp-5">
|
||||||
|
<p className="ds-kicker">Vigência</p>
|
||||||
|
<p className="ds-copy mt-sp-2">
|
||||||
|
Esta versão está vigente desde{" "}
|
||||||
|
<time dateTime="2026-09-04">2026-09-04</time>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
return [
|
return [
|
||||||
{ url: siteUrl },
|
{ url: siteUrl },
|
||||||
{ url: `${siteUrl}/curriculo` },
|
{ url: `${siteUrl}/curriculo` },
|
||||||
|
{ url: `${siteUrl}/privacidade` },
|
||||||
{ url: `${siteUrl}/servicos` },
|
{ url: `${siteUrl}/servicos` },
|
||||||
{ url: `${siteUrl}/cases` },
|
{ url: `${siteUrl}/cases` },
|
||||||
...servicePages.map(({ slug }) => ({
|
...servicePages.map(({ slug }) => ({
|
||||||
|
|||||||
+5
-1
@@ -107,6 +107,7 @@ it("keeps profile data timeless and sitemap output deterministic", () => {
|
|||||||
const expectedUrls = [
|
const expectedUrls = [
|
||||||
siteUrl,
|
siteUrl,
|
||||||
`${siteUrl}/curriculo`,
|
`${siteUrl}/curriculo`,
|
||||||
|
`${siteUrl}/privacidade`,
|
||||||
`${siteUrl}/servicos`,
|
`${siteUrl}/servicos`,
|
||||||
`${siteUrl}/cases`,
|
`${siteUrl}/cases`,
|
||||||
...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`),
|
...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`),
|
||||||
@@ -117,5 +118,8 @@ it("keeps profile data timeless and sitemap output deterministic", () => {
|
|||||||
|
|
||||||
expect(first).toEqual(expectedUrls.map((url) => ({ url })));
|
expect(first).toEqual(expectedUrls.map((url) => ({ url })));
|
||||||
expect(second).toEqual(first);
|
expect(second).toEqual(first);
|
||||||
expect(first).toHaveLength(16);
|
expect(first).toHaveLength(17);
|
||||||
|
expect(
|
||||||
|
first.filter(({ url }) => url === `${siteUrl}/privacidade`),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user