From 0be786a70d21288e5498308ccfec7985e6e6ab76 Mon Sep 17 00:00:00 2001 From: Parth Date: Mon, 23 Feb 2026 09:26:26 -0500 Subject: [PATCH] feat(analytics): implement GTM and cookie consent management --- apps/web/.source/server.ts | 6 +- apps/web/app/cookies/page.tsx | 214 ++++++++++++++++++ apps/web/app/layout.tsx | 4 + apps/web/app/privacy/page.tsx | 7 +- apps/web/components/analytics/consent.ts | 48 ++++ .../components/analytics/cookie-consent.tsx | 80 +++++++ apps/web/components/analytics/gtm.tsx | 46 ++++ apps/web/lib/constants.ts | 1 + apps/web/package.json | 1 + pnpm-lock.yaml | 20 ++ 10 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/cookies/page.tsx create mode 100644 apps/web/components/analytics/consent.ts create mode 100644 apps/web/components/analytics/cookie-consent.tsx create mode 100644 apps/web/components/analytics/gtm.tsx diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index 38c91b3..a158859 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -27,8 +27,8 @@ import * as __fd_glob_7 from "../content/docs/form-component.mdx?collection=docs import * as __fd_glob_6 from "../content/docs/custom-rendering.mdx?collection=docs" import * as __fd_glob_5 from "../content/docs/configuration.mdx?collection=docs" import * as __fd_glob_4 from "../content/docs/conditional-logic.mdx?collection=docs" -import { default as __fd_glob_3 } from "../content/docs/fields/layout/meta.json?collection=docs" -import { default as __fd_glob_2 } from "../content/docs/fields/data/meta.json?collection=docs" +import { default as __fd_glob_3 } from "../content/docs/fields/data/meta.json?collection=docs" +import { default as __fd_glob_2 } from "../content/docs/fields/layout/meta.json?collection=docs" import { default as __fd_glob_1 } from "../content/docs/fields/meta.json?collection=docs" import { default as __fd_glob_0 } from "../content/docs/meta.json?collection=docs" import { server } from 'fumadocs-mdx/runtime/server'; @@ -39,4 +39,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/data/meta.json": __fd_glob_2, "fields/layout/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "configuration.mdx": __fd_glob_5, "custom-rendering.mdx": __fd_glob_6, "form-component.mdx": __fd_glob_7, "index.mdx": __fd_glob_8, "installation.mdx": __fd_glob_9, "quick-start.mdx": __fd_glob_10, "schema.mdx": __fd_glob_11, "validation.mdx": __fd_glob_12, "your-first-form.mdx": __fd_glob_13, "fields/types.mdx": __fd_glob_14, "fields/data/checkbox-group.mdx": __fd_glob_15, "fields/data/checkbox.mdx": __fd_glob_16, "fields/data/date.mdx": __fd_glob_17, "fields/data/number.mdx": __fd_glob_18, "fields/data/password.mdx": __fd_glob_19, "fields/data/radio.mdx": __fd_glob_20, "fields/data/select.mdx": __fd_glob_21, "fields/data/switch.mdx": __fd_glob_22, "fields/data/tags.mdx": __fd_glob_23, "fields/data/text.mdx": __fd_glob_24, "fields/data/textarea.mdx": __fd_glob_25, "fields/data/upload.mdx": __fd_glob_26, "fields/layout/array.mdx": __fd_glob_27, "fields/layout/collapsible.mdx": __fd_glob_28, "fields/layout/group.mdx": __fd_glob_29, "fields/layout/row.mdx": __fd_glob_30, "fields/layout/tabs.mdx": __fd_glob_31, }); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/layout/meta.json": __fd_glob_2, "fields/data/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "configuration.mdx": __fd_glob_5, "custom-rendering.mdx": __fd_glob_6, "form-component.mdx": __fd_glob_7, "index.mdx": __fd_glob_8, "installation.mdx": __fd_glob_9, "quick-start.mdx": __fd_glob_10, "schema.mdx": __fd_glob_11, "validation.mdx": __fd_glob_12, "your-first-form.mdx": __fd_glob_13, "fields/types.mdx": __fd_glob_14, "fields/data/checkbox-group.mdx": __fd_glob_15, "fields/data/checkbox.mdx": __fd_glob_16, "fields/data/date.mdx": __fd_glob_17, "fields/data/number.mdx": __fd_glob_18, "fields/data/password.mdx": __fd_glob_19, "fields/data/radio.mdx": __fd_glob_20, "fields/data/select.mdx": __fd_glob_21, "fields/data/switch.mdx": __fd_glob_22, "fields/data/tags.mdx": __fd_glob_23, "fields/data/text.mdx": __fd_glob_24, "fields/data/textarea.mdx": __fd_glob_25, "fields/data/upload.mdx": __fd_glob_26, "fields/layout/array.mdx": __fd_glob_27, "fields/layout/collapsible.mdx": __fd_glob_28, "fields/layout/group.mdx": __fd_glob_29, "fields/layout/row.mdx": __fd_glob_30, "fields/layout/tabs.mdx": __fd_glob_31, }); \ No newline at end of file diff --git a/apps/web/app/cookies/page.tsx b/apps/web/app/cookies/page.tsx new file mode 100644 index 0000000..a7bc7d6 --- /dev/null +++ b/apps/web/app/cookies/page.tsx @@ -0,0 +1,214 @@ +import { siteConfig } from "@/lib/constants"; +import { Badge } from "@/components/ui/badge"; +import { SiteHeader } from "@/components/landing/site-header"; +import { SiteFooter } from "@/components/landing/site-footer"; + +export const metadata = { + title: `Cookie Policy`, + description: "How BuzzForm uses cookies and similar technologies.", +}; + +const lastUpdated = "February 23, 2026"; + +export default function CookiesPage() { + return ( +
+ +
+
+
+ + Legal + +

