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:
Émile Ré
2026-04-24 15:51:40 +04:00
parent c6f548b8ff
commit 7f1dffad80
14 changed files with 281 additions and 54 deletions

View File

@@ -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);

View 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);
}
}
}

View 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(),
];
}

View 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;
}

View 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();
}
}
}