Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `@vizuh/clicktrail-consent/cmp`: `connectCookiebot`, `connectOneTrust`, and
`connectComplianz` forward CMP decisions to a `ConsentHub`, never emit while
consent is pending, deduplicate repeated events, and observe in-page
withdrawals (#39).

## [0.2.0-rc.2] - pending publication

Candidate prepared 2026-09-13 from merged PR #28 for matching GitHub/npm
Expand Down
42 changes: 42 additions & 0 deletions packages/consent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,48 @@ const canSend = transmissionAllowed(snapshot, 'analytics');
Unknown or denied consent does not allow storage or transmission. Revoke or
clear data through the host integration when consent is withdrawn.

## CMP adapters

`@vizuh/clicktrail-consent/cmp` connects common consent-management platforms to
a `ConsentHub`. Adapters only read the CMP's decision; they never render a
banner or decide consent.

```ts
import { createConsentHub } from '@vizuh/clicktrail-consent';
import { connectCookiebot } from '@vizuh/clicktrail-consent/cmp';

const hub = createConsentHub();
const disconnect = connectCookiebot(hub);
hub.subscribe((record) => {
// Capture on grant (read the click ID from the current URL); clear on denial.
});
```

| Adapter | Reads | Listens to |
| --- | --- | --- |
| `connectCookiebot` | `Cookiebot.hasResponse`, `Cookiebot.consent.marketing` / `statistics` | `CookiebotOnConsentReady`, `CookiebotOnAccept`, `CookiebotOnDecline` |
| `connectOneTrust` | `OnetrustActiveGroups` (exact group match; `marketingGroup` default `C0004`, `analyticsGroup` default `C0002`) | `OneTrustGroupsUpdated`, plus a wrapped `OptanonWrapper` (host function kept) |
| `connectComplianz` | `cmplz_has_consent('marketing' / 'statistics')`, else `event.detail.categories` | `cmplz_fire_categories`, `cmplz_status_change` |

Ordering contract:

- Adapters emit the CMP's effective decision, the same state the CMP uses for
its own tags. Cookiebot emits nothing until `hasResponse`. OneTrust and
Complianz report their current state: with an opt-in banner that is `denied`
until the visitor accepts; in an opt-out/implied-consent region configured in
the CMP it can be `granted` before any click. Persist nothing before a grant.
- Some CMPs (Cookiebot) delete unclassified storage before firing their events.
Capture on the emitted grant and read the click ID from the current URL, not
from storage written before the decision.
- `state` follows marketing consent, because `storageAllowed()` checks only
`state`: analytics-only consent does not unlock attribution storage.
- Adapters stay subscribed, so in-page withdrawals emit `denied`; clear stored
attribution in your subscriber.

These semantics match the ClickTrail WordPress plugin's consent bridge. The
adapters are browser-only; without a `window` (SSR) they return a no-op
disposer. Pass `{ target }` to supply a window-like object in tests.

## License

MIT — see [LICENSE](./LICENSE).
1 change: 1 addition & 0 deletions packages/consent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"types": "./dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./cmp": { "types": "./dist/cmp/index.d.ts", "default": "./dist/cmp/index.js" },
"./package.json": "./package.json"
},
"files": ["dist", "README.md", "LICENSE"],
Expand Down
48 changes: 48 additions & 0 deletions packages/consent/src/cmp/complianz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ConsentHub } from '../listener.js';
import { createEmitter, noop, resolveTarget, toRecord } from './shared.js';
import type { CmpAdapterOptions, Disposer } from './shared.js';

type HasConsent = (category: string) => boolean;

// Complianz 6+ fires cmplz_fire_categories with event.detail.categories (the
// accepted categories) and cmplz_status_change on later changes.
const EVENTS = ['cmplz_fire_categories', 'cmplz_status_change'];

