From 567e0b2bfec61bcb52015062034c3170b84bcdc1 Mon Sep 17 00:00:00 2001 From: Nilesh Raj Date: Thu, 22 Oct 2026 13:59:37 +0530 Subject: [PATCH] Add purpose-based consent controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/preferences/route.ts | 10 +- app/cookies/page.tsx | 74 ++++---- app/globals.css | 55 +++++- app/privacy/choices/page.tsx | 21 +-- app/privacy/page.tsx | 31 ++-- components/AnalyticsPrivacyChoices.tsx | 93 +++++++--- components/ConsentBanner.tsx | 234 ++++++++++++++++++++++--- components/ConsentCategoryRow.tsx | 40 +++++ components/MetaPixel.tsx | 4 +- lib/analytics-consent.ts | 13 +- lib/cortality.ts | 5 +- lib/google-analytics.ts | 10 +- lib/optional-consent.ts | 25 +++ lib/visitor-preferences.ts | 2 + tests/cortality.test.ts | 8 +- tests/google-analytics.test.ts | 20 ++- tests/optional-consent.test.ts | 58 ++++++ 17 files changed, 549 insertions(+), 154 deletions(-) create mode 100644 components/ConsentCategoryRow.tsx create mode 100644 lib/optional-consent.ts create mode 100644 tests/optional-consent.test.ts diff --git a/app/api/preferences/route.ts b/app/api/preferences/route.ts index 126d12d..edc9b8b 100644 --- a/app/api/preferences/route.ts +++ b/app/api/preferences/route.ts @@ -5,12 +5,12 @@ import { getClientIp, jsonBodyError, readJsonBody } from "@/lib/request-security import { attachVisitorCookie, getVisitorId } from "@/lib/visitor-state-server"; import { getSupabaseAdminServer } from "@/lib/supabase"; import type { VisitorPreferences } from "@/lib/visitor-preferences"; -import { OPTIONAL_ANALYTICS_CONSENT_VERSION } from "@/lib/google-analytics"; +import { OPTIONAL_CONSENT_VERSION } from "@/lib/optional-consent"; export const runtime = "nodejs"; const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -const preferenceKeys = new Set(["theme", "analyticsConsent", "analyticsConsentVersion", "analyticsSessionId", "attribution", "recentAgentSlugs", "compareSlugs", "setupProgress"]); +const preferenceKeys = new Set(["theme", "analyticsConsent", "analyticsConsentVersion", "marketingConsent", "marketingConsentVersion", "analyticsSessionId", "attribution", "recentAgentSlugs", "compareSlugs", "setupProgress"]); function responseWithCookie(body: object, status: number, visitorId: string) { const response = NextResponse.json(body, { @@ -26,9 +26,13 @@ function validPatch(value: unknown): value is Record { if (!Object.keys(input).length || Object.keys(input).some((key) => !preferenceKeys.has(key))) return false; if ("theme" in input && input.theme !== null && input.theme !== "light" && input.theme !== "dark") return false; if ("analyticsConsent" in input && input.analyticsConsent !== null && input.analyticsConsent !== "accepted" && input.analyticsConsent !== "declined") return false; + if ("marketingConsent" in input && input.marketingConsent !== null && input.marketingConsent !== "accepted" && input.marketingConsent !== "declined") return false; if ("analyticsConsentVersion" in input && input.analyticsConsentVersion !== null - && input.analyticsConsentVersion !== OPTIONAL_ANALYTICS_CONSENT_VERSION) return false; + && input.analyticsConsentVersion !== OPTIONAL_CONSENT_VERSION) return false; + if ("marketingConsentVersion" in input + && input.marketingConsentVersion !== null + && input.marketingConsentVersion !== OPTIONAL_CONSENT_VERSION) return false; if ("analyticsSessionId" in input && input.analyticsSessionId !== null) return false; if ("attribution" in input && input.attribution !== null) return false; for (const key of ["recentAgentSlugs", "compareSlugs"] as const) { diff --git a/app/cookies/page.tsx b/app/cookies/page.tsx index 0e80cc8..f5e9b06 100644 --- a/app/cookies/page.tsx +++ b/app/cookies/page.tsx @@ -4,7 +4,7 @@ import { legalConfig } from "@/lib/legal-config"; export const metadata = createPublicPageMetadata({ title: "Cookies and preferences", - description: "How AgentNine uses necessary cookies and optional analytics and session replay.", + description: "How AgentNine uses necessary storage and optional analytics, session replay, and marketing services.", path: "/cookies", }); @@ -15,66 +15,66 @@ export default function CookiesPage() {

Legal

Cookies and preferences

- Google Analytics, Meta Pixel, and Cortality session replay are off unless you choose - “Allow” in the optional analytics prompt. None of these tags is loaded before that - choice. The Meta Pixel may use advertising cookies or similar identifiers after you allow - them. Cortality session replay is included in the same optional analytics choice on the - privacy choices page. Preferences - are stored in Supabase rather than localStorage or sessionStorage. + Google Analytics and Cortality session replay remain off unless you enable Analytics. + Meta Pixel remains off unless you enable Marketing. These services are not loaded before + you save the applicable choice. Preferences are stored in Supabase rather than + localStorage or sessionStorage.

Necessary cookies

The visitor cookie {legalConfig.visitorCookieName} is HttpOnly, lasts for up to one year, and associates theme, comparison list, setup progress, recent agents, - optional analytics and session replay choices, and feedback responses with a random visitor identifier. Clearing it in your + optional analytics and marketing choices, and feedback responses with a + random visitor identifier. Clearing it in your browser does not delete the related database record. Supabase authentication cookies beginning with sb- help maintain sign-in and last{" "} {legalConfig.authCookieLifetime}. These cookies are not used for advertising.

