From 67222999a90a451cb698ccb1f64a025bdf620c4e Mon Sep 17 00:00:00 2001 From: Eugene Fox Date: Wed, 21 Aug 2024 00:22:49 +0000 Subject: [PATCH] feat!: added cookie consent banner and management system --- .env.development | 19 +++--- app/_components/CookieBanner.module.scss | 83 ++++++++++++++++++++++++ app/_components/CookieBanner.tsx | 74 +++++++++++++++++++++ app/_components/RevokeConsentButton.tsx | 30 +++++++++ app/_utils/analytics/clarity.d.ts | 55 ++++++++++++++++ app/_utils/analytics/client.ts | 49 ++++++++++++++ app/attribution/page.module.scss | 6 ++ app/attribution/page.tsx | 18 +++-- app/globals.scss | 8 +++ app/layout.tsx | 5 +- 10 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 app/_components/CookieBanner.module.scss create mode 100644 app/_components/CookieBanner.tsx create mode 100644 app/_components/RevokeConsentButton.tsx create mode 100644 app/_utils/analytics/clarity.d.ts create mode 100644 app/_utils/analytics/client.ts diff --git a/.env.development b/.env.development index 5d4b778..2484a94 100644 --- a/.env.development +++ b/.env.development @@ -2,13 +2,14 @@ # Copy this file to .env, .env.local, or .env.production and fill in the values # Mail credentials for redirecting form inquiries (see app/_utils/sendInquiry.ts) -SMTP_HOST=mailserver # Address of your SMTP server -SMTP_PORT=port # Port of your SMTP server -SMTP_USER=username # Username of your email bot account (usually same, as email address) -SMTP_PASSWORD=password # Password of your email bot account -SMTP_FROM_EMAIL=email # Email address which will be displayed in "From" field -SMTP_TO_EMAIL=email # Email to which emails will be sent +SMTP_HOST=mailserver # Address of your SMTP server +SMTP_PORT=port # Port of your SMTP server +SMTP_USER=username # Username of your email bot account (usually same, as email address) +SMTP_PASSWORD=password # Password of your email bot account +SMTP_FROM_EMAIL=email # Email address which will be displayed in "From" field +SMTP_TO_EMAIL=email # Email to which emails will be sent -DOMAIN_NAME=example.com # Your domain name -RESUME_URL=URL # Location of the resume PDF -CLARITY_ID=string # Clarity Analytics ID (optional, remove to disable) +DOMAIN_NAME=example.com # Your domain name +RESUME_URL=URL # Location of the resume PDF +CLARITY_ID=string # Clarity Analytics ID (optional, remove to disable) +NEXT_PUBLIC_CLARITY_CONSENT=1 # 1 if you need to request explicit consent from user, 0 if not (requires CLARITY_ID) diff --git a/app/_components/CookieBanner.module.scss b/app/_components/CookieBanner.module.scss new file mode 100644 index 0000000..ed300c1 --- /dev/null +++ b/app/_components/CookieBanner.module.scss @@ -0,0 +1,83 @@ +@import "../theme.scss"; + +.banner +{ + @include flex(row); + position: fixed; + left: 0; + bottom: 8%; + background-color: $colorNeutralBackground2; + z-index: 20; + + .learnMore + { + gap: $spacingL; + padding: $spacingMNudge $spacingL; + background-size: $spacingMNudge 100%; + flex-grow: 1; + justify-content: flex-start; + + p + { + color: $colorNeutralForeground2; + @include body1; + + span + { + color: $colorNeutralForeground1; + @include body2($fontFamilyBaseAlt); + } + } + + &:hover, + &:focus-visible + { + + p, + p > span + { + color: $colorNeutralForegroundInverted; + } + } + } + + .dismiss + { + border-left: none; + } + + .controls + { + display: grid; + grid-auto-flow: column; + + @media screen and (min-width: 769px) + { + > button + { + border-left: none; + } + } + } + + @media screen and (max-width: 768px) + { + width: 100%; + bottom: 0; + + &:not(:has(> .dismiss)) + { + flex-flow: column; + + .learnMore + { + border-bottom: none; + } + + .controls > button:last-child + { + border-left: none; + } + } + } +} diff --git a/app/_components/CookieBanner.tsx b/app/_components/CookieBanner.tsx new file mode 100644 index 0000000..d913166 --- /dev/null +++ b/app/_components/CookieBanner.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { acceptCookies, dismissCookies, getCookieChoice, rejectCookies, requireExcplicitConsent } from "@/_utils/analytics/client"; +import { Dismiss24Regular } from "@fluentui/react-icons"; +import React, { useCallback, useEffect, useState } from "react"; +import Button from "./Button"; +import cls from "./CookieBanner.module.scss"; + +const CookieBanner: React.FC = () => +{ + const [visible, setVisible] = useState(false); + + useEffect(() => + { + const choice = getCookieChoice(); + setVisible(choice === "none"); + + // Since Clarity cookies expiration dates extend well beyond 60 days, + // we need to terminate them manually once our consent tracking cookie expires. + if (choice !== "accepted") + window.clarity?.("consent", false); + }, []); + + const accept = useCallback(() => + { + acceptCookies(); + setVisible(false); + }, []); + + const reject = useCallback(() => + { + rejectCookies(); + setVisible(false); + }, []); + + const dismiss = useCallback(() => + { + dismissCookies(); + setVisible(false); + }, []); + + if (!visible) + return null; + + return ( +
+ + + { requireExcplicitConsent ? +
+ + +
+ : +
+ ); +}; + +export default CookieBanner; diff --git a/app/_components/RevokeConsentButton.tsx b/app/_components/RevokeConsentButton.tsx new file mode 100644 index 0000000..63f6eeb --- /dev/null +++ b/app/_components/RevokeConsentButton.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Button from "@/_components/Button"; +import React, { useCallback, useEffect, useState } from "react"; +import { getCookieChoice, rejectCookies } from "../_utils/analytics/client"; + +const RevokeConsentButton: React.FC = () => +{ + const [hasConsent, setHasConsent] = useState(false); + + useEffect(() => + { + console.log("getCookieChoice", getCookieChoice()); + setHasConsent(getCookieChoice() === "accepted"); + }, []); + + const revoke = useCallback(() => + { + rejectCookies(); + setHasConsent(false); + window.alert("Your consent has been revoked"); + }, []); + + if (!hasConsent) + return null; + + return ; +}; + +export default RevokeConsentButton; diff --git a/app/_utils/analytics/clarity.d.ts b/app/_utils/analytics/clarity.d.ts new file mode 100644 index 0000000..d6047e2 --- /dev/null +++ b/app/_utils/analytics/clarity.d.ts @@ -0,0 +1,55 @@ +/* eslint-disable no-unused-vars */ +declare global +{ + interface Window + { + /** + * Notify Clarity about user's cookie consent + * @param cmd - command + * @param consent - consent (default: true) + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-api + */ + clarity?(cmd: "consent", consent?: boolean): void; + /** + * Set custom Clarity user identifier + * @param cmd - command + * @param customId - user identifier + * @param customSessionId - session identifier + * @param customPageId - page identifier + * @param friendlyName - user friendly name + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-api + */ + clarity?(cmd: "identify", customId: string, customSessionId?: string, customPageId?: string, friendlyName?: string): void; + /** + * Add custom tag to Clarity session recording + * @param cmd - command + * @param key - tag + * @param value - value + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-api + */ + clarity?(cmd: "set", key: string, value: string): void; + /** + * Add custom event to Clarity session recording + * @param cmd - command + * @param value - event name + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-api + */ + clarity?(cmd: "event", value: string): void; + /** + * Prioritize current Clarity session recording + * @param cmd - command + * @param reason - reason for upgrade + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-api + */ + clarity?(cmd: "upgrade", reason: string): void; + } +} + +export type ClarityProps = + { cmd: "consent", consent?: boolean; } | + { cmd: "identify", customId: string, customSessionId?: string, customPageId?: string, friendlyName?: string; } | + { cmd: "set", key: string, value: string; } | + { cmd: "event", value: string; } | + { cmd: "upgrade", reason: string; }; + +export default global; diff --git a/app/_utils/analytics/client.ts b/app/_utils/analytics/client.ts new file mode 100644 index 0000000..ad645ca --- /dev/null +++ b/app/_utils/analytics/client.ts @@ -0,0 +1,49 @@ +"use client"; + +export const acceptCookies = (): void => +{ + setCookie("CC", "1", 5184000); // 60 days + window.clarity?.("consent", true); +}; + +export const rejectCookies = (): void => +{ + setCookie("CC", "0", 86400); // 1 day + window.clarity?.("consent", false); +}; + +export const dismissCookies = (): void => +{ + setCookie("CC", "", 1209600); // 14 days +}; + +export const requireExcplicitConsent: boolean = process.env.NEXT_PUBLIC_CLARITY_CONSENT === "1"; + +export const getCookieChoice = (): "accepted" | "rejected" | "acknowledged" | "none" => +{ + switch (getCookie("CC")) + { + case "1": return "accepted"; + case "0": return "rejected"; + case "": return "acknowledged"; + default: return "none"; + } +}; + +function setCookie(name: string, value: string | number, maxAge: number): void +{ + window.document.cookie = `${name}=${value}; max-age=${maxAge}; path=/`; +} + +function getCookie(name: string): string | undefined +{ + let cookieName = name + "="; + let rawCookies = decodeURIComponent(window.document.cookie); + let cookies = rawCookies.split(";"); + + for (const cookie of cookies) + if (cookie.trim().startsWith(cookieName)) + return cookie.substring(cookieName.length); + + return undefined; +} diff --git a/app/attribution/page.module.scss b/app/attribution/page.module.scss index b02e583..84f8da7 100644 --- a/app/attribution/page.module.scss +++ b/app/attribution/page.module.scss @@ -33,4 +33,10 @@ padding: revert; } } + + .buttonRow + { + @include flex(row, wrap); + gap: $spacingS; + } } diff --git a/app/attribution/page.tsx b/app/attribution/page.tsx index c3b3614..a993c44 100644 --- a/app/attribution/page.tsx +++ b/app/attribution/page.tsx @@ -1,5 +1,6 @@ import Package from "@/../package.json"; import Button from "@/_components/Button"; +import RevokeConsentButton from "@/_components/RevokeConsentButton"; import { canonicalName, getTitle } from "@/_data/metadata"; import ThirdPartyAttribution from "@/_data/ThirdPartyAttributiont"; import { analyticsEnabled } from "@/_utils/analytics/server"; @@ -39,12 +40,19 @@ const AttributionPage: React.FC = () => (

If "Do Not Track" option is enabled in your browser, the website will not load any tracking code.

- +
+ + +
} diff --git a/app/globals.scss b/app/globals.scss index 5681da1..d450a0a 100644 --- a/app/globals.scss +++ b/app/globals.scss @@ -93,3 +93,11 @@ main.not-found + footer > .illustration { display: none; } + +@media screen and (max-width: 768px) +{ + body:has(.cookie-banner) + { + padding-bottom: 110px; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 3e8d7e2..fa698f1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,8 @@ import { metadata as myMetadata } from "@/_data/metadata"; import type { Metadata, Viewport } from "next"; -import { headers as getHeaders } from "next/headers"; import Script from "next/script"; import { PropsWithChildren } from "react"; +import CookieBanner from "./_components/CookieBanner"; import Footer from "./_components/Footer"; import Header from "./_components/Header"; import { canLoadAnalytics } from "./_utils/analytics/server"; @@ -23,8 +23,6 @@ export const metadata: Metadata = myMetadata; export default function RootLayout(props: PropsWithChildren) { - const headers = getHeaders(); - return ( i.variable).join(" ") }> { canLoadAnalytics() && @@ -32,6 +30,7 @@ export default function RootLayout(props: PropsWithChildren)