Add PostHog consent integration and extract integration plugin system
Add PostHog opt-in/opt-out consent support mirroring the existing Google Consent Mode integration: database column, GraphQL field, console UI toggle, and client-side posthog-js calls. Extract both GCM and PostHog logic from CookieBannerClient into a ConsentIntegration plugin interface so future integrations can be added without modifying the client core. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import { NotFoundError } from "./errors";
|
||||
import { fetchJSON } from "./http";
|
||||
import type { BannerTexts } from "./i18n";
|
||||
import { detectLanguage } from "./i18n";
|
||||
import type { ConsentIntegration } from "./integrations";
|
||||
import { createDefaultIntegrations } from "./integrations";
|
||||
import { enqueue, flush } from "./queue";
|
||||
import { getOrCreateVisitorId } from "./visitor";
|
||||
|
||||
@@ -40,6 +42,7 @@ export interface Category {
|
||||
kind: string;
|
||||
cookies: CookieItem[];
|
||||
gcm_consent_types: string[];
|
||||
posthog_consent: boolean;
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
@@ -84,6 +87,8 @@ export class CookieBannerClient {
|
||||
private readonly visitorId: string;
|
||||
private readonly lang: string;
|
||||
|
||||
private readonly integrations: ConsentIntegration[];
|
||||
|
||||
private bannerConfig: BannerConfig | null = null;
|
||||
private consent: VisitorConsent | null = null;
|
||||
private observer: MutationObserver | null = null;
|
||||
@@ -99,6 +104,7 @@ export class CookieBannerClient {
|
||||
this.bannerId = config.bannerId;
|
||||
this.visitorId = getOrCreateVisitorId(config.bannerId);
|
||||
this.lang = detectLanguage(config.lang);
|
||||
this.integrations = createDefaultIntegrations();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
@@ -109,7 +115,9 @@ export class CookieBannerClient {
|
||||
const config = await fetchJSON<BannerConfig>(configUrl);
|
||||
this.bannerConfig = config;
|
||||
|
||||
this.setGoogleConsentDefaults();
|
||||
for (const integration of this.integrations) {
|
||||
integration.setDefaults(config.categories);
|
||||
}
|
||||
this.startDetector(config);
|
||||
|
||||
const cookie = getConsentCookie();
|
||||
@@ -274,7 +282,9 @@ export class CookieBannerClient {
|
||||
}
|
||||
|
||||
private activate(consentData: Record<string, boolean>): void {
|
||||
this.updateGoogleConsentMode(consentData);
|
||||
for (const integration of this.integrations) {
|
||||
integration.update(this.config.categories, consentData);
|
||||
}
|
||||
|
||||
const categoryCookies: Record<string, string[]> = {};
|
||||
const categoryLabels: Record<string, string> = {};
|
||||
@@ -293,55 +303,6 @@ export class CookieBannerClient {
|
||||
this.observer = observeAndActivate(consentData, categoryLabels, texts);
|
||||
}
|
||||
|
||||
private hasGCMMapping(): boolean {
|
||||
return this.config.categories.some(
|
||||
(cat) => cat.gcm_consent_types && cat.gcm_consent_types.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
private setGoogleConsentDefaults(): void {
|
||||
if (!this.hasGCMMapping()) return;
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof gtag !== "function") return;
|
||||
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const cat of this.config.categories) {
|
||||
if (!cat.gcm_consent_types) continue;
|
||||
for (const gcmType of cat.gcm_consent_types) {
|
||||
defaults[gcmType] = "denied";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
gtag("consent", "default", defaults);
|
||||
}
|
||||
}
|
||||
|
||||
private updateGoogleConsentMode(
|
||||
consentData: Record<string, boolean>,
|
||||
): void {
|
||||
if (!this.hasGCMMapping()) return;
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof gtag !== "function") return;
|
||||
|
||||
const update: Record<string, string> = {};
|
||||
for (const cat of this.config.categories) {
|
||||
if (!cat.gcm_consent_types) continue;
|
||||
const granted = !!consentData[cat.slug];
|
||||
for (const gcmType of cat.gcm_consent_types) {
|
||||
update[gcmType] = granted ? "granted" : "denied";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
gtag("consent", "update", update);
|
||||
}
|
||||
}
|
||||
|
||||
private startDetector(config: BannerConfig): void {
|
||||
const knownNames = new Set<string>();
|
||||
knownNames.add(COOKIE_NAME);
|
||||
|
||||
73
packages/cookie-banner/src/integrations/gcm.ts
Normal file
73
packages/cookie-banner/src/integrations/gcm.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { Category } from "../client";
|
||||
import type { ConsentIntegration } from "./integration";
|
||||
|
||||
export class GoogleConsentModeIntegration implements ConsentIntegration {
|
||||
private hasMapping(categories: Category[]): boolean {
|
||||
return categories.some(
|
||||
(cat) => cat.gcm_consent_types && cat.gcm_consent_types.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
private getGtag(): ((...args: unknown[]) => void) | null {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof gtag !== "function") return null;
|
||||
return gtag;
|
||||
}
|
||||
|
||||
setDefaults(categories: Category[]): void {
|
||||
if (!this.hasMapping(categories)) return;
|
||||
|
||||
const gtag = this.getGtag();
|
||||
if (!gtag) return;
|
||||
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const cat of categories) {
|
||||
if (!cat.gcm_consent_types) continue;
|
||||
for (const gcmType of cat.gcm_consent_types) {
|
||||
defaults[gcmType] = "denied";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
gtag("consent", "default", defaults);
|
||||
}
|
||||
}
|
||||
|
||||
update(
|
||||
categories: Category[],
|
||||
consentData: Record<string, boolean>,
|
||||
): void {
|
||||
if (!this.hasMapping(categories)) return;
|
||||
|
||||
const gtag = this.getGtag();
|
||||
if (!gtag) return;
|
||||
|
||||
const update: Record<string, string> = {};
|
||||
for (const cat of categories) {
|
||||
if (!cat.gcm_consent_types) continue;
|
||||
const granted = !!consentData[cat.slug];
|
||||
for (const gcmType of cat.gcm_consent_types) {
|
||||
update[gcmType] = granted ? "granted" : "denied";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
gtag("consent", "update", update);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/cookie-banner/src/integrations/index.ts
Normal file
28
packages/cookie-banner/src/integrations/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
export type { ConsentIntegration } from "./integration";
|
||||
export { GoogleConsentModeIntegration } from "./gcm";
|
||||
export { PostHogIntegration } from "./posthog";
|
||||
|
||||
import type { ConsentIntegration } from "./integration";
|
||||
import { GoogleConsentModeIntegration } from "./gcm";
|
||||
import { PostHogIntegration } from "./posthog";
|
||||
|
||||
export function createDefaultIntegrations(): ConsentIntegration[] {
|
||||
return [
|
||||
new GoogleConsentModeIntegration(),
|
||||
new PostHogIntegration(),
|
||||
];
|
||||
}
|
||||
23
packages/cookie-banner/src/integrations/integration.ts
Normal file
23
packages/cookie-banner/src/integrations/integration.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { Category } from "../client";
|
||||
|
||||
export interface ConsentIntegration {
|
||||
/** Called once after config is loaded, before any consent is applied. */
|
||||
setDefaults(categories: Category[]): void;
|
||||
|
||||
/** Called whenever consent changes (accept, reject, customize, GPC). */
|
||||
update(categories: Category[], consentData: Record<string, boolean>): void;
|
||||
}
|
||||
69
packages/cookie-banner/src/integrations/posthog.ts
Normal file
69
packages/cookie-banner/src/integrations/posthog.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { Category } from "../client";
|
||||
import type { ConsentIntegration } from "./integration";
|
||||
|
||||
interface PostHogInstance {
|
||||
opt_in_capturing: () => void;
|
||||
opt_out_capturing: () => void;
|
||||
}
|
||||
|
||||
export class PostHogIntegration implements ConsentIntegration {
|
||||
private hasMapping(categories: Category[]): boolean {
|
||||
return categories.some((cat) => cat.posthog_consent);
|
||||
}
|
||||
|
||||
private getPostHog(): PostHogInstance | null {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const posthog = w.posthog as (PostHogInstance & Record<string, unknown>) | undefined;
|
||||
if (
|
||||
!posthog ||
|
||||
typeof posthog.opt_in_capturing !== "function" ||
|
||||
typeof posthog.opt_out_capturing !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return posthog;
|
||||
}
|
||||
|
||||
setDefaults(categories: Category[]): void {
|
||||
if (!this.hasMapping(categories)) return;
|
||||
|
||||
const posthog = this.getPostHog();
|
||||
if (!posthog) return;
|
||||
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
|
||||
update(
|
||||
categories: Category[],
|
||||
consentData: Record<string, boolean>,
|
||||
): void {
|
||||
if (!this.hasMapping(categories)) return;
|
||||
|
||||
const posthog = this.getPostHog();
|
||||
if (!posthog) return;
|
||||
|
||||
const granted = categories
|
||||
.filter((cat) => cat.posthog_consent)
|
||||
.every((cat) => !!consentData[cat.slug]);
|
||||
|
||||
if (granted) {
|
||||
posthog.opt_in_capturing();
|
||||
} else {
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user