Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers. The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw. So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true. QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it. Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue. The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account. Closes #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
/**
|
|
* Brevo's web tracker (#56).
|
|
*
|
|
* Same shape as paypal.ts: the script is injected at runtime, so there is no
|
|
* package to import types from and this file declares the sliver actually used.
|
|
* Deliberately narrow — it describes what is called here, not the whole SDK.
|
|
*
|
|
* Two rules hold everywhere in this module, and every exported function is
|
|
* written so that breaking either is impossible rather than merely discouraged:
|
|
*
|
|
* 1. **Nothing loads without a key.** No key means no script, no cookie, no
|
|
* request. That is what keeps QA out of the production Brevo account.
|
|
* 2. **Nothing loads without consent.** `start` is the only thing that injects
|
|
* the script, and its caller gates on the customer's `analytics_consent`,
|
|
* which the server derives from the wording that customer actually agreed
|
|
* to. A signed-out visitor is never tracked, because there is no consent
|
|
* record to consult.
|
|
*/
|
|
|
|
interface SendinblueSdk {
|
|
page: (name?: string, properties?: Record<string, unknown>) => void;
|
|
identify: (email: string, attributes?: Record<string, unknown>) => void;
|
|
track: (event: string, properties?: Record<string, unknown>) => void;
|
|
}
|
|
|
|
/** The queue the snippet installs so calls made before load are not lost. */
|
|
interface SibQueue {
|
|
equeue: unknown[];
|
|
client_key?: string;
|
|
[method: string]: unknown;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
sendinblue?: SendinblueSdk;
|
|
sib?: SibQueue;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gates every call below. False until `start`, and false again after `stop`.
|
|
*
|
|
* Kept separate from `window.sendinblue` being present because the two answer
|
|
* different questions: the SDK stays in the document once injected, but consent
|
|
* can be withdrawn within the same page. Checking only for the SDK would keep
|
|
* reporting after sign-out.
|
|
*/
|
|
let enabled = false;
|
|
let loaded = false;
|
|
|
|
/**
|
|
* Installs the method queue and injects the script.
|
|
*
|
|
* The queue matters: `sa.js` loads asynchronously and the methods have to exist
|
|
* before it arrives, or the `page()` for the landing route — the one call that
|
|
* always happens immediately — is dropped. This is Brevo's own snippet, written
|
|
* out as typed code rather than pasted as an opaque blob.
|
|
*/
|
|
function inject(key: string): void {
|
|
if (loaded) return;
|
|
loaded = true;
|
|
|
|
const queue: SibQueue = { equeue: [], client_key: key };
|
|
window.sib = queue;
|
|
|
|
const sdk = {} as SendinblueSdk;
|
|
const methods = ['track', 'identify', 'page'] as const;
|
|
for (const method of methods) {
|
|
sdk[method] = (...args: unknown[]) => {
|
|
const ready = queue[method];
|
|
if (typeof ready === 'function') {
|
|
(ready as (...a: unknown[]) => void)(...args);
|
|
} else {
|
|
queue.equeue.push({ [method]: args });
|
|
}
|
|
};
|
|
}
|
|
window.sendinblue = sdk;
|
|
|
|
const script = document.createElement('script');
|
|
script.id = 'sendinblue-js';
|
|
script.async = true;
|
|
script.src = `https://sibautomation.com/sa.js?key=${encodeURIComponent(key)}`;
|
|
// A tracker that cannot load must never take the page down with it. There is
|
|
// no retry and no error surfaced: losing analytics is not worth telling a
|
|
// customer about, and a visible failure here would be noise on every visit
|
|
// from anyone running a blocker.
|
|
script.onerror = () => { enabled = false; };
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
/**
|
|
* Begins tracking for a consenting, signed-in customer.
|
|
*
|
|
* Safe to call repeatedly — React effects will. The script is injected once;
|
|
* later calls only re-assert identity, which is what a customer switching
|
|
* accounts in one session needs.
|
|
*/
|
|
export function startBrevoTracking(key: string | null, email: string): void {
|
|
if (!key) return;
|
|
inject(key);
|
|
enabled = true;
|
|
window.sendinblue?.identify(email);
|
|
}
|
|
|
|
/**
|
|
* Stops tracking, on sign-out or when consent is withdrawn.
|
|
*
|
|
* **This stops calls, it does not unload the script.** Nothing can un-inject a
|
|
* script tag or take back the cookies it set, so `sa.js` stays in the document
|
|
* until the next full page load. What this guarantees is that no further page
|
|
* view or event is reported, and no identity is re-asserted, which is the part
|
|
* this application actually controls. Said plainly here because "tracking
|
|
* stops" is easy to read as a stronger promise than any web tracker can make.
|
|
*/
|
|
export function stopBrevoTracking(): void {
|
|
enabled = false;
|
|
}
|
|
|
|
/** A route change. No-op unless tracking is currently permitted. */
|
|
export function brevoPage(path: string): void {
|
|
if (!enabled) return;
|
|
window.sendinblue?.page(path);
|
|
}
|
|
|
|
/** A named event. No-op unless tracking is currently permitted. */
|
|
export function brevoTrack(event: string, properties?: Record<string, unknown>): void {
|
|
if (!enabled) return;
|
|
window.sendinblue?.track(event, properties);
|
|
}
|
|
|
|
/** Exported for tests, which need to observe the gate without a real script. */
|
|
export function isBrevoTrackingEnabled(): boolean {
|
|
return enabled;
|
|
}
|