feat(analytics): report consenting customers' activity to Brevo (#56)
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e0afa1b43e
commit
ac3f6e91f5
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchConfig } from './api';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
import { brevoPage, startBrevoTracking, stopBrevoTracking } from './brevo';
|
||||
|
||||
/**
|
||||
* Drives the Brevo tracker from the two things that decide whether it may run
|
||||
* at all: the environment's key and the signed-in customer's consent (#56).
|
||||
*
|
||||
* Renders nothing. It exists as a component rather than a hook because it needs
|
||||
* both the router and the auth context, and mounting it inside `AppRoutes` is
|
||||
* what guarantees it sits under both — a hook called from the wrong place would
|
||||
* fail at runtime instead of being impossible to misplace.
|
||||
*
|
||||
* ## Why the gate is a server-computed field
|
||||
*
|
||||
* `analytics_consent` is not `marketing_consent`. The consent sentence was
|
||||
* widened to mention analytics, and the server reports this flag by comparing
|
||||
* the wording each customer actually agreed to against the current text. A
|
||||
* customer who consented to the older, email-only wording is not tracked. Never
|
||||
* substitute `marketing_consent` here — that is the exact retroactive widening
|
||||
* the field exists to prevent.
|
||||
*
|
||||
* A signed-out visitor has no consent record, so no key is ever used and the
|
||||
* script is never injected. Anonymous browsing is not reported at all.
|
||||
*/
|
||||
export default function BrevoTracking() {
|
||||
const { customer } = useCustomerAuth();
|
||||
const location = useLocation();
|
||||
const [trackerKey, setTrackerKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Swallowed for the same reason main.tsx swallows its warm-up call: a
|
||||
// config that cannot be fetched is a broken deployment every other request
|
||||
// will report, and losing analytics is not worth surfacing to a customer.
|
||||
void fetchConfig()
|
||||
.then(config => { if (!cancelled) setTrackerKey(config.brevoTrackerKey); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const consented = customer?.analytics_consent === true;
|
||||
const email = customer?.email;
|
||||
|
||||
// Start and stop are driven by the same effect so there is no state in which
|
||||
// consent has gone away and nothing has acted on it. Withdrawing consent,
|
||||
// signing out, and deleting the account all arrive here as `consented`
|
||||
// turning false, because each one ends with the customer no longer being a
|
||||
// consenting signed-in customer.
|
||||
useEffect(() => {
|
||||
if (consented && trackerKey && email) {
|
||||
startBrevoTracking(trackerKey, email);
|
||||
} else {
|
||||
stopBrevoTracking();
|
||||
}
|
||||
}, [consented, trackerKey, email]);
|
||||
|
||||
// Declared after the effect above so that on the render where consent or the
|
||||
// key first arrives, tracking is started before this reports the route.
|
||||
//
|
||||
// The storefront is a single-page app: without this, the tracker's own
|
||||
// initial call would be the only page view it ever saw, and every customer
|
||||
// would look like they viewed one page and left.
|
||||
useEffect(() => {
|
||||
brevoPage(location.pathname);
|
||||
}, [location.pathname, consented, trackerKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -55,6 +55,12 @@ export interface SiteConfig {
|
||||
currency: string;
|
||||
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
||||
uploadsBaseUrl: string;
|
||||
/**
|
||||
* Brevo Marketing Automation key (#56). Null when the environment sets none,
|
||||
* which is how QA avoids reporting test browsing into the live Brevo account.
|
||||
* A key alone does not start tracking — see brevo.ts.
|
||||
*/
|
||||
brevoTrackerKey: string | null;
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<SiteConfig> {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
// Reported here rather than at the UI call sites so that no caller can add an
|
||||
// item or complete a checkout without it being counted (#56). Every one of
|
||||
// these calls is a no-op unless a consenting customer is signed in and the
|
||||
// environment has a key, so importing this into the API layer does not make the
|
||||
// cart depend on analytics being configured.
|
||||
import { brevoTrack } from '../brevo';
|
||||
|
||||
export interface CartItem {
|
||||
item_id: number;
|
||||
name: string;
|
||||
@@ -34,7 +41,14 @@ export function fetchCart(): Promise<{ items: CartItem[] }> {
|
||||
}
|
||||
|
||||
export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> {
|
||||
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' }).then(res => handle(res));
|
||||
// After handle(), which throws on a non-OK response — so a refused add (the
|
||||
// item was already reserved by someone else) is not reported as one.
|
||||
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' })
|
||||
.then(res => handle<{ itemId: number; expiresAt: string }>(res))
|
||||
.then(result => {
|
||||
brevoTrack('added_to_cart', { itemId });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFromCart(itemId: number): Promise<void> {
|
||||
@@ -80,12 +94,19 @@ export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orde
|
||||
}).then(res => handle(res));
|
||||
}
|
||||
|
||||
// The two checkout completions are separate functions rather than one, so both
|
||||
// report the event and both say which they were. Without the processor these
|
||||
// would be indistinguishable in Brevo, and a demo purchase charges nothing —
|
||||
// counting it as a sale would overstate revenue. QA cannot reach here anyway
|
||||
// (no key), but DEMO_MODE is not exclusive to QA, so the distinction is real.
|
||||
export function captureCartPaypalOrder(orderID: string): Promise<void> {
|
||||
return fetch('/api/checkout/cart/paypal/capture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orderID })
|
||||
}).then(res => handle(res));
|
||||
})
|
||||
.then(res => handle<void>(res))
|
||||
.then(() => { brevoTrack('checkout_completed', { processor: 'paypal' }); });
|
||||
}
|
||||
|
||||
export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
||||
@@ -93,5 +114,7 @@ export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shippingAddressId })
|
||||
}).then(res => handle(res));
|
||||
})
|
||||
.then(res => handle<void>(res))
|
||||
.then(() => { brevoTrack('checkout_completed', { processor: 'demo' }); });
|
||||
}
|
||||
|
||||
@@ -20,8 +20,13 @@ export type AuthMode = 'register' | 'login';
|
||||
// this was shared there were three wordings in play — this one, a shorter one in
|
||||
// the cart prompt, and the string the server actually recorded — and none of
|
||||
// them matched.
|
||||
//
|
||||
// Widened for #56 so it covers the Brevo tracker as well as email. The tracker
|
||||
// is gated on whether a customer's *stored* copy of this sentence matches the
|
||||
// current one, so changing it here does not retroactively widen the consent of
|
||||
// anyone who agreed to the previous wording.
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
|
||||
|
||||
type Props = Readonly<{
|
||||
mode: AuthMode;
|
||||
|
||||
@@ -28,6 +28,22 @@ export default function PrivacyPolicy() {
|
||||
link included in every marketing email — no login required.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Analytics and tracking</Title>
|
||||
<Paragraph>
|
||||
If — and only if — you have opted in to marketing, we share what you browse and buy on
|
||||
this site with Brevo, our email provider, so the emails we send you are about things you
|
||||
are actually interested in. That covers the pages you visit here, items you add to your
|
||||
cart or favorite, and completed orders, linked to your email address.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If you have not opted in, this does not happen: nothing is loaded, nothing is sent, and no
|
||||
tracking cookie is set. The same is true if you are not signed in — we do not track
|
||||
visitors who do not have an account. Withdrawing consent from your account page stops any
|
||||
further activity being sent. Note that anything already shared with Brevo before you
|
||||
withdrew remains with them, and a tracking cookie set earlier in your visit stays in your
|
||||
browser until you close the tab or clear it.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Your rights</Title>
|
||||
<Paragraph>
|
||||
You may request a copy of your data ("Download my data" in your account page), or delete your
|
||||
|
||||
@@ -7,6 +7,16 @@ export interface Customer {
|
||||
last_name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
/**
|
||||
* Whether this customer agreed to the *current* consent wording, which is the
|
||||
* only thing that authorises the Brevo tracker (#56).
|
||||
*
|
||||
* Not a duplicate of marketing_consent: the two disagree for anyone who
|
||||
* consented before that sentence was widened to mention analytics. Computed
|
||||
* on the server from the wording stored against the customer — never derive
|
||||
* it here from marketing_consent, which is the mistake it exists to prevent.
|
||||
*/
|
||||
analytics_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Customer } from './customerApi';
|
||||
// See the note in cart/cartApi.ts: reported here rather than at the call sites
|
||||
// so no caller can favorite an item without it being counted (#56).
|
||||
import { brevoTrack } from '../brevo';
|
||||
|
||||
export interface Favorite {
|
||||
item_id: number;
|
||||
@@ -26,6 +29,11 @@ export async function addFavorite(itemId: number): Promise<void> {
|
||||
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
|
||||
'failed to save favorite'
|
||||
);
|
||||
// After expectOk, which throws on failure, so a favorite that was not saved
|
||||
// is not reported as one. Only favoriting is tracked, not un-favoriting:
|
||||
// #56 asked for the act of favoriting as a signal of interest, and removal is
|
||||
// a different question nobody has asked yet.
|
||||
brevoTrack('favorited', { itemId });
|
||||
}
|
||||
|
||||
export async function removeFavorite(itemId: number): Promise<void> {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { FavoritesProvider } from './customer/FavoritesContext';
|
||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||
import './styles.css';
|
||||
import { fetchConfig } from './api';
|
||||
import BrevoTracking from './BrevoTracking';
|
||||
|
||||
// The brand accent is monochrome, so it inverts between themes rather than
|
||||
// switching to a different hue.
|
||||
@@ -163,6 +164,10 @@ function AppRoutes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Renders nothing. Mounted here because this is the innermost place
|
||||
that has both the router and the auth context, which is what it
|
||||
needs to report route changes for a consenting customer (#56). */}
|
||||
<BrevoTracking />
|
||||
{import.meta.env.DEV && <DevThrow scope="page" />}
|
||||
<Routes location={backdrop}>
|
||||
<Route path="/" element={<App />} />
|
||||
|
||||
@@ -27,8 +27,13 @@ test.describe('Customer accounts', () => {
|
||||
// customer actually saw. Three different wordings were in circulation
|
||||
// before the sign-in form was shared between the routes and the cart
|
||||
// prompt, and none of them matched what was stored.
|
||||
// Widened in #56 to cover the Brevo tracker as well as email. The literal is
|
||||
// repeated here rather than imported on purpose: importing it from the app
|
||||
// would make this assert that a constant equals itself, and the drift it
|
||||
// guards against is exactly the rendered label parting from the stored
|
||||
// string.
|
||||
const consent =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
|
||||
await expect(authModal.registerDialog).toContainText(consent);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user