Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @JPedersen @DhiaPhntm
* @DhiaPhntm
9 changes: 8 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@ version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
rebase-strategy: "auto"
schedule:
interval: "monthly"
interval: "weekly"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Will we check these weekly? Just thinking it's better we ensure have an interval we honour rather than having lots of PRs made that goes ignored. More than happy to make this change just wanted to check!

@DhiaPhntm DhiaPhntm Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is just for the 'weekly' patch and minor grouped PR i think - we're still getting individual major versions and security PRs immediately as these aren't explicitly defined in the dependabot config and so are being PR'd from repo dependabot or default settings.

Need to revisit this again with an updated dependabot config i reckon - to spec behaviour for all types of updates - we could also auto-merge minor/patch and security if it passes the build step (Vercel or the long Wagtail build action).

time: "08:00"
groups:
patch-and-minor:
update-types:
- "minor"
- "patch"
4,172 changes: 2,270 additions & 1,902 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@phantomstudios/ft-lib",
"description": "A collection of Javascript UI & tracking utils for FT sites",
"version": "0.5.1",
"version": "0.5.8",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"homepage": "https://github.com/phantomstudios/ft-lib#readme",
Expand Down Expand Up @@ -78,4 +78,4 @@
"@financial-times/o-tracking": "^4.5.1",
"@financial-times/o-viewport": "^5.1.2"
}
}
}
9 changes: 6 additions & 3 deletions src/cmp/loadFtCmp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export function loadFtCmpScript(): Promise<void> {
});
}

export function enqueueCmpCallback(cb: () => void): void {
if (!window._sp_) window._sp_ = {};
if (!window._sp_queue) window._sp_queue = [];
export function initCmpQueue(): void {
window._sp_ = window._sp_ || {};
window._sp_queue = window._sp_queue || [];
}

export function enqueueCmpCallback(cb: () => void): void {
initCmpQueue();
window._sp_queue!.push(cb);
}
108 changes: 108 additions & 0 deletions src/cmp/vendorConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export interface VendorConsentResults {
brandmetrics: boolean;
linkedIn: boolean;
purpose1: boolean;
gdprApplies: boolean;
usnatApplies: boolean;
}

export function initVendorConsentListener(
onConsentUpdate: (results: VendorConsentResults) => void,
): void {
// --- Path 1: Check TCF API First ---
if (typeof window.__tcfapi === "function") {
window.__tcfapi("addEventListener", 2, (tcData, success) => {
if (!success || !tcData) return;

const isConsentReady =
tcData.eventStatus === "tcloaded" ||
tcData.eventStatus === "useractioncomplete";

if (isConsentReady) {
// If GDPR actually applies, process TCF consents (Opt-In model)
if (tcData.gdprApplies !== false) {
const results: VendorConsentResults = {
brandmetrics: tcData.vendor?.consents?.[422] === true,
linkedIn: tcData.vendor?.consents?.[804] === true,
purpose1: tcData.purpose?.consents?.[1] === true,
gdprApplies: true,
usnatApplies: false,
};

onConsentUpdate(results);

// Clean up TCF listener
if (typeof tcData.listenerId === "number") {
window.__tcfapi?.(
"removeEventListener",
2,
() => {},
tcData.listenerId,
);
}
return;
}

// --- Path 2: TCF explicitly returned gdprApplies: false -> Fall back to USNat ---
evaluateUSNatConsent(onConsentUpdate);

// Clean up TCF listener since GDPR does not apply
if (typeof tcData.listenerId === "number") {
window.__tcfapi?.(
"removeEventListener",
2,
() => {},
tcData.listenerId,
);
}
}
});
} else {
// If __tcfapi doesn't exist at all, try evaluating USNat directly
evaluateUSNatConsent(onConsentUpdate);
}
}

/**
* Helper to evaluate USNat / US Privacy consent (Opt-Out model)
*/
function evaluateUSNatConsent(
onConsentUpdate: (results: VendorConsentResults) => void,
): void {
if (typeof window.__gpp === "function") {
window.__gpp("addEventListener", (evt, success) => {
if (!success || !evt) return;
if (
(evt.eventName === "signalStatus" &&
evt.pingData?.signalStatus === "ready") ||
evt.eventName === "postConsent"
) {
fetchSourcepointUSNatConsent(onConsentUpdate);
}
});
} else {
// Direct fallback if __gpp is unavailable
fetchSourcepointUSNatConsent(onConsentUpdate);
}
}

function fetchSourcepointUSNatConsent(
onConsentUpdate: (results: VendorConsentResults) => void,
): void {
if (typeof window._sp_?.usnat?.getUserConsents === "function") {
window._sp_.usnat.getUserConsents((consents) => {
const isConsented =
consents?.categories?.[0]?.consented === true &&
consents?.categories?.[1]?.consented === true;

onConsentUpdate({
brandmetrics: isConsented,
linkedIn: isConsented,
purpose1: isConsented,
gdprApplies: false,
usnatApplies: true,
});
});
return;
}
}
102 changes: 74 additions & 28 deletions src/consentMonitor/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
initSourcepointCmp,
interceptManageCookiesLinks,
properties,
} from "@financial-times/cmp-client";
import { interceptManageCookiesLinks } from "@financial-times/cmp-client";
import Debug from "debug";