/**
* Forward Complianz decisions to the hub. Reads the documented
* `cmplz_has_consent(category)` API; falls back to the event's accepted
* categories when the function is unavailable.
*/
export function connectComplianz(hub: ConsentHub, options?: CmpAdapterOptions): Disposer {
const target = resolveTarget(options);
if (!target) return noop;
const now = options?.now ?? (() => new Date());
const emit = createEmitter(hub);
const doc = (target.document as typeof target | undefined) ?? target;

const read = (event?: unknown) => {
const hasConsent = target.cmplz_has_consent as HasConsent | undefined;
const categories = (event as { detail?: { categories?: unknown } } | undefined)?.detail?.categories;
let marketing: boolean;
let analytics: boolean;
if (typeof hasConsent === 'function') {
marketing = !!hasConsent('marketing');
analytics = !!hasConsent('statistics');
} else if (Array.isArray(categories)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle category-only Complianz status changes

When cmplz_has_consent is unavailable—the explicitly supported fallback case—Complianz's later cmplz_status_change events provide the changed detail.category and detail.value, rather than a detail.categories array. After an initial cmplz_fire_categories grant, a subsequent marketing withdrawal therefore skips this branch and returns without notifying the hub, leaving stored attribution uncleared. Preserve the fallback category state and apply these category/value updates.

Useful? React with 👍 / 👎.

marketing = categories.includes('marketing');
analytics = categories.includes('statistics');
} else {
return; // no decision available: pending
}
emit(toRecord({ marketing, analytics }, 'complianz', now));
};

EVENTS.forEach((type) => doc.addEventListener(type, read));
// A stored decision marks the banner 'dismissed'; before that the visitor is pending.
const status = typeof target.cmplz_get_banner_status === 'function'
? String((target.cmplz_get_banner_status as () => unknown)())
: '';
if (status === 'dismissed') read();

return () => EVENTS.forEach((type) => doc.removeEventListener(type, read));
}
39 changes: 39 additions & 0 deletions packages/consent/src/cmp/cookiebot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ConsentHub } from '../listener.js';
import { createEmitter, noop, resolveTarget, toRecord } from './shared.js';
import type { CmpAdapterOptions, Disposer } from './shared.js';

interface CookiebotGlobal {
hasResponse?: boolean;
consent?: { marketing?: boolean; statistics?: boolean };
}

// Cookiebot may wipe unclassified storage before these fire, so capture must
// run on the emitted grant (reading the click ID from the current URL).
const EVENTS = ['CookiebotOnConsentReady', 'CookiebotOnAccept', 'CookiebotOnDecline'];

/**
* Forward Cookiebot decisions to the hub. Stays subscribed so in-page changes
* and withdrawals (the renew dialog) are delivered, not only the first answer.
*/
export function connectCookiebot(hub: ConsentHub, options?: CmpAdapterOptions): Disposer {
const target = resolveTarget(options);
if (!target) return noop;
const now = options?.now ?? (() => new Date());
const emit = createEmitter(hub);

const read = () => {
const cookiebot = target.Cookiebot as CookiebotGlobal | undefined;
if (!cookiebot?.hasResponse) return; // pending: never emit
emit(
toRecord(
{ marketing: !!cookiebot.consent?.marketing, analytics: !!cookiebot.consent?.statistics },
'cookiebot',
now,
),
);
};

EVENTS.forEach((type) => target.addEventListener(type, read));
read();
return () => EVENTS.forEach((type) => target.removeEventListener(type, read));
}
5 changes: 5 additions & 0 deletions packages/consent/src/cmp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { connectCookiebot } from './cookiebot.js';
export { connectOneTrust } from './onetrust.js';
export type { OneTrustOptions } from './onetrust.js';
export { connectComplianz } from './complianz.js';
export type { CmpAdapterOptions, CmpTarget, Disposer } from './shared.js';
55 changes: 55 additions & 0 deletions packages/consent/src/cmp/onetrust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ConsentHub } from '../listener.js';
import { createEmitter, noop, resolveTarget, toRecord } from './shared.js';
import type { CmpAdapterOptions, Disposer } from './shared.js';

