Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions app/api/preferences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -26,9 +26,13 @@ function validPatch(value: unknown): value is Record<string, unknown> {
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) {
Expand Down
74 changes: 37 additions & 37 deletions app/cookies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});

Expand All @@ -15,66 +15,66 @@ export default function CookiesPage() {
<p className="eyebrow">Legal</p>
<h1>Cookies and preferences</h1>
<p>
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.
</p>

<h2>Necessary cookies</h2>
<p>
The visitor cookie <code>{legalConfig.visitorCookieName}</code> 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 <code>sb-</code> help maintain sign-in and last{" "}
{legalConfig.authCookieLifetime}. These cookies are not used for advertising.
</p>

<h2>Optional analytics</h2>
<h2>Analytics and session replay</h2>
<p>
If you allow Google Analytics, it measures page views and selected interactions using
first-party cookies such as <code>_ga</code> and <code>_ga_*</code>. The cookie lifetime is
configured to six months and is not extended on each visit. Page paths are sent without
query strings; only <code>utm_source</code>, <code>utm_medium</code>, and{" "}
If you enable Analytics, Google Analytics measures page views and selected interactions
using first-party cookies such as <code>_ga</code> and <code>_ga_*</code>. The cookie
lifetime is configured to six months and is not extended on each visit. Page paths are
sent without query strings; only <code>utm_source</code>, <code>utm_medium</code>, and{" "}
<code>utm_campaign</code> 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.
</p>
<p>
If you allow optional analytics, AgentNine also loads Meta Pixel ID{" "}
<code>927600866897675</code> 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 <code>utm_source</code>, <code>utm_medium</code>, and <code>utm_campaign</code>.
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.
</p>
<p>
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{" "}
<Link className="text-link" href="/privacy">Privacy policy</Link>, 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.
</p>

<h2>Marketing</h2>
<p>
If you enable Marketing, AgentNine loads Meta Pixel ID <code>927600866897675</code> 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 <code>utm_source</code>, <code>utm_medium</code>, and <code>utm_campaign</code>.
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.
</p>

<p>
For details about preference retention, privacy requests, and changing your analytics
choices, see the <Link className="text-link" href="/privacy">Privacy policy</Link> or go
directly to <Link className="text-link" href="/privacy/choices">Privacy choices</Link>.
For details about preference retention, privacy requests, and changing your Analytics or
Marketing choices, see the <Link className="text-link" href="/privacy">Privacy policy</Link>{" "}
or go directly to <Link className="text-link" href="/privacy/choices">Privacy choices</Link>.
</p>
</div>
</main>
Expand Down
55 changes: 47 additions & 8 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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; }
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
Loading
Loading