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 (
+
+
🍪 }>
+
+
+ We use cookies for analytics purposes
+ Click to learn more
+
+
+
+ { requireExcplicitConsent ?
+
+ Accept
+ Reject
+
+ :
+
}
+ onClick={ dismiss } className={ cls.dismiss } />
+ }
+
+
+ );
+};
+
+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 Revoke my consent ;
+};
+
+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.
- }>
+
+ If you previously gave your consent to use cookies, you can revoke it by clicking "Revoke my consent" button on this page below (the button is available only if the consent was given). Recorded data will be deleted after 30 day retention period.
+
- Visit Clarity privacy FAQ
-
+
+
+ }>
+
+ Visit Clarity privacy FAQ
+
+
}
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)
}
+ { canLoadAnalytics() && }
{ props.children }