+ Cookie Policy +

+

+ This policy explains which cookies and similar technologies we use + on {siteConfig.name}, and how you can control them. +

+
+ + Last updated: {lastUpdated} + +
+
+
+
+
+ +
+
+
+
+
+

+ 1. What This Policy Covers +

+

+ We use cookies and similar technologies (such as local + storage) to keep {siteConfig.name} working and, if you + allow it, to measure usage trends and improve the product. +

+
+ +
+

+ 2. Types of Technologies We Use +

+

+ We currently use the following categories: +

+
    +
  • + + Essential technologies: + {" "} + required for core site behavior and security. +
  • +
  • + + Analytics technologies: + {" "} + optional, used to understand traffic and product usage. +
  • +
+
+ + + +
+

+ 4. How We Store Your Choice +

+

+ We store your preference locally in your browser using a{" "} + + cookie_consent + {" "} + value of{" "} + + granted + {" "} + or{" "} + + denied + + . You can remove this value anytime by clearing site data in + your browser. +

+
+ +
+

+ 5. Third-Party Analytics Providers +

+

+ We use Google Tag Manager to manage analytics tags. When + consent is denied, tags operate under denied consent + settings. When consent is granted, analytics storage and + related consent signals are enabled. These providers may + process data according to their own privacy policies. +

+
+ +
+

6. Policy Updates

+

+ We may update this policy as our product or legal + obligations evolve. We will post the latest version on this + page and update the "Last updated" date. +

+
+ +
+

7. Contact

+

+ If you have cookie-related questions, contact us through our + official channels listed in the footer. +