-

Optional analytics

+

Analytics and session replay

- If you allow Google Analytics, it measures page views and selected interactions using - first-party cookies such as _ga and _ga_*. The cookie lifetime is - configured to six months and is not extended on each visit. Page paths are sent without - query strings; only utm_source, utm_medium, and{" "} + If you enable Analytics, Google Analytics measures page views and selected interactions + using first-party cookies such as _ga and _ga_*. The cookie + lifetime is configured to six months and is not extended on each visit. Page paths are + sent without query strings; only utm_source, utm_medium, and{" "} utm_campaign may be included. Search terms are not sent. Declining or - withdrawing consent stops AgentNine from sending page views and selected interactions; - a consent-state update is sent to Google and may involve a limited cookieless request. - Cookies already stored by Google are not automatically cleared when consent is - withdrawn; you can remove them through your browser settings. + withdrawing Analytics consent stops AgentNine from sending page views and selected + interactions; a consent-state update is sent to Google and may involve a limited + cookieless request. Cookies already stored by Google are not automatically cleared when + consent is withdrawn; you can remove them through your browser settings.

- If you allow optional analytics, AgentNine also loads Meta Pixel ID{" "} - 927600866897675 and sends a PageView event on eligible public pages. It does - not send pageviews from private or authentication pages, the search page, URLs containing - non-campaign query parameters, or URLs with fragments. Campaign parameters are limited - to utm_source, utm_medium, and utm_campaign. - Meta may receive page and browser/device information, network information such as your IP - address, and cookie or similar identifier data under its own terms. The Pixel does not - load before an up-to-date “Allow” choice. If you decline or withdraw consent, AgentNine - stops sending PageView events and sends a consent-revocation command when the Pixel was - already initialized. Cookies already stored by Meta are not automatically removed; - remove them through your browser settings if desired. -

-

- Cortality session replay uses the same Allow or Decline choice. When allowed, it loads on - the public content pages listed in the{" "} + Cortality session replay is part of Analytics and loads only after you enable that + category. It is restricted to the public content pages listed in the{" "} Privacy policy, only for signed-out visitors. Query strings are allowed on Search, Compare, Contact, and Join, and their contents may be visible to Cortality. Fragments are excluded. The vendor says it records mouse, scroll, and click positions, page-layout images, and page HTML, while blanking typed text and form values. AgentNine has not independently verified that behavior. Cortality does not load on account, admin, authentication, editor, privacy-choice, or other - non-allowlisted pages. Search, comparison, contact, and join are included, and their query - strings may be visible to Cortality. + non-allowlisted pages. +

+ +

Marketing

+

+ If you enable Marketing, AgentNine loads Meta Pixel ID 927600866897675 and + sends a PageView event on eligible public pages. It does not send pageviews from private + or authentication pages, the search page, URLs containing + non-campaign query parameters, or URLs with fragments. Campaign parameters are limited + to utm_source, utm_medium, and utm_campaign. + Meta may receive page and browser/device information, network information such as your IP + address, and cookie or similar identifier data under its own terms. The Pixel does not + load unless Marketing is enabled in your current privacy choices. If you decline or + withdraw Marketing consent, AgentNine stops sending PageView events and sends a + consent-revocation command when the Pixel was already initialized. Cookies already stored + by Meta are not automatically removed; remove them through your browser settings if desired.

- For details about preference retention, privacy requests, and changing your analytics - choices, see the Privacy policy or go - directly to Privacy choices. + For details about preference retention, privacy requests, and changing your Analytics or + Marketing choices, see the Privacy policy{" "} + or go directly to Privacy choices.

