Compare commits

...
3 Commits
Author SHA1 Message Date
westfarn d6f6698a15 Wire Tianji custom event tracking for auth, billing, and chat (#36)
Unit Tests / test (pull_request) Successful in 13s
Add identifyAccount + AnalyticsSession, instrument MVP funnel call sites without PII payloads, and document event names in ANALYTICS.md. Billing portal event reserved until #33.
2026-07-27 10:37:05 -05:00
westfarn 1c9e04cf86 Merge pull request 'Add cookie / analytics consent banner polish (#37)' (#44) from feat/37-analytics-consent-banner into master
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / deploy-beta (push) Successful in 9s
Unit Tests / test (push) Successful in 12s
Reviewed-on: #44
2026-07-27 08:22:39 -07:00
westfarn 9c814dcc60 Harden analytics consent banner storage and ToS deep-link (#37)
Unit Tests / test (pull_request) Successful in 13s
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:50:40 -05:00
18 changed files with 230 additions and 29 deletions
+3
View File
@@ -227,6 +227,9 @@ 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
- Event catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md)
## Related repos
+32
View File
@@ -0,0 +1,32 @@
# Tianji product events (Hesychia)
Helpers: `llm-fe/src/llm-fe/utils/analytics.ts`
Consent gate: custom events + identify only when `hesychia_analytics_consent*` is `granted` (see #37).
Page views: always via `Tracker` / `tracker.js` on prod + beta.
## Event catalog
| Event name | Constant | Call site(s) | Payload (non-PII) |
|------------|----------|--------------|-------------------|
| Login Success | `LOGIN_SUCCESS` | SignIn, AuthCallback | `{ method: 'password' \| 'sso' }` |
| Login Failed | `LOGIN_FAILED` | SignIn, AuthCallback | `{ method }` |
| Sign Up Started | `SIGNUP_STARTED` | SignUp | — |
| Sign Up Success | `SIGNUP_SUCCESS` | SignUp, AuthCallback (`created`) | `{ method? }` |
| Sign Up Failed | `SIGNUP_FAILED` | SignUp, AuthCallback | `{ method? }` |
| Logout | `LOGOUT` | AnalyticsSession (auth → logged out) | — |
| Checkout Started | `CHECKOUT_STARTED` | SignUp, AuthCallback | `{ source }` |
| Payment Success | `PAYMENT_SUCCESS` | BillingSuccess | `{ hasSessionId }` |
| Payment Canceled | `PAYMENT_CANCELED` | BillingCancel | — |
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | *(pending #33 Account portal CTA)* | — |
## Identify
`identifyAccount(account)` sends `{ userId: email, companyId?, isCompanyManager? }`.
No message text, document contents, or names.
## Environments
Only prod/beta (`REACT_APP_DEPLOY_ENV` or production build with Tianji website id). Local dev skips unless env set.
+2
View File
@@ -22,6 +22,7 @@ import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThe
import Tracker from './llm-fe/components/Tracker/Tracker';
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext);
@@ -45,6 +46,7 @@ class App extends Component {
<GlobalThemeWrapper>
<AnalyticsConsentProvider>
<Tracker />
<AnalyticsSession />
<AnalyticsConsentBanner />
<div className='site'>
<main>
@@ -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');
});
});
@@ -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{' '}
<Link component={RouterLink} to="/terms_of_service/" underline="hover">
Terms of Service
signed in, we may associate that activity with your account. See the{' '}
<Link
component={RouterLink}
to="/terms_of_service/#analytics"
underline="hover"
>
Analytics and Cookies
</Link>{' '}
for details.
section of our Terms of Service for details.
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}>
@@ -0,0 +1,36 @@
import { useContext, useEffect, useRef } from 'react';
import { AccountContext } from '../contexts/AccountContext';
import { AuthContext } from '../contexts/AuthContext';
import { useAnalyticsConsent } from '../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../utils/analytics';
/**
* Identify on authenticated session; track Logout when session ends.
* Mount inside AnalyticsConsentProvider.
*/
const AnalyticsSession = (): null => {
const { account } = useContext(AccountContext);
const { authenticated, loading } = useContext(AuthContext);
const { hasConsent } = useAnalyticsConsent();
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (!hasConsent || loading) return;
if (account) {
identifyAccount(account);
wasAuthenticatedRef.current = true;
return;
}
if (wasAuthenticatedRef.current && !authenticated) {
trackEvent(AnalyticsEvents.LOGOUT);
}
wasAuthenticatedRef.current = authenticated;
}, [account, authenticated, hasConsent, loading]);
return null;
};
export default AnalyticsSession;
@@ -5,6 +5,7 @@ import { ConversationContext } from "./ConversationContext";
import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
type MessageProviderProps ={
children? : ReactNode;
@@ -177,7 +178,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
if (messageResponsePart.current === 1){
// this has to do with the conversation id
if(!selectedConversation){
setSelectedConversation(Number(message))
const conversationId = Number(message);
setSelectedConversation(conversationId)
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
conversationId,
});
}
}
else if (messageResponsePart.current === 2){
@@ -16,6 +16,7 @@ import { MessageContext } from "../../contexts/MessageContext";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import Header2 from "../../components/Header2/Header2";
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
// Styled Components
const PageContainer = styled.div`
@@ -335,6 +336,10 @@ const AsyncDashboardInner = (): JSX.Element => {
conversationRef.current = tempConversations;
setConversationDetails(tempConversations);
sendMessage(prompt, selectedConversation, file, fileType, modelName);
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
hasConversation: Boolean(selectedConversation),
hasAttachment: Boolean(file),
});
resetForm({
values: {
prompt: "",
@@ -9,6 +9,7 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div`
position: relative;
@@ -102,6 +103,7 @@ const AuthCallback = (): JSX.Element => {
const run = async () => {
const error = searchParams.get('error');
if (error) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', error });
setErrorMessage(
oauthErrorMessage(error, searchParams.get('error_description'))
);
@@ -112,8 +114,10 @@ const AuthCallback = (): JSX.Element => {
const access = searchParams.get('access');
const refresh = searchParams.get('refresh');
const needsCheckout = searchParams.get('needs_checkout') === '1';
const isNewUser = searchParams.get('created') === '1';
if (!access || !refresh) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', reason: 'missing_tokens' });
setErrorMessage('Missing sign-in tokens. Please try again.');
setStatusText('Sign-in failed');
return;
@@ -148,9 +152,17 @@ const AuthCallback = (): JSX.Element => {
setAuthentication(true);
setNeedsNewPassword(get_user_response.data.has_usable_password);
if (isNewUser) {
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'sso' });
} else {
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
}
identifyAccount(account);
if (needsCheckout) {
setStatusText('Starting checkout…');
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
cancel_url,
@@ -175,6 +187,7 @@ const AuthCallback = (): JSX.Element => {
} catch (err) {
console.log(err);
if (!cancelled) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso' });
setErrorMessage('Could not finish sign-in. Try again.');
setStatusText('Sign-in failed');
}
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div`
position: relative;
@@ -81,6 +82,10 @@ const NavLink = styled(Link)`
const BillingCancel = (): JSX.Element => {
const navigate = useNavigate();
useEffect(() => {
trackEvent(AnalyticsEvents.PAYMENT_CANCELED);
}, []);
return (
<PageContainer>
<ParticleBackground />
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AuthContext } from '../../contexts/AuthContext';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div`
position: relative;
@@ -85,6 +86,12 @@ const BillingSuccess = (): JSX.Element => {
const [searchParams] = useSearchParams();
const [sessionId] = useState(() => searchParams.get('session_id') || '');
useEffect(() => {
trackEvent(AnalyticsEvents.PAYMENT_SUCCESS, {
hasSessionId: Boolean(sessionId),
});
}, [sessionId]);
useEffect(() => {
if (!loading && authenticated) {
const timer = window.setTimeout(() => navigate('/'), 2500);
+4 -1
View File
@@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import styled from 'styled-components';
import * as Yup from 'yup';
@@ -214,7 +215,8 @@ const SignIn = (): JSX.Element => {
setAccount(account);
setAuthentication(true)
setNeedsNewPassword(get_user_response.data.has_usable_password)
// TODO: terms of service
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
identifyAccount(account);
if (account.has_signed_tos) {
navigate('/');
} else {
@@ -223,6 +225,7 @@ const SignIn = (): JSX.Element => {
} catch (error) {
console.log(error)
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'password' });
setErrorMessage('Error retrieving account. Try again');
}
}
+7 -1
View File
@@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import styled from 'styled-components';
import * as Yup from 'yup';
@@ -224,6 +225,7 @@ const SignUp = (): JSX.Element => {
const handleSignUp = async (values: SignUpValues): Promise<void> => {
setErrorMessage('');
trackEvent(AnalyticsEvents.SIGNUP_STARTED, { method: 'password' });
try {
const registerResponse = await axiosInstance.post('/user/create/', {
email: values.email,
@@ -235,9 +237,12 @@ const SignUp = (): JSX.Element => {
await setTokens(registerResponse.data.access, registerResponse.data.refresh);
applyAccessToken(registerResponse.data.access);
await loadAccount();
const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account);
const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
cancel_url,
@@ -252,6 +257,7 @@ const SignUp = (): JSX.Element => {
window.location.assign(checkoutUrl);
} catch (error: unknown) {
console.log(error);
trackEvent(AnalyticsEvents.SIGNUP_FAILED, { method: 'password' });
const axiosError = error as {
response?: { status?: number; data?: { detail?: string; email?: string[] } };
};
@@ -7,6 +7,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
type TermsOfServiceProps = {
/** Marketing / legal alias: always read-only chrome (no app shell). */
@@ -30,6 +31,7 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
setSubmitting(true);
try {
await axiosInstance.post('/user/acknowledge_tos/');
trackEvent(AnalyticsEvents.TOS_ACKNOWLEDGED);
if (account) {
setAccount(new Account({ ...account, has_signed_tos: true }));
}
+28 -4
View File
@@ -1,4 +1,5 @@
import { flushAnalyticsQueue, identifyUser, trackEvent } from './analytics';
import { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data';
describe('analytics event helpers', () => {
const originalEnv = process.env;
@@ -23,7 +24,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 +33,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,8 +50,31 @@ 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' });
});
it('identifyAccount sends email as userId without names', () => {
localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
const identify = jest.fn();
window.tianji = { track: jest.fn(), identify };
identifyAccount(
new Account({
email: 'user@example.com',
first_name: 'Secret',
last_name: 'Name',
is_company_manager: true,
company: { id: 42, name: 'Acme' },
}),
);
expect(identify).toHaveBeenCalledWith({
userId: 'user@example.com',
companyId: 42,
isCompanyManager: true,
});
expect(JSON.stringify(identify.mock.calls[0][0])).not.toMatch(/Secret|Name|Acme/);
});
});
+30 -1
View File
@@ -1,4 +1,5 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
@@ -6,7 +7,21 @@ type QueuedCall =
const queue: QueuedCall[] = [];
/** Named events for product analytics. Page views stay on automatic tracker.js. */
/**
* Named product events for Tianji (consent-gated). Page views stay on tracker.js.
*
* | Event | When |
* |-------|------|
* | Login Success / Failed | Password or SSO sign-in |
* | Sign Up Started / Success / Failed | Self-serve registration |
* | Logout | Sign out |
* | Checkout Started | Stripe Checkout session created |
* | Payment Success / Canceled | Billing return URLs |
* | Conversation Created | New chat id assigned over WS |
* | Message Sent | User submits prompt (no content) |
* | ToS Acknowledged | POST acknowledge_tos succeeds |
* | Billing Portal Opened | When #33 portal CTA ships |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
LOGIN_FAILED: 'Login Failed',
@@ -75,3 +90,17 @@ export const identifyUser = (userInfo: Record<string, unknown>): void => {
queue.push({ type: 'identify', userInfo: { ...userInfo } });
};
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
const traits: Record<string, unknown> = {
userId: account.email,
};
if (account.company?.id != null) {
traits.companyId = account.company.id;
}
if (account.is_company_manager) {
traits.isCompanyManager = true;
}
identifyUser(traits);
};
@@ -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);
});
+22 -7
View File
@@ -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 */
}