import { enqueueCmpCallback, loadFtCmpScript } from "../cmp/loadFtCmp";
import {
initVendorConsentListener,
VendorConsentResults,
} from "../cmp/vendorConsent";

const debug = Debug("@phantomstudios/ft-lib/consentMonitor");

Expand Down Expand Up @@ -35,6 +35,13 @@ export class ConsentMonitor {
private _isDevEnvironment = false;
private _isInitialized = false;
private _hostname: string;
private _vendorConsents = {
brandmetrics: false,
linkedIn: false,
purpose1: false,
gdprApplies: false,
usnatApplies: false,
} as VendorConsentResults;

public get consent(): boolean {
return this._consent;
Expand All @@ -48,10 +55,12 @@ export class ConsentMonitor {
public get isInitialized(): boolean {
return this._isInitialized;
}

public get userHasConsented(): boolean {
return this._consent;
}
public get vendorConsents(): VendorConsentResults {
return this._vendorConsents;
}

getCookieValue = (name: string) =>
document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)")?.pop() || "";
Expand All @@ -71,24 +80,36 @@ export class ConsentMonitor {
this._hostname.includes(h),
);

this.attachVendorConsentListeners(); // listen to existing consent data fetch
this.attachCmpListeners(); // listen to banner interaction

// load banner
loadFtCmpScript()
.then(() => {
this.attachCmpListeners();

const propertyConfig = window.location.hostname.endsWith(".ft.com")
? properties["FT_DOTCOM_PROD"]
: properties["FT_DOTCOM_TEST"];

// initialize CMP
initSourcepointCmp({ propertyConfig });
// use cmp client lib to intercept footer 'Manage Cookies' links (opens privacy modal)
// Note, function requires very specific link: text = 'Manage Cookies' and href = 'https://ft.com/preferences/manage-cookies'
interceptManageCookiesLinks();
const host = this._hostname.split(":")[0].toLowerCase();
const isFtDomain = host === "ft.com" || host.endsWith(".ft.com");
if (!isFtDomain) {
interceptManageCookiesLinks();
}
this._isInitialized = true;
})
.catch((err) => console.error(err));
}

// Added for required brandmetrics image pixels and linkedin script consent (CMP specific vendor consents integration)
private attachVendorConsentListeners(): void {
enqueueCmpCallback(() => {
initVendorConsentListener((vendorConsents: VendorConsentResults) => {
const vendorConsentEvent = new CustomEvent("cmp_vendorConsent", {
detail: vendorConsents,
});

window.dispatchEvent(vendorConsentEvent);
debug("[CMP Consent lookup Event", vendorConsents);
});
});
}

private attachCmpListeners(): void {
enqueueCmpCallback(() => {
const onReady: ConsentReadyHandler = (_l, _u, _t, info) => {
Expand All @@ -102,23 +123,48 @@ export class ConsentMonitor {

const onChoice: MessageChoiceHandler = (_l, _c, typeId) => {
debug("onMessageChoiceSelect:", typeId);
if (typeId === CMP_CHOICE_ACCEPT_ALL) this.enablePermutive();
const isAccept = typeId === CMP_CHOICE_ACCEPT_ALL;

if (isAccept) this.enablePermutive();
else if (typeId === CMP_CHOICE_REJECT_ALL) this.disablePermutive();

//Simulate cookie consent behaviour in non-prod environments as banner does not set cookies in non .ft.com domains
this._devHosts.map(
(devHost) =>
this._hostname.includes(devHost) &&
typeId === CMP_CHOICE_ACCEPT_ALL &&
this.setDevConsentCookies(),
this.setDevConsentCookies(isAccept),
);

// banner updated - check new cookie value to fire consent_update event
setTimeout(this.cookieConsentTest, 3000);
// Re-run vendor consent check immediately after choice is selected
this.triggerVendorConsentUpdate();

// Banner updated - check new cookie value to fire consent_update event
setTimeout(this.cookieConsentTest, 1000);
};

window._sp_.addEventListener?.("onConsentReady", onReady);
window._sp_.addEventListener?.("onMessageChoiceSelect", onChoice);
window._sp_?.addEventListener?.("onConsentReady", onReady);
window._sp_?.addEventListener?.("onMessageChoiceSelect", onChoice);

// Additional safety listener for when Sourcepoint completes saving consent
window._sp_?.addEventListener?.("onSPFinished", () => {
this.triggerVendorConsentUpdate();
});
});
}

// re-fetch vendor consents and dispatch the CustomEvent
private triggerVendorConsentUpdate(): void {
initVendorConsentListener((vendorConsents: VendorConsentResults) => {
this._vendorConsents = vendorConsents;
const vendorConsentEvent = new CustomEvent("cmp_vendorConsent", {
detail: vendorConsents,
});

window.dispatchEvent(vendorConsentEvent);
debug(
"[CMP Consent lookup Event - Post Banner Selection]",
vendorConsents,
);
});
}

Expand Down Expand Up @@ -156,12 +202,12 @@ export class ConsentMonitor {
}
};

setDevConsentCookies = () => {
setDevConsentCookies = (allow: boolean) => {
this._isDevEnvironment = true;
const value = allow ? "on" : "off";
debug("setting development FT consent cookies");
document.cookie =
"FTConsent=behaviouraladsOnsite%3Aon%2CcookiesOnsite%3Aon%2CpermutiveadsOnsite%3Aon";
document.cookie = "FTCookieConsentGDPR=true";
document.cookie = `FTConsent=behaviouraladsOnsite%3A${value}%2CcookiesOnsite%3A${value}%2CpermutiveadsOnsite%3A${value}`;
document.cookie = `FTCookieConsentGDPR=${allow}`;
};
}

Expand Down
29 changes: 28 additions & 1 deletion src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,44 @@ declare module "@financial-times/cmp-client" {
propertyConfig?: any;
}): Promise<void>;
}

interface TCFData {
eventStatus: "tcloaded" | "useractioncomplete" | "cmpuishown";
listenerId: number;
purpose?: {
consents?: Record<number, boolean>;
};
vendor?: {
consents?: Record<number, boolean>;
};
gdprApplies?: boolean | undefined;
}
interface SourcepointCmpAPI {
queue?: Array<() => void>;
addEventListener?: (
eventName: string,

callback: (...args: any[]) => void,
) => void;
usnat?: {
getUserConsents: (callback: (consents: any) => void) => void;
};
}

interface Window {
_sp_: SourcepointCmpAPI;
_sp_queue?: Array<() => void>;
__tcfapi?: (
command: string,
version: number,
callback: (tcData: TCFData | null, success: boolean) => void,
parameter?: any,
) => void;
__gpp?: (
command: string,
callback?: (data: any, success: boolean) => void,
parameter?: any,
) => void;

dataLayer: any;
gtag: any;
permutive: {
Expand Down
Loading