diff --git a/backend/src/app.ts b/backend/src/app.ts index e4acc46..2e1f926 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 }); }); diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index f9b8477..170278e 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -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 +): 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 }; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 9c64e76..ab19c3e 100755 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -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. diff --git a/backend/tests/unit/analyticsConsent.test.ts b/backend/tests/unit/analyticsConsent.test.ts new file mode 100644 index 0000000..ea2808a --- /dev/null +++ b/backend/tests/unit/analyticsConsent.test.ts @@ -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); + }); +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b73b560..f4efaec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml index 5e084a5..963d25d 100644 --- a/docker-compose.qa.yml +++ b/docker-compose.qa.yml @@ -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 diff --git a/frontend/src/BrevoTracking.tsx b/frontend/src/BrevoTracking.tsx new file mode 100644 index 0000000..fd9164c --- /dev/null +++ b/frontend/src/BrevoTracking.tsx @@ -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(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; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index af72130..1463440 100755 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { diff --git a/frontend/src/brevo.ts b/frontend/src/brevo.ts new file mode 100644 index 0000000..7a8a707 --- /dev/null +++ b/frontend/src/brevo.ts @@ -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) => void; + identify: (email: string, attributes?: Record) => void; + track: (event: string, properties?: Record) => 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): 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; +} diff --git a/frontend/src/cart/cartApi.ts b/frontend/src/cart/cartApi.ts index 7d0a0eb..5dfd2f5 100644 --- a/frontend/src/cart/cartApi.ts +++ b/frontend/src/cart/cartApi.ts @@ -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 { @@ -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 { 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(res)) + .then(() => { brevoTrack('checkout_completed', { processor: 'paypal' }); }); } export function demoCartPurchase(shippingAddressId: number): Promise { @@ -93,5 +114,7 @@ export function demoCartPurchase(shippingAddressId: number): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shippingAddressId }) - }).then(res => handle(res)); + }) + .then(res => handle(res)) + .then(() => { brevoTrack('checkout_completed', { processor: 'demo' }); }); } diff --git a/frontend/src/customer/AuthForm.tsx b/frontend/src/customer/AuthForm.tsx index 90751b7..bdd6732 100644 --- a/frontend/src/customer/AuthForm.tsx +++ b/frontend/src/customer/AuthForm.tsx @@ -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; diff --git a/frontend/src/customer/PrivacyPolicy.tsx b/frontend/src/customer/PrivacyPolicy.tsx index ef4d23d..95b2c62 100755 --- a/frontend/src/customer/PrivacyPolicy.tsx +++ b/frontend/src/customer/PrivacyPolicy.tsx @@ -28,6 +28,22 @@ export default function PrivacyPolicy() { link included in every marketing email — no login required. + Analytics and tracking + + 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. + + + 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. + + Your rights You may request a copy of your data ("Download my data" in your account page), or delete your diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index b1f9e32..eec6e9e 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -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; } diff --git a/frontend/src/customer/favoritesApi.ts b/frontend/src/customer/favoritesApi.ts index 2691690..36f9f87 100644 --- a/frontend/src/customer/favoritesApi.ts +++ b/frontend/src/customer/favoritesApi.ts @@ -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 { 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 { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d377603..9a5cba3 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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). */} + {import.meta.env.DEV && } } /> diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index b5c7335..b503b72 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -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); });