export interface OneTrustOptions extends CmpAdapterOptions {
/** OneTrust group for targeting/marketing cookies (default 'C0004'). */
marketingGroup?: string;
/** OneTrust group for performance/analytics cookies (default 'C0002'). */
analyticsGroup?: string;
}

/**
* Forward OneTrust decisions to the hub. Listens to `OneTrustGroupsUpdated` and
* also wraps OptanonWrapper (the host's wrapper still runs), so a later
* reassignment of OptanonWrapper by the install snippet cannot silence it.
*/
export function connectOneTrust(hub: ConsentHub, options?: OneTrustOptions): Disposer {
const target = resolveTarget(options);
if (!target) return noop;
const now = options?.now ?? (() => new Date());
const marketingGroup = options?.marketingGroup ?? 'C0004';
const analyticsGroup = options?.analyticsGroup ?? 'C0002';
const emit = createEmitter(hub);
let disposed = false;

const read = () => {
if (disposed) return; // our wrapper may still sit inside another wrapper chain
const raw = target.OnetrustActiveGroups;
if (typeof raw !== 'string' || raw === '') return; // SDK not loaded yet
// Exact group match; ",C0004," style lists must not match "C00040".
const groups = new Set(raw.split(',').map((group) => group.trim()).filter(Boolean));
emit(
toRecord(
{ marketing: groups.has(marketingGroup), analytics: groups.has(analyticsGroup) },
'onetrust',
now,
),
);
};

const hostWrapper = target.OptanonWrapper;
const wrapper = function (this: unknown, ...args: unknown[]) {
if (typeof hostWrapper === 'function') hostWrapper.apply(this, args);
read();
};
target.OptanonWrapper = wrapper;
target.addEventListener('OneTrustGroupsUpdated', read);
read();

return () => {
disposed = true;
target.removeEventListener('OneTrustGroupsUpdated', read);
if (target.OptanonWrapper === wrapper) target.OptanonWrapper = hostWrapper;
};
}
61 changes: 61 additions & 0 deletions packages/consent/src/cmp/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Shared seams for CMP adapters. Nothing here touches the DOM at import time;
* adapters receive the window-like target when they are connected.
*/
import type { ConsentHub } from '../listener.js';
import type { ConsentRecord } from '../types.js';

/** Minimal window/document surface the adapters need (injectable for tests). */
export interface CmpTarget {
addEventListener(type: string, listener: (event: unknown) => void): void;
removeEventListener(type: string, listener: (event: unknown) => void): void;
[key: string]: unknown;
}

export interface CmpAdapterOptions {
/** Window-like object; defaults to globalThis.window when present. */
target?: CmpTarget;
/** Clock seam for the record's `at` field. */
now?: () => Date;
}

export type Disposer = () => void;

export const noop: Disposer = () => {};

export function resolveTarget(options: CmpAdapterOptions | undefined): CmpTarget | null {
if (options?.target) return options.target;
const win = (globalThis as { window?: unknown }).window;
return win && typeof (win as CmpTarget).addEventListener === 'function' ? (win as CmpTarget) : null;
}

/**
* Build a record. `state` follows marketing consent: storageAllowed() only
* checks `state`, so analytics-only consent must not unlock attribution storage
* (same rule as the ClickTrail WordPress consent bridge).
*/
export function toRecord(
purposes: { marketing: boolean; analytics: boolean },
source: string,
now: () => Date,
): ConsentRecord {
return {
state: purposes.marketing ? 'granted' : 'denied',
marketing: purposes.marketing,
advertising: purposes.marketing,
analytics: purposes.analytics,
source,
at: now().toISOString(),
};
}

/** Notify only when the decision actually changed (CMPs fire several events per click). */
export function createEmitter(hub: ConsentHub): (record: ConsentRecord) => void {
let last = '';
return (record) => {
const key = `${record.state}|${record.marketing}|${record.analytics}`;
if (key === last) return;
last = key;
hub.notify(record);
};
}
Loading
Loading