Compare commits

Author SHA1 Message Date
westfarn 11b5734812 Harden analytics consent banner storage and ToS deep-link (#37)
Unit Tests / test (pull_request) Successful in 12s
Version consent in localStorage (v1 + legacy migrate), point banner at Terms analytics section, and document the always-on vs optional policy. Banner/provider shipped in #42.
2026-07-27 09:02:26 -05:00
6 changed files with 54 additions and 23 deletions
+2
View File
@@ -227,6 +227,8 @@ See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operat
- Script: `https://tianji.aimloperations.com/tracker.js` - Script: `https://tianji.aimloperations.com/tracker.js`
- **Page views** always load on prod/beta (no consent gate) - **Page views** always load on prod/beta (no consent gate)
- **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`) - **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`)
- Consent choice stored in `localStorage` key `hesychia_analytics_consent_v1` (`granted` / `denied`)
- Banner links to `/terms_of_service/#analytics` for the always-on vs optional split
## Related repos ## Related repos
@@ -29,19 +29,23 @@ describe('AnalyticsConsentBanner', () => {
renderBanner(); renderBanner();
expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument();
expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument(); expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Analytics and Cookies/i })).toHaveAttribute(
'href',
'/terms_of_service/#analytics',
);
}); });
it('hides after accept and persists granted', () => { it('hides after accept and persists granted', () => {
renderBanner(); renderBanner();
fireEvent.click(screen.getByRole('button', { name: /accept analytics/i })); fireEvent.click(screen.getByRole('button', { name: /accept analytics/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent')).toBe('granted'); expect(localStorage.getItem('hesychia_analytics_consent_v1')).toBe('granted');
}); });
it('hides after decline and persists denied', () => { it('hides after decline and persists denied', () => {
renderBanner(); renderBanner();
fireEvent.click(screen.getByRole('button', { name: /decline/i })); fireEvent.click(screen.getByRole('button', { name: /decline/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent')).toBe('denied'); expect(localStorage.getItem('hesychia_analytics_consent_v1')).toBe('denied');
}); });
}); });
@@ -51,11 +51,15 @@ const AnalyticsConsentBanner = (): JSX.Element | null => {
We always collect anonymous page views and referral data to understand traffic. We always collect anonymous page views and referral data to understand traffic.
With your consent, we also collect how you use features (sign-in, checkout, chat With your consent, we also collect how you use features (sign-in, checkout, chat
actions, and similar). We do not use advertising cookies. When you accept and are actions, and similar). We do not use advertising cookies. When you accept and are
signed in, we may associate that activity with your account. See our{' '} signed in, we may associate that activity with your account. See the{' '}
<Link component={RouterLink} to="/terms_of_service/" underline="hover"> <Link
Terms of Service component={RouterLink}
to="/terms_of_service/#analytics"
underline="hover"
>
Analytics and Cookies
</Link>{' '} </Link>{' '}
for details. section of our Terms of Service for details.
</Typography> </Typography>
</Box> </Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}>
+3 -3
View File
@@ -23,7 +23,7 @@ describe('analytics event helpers', () => {
}); });
it('tracks custom events when consent granted', () => { it('tracks custom events when consent granted', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
const track = jest.fn(); const track = jest.fn();
window.tianji = { track, identify: jest.fn() }; window.tianji = { track, identify: jest.fn() };
@@ -32,7 +32,7 @@ describe('analytics event helpers', () => {
}); });
it('queues events until tianji is ready then flushes after consent', () => { it('queues events until tianji is ready then flushes after consent', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
trackEvent('Checkout Started'); trackEvent('Checkout Started');
const track = jest.fn(); const track = jest.fn();
@@ -49,7 +49,7 @@ describe('analytics event helpers', () => {
identifyUser({ userId: 'u1' }); identifyUser({ userId: 'u1' });
expect(identify).not.toHaveBeenCalled(); expect(identify).not.toHaveBeenCalled();
localStorage.setItem('hesychia_analytics_consent', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
identifyUser({ userId: 'u1' }); identifyUser({ userId: 'u1' });
expect(identify).toHaveBeenCalledWith({ userId: 'u1' }); expect(identify).toHaveBeenCalledWith({ userId: 'u1' });
}); });
@@ -5,10 +5,9 @@ import {
isAnalyticsEnvironment, isAnalyticsEnvironment,
setAnalyticsConsent, setAnalyticsConsent,
ANALYTICS_CONSENT_CHANGED_EVENT, ANALYTICS_CONSENT_CHANGED_EVENT,
CONSENT_STORAGE_KEY,
} from './analyticsConsent'; } from './analyticsConsent';
const CONSENT_KEY = 'hesychia_analytics_consent';
describe('isAnalyticsEnvironment', () => { describe('isAnalyticsEnvironment', () => {
const originalEnv = process.env; const originalEnv = process.env;
@@ -64,26 +63,33 @@ describe('analytics consent storage', () => {
expect(hasAnalyticsConsent()).toBe(true); expect(hasAnalyticsConsent()).toBe(true);
}); });
it('reads granted and denied from localStorage', () => { it('reads granted and denied from versioned localStorage key', () => {
localStorage.setItem(CONSENT_KEY, 'granted'); localStorage.setItem(CONSENT_STORAGE_KEY, 'granted');
expect(getAnalyticsConsent()).toBe('granted'); expect(getAnalyticsConsent()).toBe('granted');
expect(isAnalyticsConsentResolved()).toBe(true); expect(isAnalyticsConsentResolved()).toBe(true);
localStorage.setItem(CONSENT_KEY, 'denied'); localStorage.setItem(CONSENT_STORAGE_KEY, 'denied');
expect(getAnalyticsConsent()).toBe('denied'); expect(getAnalyticsConsent()).toBe('denied');
expect(hasAnalyticsConsent()).toBe(false); expect(hasAnalyticsConsent()).toBe(false);
}); });
it('migrates legacy consent key to v1', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted');
expect(getAnalyticsConsent()).toBe('granted');
expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted');
expect(localStorage.getItem('hesychia_analytics_consent')).toBeNull();
});
it('persists consent and dispatches change event', () => { it('persists consent and dispatches change event', () => {
const listener = jest.fn(); const listener = jest.fn();
window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
setAnalyticsConsent('denied'); setAnalyticsConsent('denied');
expect(localStorage.getItem(CONSENT_KEY)).toBe('denied'); expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('denied');
expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenCalled();
setAnalyticsConsent('granted'); setAnalyticsConsent('granted');
expect(localStorage.getItem(CONSENT_KEY)).toBe('granted'); expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted');
window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
}); });
+22 -7
View File
@@ -1,6 +1,10 @@
export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending'; export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending';
const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent'; /** Versioned so policy/copy changes can force a fresh choice later. */
export const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent_v1';
/** Pre-#37 key from beta Tianji baseline (#42); migrated once on read. */
const LEGACY_CONSENT_STORAGE_KEY = 'hesychia_analytics_consent';
export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed'; export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed';
/** Prod + beta builds only (CRA NODE_ENV is production for both). */ /** Prod + beta builds only (CRA NODE_ENV is production for both). */
@@ -14,17 +18,27 @@ export const isAnalyticsEnvironment = (): boolean => {
); );
}; };
export const getAnalyticsConsent = (): AnalyticsConsentStatus => { const readStoredConsent = (): AnalyticsConsentStatus | null => {
if (!isAnalyticsEnvironment()) return 'granted';
try { try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY); const current = localStorage.getItem(CONSENT_STORAGE_KEY);
if (value === 'granted' || value === 'denied') return value; if (current === 'granted' || current === 'denied') return current;
const legacy = localStorage.getItem(LEGACY_CONSENT_STORAGE_KEY);
if (legacy === 'granted' || legacy === 'denied') {
localStorage.setItem(CONSENT_STORAGE_KEY, legacy);
localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY);
return legacy;
}
} catch { } catch {
/* localStorage unavailable */ /* localStorage unavailable */
} }
return 'pending'; return null;
};
export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
if (!isAnalyticsEnvironment()) return 'granted';
return readStoredConsent() ?? 'pending';
}; };
export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted'; export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted';
@@ -34,6 +48,7 @@ export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() !
export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => { export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => {
try { try {
localStorage.setItem(CONSENT_STORAGE_KEY, status); localStorage.setItem(CONSENT_STORAGE_KEY, status);
localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY);
} catch { } catch {
/* localStorage unavailable */ /* localStorage unavailable */
} }