feat(analytics): report consenting customers' activity to Brevo (#56) #317
+10
-1
@@ -60,7 +60,16 @@ app.get('/api/config', (_req, res) => {
|
|||||||
//
|
//
|
||||||
// Trailing slash trimmed so callers can join with a stored path, which
|
// Trailing slash trimmed so callers can join with a stored path, which
|
||||||
// always begins with one, without producing a double.
|
// always begins with one, without producing a double.
|
||||||
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? '')
|
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? ''),
|
||||||
|
// Brevo's Marketing Automation key (#56). Not a secret — it ships to the
|
||||||
|
// browser by design — but it differs per environment, which is the whole
|
||||||
|
// reason it is here rather than built in.
|
||||||
|
//
|
||||||
|
// Null when unset, and the tracker never loads without it. That is what
|
||||||
|
// keeps QA out of production's Brevo account: QA sets no key, so no QA
|
||||||
|
// browsing is ever reported, and there is no flag anyone can forget to
|
||||||
|
// turn off. Same shape as paypalClientId above.
|
||||||
|
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,39 @@ interface CustomerOrderRow {
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicCustomer(c: CustomerRow) {
|
/**
|
||||||
|
* Whether this customer has agreed to the *current* consent wording, which is
|
||||||
|
* the only thing that authorises the Brevo tracker (#56).
|
||||||
|
*
|
||||||
|
* Not the same question as `marketing_consent`. That flag says the customer
|
||||||
|
* agreed to something; `marketing_consent_text` says what. The sentence was
|
||||||
|
* widened to cover analytics, so a customer who consented to the older wording
|
||||||
|
* agreed to emails and nothing more — they keep receiving email and are not
|
||||||
|
* tracked until they re-consent to the current text through the account page.
|
||||||
|
*
|
||||||
|
* Comparing the stored string is the point rather than an implementation
|
||||||
|
* detail: it is why the wording is recorded per customer at all. A boolean on
|
||||||
|
* its own could not tell these two populations apart, and assuming they are the
|
||||||
|
* same is exactly the retroactive widening this avoids.
|
||||||
|
*
|
||||||
|
* Computed here rather than stored, so it can never drift from the constant.
|
||||||
|
*
|
||||||
|
* Exported for the unit test, and narrowed to the two fields it actually reads
|
||||||
|
* rather than taking a whole CustomerRecord — the rule is about those two and
|
||||||
|
* nothing else, and a test should not have to invent a customer to state it.
|
||||||
|
*/
|
||||||
|
export function analyticsConsent(
|
||||||
|
c: Pick<CustomerRecord, 'marketing_consent' | 'marketing_consent_text'>
|
||||||
|
): boolean {
|
||||||
|
return c.marketing_consent && c.marketing_consent_text === MARKETING_CONSENT_TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a CustomerRecord rather than a CustomerRow because `analytics_consent`
|
||||||
|
* is derived from `marketing_consent_text`, which is not on the narrower type.
|
||||||
|
* Every caller already holds a full record — each query is `SELECT *`.
|
||||||
|
*/
|
||||||
|
function publicCustomer(c: CustomerRecord) {
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
email: c.email,
|
email: c.email,
|
||||||
@@ -184,6 +216,9 @@ function publicCustomer(c: CustomerRow) {
|
|||||||
last_name: c.last_name,
|
last_name: c.last_name,
|
||||||
email_verified: c.email_verified,
|
email_verified: c.email_verified,
|
||||||
marketing_consent: c.marketing_consent,
|
marketing_consent: c.marketing_consent,
|
||||||
|
// Deliberately separate from marketing_consent: the two disagree for every
|
||||||
|
// customer who consented before the wording was widened.
|
||||||
|
analytics_consent: analyticsConsent(c),
|
||||||
favorite_alerts: c.favorite_alerts,
|
favorite_alerts: c.favorite_alerts,
|
||||||
created_at: c.created_at
|
created_at: c.created_at
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-1
@@ -71,8 +71,23 @@ export function tagColorFor(name: string): string {
|
|||||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widened for #56 to cover analytics as well as email.
|
||||||
|
*
|
||||||
|
* The previous wording named only emails. Gating the Brevo tracker on
|
||||||
|
* `marketing_consent` while that sentence was the thing customers agreed to
|
||||||
|
* would have treated "email me about new items" as authorisation to send their
|
||||||
|
* browsing to a third party, which it plainly did not say.
|
||||||
|
*
|
||||||
|
* Changing the sentence does not retroactively widen anyone's consent, because
|
||||||
|
* `marketing_consent_text` records what each customer was actually shown.
|
||||||
|
* Everyone who agreed to the old wording keeps their email consent and is not
|
||||||
|
* tracked; see `analyticsConsent` in routes/customers.ts, which is what the
|
||||||
|
* tracker is gated on. That is the whole reason this string is stored per
|
||||||
|
* customer rather than assumed.
|
||||||
|
*/
|
||||||
export const MARKETING_CONSENT_TEXT =
|
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.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips trailing slashes so a base URL can be joined with a stored path.
|
* Strips trailing slashes so a base URL can be joined with a stored path.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { analyticsConsent } from '../../src/routes/customers';
|
||||||
|
import { MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule this file exists for: agreeing to the *old* consent wording does not
|
||||||
|
* authorise the Brevo tracker (#56).
|
||||||
|
*
|
||||||
|
* The sentence customers agree to was widened to mention analytics. Everyone
|
||||||
|
* who consented before that agreed to a sentence about email and nothing else,
|
||||||
|
* and `marketing_consent` alone cannot tell the two populations apart — which
|
||||||
|
* is exactly why the wording is stored per customer. Getting this wrong would
|
||||||
|
* silently track people who never agreed to it, and would do so invisibly,
|
||||||
|
* because the flag they set really is `true`.
|
||||||
|
*/
|
||||||
|
describe('analyticsConsent', () => {
|
||||||
|
const OLD_WORDING =
|
||||||
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||||
|
|
||||||
|
it('is true for a customer who agreed to the current wording', () => {
|
||||||
|
expect(
|
||||||
|
analyticsConsent({ marketing_consent: true, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a customer who agreed to the previous, email-only wording', () => {
|
||||||
|
// The case the whole mechanism exists for. They consented, and their
|
||||||
|
// consent does not cover this.
|
||||||
|
expect(
|
||||||
|
analyticsConsent({ marketing_consent: true, marketing_consent_text: OLD_WORDING })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when consent was never given, whatever text is stored', () => {
|
||||||
|
expect(
|
||||||
|
analyticsConsent({ marketing_consent: false, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when no wording was recorded at all', () => {
|
||||||
|
// Withdrawal writes a sentinel rather than the consent text, and older rows
|
||||||
|
// may predate the column being populated. Neither is agreement.
|
||||||
|
expect(
|
||||||
|
analyticsConsent({ marketing_consent: true, marketing_consent_text: null })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept a near-miss, so a reworded sentence re-asks rather than assumes', () => {
|
||||||
|
expect(
|
||||||
|
analyticsConsent({
|
||||||
|
marketing_consent: true,
|
||||||
|
marketing_consent_text: `${MARKETING_CONSENT_TEXT} `
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards the wording itself: the current text must mention what is shared', () => {
|
||||||
|
// Not a tautology — it fails if someone narrows the sentence back to email
|
||||||
|
// while leaving the tracker gated on it, which would put this project back
|
||||||
|
// in the position #56 was filed to get it out of.
|
||||||
|
expect(MARKETING_CONSENT_TEXT).toContain('browse');
|
||||||
|
expect(MARKETING_CONSENT_TEXT).not.toBe(OLD_WORDING);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -244,6 +244,17 @@ services:
|
|||||||
# See docs/ops/image-background-removal-stack.md.
|
# See docs/ops/image-background-removal-stack.md.
|
||||||
- REMBG_URL=${REMBG_URL:-}
|
- REMBG_URL=${REMBG_URL:-}
|
||||||
|
|
||||||
|
# Brevo's Marketing Automation key (#56), from
|
||||||
|
# https://app.brevo.com/automation/parameters. Optional: unset means the
|
||||||
|
# tracker is never loaded and no browsing is reported to anyone.
|
||||||
|
#
|
||||||
|
# Not a secret — it ships to the browser by design — but it is
|
||||||
|
# per-environment, and this is the only place production's is named. A
|
||||||
|
# key here is still not sufficient to track anybody: the script loads
|
||||||
|
# only for a signed-in customer whose stored consent wording covers
|
||||||
|
# analytics. See the note on MARKETING_CONSENT_TEXT in backend/src/utils.ts.
|
||||||
|
- BREVO_TRACKER_KEY=${BREVO_TRACKER_KEY:-}
|
||||||
|
|
||||||
# Signs the regenerate and discard links in the intake notification email
|
# Signs the regenerate and discard links in the intake notification email
|
||||||
# (#224). Optional: absent, the notification still sends and links to the
|
# (#224). Optional: absent, the notification still sends and links to the
|
||||||
# review queue without shortcuts. Rotating it revokes every outstanding
|
# review queue without shortcuts. Rotating it revokes every outstanding
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ services:
|
|||||||
# unset stack variable cannot fail a deploy.
|
# unset stack variable cannot fail a deploy.
|
||||||
- REMBG_URL=${QA_REMBG_URL:-}
|
- REMBG_URL=${QA_REMBG_URL:-}
|
||||||
|
|
||||||
|
# Deliberately empty, and deliberately not a stack variable (#56).
|
||||||
|
#
|
||||||
|
# This is what keeps QA browsing out of the live Brevo account. Written as
|
||||||
|
# an empty literal rather than left out entirely so it cannot inherit a
|
||||||
|
# value from the host environment, and with no `${...}` so there is no
|
||||||
|
# stack variable anyone could set here by pasting production's in — the
|
||||||
|
# same reasoning as QA_DB_PASSWORD and the QA_SMTP_* names above.
|
||||||
|
#
|
||||||
|
# QA runs against disposable fixtures. Reporting that browsing as though
|
||||||
|
# it were customer behaviour would corrupt the segmentation the tracker
|
||||||
|
# exists to feed, and it would be indistinguishable from real traffic
|
||||||
|
# after the fact.
|
||||||
|
- BREVO_TRACKER_KEY=
|
||||||
|
|
||||||
# Signs the regenerate and discard links in the intake notification email
|
# Signs the regenerate and discard links in the intake notification email
|
||||||
# (#224). Optional: absent, the notification still sends and simply links
|
# (#224). Optional: absent, the notification still sends and simply links
|
||||||
# to the review queue without shortcuts. Anyone holding a link can act on
|
# to the review queue without shortcuts. Anyone holding a link can act on
|
||||||
|
|||||||
@@ -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;
|
currency: string;
|
||||||
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
||||||
uploadsBaseUrl: string;
|
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> {
|
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 {
|
export interface CartItem {
|
||||||
item_id: number;
|
item_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,7 +41,14 @@ export function fetchCart(): Promise<{ items: CartItem[] }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> {
|
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> {
|
export function removeFromCart(itemId: number): Promise<void> {
|
||||||
@@ -80,12 +94,19 @@ export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orde
|
|||||||
}).then(res => handle(res));
|
}).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> {
|
export function captureCartPaypalOrder(orderID: string): Promise<void> {
|
||||||
return fetch('/api/checkout/cart/paypal/capture', {
|
return fetch('/api/checkout/cart/paypal/capture', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ orderID })
|
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> {
|
export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
||||||
@@ -93,5 +114,7 @@ export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ shippingAddressId })
|
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
|
// 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
|
// the cart prompt, and the string the server actually recorded — and none of
|
||||||
// them matched.
|
// 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 =
|
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<{
|
type Props = Readonly<{
|
||||||
mode: AuthMode;
|
mode: AuthMode;
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ export default function PrivacyPolicy() {
|
|||||||
link included in every marketing email — no login required.
|
link included in every marketing email — no login required.
|
||||||
</Paragraph>
|
</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>
|
<Title level={4}>Your rights</Title>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
You may request a copy of your data ("Download my data" in your account page), or delete your
|
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;
|
last_name: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
marketing_consent: 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;
|
favorite_alerts: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Customer } from './customerApi';
|
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 {
|
export interface Favorite {
|
||||||
item_id: number;
|
item_id: number;
|
||||||
@@ -26,6 +29,11 @@ export async function addFavorite(itemId: number): Promise<void> {
|
|||||||
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
|
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
|
||||||
'failed to save favorite'
|
'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> {
|
export async function removeFavorite(itemId: number): Promise<void> {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { FavoritesProvider } from './customer/FavoritesContext';
|
|||||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { fetchConfig } from './api';
|
import { fetchConfig } from './api';
|
||||||
|
import BrevoTracking from './BrevoTracking';
|
||||||
|
|
||||||
// The brand accent is monochrome, so it inverts between themes rather than
|
// The brand accent is monochrome, so it inverts between themes rather than
|
||||||
// switching to a different hue.
|
// switching to a different hue.
|
||||||
@@ -163,6 +164,10 @@ function AppRoutes() {
|
|||||||
|
|
||||||
return (
|
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" />}
|
{import.meta.env.DEV && <DevThrow scope="page" />}
|
||||||
<Routes location={backdrop}>
|
<Routes location={backdrop}>
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ test.describe('Customer accounts', () => {
|
|||||||
// customer actually saw. Three different wordings were in circulation
|
// customer actually saw. Three different wordings were in circulation
|
||||||
// before the sign-in form was shared between the routes and the cart
|
// before the sign-in form was shared between the routes and the cart
|
||||||
// prompt, and none of them matched what was stored.
|
// 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 =
|
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);
|
await expect(authModal.registerDialog).toContainText(consent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user