Files
redefined-designs/frontend/src/main.tsx
T
synAdminandClaude Opus 5 ac3f6e91f5
Linting / lint (pull_request) Successful in 3m35s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m26s
feat(analytics): report consenting customers' activity to Brevo (#56)
Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers.

The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw.

So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true.

QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it.

Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue.

The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make.

Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account.

Closes #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:49:30 -05:00

270 lines
10 KiB
TypeScript
Executable File

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import ConfigProvider from 'antd/es/config-provider';
import antdTheme from 'antd/es/theme';
import 'antd/dist/reset.css';
import Button from 'antd/es/button';
import ModalDialog from 'antd/es/modal';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
import Admin from './admin/Admin';
import AuthRouteModal from './customer/AuthRouteModal';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Submit from './intake/Submit';
import VerifyEmail from './customer/VerifyEmail';
import ForgotPassword from './customer/ForgotPassword';
import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart';
import Orders from './customer/Orders';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
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.
const LIGHT_ACCENT = '#1a1a1a';
const DARK_ACCENT = '#f0f0f0';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// What /account renders over when it was entered directly — a bookmark, a link
// in an email, or a redirect after signing in. There is no page behind in that
// case, and a modal floating on nothing has nowhere to close back to.
const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash: '' };
// Routes presented as a modal over the page the customer was already on rather
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
// refreshable, and closed by the browser's Back button — while never being
// somewhere with no way out.
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password'];
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
// standing source of flake in end-to-end tests, which drive the app with this
// preference enabled.
function usePrefersReducedMotion(): boolean {
const [prefers, setPrefers] = useState(
() => typeof window !== 'undefined' && window.matchMedia(REDUCED_MOTION_QUERY).matches
);
useEffect(() => {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
const update = () => setPrefers(query.matches);
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return prefers;
}
// /account is a route that renders as a modal over whatever the customer was
// looking at, rather than a page of its own. It stays a real, linkable URL —
// bookmarkable, refreshable, and closed by the browser's Back button — while
// never being a place with no way out of it.
// Both hoisted out of the components that declared them inline.
//
// S6478 flags a function-returning-JSX in a prop as "defining a component
// during render". These are render props — ErrorBoundary's `fallback` is typed
// `(error: Error) => React.ReactNode` and called as `this.props.fallback(...)`
// — so React only ever sees the returned elements, never a new component type,
// and the subtree destruction the rule warns about does not happen. The rule's
// own message offers `allowAsProps` for exactly this shape, which cannot be set
// from here.
//
// Hoisting rather than suppressing because it costs nothing: neither closes
// over anything local, so at module level each is one stable function instead
// of a new closure per render. See #181.
function modalErrorFallback(error: Error) {
return (
<ModalDialog
open
// No `title` here: ErrorFallback renders the same string as an <h3>, and
// antd would otherwise announce the dialog's accessible name and then the
// identical heading right after it.
footer={null}
onCancel={() => {
window.location.href = '/';
}}
>
<ErrorFallback
error={error}
title="Couldn't open that"
actions={
<Button
type="primary"
onClick={() => {
window.location.href = '/';
}}
>
Close
</Button>
}
/>
</ModalDialog>
);
}
function pageErrorFallback(error: Error) {
return (
<ErrorFallback
error={error}
title="Something went wrong"
fullPage
actions={[
<Button key="reload" type="primary" onClick={() => window.location.reload()}>
Reload
</Button>,
<Button
key="home"
onClick={() => {
window.location.href = '/';
}}
>
Back to the shop
</Button>
]}
/>
);
}
function AppRoutes() {
const location = useLocation();
const navigate = useNavigate();
const state = location.state as { background?: Location } | null;
const modalPath = MODAL_ROUTES.includes(location.pathname) ? location.pathname : null;
// In-app navigation names the page to render behind. Anything else — a
// bookmark, an email link, a link in a password reset message — falls back to
// the storefront, so closing always lands somewhere real.
const background = state?.background;
const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
function closeModal() {
// Back, when there is somewhere to go back to, so closing the modal and
// pressing Back do the same thing and neither leaves a dead entry behind.
if (background) navigate(-1);
else navigate('/', { replace: true });
}
// Steps within the auth flow replace rather than push, so the whole detour
// stays a single history entry and closing returns to where it started
// instead of walking back through every tab that was looked at.
function goWithinAuth(path: string) {
navigate(path, { state: background ? { background } : undefined, replace: true });
}
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 />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cart" element={<Cart />} />
{/* A page rather than a modal route, deliberately: order history is a
list you read, like the cart, not a dialog you dismiss. Adding it to
MODAL_ROUTES would put it back in the 700px box it just left. */}
<Route path="/orders" element={<Orders />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
{/* Public and unauthenticated: the token is the whole access control. */}
<Route path="/submit/:token" element={<Submit />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
{/* Rendered outside the Routes above, which are showing the backdrop. */}
<ErrorBoundary
context="modal"
fallback={modalErrorFallback}
>
{/* Unconditional, so /?boom=modal fires this boundary with the
storefront rendered behind it — no session needed. */}
{import.meta.env.DEV && <DevThrow scope="modal" />}
{modalPath === '/account' && <Account onClose={closeModal} />}
{modalPath === '/login' && (
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/register' && (
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}
onRequestNewLink={() => goWithinAuth('/forgot-password')}
onBackToSignIn={() => goWithinAuth('/login')}
/>
)}
</ErrorBoundary>
</>
);
}
function Root() {
const { mode } = useThemeMode();
const prefersReducedMotion = usePrefersReducedMotion();
return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
// The accent inverts with the theme rather than staying near-black.
// Held constant, it rendered the active tab label in #1a1a1a on a
// dark background — invisible.
colorPrimary: mode === 'dark' ? DARK_ACCENT : LIGHT_ACCENT,
// Text drawn *on* the accent (primary buttons, selected rows) has to
// invert with it too, or a near-white accent gets antd's default
// white label and disappears.
colorTextLightSolid: mode === 'dark' ? LIGHT_ACCENT : '#ffffff',
motion: !prefersReducedMotion
}
}}
>
<BrowserRouter>
<ErrorBoundary
context="page"
fallback={pageErrorFallback}
>
<AppRoutes />
</ErrorBoundary>
</BrowserRouter>
</ConfigProvider>
);
}
// Fired at entry purely for its side effect: it sets the origin uploaded
// images are fetched from (#103). Not awaited, because nothing should wait on
// it — anything that renders first gets a site-relative path, which the app's
// own origin still serves.
//
// Rejections are swallowed on purpose. A config that cannot be fetched is a
// broken deployment which every other request will report; failing here would
// only replace the app with an error page before it has drawn anything.
void fetchConfig().catch(() => {});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeModeProvider>
<CustomerAuthProvider>
<CartProvider>
<FavoritesProvider>
<Root />
</FavoritesProvider>
</CartProvider>
</CustomerAuthProvider>
</ThemeModeProvider>
</React.StrictMode>
);