+
+
+
+ + +
+
+
+ +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 878b7ef..f0fb7d8 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,6 +4,8 @@ import "./globals.css"; import { Providers } from "@/providers"; import { Toaster } from "@/components/ui/sonner"; import { Analytics } from "@vercel/analytics/next"; +import { CookieConsent } from "@/components/analytics/cookie-consent"; +import { GTM } from "@/components/analytics/gtm"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -87,8 +89,10 @@ export default function RootLayout({ + {children} + diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx index 501b8a6..b50a5df 100644 --- a/apps/web/app/privacy/page.tsx +++ b/apps/web/app/privacy/page.tsx @@ -1,6 +1,7 @@ import { Badge } from "@/components/ui/badge"; import { SiteHeader } from "@/components/landing/site-header"; import { SiteFooter } from "@/components/landing/site-footer"; +import Link from "next/link"; export const metadata = { title: `Privacy Policy`, @@ -82,7 +83,11 @@ export default function PrivacyPage() { We use analytics tools to understand how people interact with BuzzForm. These tools may use cookies or similar technologies to collect usage data. You can control cookies through your - browser settings. + browser settings. For details, review our{" "} + + Cookie Policy + + .

diff --git a/apps/web/components/analytics/consent.ts b/apps/web/components/analytics/consent.ts new file mode 100644 index 0000000..f17e531 --- /dev/null +++ b/apps/web/components/analytics/consent.ts @@ -0,0 +1,48 @@ +export const COOKIE_CONSENT_STORAGE_KEY = "cookie_consent"; +export const COOKIE_CONSENT_UPDATED_EVENT = "buzzform-cookie-consent-updated"; + +export type CookieConsentValue = "granted" | "denied"; + +export function isGrantedConsent(value: string | null): value is "granted" { + return value === "granted"; +} + +export function isCookieConsentValue( + value: string | null, +): value is CookieConsentValue { + return value === "granted" || value === "denied"; +} + +type ConsentUpdatePayload = { + ad_storage: CookieConsentValue; + analytics_storage: CookieConsentValue; + ad_user_data: CookieConsentValue; + ad_personalization: CookieConsentValue; +}; + +export function toConsentUpdatePayload( + consent: CookieConsentValue, +): ConsentUpdatePayload { + return { + ad_storage: consent, + analytics_storage: consent, + ad_user_data: consent, + ad_personalization: consent, + }; +} + +export function updateGoogleConsent(consent: CookieConsentValue): void { + if (typeof window === "undefined") { + return; + } + + const runtimeWindow = window as Window & { dataLayer?: unknown[] }; + runtimeWindow.dataLayer = runtimeWindow.dataLayer || []; + + const gtag: (...args: unknown[]) => void = function () { + // eslint-disable-next-line prefer-rest-params + runtimeWindow.dataLayer?.push(arguments); + }; + + gtag("consent", "update", toConsentUpdatePayload(consent)); +} diff --git a/apps/web/components/analytics/cookie-consent.tsx b/apps/web/components/analytics/cookie-consent.tsx new file mode 100644 index 0000000..245010c --- /dev/null +++ b/apps/web/components/analytics/cookie-consent.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + COOKIE_CONSENT_STORAGE_KEY, + COOKIE_CONSENT_UPDATED_EVENT, + isCookieConsentValue, + updateGoogleConsent, +} from "./consent"; + +export function CookieConsent() { + const [showConsent, setShowConsent] = useState(false); + + useEffect(() => { + // Check if the user has already made a choice + const consent = localStorage.getItem(COOKIE_CONSENT_STORAGE_KEY); + + if (isCookieConsentValue(consent)) { + updateGoogleConsent(consent); + return; + } + + if (!consent) { + // Defer state update to avoid React Compiler synchronous setState warning + requestAnimationFrame(() => { + setShowConsent(true); + }); + return; + } + + // Unknown stored value should be treated as no consent. + localStorage.removeItem(COOKIE_CONSENT_STORAGE_KEY); + requestAnimationFrame(() => { + setShowConsent(true); + }); + }, []); + + const acceptCookies = () => { + localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, "granted"); + updateGoogleConsent("granted"); + window.dispatchEvent(new Event(COOKIE_CONSENT_UPDATED_EVENT)); + setShowConsent(false); + }; + + const declineCookies = () => { + localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, "denied"); + updateGoogleConsent("denied"); + window.dispatchEvent(new Event(COOKIE_CONSENT_UPDATED_EVENT)); + setShowConsent(false); + }; + + const gtmId = process.env.NEXT_PUBLIC_GTM_ID; + + if (!gtmId || !showConsent) { + return null; + } + + return ( +
+
+ We use essential cookies for core functionality and optional analytics + cookies to understand site usage. Read our{" "} + + Cookie Policy + {" "} + for details. +
+
+ + +
+
+ ); +} diff --git a/apps/web/components/analytics/gtm.tsx b/apps/web/components/analytics/gtm.tsx new file mode 100644 index 0000000..2c20c6d --- /dev/null +++ b/apps/web/components/analytics/gtm.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { GoogleTagManager } from "@next/third-parties/google"; +import Script from "next/script"; +import { COOKIE_CONSENT_STORAGE_KEY } from "./consent"; + +export function GTM() { + const gtmId = process.env.NEXT_PUBLIC_GTM_ID; + if (!gtmId) { + return null; + } + + const bootstrapConsentScript = ` + (function() { + var consentKey = ${JSON.stringify(COOKIE_CONSENT_STORAGE_KEY)}; + var consent = null; + try { + consent = window.localStorage.getItem(consentKey); + } catch (error) {} + + var hasStoredChoice = consent === "granted" || consent === "denied"; + var consentState = consent === "granted" ? "granted" : "denied"; + var waitForUpdate = hasStoredChoice ? 0 : 500; + + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + + gtag("consent", "default", { + "analytics_storage": consentState, + "ad_storage": consentState, + "ad_user_data": consentState, + "ad_personalization": consentState, + "wait_for_update": waitForUpdate + }); + })(); + `; + + return ( + <> + + + + ); +} diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 3311736..9b363b3 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -143,6 +143,7 @@ export const footerSections: FooterSection[] = [ title: "Legal", links: [ { label: "Privacy Policy", href: "/privacy" }, + { label: "Cookie Policy", href: "/cookies" }, { label: "Terms of Service", href: "/terms" }, ], }, diff --git a/apps/web/package.json b/apps/web/package.json index 976eb2d..33d7286 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", + "@next/third-parties": "^16.1.6", "@phosphor-icons/react": "^2.1.10", "@remixicon/react": "^4.8.0", "@supabase/ssr": "^0.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 733a11b..451a993 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@hugeicons/react': specifier: ^1.1.4 version: 1.1.4(react@19.2.3) + '@next/third-parties': + specifier: ^16.1.6 + version: 16.1.6(next@16.1.1(@babel/core@7.28.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1092,6 +1095,12 @@ packages: cpu: [x64] os: [win32] + '@next/third-parties@16.1.6': + resolution: {integrity: sha512-/cLY1egaH529ylSMSK+C8dA3nWDLL4hOFR4fca9OLWWxjcNwzsbuq2pPb/tmdWL9Zj3K1nTjd1pWQoSlaDQ0VA==} + peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -4822,6 +4831,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + third-party-capital@1.0.20: + resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6221,6 +6233,12 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.1': optional: true + '@next/third-parties@16.1.6(next@16.1.1(@babel/core@7.28.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + dependencies: + next: 16.1.1(@babel/core@7.28.6)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + third-party-capital: 1.0.20 + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -10503,6 +10521,8 @@ snapshots: dependencies: any-promise: 1.3.0 + third-party-capital@1.0.20: {} + tiny-invariant@1.3.3: {} tinyexec@0.3.2: {}