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 */
}