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
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
@@ -184,6 +216,9 @@ function publicCustomer(c: CustomerRow) {
|
||||
last_name: c.last_name,
|
||||
email_verified: c.email_verified,
|
||||
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,
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
'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.
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
# (#224). Optional: absent, the notification still sends and links to the
|
||||
# review queue without shortcuts. Rotating it revokes every outstanding
|
||||
|
||||
@@ -174,6 +174,20 @@ services:
|
||||
# unset stack variable cannot fail a deploy.
|
||||
- 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
|
||||
# (#224). Optional: absent, the notification still sends and simply links
|
||||
# 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;
|
||||
/** 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