diff --git a/README.md b/README.md index 7e3a8af..edd3841 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,8 @@ See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operat - Script: `https://tianji.aimloperations.com/tracker.js` - **Page views** always load on prod/beta (no consent gate) - **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 diff --git a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx index 7589137..7f39157 100644 --- a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx +++ b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.test.tsx @@ -29,19 +29,23 @@ describe('AnalyticsConsentBanner', () => { renderBanner(); expect(screen.getByRole('dialog', { name: /analytics consent/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', () => { renderBanner(); fireEvent.click(screen.getByRole('button', { name: /accept analytics/i })); 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', () => { renderBanner(); fireEvent.click(screen.getByRole('button', { name: /decline/i })); 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'); }); }); diff --git a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx index 6703848..3662c34 100644 --- a/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx +++ b/llm-fe/src/llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner.tsx @@ -51,11 +51,15 @@ const AnalyticsConsentBanner = (): JSX.Element | null => { 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 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{' '} - - Terms of Service + signed in, we may associate that activity with your account. See the{' '} + + Analytics and Cookies {' '} - for details. + section of our Terms of Service for details. diff --git a/llm-fe/src/llm-fe/utils/analytics.test.ts b/llm-fe/src/llm-fe/utils/analytics.test.ts index 3ae4dac..921c99c 100644 --- a/llm-fe/src/llm-fe/utils/analytics.test.ts +++ b/llm-fe/src/llm-fe/utils/analytics.test.ts @@ -23,7 +23,7 @@ describe('analytics event helpers', () => { }); it('tracks custom events when consent granted', () => { - localStorage.setItem('hesychia_analytics_consent', 'granted'); + localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); const track = 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', () => { - localStorage.setItem('hesychia_analytics_consent', 'granted'); + localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); trackEvent('Checkout Started'); const track = jest.fn(); @@ -49,7 +49,7 @@ describe('analytics event helpers', () => { identifyUser({ userId: 'u1' }); expect(identify).not.toHaveBeenCalled(); - localStorage.setItem('hesychia_analytics_consent', 'granted'); + localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); identifyUser({ userId: 'u1' }); expect(identify).toHaveBeenCalledWith({ userId: 'u1' }); }); diff --git a/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts b/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts index 448137c..d151a61 100644 --- a/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts +++ b/llm-fe/src/llm-fe/utils/analyticsConsent.test.ts @@ -5,10 +5,9 @@ import { isAnalyticsEnvironment, setAnalyticsConsent, ANALYTICS_CONSENT_CHANGED_EVENT, + CONSENT_STORAGE_KEY, } from './analyticsConsent'; -const CONSENT_KEY = 'hesychia_analytics_consent'; - describe('isAnalyticsEnvironment', () => { const originalEnv = process.env; @@ -64,26 +63,33 @@ describe('analytics consent storage', () => { expect(hasAnalyticsConsent()).toBe(true); }); - it('reads granted and denied from localStorage', () => { - localStorage.setItem(CONSENT_KEY, 'granted'); + it('reads granted and denied from versioned localStorage key', () => { + localStorage.setItem(CONSENT_STORAGE_KEY, 'granted'); expect(getAnalyticsConsent()).toBe('granted'); expect(isAnalyticsConsentResolved()).toBe(true); - localStorage.setItem(CONSENT_KEY, 'denied'); + localStorage.setItem(CONSENT_STORAGE_KEY, 'denied'); expect(getAnalyticsConsent()).toBe('denied'); 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', () => { const listener = jest.fn(); window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); setAnalyticsConsent('denied'); - expect(localStorage.getItem(CONSENT_KEY)).toBe('denied'); + expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('denied'); expect(listener).toHaveBeenCalled(); setAnalyticsConsent('granted'); - expect(localStorage.getItem(CONSENT_KEY)).toBe('granted'); + expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted'); window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); }); diff --git a/llm-fe/src/llm-fe/utils/analyticsConsent.ts b/llm-fe/src/llm-fe/utils/analyticsConsent.ts index f95642f..36a1c3d 100644 --- a/llm-fe/src/llm-fe/utils/analyticsConsent.ts +++ b/llm-fe/src/llm-fe/utils/analyticsConsent.ts @@ -1,6 +1,10 @@ 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'; /** Prod + beta builds only (CRA NODE_ENV is production for both). */ @@ -14,17 +18,27 @@ export const isAnalyticsEnvironment = (): boolean => { ); }; -export const getAnalyticsConsent = (): AnalyticsConsentStatus => { - if (!isAnalyticsEnvironment()) return 'granted'; - +const readStoredConsent = (): AnalyticsConsentStatus | null => { try { - const value = localStorage.getItem(CONSENT_STORAGE_KEY); - if (value === 'granted' || value === 'denied') return value; + const current = localStorage.getItem(CONSENT_STORAGE_KEY); + 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 { /* localStorage unavailable */ } - return 'pending'; + return null; +}; + +export const getAnalyticsConsent = (): AnalyticsConsentStatus => { + if (!isAnalyticsEnvironment()) return 'granted'; + return readStoredConsent() ?? 'pending'; }; export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted'; @@ -34,6 +48,7 @@ export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() ! export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => { try { localStorage.setItem(CONSENT_STORAGE_KEY, status); + localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY); } catch { /* localStorage unavailable */ }