diff --git a/app/globals.css b/app/globals.css index 656ca0b..b53be53 100644 --- a/app/globals.css +++ b/app/globals.css @@ -250,12 +250,14 @@ pre, code, kbd { font-family: var(--font-geist-mono), monospace; } .site-header { position: sticky; top: 0; z-index: 10; background: transparent; } .site-header > .container { width: 100%; max-width: none; padding: 0 24px; } .scroll-progress { position: fixed; z-index: 30; top: 0; left: 0; width: 100%; height: 3px; background: var(--blue); transform: scaleX(0); transform-origin: left; transition: transform 100ms linear; } -.consent-banner { position: fixed; right: 22px; bottom: 22px; z-index: 12; display: flex; align-items: center; gap: 24px; width: min(640px, calc(100vw - 32px)); max-height: min(55dvh, 320px); overflow-y: auto; overscroll-behavior: contain; padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--panel) 94%, transparent); box-shadow: var(--shadow); backdrop-filter: blur(18px); } +.consent-banner { position: fixed; right: 22px; bottom: 22px; z-index: 12; display: grid; gap: 14px; width: min(720px, calc(100vw - 32px)); max-height: min(55dvh, 360px); overflow-y: auto; overscroll-behavior: contain; padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--panel) 94%, transparent); box-shadow: var(--shadow); backdrop-filter: blur(18px); } .cortality-route-transition { display: grid; flex: 1; min-height: 100dvh; place-items: center; color: var(--muted); } -html:has(.consent-banner) { scroll-padding-bottom: min(55dvh, 320px); } +html:has(.consent-banner) { scroll-padding-bottom: min(55dvh, 360px); } .consent-banner strong { color: var(--ink); font-size: var(--font-size-body); } .consent-banner p { margin: 4px 0 0; color: var(--muted); font-size: var(--font-size-sm); line-height: 1.45; } -.consent-actions { display: flex; flex-shrink: 0; gap: 8px; } +.consent-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; } +.consent-actions > .button { min-height: 44px; } +.consent-banner > .form-error { margin: 0; } .consent-actions .button-dark { background: var(--blue); color: #fff; box-shadow: 0 8px 18px color-mix(in srgb, var(--blue) 22%, transparent); } .consent-actions .button-dark:hover { background: var(--blue-dark); color: #fff; box-shadow: 0 10px 22px color-mix(in srgb, var(--blue-dark) 24%, transparent); } [data-theme="dark"] .consent-actions .button-dark, @@ -274,7 +276,8 @@ html:has(.consent-banner) { scroll-padding-bottom: min(55dvh, 320px); } .signup-next-links a { color: var(--blue-text); font-size: var(--font-size-md); font-weight: 700; text-decoration: underline; text-underline-offset: 3px; } @media (max-width: 640px) { .consent-banner { right: 16px; bottom: 16px; display: grid; gap: 14px; } - .consent-actions { justify-content: flex-end; } + .consent-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .consent-actions > .button-dark { grid-column: 1 / -1; } } .site-utilities { position: fixed; z-index: 8; right: 20px; bottom: 20px; display: flex; align-items: end; gap: 8px; } .floating-contact, .back-to-top { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 13px; border: 1px solid var(--line); border-radius: var(--radius-pill); background: var(--panel); color: var(--ink); box-shadow: var(--shadow); font-size: var(--font-size-sm); font-weight: 700; } @@ -284,6 +287,46 @@ html:has(.consent-banner) { scroll-padding-bottom: min(55dvh, 320px); } .site-utilities { display: none; } } .dialog-backdrop { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: 20px; background: color-mix(in srgb, var(--ink) 48%, transparent); } +.consent-dialog-backdrop { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 20px; background: color-mix(in srgb, var(--ink) 54%, transparent); } +.consent-dialog { width: min(100%, 580px); max-height: min(88dvh, 760px); overflow-y: auto; overscroll-behavior: contain; padding: clamp(20px, 4vw, 30px); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); color: var(--ink); box-shadow: var(--shadow); } +.consent-dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding-bottom: 20px; border-bottom: 1px solid var(--line); } +.consent-dialog-header h2 { margin: 0; color: var(--ink); font-size: clamp(1.3rem, 4vw, 1.55rem); line-height: 1.2; letter-spacing: -.035em; } +.consent-dialog-header p { max-width: 54ch; margin: 8px 0 0; color: var(--muted); font-size: var(--font-size-body); line-height: 1.5; } +.consent-dialog-close { display: grid; flex: 0 0 44px; width: 44px; height: 44px; place-items: center; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); color: var(--ink); cursor: pointer; } +.consent-dialog-close:hover { border-color: var(--line-strong); background: var(--surface-soft); } +.consent-dialog-close:focus-visible, .consent-category-switch:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; } +.consent-dialog-close svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; } +.consent-category-list { padding: 2px 0; } +.consent-category-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 84px; padding: 15px 2px; border-bottom: 1px solid var(--line); } +.consent-category-copy { display: grid; gap: 5px; } +.consent-category-copy strong { color: var(--ink); font-size: var(--font-size-body-lg); font-weight: 700; } +.consent-category-copy p { max-width: 62ch; margin: 0; color: var(--muted); font-size: var(--font-size-sm); line-height: 1.5; } +.consent-category-switch { position: relative; flex: 0 0 48px; width: 48px; height: 44px; margin: 0; padding: 0; appearance: none; border: 0; border-radius: var(--radius-pill); background: transparent; cursor: pointer; } +.consent-category-switch::before { position: absolute; top: 9px; left: 0; width: 48px; height: 26px; border-radius: var(--radius-pill); background: var(--line-strong); content: ""; transition: background-color 150ms ease; } +.consent-category-switch::after { position: absolute; top: 12px; left: 3px; width: 20px; height: 20px; border-radius: var(--radius-pill); background: var(--panel); content: ""; transition: transform 150ms ease; } +.consent-category-switch:checked::before { background: var(--blue); } +.consent-category-switch:checked::after { transform: translateX(22px); } +.consent-category-switch:disabled { cursor: not-allowed; } +.consent-category-switch:disabled::before { opacity: .7; } +.consent-dialog-footer { padding-top: 20px; } +.consent-dialog-footer > .form-error { margin: 0 0 12px; } +.consent-dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 10px; } +.consent-dialog-actions > .button { min-height: 44px; } +.consent-dialog-actions .button-dark { background: var(--blue); color: #fff; } +.consent-dialog-actions .button-dark:hover { background: var(--blue-dark); color: #fff; } +.privacy-choice-categories { max-width: 650px; margin: 18px 0; border-top: 1px solid var(--line); } +.privacy-choice-categories .consent-category-row { min-height: 76px; } +.privacy-choice-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.privacy-choice-actions .button-dark { background: var(--blue); color: #fff; } +.privacy-choice-actions .button-dark:hover { background: var(--blue-dark); color: #fff; } +.privacy-choice-controls [role="status"], .privacy-choice-controls .form-error, .privacy-choice-controls .form-success { margin: 14px 0 0; } +@media (max-width: 640px) { + .consent-dialog-backdrop { align-items: end; padding: 8px; } + .consent-dialog { max-height: 92dvh; border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-xs); } + .consent-category-row { gap: 12px; min-height: 80px; } + .consent-dialog-actions, .privacy-choice-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .consent-dialog-actions > .button-dark, .privacy-choice-actions > .button-dark { grid-column: 1 / -1; } +} .confirm-dialog { width: min(100%, 440px); padding: 24px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow); } .confirm-dialog h2 { margin: 0; font-size: 22px; letter-spacing: -.04em; } .confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.55; } @@ -591,10 +634,6 @@ body:has(.faq-page) .floating-contact { display: none; } .privacy-choice-controls { max-width: 650px; margin-top: 38px; padding-top: 24px; border-top: 1px solid var(--line); } .prose-page .privacy-choice-controls h2 { margin: 0 0 8px; } .privacy-choice-controls > p { margin: 0 0 18px; } -.privacy-choice-actions { display: flex; flex-wrap: wrap; gap: 10px; } -.privacy-choice-actions .button-dark { background: var(--blue); color: #fff; } -.privacy-choice-actions .button-dark:hover { background: var(--blue-dark); color: #fff; } -.privacy-choice-controls [role="status"], .privacy-choice-controls .form-error, .privacy-choice-controls .form-success { margin: 14px 0 0; } .footer-bottom { display: flex; justify-content: space-between; gap: 20px; margin-top: 52px; padding-top: 17px; border-top: 1px solid var(--footer-border); color: var(--footer-muted); font-size: var(--font-size-sm); } .page-shell { padding: 76px 0 20px; min-height: 600px; } .page-heading { max-width: 720px; margin-bottom: 45px; } diff --git a/app/privacy/choices/page.tsx b/app/privacy/choices/page.tsx index 539e11e..1b66442 100644 --- a/app/privacy/choices/page.tsx +++ b/app/privacy/choices/page.tsx @@ -4,7 +4,7 @@ import { createPublicPageMetadata } from "@/lib/seo"; export const metadata = { ...createPublicPageMetadata({ title: "Privacy choices", - description: "Review or change optional analytics and session replay choices on AgentNine.", + description: "Review or change optional analytics, session replay, and marketing choices on AgentNine.", path: "/privacy/choices", }), robots: { index: false, follow: true }, @@ -17,18 +17,13 @@ export default function PrivacyChoicesPage() {

Privacy

Privacy choices

- Optional Google Analytics, Meta Pixel, and Cortality session replay are enabled only - after you allow them. Google Analytics measures page views and selected directory interactions. Meta Pixel sends - PageView events on eligible public pages only; it is not used on private or authentication - pages or to send search text. Cortality records replay data on an allowlist of public pages, - including search, comparison, contact, and join pages. The directory works without these - services, and declining - does not affect your other preferences. Withdrawing consent stops new events and sends - Google an updated consent state and a Meta consent-revocation command when applicable. - Existing cookies held by these providers are not automatically removed. Existing - first-party analytics records collected before this change remain in Supabase; no new - first-party event records are being collected. Your Allow or Decline choice applies to - all three services. + Google Analytics and Cortality session replay are grouped under Analytics. Meta Pixel is + grouped under Marketing. You can enable either category independently; essential site + storage remains active. The directory works without optional services. Withdrawing + consent stops new events and requests the applicable provider consent updates. Existing + cookies held by these providers are not automatically removed. Existing first-party + analytics records collected before this change remain in Supabase; no new first-party + event records are being collected.

diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 0cc25aa..32cea22 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -10,9 +10,9 @@ export const metadata = createPublicPageMetadata({ const processorRows = [ { provider: "Supabase", purpose: "Database, authentication, preferences, feedback, and account data" }, - { provider: "Google Analytics", purpose: "Optional website measurement, only after you allow it" }, - { provider: "Meta", purpose: "Optional Meta Pixel PageView measurement, only after you allow it" }, - { provider: "Cortality", purpose: "Optional session replay on allowlisted public pages, only after optional analytics consent" }, + { provider: "Google Analytics", purpose: "Optional website measurement, only after you enable Analytics" }, + { provider: "Meta", purpose: "Optional Meta Pixel PageView measurement, only after you enable Marketing" }, + { provider: "Cortality", purpose: "Optional session replay on allowlisted public pages, only after you enable Analytics" }, { provider: "Google", purpose: "Sign-in provider, when you choose Google sign-in" }, { provider: "Vercel", purpose: "Website hosting and request delivery" }, ...(legalConfig.upstashEnabled ? [{ provider: "Upstash", purpose: "Shared rate limiting" }] : []), @@ -78,8 +78,7 @@ export default function PrivacyPage() {

Information collected automatically

- Google Analytics 4 and Meta Pixel are off until you make a new choice to allow optional - analytics. Google Analytics receives + Google Analytics 4 is off until you enable the Analytics category. Google Analytics receives page views and selected directory interactions, such as agent-guide views, searches and filter counts, source-link clicks, saved-agent and comparison actions, and guide feedback choices. Page paths and referring paths are sent without query strings; campaign @@ -89,25 +88,25 @@ export default function PrivacyPage() { account details, and private/admin page views are not sent as analytics events. Google may process browser/device information, page and event data, cookies or similar identifiers, and information used to estimate general location. Google may process information outside - India under its own terms and privacy documentation. If you withdraw consent after + India under its own terms and privacy documentation. If you withdraw Analytics consent after enabling the tag, AgentNine sends Google a consent-state update so it can stop analytics storage. Google may process that signal, including a limited cookieless request; AgentNine then stops sending page views and selected interaction events.

- If you allow optional analytics, Meta Pixel (ID 927600866897675) also + If you enable the Marketing category, Meta Pixel (ID 927600866897675) receives PageView events on eligible public pages. It is not activated on private or authentication pages, the search page, URLs with non-campaign query parameters, or URLs with fragments. The Pixel may receive the page URL with limited campaign parameters, browser/device information, network information such as an IP address, and cookie or similar identifier data. Meta processes this information under its own privacy terms and - may process it outside India. Declining or withdrawing consent stops AgentNine from - sending further PageView events and requests consent revocation from the Pixel; cookies - already stored by Meta are not automatically deleted. + may process it outside India. Declining or withdrawing Marketing consent stops AgentNine + from sending further PageView events and requests consent revocation from the Pixel; + cookies already stored by Meta are not automatically deleted.

- Cortality session replay is included in the same optional consent as Google Analytics and - Meta Pixel. When enabled, AgentNine loads it only for signed-out visitors + Cortality session replay is included in the Analytics category with Google Analytics, and + is separate from Marketing. When enabled, AgentNine loads it only for signed-out visitors on these public content pages: the home page; agent listings and individual agent pages; categories and individual category pages; FAQ; About; methodology; news and published news articles; Terms; Privacy; and Cookies on agentnine.pro and{" "} @@ -136,7 +135,7 @@ export default function PrivacyPage() { rather than the raw IP address or email address.

- Theme choice, optional analytics and session replay consent, comparison selection, setup + Theme choice, optional analytics and marketing choices, comparison selection, setup progress, and recent agent names are stored in Supabase against a random visitor identifier. The identifier is held in an HttpOnly cookie. Supabase authentication may also use cookies to maintain signed-in sessions. AgentNine does not use localStorage or sessionStorage for @@ -149,7 +148,7 @@ export default function PrivacyPage() {

  • To respond to contact messages, review contributions, and consider feedback about AgentNine;
  • To protect accounts and the service and prevent abuse;
  • To show aggregate guide feedback and improve directory information; and
  • -
  • To understand directory usage through Google Analytics, Meta Pixel, and Cortality session replay, only if you allow them.
  • +
  • To understand directory usage through Google Analytics and Cortality session replay, only if you enable Analytics, and Meta Pixel, only if you enable Marketing.
  • Service providers

    @@ -184,7 +183,7 @@ export default function PrivacyPage() {

    Older visitor-preference records may also contain an anonymous analytics session identifier or campaign attribution values collected before this change. AgentNine no - longer creates or uses those values, and saving a new analytics choice clears them. + longer creates or uses those values, and saving new privacy choices clears them. Until that happens, they remain subject to the visitor-preference retention described below.

    @@ -220,7 +219,7 @@ export default function PrivacyPage() {

    Your privacy choices and rights

    - You can change your optional analytics and session replay choice at any time on the{" "} + You can change your Analytics and Marketing choices at any time on the{" "} privacy choices page. Depending on applicable law and your location, you may also have rights to request access to, correction of, or deletion of personal information, object to or restrict certain diff --git a/components/AnalyticsPrivacyChoices.tsx b/components/AnalyticsPrivacyChoices.tsx index cf004b5..21f209c 100644 --- a/components/AnalyticsPrivacyChoices.tsx +++ b/components/AnalyticsPrivacyChoices.tsx @@ -1,18 +1,21 @@ "use client"; import { useEffect, useState } from "react"; -import { saveAnalyticsConsent } from "@/lib/analytics-consent"; +import { ConsentCategoryRow } from "@/components/ConsentCategoryRow"; +import { saveOptionalConsentChoices } from "@/lib/analytics-consent"; +import { + hasOptionalAnalyticsConsent, + hasOptionalMarketingConsent, +} from "@/lib/optional-consent"; import { loadVisitorPreferences, useVisitorPreferences, } from "@/lib/visitor-preferences"; -import { - OPTIONAL_ANALYTICS_CONSENT_VERSION, - hasGoogleAnalyticsConsent, -} from "@/lib/google-analytics"; export function AnalyticsPrivacyChoices() { const { preferences, ready } = useVisitorPreferences(); + const [analyticsEnabled, setAnalyticsEnabled] = useState(false); + const [marketingEnabled, setMarketingEnabled] = useState(false); const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(false); const [saveError, setSaveError] = useState(""); @@ -25,16 +28,24 @@ export function AnalyticsPrivacyChoices() { }); }, []); - async function choose(value: "accepted" | "declined") { + useEffect(() => { + setAnalyticsEnabled(hasOptionalAnalyticsConsent(preferences)); + setMarketingEnabled(hasOptionalMarketingConsent(preferences)); + }, [preferences]); + + async function saveChoices(analytics: boolean, marketing: boolean) { setSaving(true); setSaveError(""); setSavedMessage(""); try { - await saveAnalyticsConsent(value); - setSavedMessage("Your privacy choice has been saved."); + await saveOptionalConsentChoices( + analytics ? "accepted" : "declined", + marketing ? "accepted" : "declined", + ); + setSavedMessage("Your privacy choices have been saved."); } catch (error) { - console.error("Privacy choice could not be saved", error); - setSaveError("Your privacy choice could not be saved. Please try again."); + console.error("Privacy choices could not be saved", error); + setSaveError("Your privacy choices could not be saved. Please try again."); } finally { setSaving(false); } @@ -50,51 +61,75 @@ export function AnalyticsPrivacyChoices() { } } - const isAllowed = hasGoogleAnalyticsConsent(preferences); - const currentChoice = preferences.analyticsConsentVersion === OPTIONAL_ANALYTICS_CONSENT_VERSION - ? preferences.analyticsConsent - : undefined; - return (

    -

    Optional analytics and session replay

    +

    Manage optional services

    - Optional Google Analytics, Meta Pixel, and Cortality session replay are {isAllowed ? "on" : "off"}. - {currentChoice - ? " You can change this choice at any time." - : " They stay off unless you choose to allow them."} + Essential storage is always on. Analytics and marketing services are optional and can be + changed independently.

    {loadError ? (
    -

    Your current choice could not be loaded.

    +

    Your current choices could not be loaded.

    ) : ( <> +
    + + + +
    +
    - {!ready ?

    Loading your current choice…

    : null} - {saving ?

    Saving your choice…

    : null} + {!ready ?

    Loading your current choices…

    : null} + {saving ?

    Saving your choices…

    : null} {savedMessage ?

    {savedMessage}

    : null} {saveError ?

    {saveError}

    : null} diff --git a/components/ConsentBanner.tsx b/components/ConsentBanner.tsx index 2164fce..ea4018f 100644 --- a/components/ConsentBanner.tsx +++ b/components/ConsentBanner.tsx @@ -1,44 +1,238 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { usePathname } from "next/navigation"; -import { saveAnalyticsConsent } from "@/lib/analytics-consent"; -import { useVisitorPreferences } from "@/lib/visitor-preferences"; -import { hasCurrentOptionalAnalyticsChoice } from "@/lib/google-analytics"; import Link from "next/link"; +import { ConsentCategoryRow } from "@/components/ConsentCategoryRow"; +import { saveOptionalConsentChoices } from "@/lib/analytics-consent"; +import { + hasCurrentOptionalConsentChoices, + hasOptionalAnalyticsConsent, + hasOptionalMarketingConsent, +} from "@/lib/optional-consent"; +import { useVisitorPreferences } from "@/lib/visitor-preferences"; export function ConsentBanner() { const [saving, setSaving] = useState(false); const [error, setError] = useState(""); + const [customizing, setCustomizing] = useState(false); + const [analyticsEnabled, setAnalyticsEnabled] = useState(false); + const [marketingEnabled, setMarketingEnabled] = useState(false); const { preferences, ready } = useVisitorPreferences(); const pathname = usePathname(); + const dialogRef = useRef(null); + const customizeButtonRef = useRef(null); + + useEffect(() => { + if (!customizing) return; + + const previousActiveElement = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + dialogRef.current?.querySelector("[data-dialog-initial-focus]")?.focus(); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setCustomizing(false); + return; + } + if (event.key !== "Tab" || !dialogRef.current) return; + + const controls = [...dialogRef.current.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), a[href]', + )]; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + const returnFocus = customizeButtonRef.current?.isConnected + ? customizeButtonRef.current + : previousActiveElement?.isConnected + ? previousActiveElement + : document.querySelector('[aria-label="AgentNine home"]'); + if (!returnFocus) return; + returnFocus.focus({ preventScroll: true }); + }; + }, [customizing]); - async function choose(value: "accepted" | "declined") { + async function saveChoices(analytics: boolean, marketing: boolean) { setSaving(true); setError(""); try { - await saveAnalyticsConsent(value); + await saveOptionalConsentChoices( + analytics ? "accepted" : "declined", + marketing ? "accepted" : "declined", + ); + window.requestAnimationFrame(() => { + document.querySelector('[aria-label="AgentNine home"]') + ?.focus({ preventScroll: true }); + }); + setCustomizing(false); } catch (saveError) { - console.error("Analytics preference could not be saved", saveError); - setError("Your privacy choice could not be saved. Please try again."); + console.error("Privacy preferences could not be saved", saveError); + setError("Your privacy choices could not be saved. Please try again."); } finally { setSaving(false); } } - const visible = pathname !== "/privacy/choices" && ready && !hasCurrentOptionalAnalyticsChoice(preferences); + function openCustomizer() { + setAnalyticsEnabled(hasOptionalAnalyticsConsent(preferences)); + setMarketingEnabled(hasOptionalMarketingConsent(preferences)); + setError(""); + setCustomizing(true); + } + + const visible = pathname !== "/privacy/choices" + && ready + && !hasCurrentOptionalConsentChoices(preferences); if (!visible || !ready) return null; + return ( - + <> + + + {customizing ? ( +
    { + if (event.target === event.currentTarget) setCustomizing(false); + }} + > +
    +
    +
    + + +
    + +
    + +
    + + + +
    + +
    + {error ?

    {error}

    : null} +
    + + +
    +
    +
    +
    + ) : null} + ); } diff --git a/components/ConsentCategoryRow.tsx b/components/ConsentCategoryRow.tsx new file mode 100644 index 0000000..7151b83 --- /dev/null +++ b/components/ConsentCategoryRow.tsx @@ -0,0 +1,40 @@ +type ConsentCategoryRowProps = { + id: string; + title: string; + description: string; + checked: boolean; + disabled?: boolean; + onChange?: (checked: boolean) => void; +}; + +export function ConsentCategoryRow({ + id, + title, + description, + checked, + disabled = false, + onChange, +}: ConsentCategoryRowProps) { + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + + return ( +
    +
    + {title} +

    {description}

    +
    + onChange?.(event.currentTarget.checked)} + /> +
    + ); +} diff --git a/components/MetaPixel.tsx b/components/MetaPixel.tsx index d9db0c9..bf5949a 100644 --- a/components/MetaPixel.tsx +++ b/components/MetaPixel.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; import { useVisitorPreferences } from "@/lib/visitor-preferences"; -import { hasGoogleAnalyticsConsent } from "@/lib/google-analytics"; +import { hasOptionalMarketingConsent } from "@/lib/optional-consent"; import { loadMetaPixelScript, META_PIXEL_ID, @@ -14,7 +14,7 @@ import { export function MetaPixel() { const pathname = usePathname(); const { preferences, ready } = useVisitorPreferences(); - const consented = hasGoogleAnalyticsConsent(preferences); + const consented = hasOptionalMarketingConsent(preferences); const initialized = useRef(false); const previouslyConsented = useRef(false); const lastTrackedPath = useRef(null); diff --git a/lib/analytics-consent.ts b/lib/analytics-consent.ts index 724dbed..c99a8f2 100644 --- a/lib/analytics-consent.ts +++ b/lib/analytics-consent.ts @@ -1,12 +1,17 @@ "use client"; import { updateVisitorPreferences } from "@/lib/visitor-preferences"; -import { OPTIONAL_ANALYTICS_CONSENT_VERSION } from "@/lib/google-analytics"; +import { OPTIONAL_CONSENT_VERSION } from "@/lib/optional-consent"; -export async function saveAnalyticsConsent(value: "accepted" | "declined") { +export async function saveOptionalConsentChoices( + analyticsConsent: "accepted" | "declined", + marketingConsent: "accepted" | "declined", +) { await updateVisitorPreferences({ - analyticsConsent: value, - analyticsConsentVersion: OPTIONAL_ANALYTICS_CONSENT_VERSION, + analyticsConsent, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + marketingConsent, + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, analyticsSessionId: null, attribution: null, }); diff --git a/lib/cortality.ts b/lib/cortality.ts index 7c97a28..5bc68a3 100644 --- a/lib/cortality.ts +++ b/lib/cortality.ts @@ -1,5 +1,5 @@ import type { VisitorPreferences } from "@/lib/visitor-preferences"; -import { OPTIONAL_ANALYTICS_CONSENT_VERSION } from "@/lib/google-analytics"; +import { hasOptionalAnalyticsConsent } from "@/lib/optional-consent"; const CORTALITY_SCRIPT_URL = "https://cdn.cortality.com/c.js"; @@ -22,8 +22,7 @@ const exactPublicPaths = new Set([ const allowedOrigins = new Set(["https://agentnine.pro", "https://www.agentnine.pro"]); export function hasCortalityConsent(preferences: VisitorPreferences): boolean { - return preferences.analyticsConsent === "accepted" - && preferences.analyticsConsentVersion === OPTIONAL_ANALYTICS_CONSENT_VERSION; + return hasOptionalAnalyticsConsent(preferences); } export function isCortalityPublicPath(pathname: string): boolean { diff --git a/lib/google-analytics.ts b/lib/google-analytics.ts index e85503e..485f2c8 100644 --- a/lib/google-analytics.ts +++ b/lib/google-analytics.ts @@ -1,7 +1,7 @@ import type { VisitorPreferences } from "@/lib/visitor-preferences"; +import { hasOptionalAnalyticsConsent } from "@/lib/optional-consent"; export const GOOGLE_ANALYTICS_MEASUREMENT_ID = "G-FG116C7C4W"; -export const OPTIONAL_ANALYTICS_CONSENT_VERSION = "analytics-ga4-meta-pixel-cortality-v3"; type GtagCommand = [command: string, ...parameters: unknown[]]; @@ -13,13 +13,7 @@ declare global { } export function hasGoogleAnalyticsConsent(preferences: VisitorPreferences): boolean { - return preferences.analyticsConsent === "accepted" - && preferences.analyticsConsentVersion === OPTIONAL_ANALYTICS_CONSENT_VERSION; -} - -export function hasCurrentOptionalAnalyticsChoice(preferences: VisitorPreferences): boolean { - return (preferences.analyticsConsent === "accepted" || preferences.analyticsConsent === "declined") - && preferences.analyticsConsentVersion === OPTIONAL_ANALYTICS_CONSENT_VERSION; + return hasOptionalAnalyticsConsent(preferences); } export function getSafePageLocation( diff --git a/lib/optional-consent.ts b/lib/optional-consent.ts new file mode 100644 index 0000000..0c46016 --- /dev/null +++ b/lib/optional-consent.ts @@ -0,0 +1,25 @@ +import type { VisitorPreferences } from "@/lib/visitor-preferences"; + +export const OPTIONAL_CONSENT_VERSION = "optional-consent-categories-v4"; + +type ConsentChoice = "accepted" | "declined"; + +function isCurrentChoice(choice: string | undefined, version: string | undefined): choice is ConsentChoice { + return (choice === "accepted" || choice === "declined") + && version === OPTIONAL_CONSENT_VERSION; +} + +export function hasOptionalAnalyticsConsent(preferences: VisitorPreferences): boolean { + return preferences.analyticsConsent === "accepted" + && preferences.analyticsConsentVersion === OPTIONAL_CONSENT_VERSION; +} + +export function hasOptionalMarketingConsent(preferences: VisitorPreferences): boolean { + return preferences.marketingConsent === "accepted" + && preferences.marketingConsentVersion === OPTIONAL_CONSENT_VERSION; +} + +export function hasCurrentOptionalConsentChoices(preferences: VisitorPreferences): boolean { + return isCurrentChoice(preferences.analyticsConsent, preferences.analyticsConsentVersion) + && isCurrentChoice(preferences.marketingConsent, preferences.marketingConsentVersion); +} diff --git a/lib/visitor-preferences.ts b/lib/visitor-preferences.ts index 968d18b..bf5f94f 100644 --- a/lib/visitor-preferences.ts +++ b/lib/visitor-preferences.ts @@ -11,6 +11,8 @@ export type VisitorPreferences = { theme?: "light" | "dark"; analyticsConsent?: "accepted" | "declined"; analyticsConsentVersion?: string; + marketingConsent?: "accepted" | "declined"; + marketingConsentVersion?: string; analyticsSessionId?: string; attribution?: AnalyticsAttribution; recentAgentSlugs?: string[]; diff --git a/tests/cortality.test.ts b/tests/cortality.test.ts index 384c562..122dcd8 100644 --- a/tests/cortality.test.ts +++ b/tests/cortality.test.ts @@ -6,22 +6,22 @@ import { hasCortalityConsent, isCortalityPublicPath, } from "@/lib/cortality"; -import { OPTIONAL_ANALYTICS_CONSENT_VERSION } from "@/lib/google-analytics"; +import { OPTIONAL_CONSENT_VERSION } from "@/lib/optional-consent"; import { getContentSecurityPolicy } from "@/lib/content-security-policy"; import { NextRequest } from "next/server"; import { proxy } from "../proxy"; describe("Cortality consent", () => { - it("uses the same current optional consent as Analytics and Meta Pixel", () => { + it("uses the current Analytics category consent", () => { expect(hasCortalityConsent({ analyticsConsent: "accepted" })).toBe(false); const accepted = { analyticsConsent: "accepted" as const, - analyticsConsentVersion: OPTIONAL_ANALYTICS_CONSENT_VERSION, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, }; const declined = { analyticsConsent: "declined" as const, - analyticsConsentVersion: OPTIONAL_ANALYTICS_CONSENT_VERSION, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, }; expect(hasCortalityConsent(accepted)).toBe(true); diff --git a/tests/google-analytics.test.ts b/tests/google-analytics.test.ts index 9fd748a..7da5aed 100644 --- a/tests/google-analytics.test.ts +++ b/tests/google-analytics.test.ts @@ -1,17 +1,19 @@ import { describe, expect, it } from "vitest"; import { - OPTIONAL_ANALYTICS_CONSENT_VERSION, getSafePageLocation, getSafePageReferrer, - hasCurrentOptionalAnalyticsChoice, hasGoogleAnalyticsConsent, shouldTrackPageView, } from "@/lib/google-analytics"; +import { + OPTIONAL_CONSENT_VERSION, + hasCurrentOptionalConsentChoices, +} from "@/lib/optional-consent"; describe("Google Analytics consent", () => { it("does not treat first-party analytics consent as consent to Google Analytics", () => { expect(hasGoogleAnalyticsConsent({ analyticsConsent: "accepted" })).toBe(false); - expect(hasCurrentOptionalAnalyticsChoice({ analyticsConsent: "declined" })).toBe(false); + expect(hasCurrentOptionalConsentChoices({ analyticsConsent: "declined" })).toBe(false); expect(hasGoogleAnalyticsConsent({ analyticsConsent: "accepted", analyticsConsentVersion: "ga4-v1", @@ -21,17 +23,21 @@ describe("Google Analytics consent", () => { it("enables measurement only after the current consent version is accepted", () => { const accepted = { analyticsConsent: "accepted" as const, - analyticsConsentVersion: OPTIONAL_ANALYTICS_CONSENT_VERSION, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + marketingConsent: "accepted" as const, + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, }; const declined = { analyticsConsent: "declined" as const, - analyticsConsentVersion: OPTIONAL_ANALYTICS_CONSENT_VERSION, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + marketingConsent: "declined" as const, + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, }; expect(hasGoogleAnalyticsConsent(accepted)).toBe(true); - expect(hasCurrentOptionalAnalyticsChoice(accepted)).toBe(true); + expect(hasCurrentOptionalConsentChoices(accepted)).toBe(true); expect(hasGoogleAnalyticsConsent(declined)).toBe(false); - expect(hasCurrentOptionalAnalyticsChoice(declined)).toBe(true); + expect(hasCurrentOptionalConsentChoices(declined)).toBe(true); }); }); diff --git a/tests/optional-consent.test.ts b/tests/optional-consent.test.ts new file mode 100644 index 0000000..ec9b4fb --- /dev/null +++ b/tests/optional-consent.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + OPTIONAL_CONSENT_VERSION, + hasCurrentOptionalConsentChoices, + hasOptionalAnalyticsConsent, + hasOptionalMarketingConsent, +} from "@/lib/optional-consent"; + +describe("optional consent categories", () => { + it("gates Analytics and Marketing independently", () => { + const analyticsOnly = { + analyticsConsent: "accepted" as const, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + marketingConsent: "declined" as const, + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, + }; + + expect(hasOptionalAnalyticsConsent(analyticsOnly)).toBe(true); + expect(hasOptionalMarketingConsent(analyticsOnly)).toBe(false); + expect(hasCurrentOptionalConsentChoices(analyticsOnly)).toBe(true); + }); + + it("allows Marketing without enabling Analytics", () => { + const marketingOnly = { + analyticsConsent: "declined" as const, + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + marketingConsent: "accepted" as const, + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, + }; + + expect(hasOptionalAnalyticsConsent(marketingOnly)).toBe(false); + expect(hasOptionalMarketingConsent(marketingOnly)).toBe(true); + expect(hasCurrentOptionalConsentChoices(marketingOnly)).toBe(true); + }); + + it("requires a current choice for both categories before dismissing the banner", () => { + expect(hasCurrentOptionalConsentChoices({ + analyticsConsent: "accepted", + analyticsConsentVersion: OPTIONAL_CONSENT_VERSION, + })).toBe(false); + expect(hasCurrentOptionalConsentChoices({ + analyticsConsent: "accepted", + analyticsConsentVersion: "analytics-ga4-meta-pixel-cortality-v3", + marketingConsent: "declined", + marketingConsentVersion: OPTIONAL_CONSENT_VERSION, + })).toBe(false); + }); + + it("does not carry legacy bundled consent into the new category choices", () => { + const legacyChoice = { + analyticsConsent: "accepted" as const, + analyticsConsentVersion: "analytics-ga4-meta-pixel-cortality-v3", + }; + + expect(hasOptionalAnalyticsConsent(legacyChoice)).toBe(false); + expect(hasOptionalMarketingConsent(legacyChoice)).toBe(false); + }); +});