Enable beta auto-deploy and Tianji consent-gated events (#35)
Unit Tests / test (pull_request) Successful in 14s
Unit Tests / test (pull_request) Successful in 14s
Flip workflows so master deploys beta and prod is workflow_dispatch only. Load distinct Tianji website IDs from env; page views always on, custom events require consent.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
name: Deploy Beta
|
||||
|
||||
# Auto-deploy beta after push to master (mirrors dta_webapp).
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
name: Deploy Prod
|
||||
|
||||
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
|
||||
# Manual prod deploy only (mirrors dta_webapp). Push to master deploys beta.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Unit Tests]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||
unit-tests:
|
||||
runs-on: self-hosted
|
||||
defaults:
|
||||
run:
|
||||
working-directory: llm-fe
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:ci
|
||||
|
||||
deploy-prod:
|
||||
needs: unit-tests
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||
@@ -19,4 +35,4 @@ jobs:
|
||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||
--app chat_web_app \
|
||||
--env prod \
|
||||
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
||||
--ref "${{ gitea.sha }}"
|
||||
|
||||
@@ -92,10 +92,12 @@ to be visible in the browser bundle.
|
||||
| File | Used when | Backend (current) |
|
||||
|------|-----------|-------------------|
|
||||
| `.env.development` | `npm start` | `http://127.0.0.1:8001` |
|
||||
| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` |
|
||||
| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` |
|
||||
| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` + prod Tianji ID |
|
||||
| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` + beta Tianji ID |
|
||||
| `.env.mobile` | `npm run build:mobile` | prod by default (override for beta shell) |
|
||||
|
||||
Each committed env sets `REACT_APP_DEPLOY_ENV` (`production` / `beta`) and `REACT_APP_TIANJI_WEBSITE_ID`.
|
||||
|
||||
Required keys (already set in the committed env files):
|
||||
|
||||
```env
|
||||
@@ -184,20 +186,22 @@ npm run test:ci # one-shot (matches Gitea Unit Tests)
|
||||
|
||||
## CI / deployment
|
||||
|
||||
Gitea Actions workflows in `.gitea/workflows/`:
|
||||
Gitea Actions workflows in `.gitea/workflows/` (same pattern as `dta_webapp`):
|
||||
|
||||
| Workflow | Trigger | Behavior |
|
||||
|----------|---------|----------|
|
||||
| `unit-tests.yml` | push/PR to `master` | `npm ci` + `npm run test:ci` in `llm-fe/` |
|
||||
| `deploy-prod.yml` | after green **Unit Tests** on `master` **push** | `server-infra/scripts/deploy.sh --app chat_web_app --env prod --ref <sha>` |
|
||||
| `deploy-beta.yml` | `workflow_dispatch` | tests, then deploy `--env beta` |
|
||||
| `deploy-beta.yml` | **push** to `master` (after unit tests in-workflow) | `server-infra/scripts/deploy.sh --app chat_web_app --env beta --ref <sha>` |
|
||||
| `deploy-prod.yml` | **manual** `workflow_dispatch` only | tests, then deploy `--env prod` |
|
||||
|
||||
Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea **Run workflow** button on Deploy Prod.
|
||||
|
||||
Deploy flow (on each webserver listed in `host_apps`):
|
||||
|
||||
1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_<env>`
|
||||
2. `npm ci` in `llm-fe/`
|
||||
3. `npm run build:<env>` → writes the static tree under `/var/www/...`
|
||||
4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083** if enabled)
|
||||
4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083**)
|
||||
|
||||
Manual deploy from the control node:
|
||||
|
||||
@@ -209,9 +213,21 @@ Manual deploy from the control node:
|
||||
NPM (not managed by Ansible) should balance public Hesychia domains to:
|
||||
|
||||
- `adama:8082` + `roslin:8082` + `ai-server-4080:8082` (prod)
|
||||
- beta hosts on **8083** / `beta.chat.aimloperations.com` (see server-infra)
|
||||
|
||||
See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operations/server-infra).
|
||||
|
||||
### Tianji analytics
|
||||
|
||||
| Build | Env file | Website ID |
|
||||
|-------|----------|------------|
|
||||
| prod | `.env.production` | `REACT_APP_TIANJI_WEBSITE_ID` (prod site) |
|
||||
| beta | `.env.beta` | distinct beta site ID (set after creating website in Tianji) |
|
||||
|
||||
- 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`)
|
||||
|
||||
## Related repos
|
||||
|
||||
- **Backend:** [chat_backend](https://git.aimloperations.com/ai_ml_operations/chat_backend) — Django API + WebSocket
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Beta API hosts (current). Target public product domain: hesychia.ai
|
||||
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
|
||||
REACT_APP_DEPLOY_ENV=beta
|
||||
REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/
|
||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
|
||||
# Tianji website id for Hesychia beta (distinct from prod so analytics stay separate).
|
||||
# Create "Hesychia Beta" in Tianji UI (workspace cm7w8087y020lddswyhamadj2), then paste id here.
|
||||
REACT_APP_TIANJI_WEBSITE_ID=
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# Production API hosts (current). Target public product domain: hesychia.ai
|
||||
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
|
||||
REACT_APP_DEPLOY_ENV=production
|
||||
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
|
||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
|
||||
# Tianji website id (prod). Page views always on; custom events need consent.
|
||||
REACT_APP_TIANJI_WEBSITE_ID=cm7x7m52m03kbddswbswrt17y
|
||||
|
||||
@@ -19,6 +19,8 @@ import AnalyticsPage from './llm-fe/pages/Analytics/Analytics';
|
||||
import PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset';
|
||||
import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper';
|
||||
import Tracker from './llm-fe/components/Tracker/Tracker';
|
||||
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
|
||||
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
||||
|
||||
const ProtectedRoutes = () => {
|
||||
const { authenticated, loading } = useContext(AuthContext);
|
||||
@@ -40,7 +42,9 @@ class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<GlobalThemeWrapper>
|
||||
<AnalyticsConsentProvider>
|
||||
<Tracker />
|
||||
<AnalyticsConsentBanner />
|
||||
<div className='site'>
|
||||
<main>
|
||||
<div className="main-container">
|
||||
@@ -76,6 +80,7 @@ class App extends Component {
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</AnalyticsConsentProvider>
|
||||
</GlobalThemeWrapper>
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const root = ReactDOM.createRoot(
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AppRouter>
|
||||
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
|
||||
<AuthProvider>
|
||||
<AccountProvider>
|
||||
<WebSocketProvider>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AnalyticsConsentProvider } from '../../contexts/AnalyticsConsentContext';
|
||||
import AnalyticsConsentBanner from './AnalyticsConsentBanner';
|
||||
|
||||
const renderBanner = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AnalyticsConsentProvider>
|
||||
<AnalyticsConsentBanner />
|
||||
</AnalyticsConsentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('AnalyticsConsentBanner', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('shows until the user accepts or declines', () => {
|
||||
renderBanner();
|
||||
expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Box, Button, Link, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
|
||||
|
||||
/**
|
||||
* Opt-in for custom Tianji events / identify. Page views already load via Tracker.
|
||||
*/
|
||||
const AnalyticsConsentBanner = (): JSX.Element | null => {
|
||||
const { showBanner, acceptAnalytics, declineAnalytics } = useAnalyticsConsent();
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-label="Analytics consent"
|
||||
aria-modal="false"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1400,
|
||||
px: { xs: 2, sm: 3 },
|
||||
pb: { xs: 2, sm: 3 },
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
pointerEvents: 'auto',
|
||||
maxWidth: 960,
|
||||
mx: 'auto',
|
||||
p: { xs: 2, sm: 2.5 },
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'stretch', md: 'center' }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Help us improve Hesychia
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
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
|
||||
</Link>{' '}
|
||||
for details.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}>
|
||||
<Button variant="outlined" color="inherit" onClick={declineAnalytics}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button variant="contained" onClick={acceptAnalytics}>
|
||||
Accept analytics
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsConsentBanner;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import Tracker from './Tracker';
|
||||
|
||||
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
|
||||
|
||||
describe('Tracker', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
REACT_APP_DEPLOY_ENV: 'production',
|
||||
REACT_APP_TIANJI_WEBSITE_ID: 'cm7x7m52m03kbddswbswrt17y',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('loads Tianji tracker without analytics consent (page views always on)', () => {
|
||||
render(<Tracker />);
|
||||
|
||||
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
expect(script).not.toBeNull();
|
||||
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
|
||||
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y');
|
||||
});
|
||||
|
||||
it('loads distinct website id for beta builds', () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
REACT_APP_DEPLOY_ENV: 'beta',
|
||||
REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test',
|
||||
};
|
||||
|
||||
render(<Tracker />);
|
||||
|
||||
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test');
|
||||
});
|
||||
|
||||
it('does not load Tianji tracker outside analytics environments', () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
NODE_ENV: 'development',
|
||||
REACT_APP_DEPLOY_ENV: undefined,
|
||||
REACT_APP_TIANJI_WEBSITE_ID: undefined,
|
||||
};
|
||||
|
||||
render(<Tracker />);
|
||||
|
||||
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,42 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { flushAnalyticsQueue } from '../../utils/analytics';
|
||||
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
|
||||
|
||||
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
|
||||
|
||||
/**
|
||||
* Loads Tianji tracker.js for automatic page views on prod/beta builds.
|
||||
* Page views always run (no consent gate). Custom events use analytics.ts + consent.
|
||||
*/
|
||||
const Tracker: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://tianji.aimloperations.com/tracker.js";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.setAttribute('data-website-id', 'cm7x7m52m03kbddswbswrt17y');
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnvironment()) return;
|
||||
|
||||
document.body.appendChild(script);
|
||||
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
|
||||
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
const script = document.createElement('script');
|
||||
script.id = TRACKER_SCRIPT_ID;
|
||||
script.src = 'https://tianji.aimloperations.com/tracker.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.setAttribute('data-website-id', websiteId);
|
||||
script.setAttribute('data-do-not-track', 'true');
|
||||
script.onload = () => {
|
||||
flushAnalyticsQueue();
|
||||
};
|
||||
|
||||
return null;
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const loaded = document.getElementById(TRACKER_SCRIPT_ID);
|
||||
if (loaded?.parentNode) {
|
||||
loaded.parentNode.removeChild(loaded);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Tracker;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ANALYTICS_CONSENT_CHANGED_EVENT,
|
||||
AnalyticsConsentStatus,
|
||||
getAnalyticsConsent,
|
||||
isAnalyticsEnvironment,
|
||||
setAnalyticsConsent,
|
||||
} from '../utils/analyticsConsent';
|
||||
import { flushAnalyticsQueue } from '../utils/analytics';
|
||||
|
||||
type AnalyticsConsentContextValue = {
|
||||
consent: AnalyticsConsentStatus;
|
||||
hasConsent: boolean;
|
||||
isResolved: boolean;
|
||||
showBanner: boolean;
|
||||
acceptAnalytics: () => void;
|
||||
declineAnalytics: () => void;
|
||||
};
|
||||
|
||||
const AnalyticsConsentContext = createContext<AnalyticsConsentContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
type AnalyticsConsentProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const AnalyticsConsentProvider = ({ children }: AnalyticsConsentProviderProps) => {
|
||||
const [consent, setConsent] = useState<AnalyticsConsentStatus>(() => getAnalyticsConsent());
|
||||
|
||||
useEffect(() => {
|
||||
const onConsentChanged = (event: Event) => {
|
||||
const next = (event as CustomEvent<AnalyticsConsentStatus>).detail;
|
||||
if (next === 'granted' || next === 'denied') {
|
||||
setConsent(next);
|
||||
} else {
|
||||
setConsent(getAnalyticsConsent());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged);
|
||||
return () => window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged);
|
||||
}, []);
|
||||
|
||||
const acceptAnalytics = useCallback(() => {
|
||||
setAnalyticsConsent('granted');
|
||||
setConsent('granted');
|
||||
flushAnalyticsQueue();
|
||||
}, []);
|
||||
|
||||
const declineAnalytics = useCallback(() => {
|
||||
setAnalyticsConsent('denied');
|
||||
setConsent('denied');
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AnalyticsConsentContextValue>(
|
||||
() => ({
|
||||
consent,
|
||||
hasConsent: consent === 'granted',
|
||||
isResolved: consent !== 'pending',
|
||||
showBanner: isAnalyticsEnvironment() && consent === 'pending',
|
||||
acceptAnalytics,
|
||||
declineAnalytics,
|
||||
}),
|
||||
[acceptAnalytics, consent, declineAnalytics],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsConsentContext.Provider value={value}>{children}</AnalyticsConsentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAnalyticsConsent = (): AnalyticsConsentContextValue => {
|
||||
const context = useContext(AnalyticsConsentContext);
|
||||
if (!context) {
|
||||
throw new Error('useAnalyticsConsent must be used within AnalyticsConsentProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
tianji?: {
|
||||
track: (eventName: string, data?: Record<string, unknown>) => void;
|
||||
identify: (userInfo: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { flushAnalyticsQueue, identifyUser, trackEvent } from './analytics';
|
||||
|
||||
describe('analytics event helpers', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
|
||||
delete (window as { tianji?: unknown }).tianji;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete (window as { tianji?: unknown }).tianji;
|
||||
});
|
||||
|
||||
it('does not track custom events without consent', () => {
|
||||
const track = jest.fn();
|
||||
window.tianji = { track, identify: jest.fn() };
|
||||
|
||||
trackEvent('Login Success');
|
||||
expect(track).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks custom events when consent granted', () => {
|
||||
localStorage.setItem('hesychia_analytics_consent', 'granted');
|
||||
const track = jest.fn();
|
||||
window.tianji = { track, identify: jest.fn() };
|
||||
|
||||
trackEvent('Login Success', { method: 'password' });
|
||||
expect(track).toHaveBeenCalledWith('Login Success', { method: 'password' });
|
||||
});
|
||||
|
||||
it('queues events until tianji is ready then flushes after consent', () => {
|
||||
localStorage.setItem('hesychia_analytics_consent', 'granted');
|
||||
|
||||
trackEvent('Checkout Started');
|
||||
const track = jest.fn();
|
||||
window.tianji = { track, identify: jest.fn() };
|
||||
flushAnalyticsQueue();
|
||||
|
||||
expect(track).toHaveBeenCalledWith('Checkout Started', undefined);
|
||||
});
|
||||
|
||||
it('identify requires consent', () => {
|
||||
const identify = jest.fn();
|
||||
window.tianji = { track: jest.fn(), identify };
|
||||
|
||||
identifyUser({ userId: 'u1' });
|
||||
expect(identify).not.toHaveBeenCalled();
|
||||
|
||||
localStorage.setItem('hesychia_analytics_consent', 'granted');
|
||||
identifyUser({ userId: 'u1' });
|
||||
expect(identify).toHaveBeenCalledWith({ userId: 'u1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
|
||||
|
||||
type QueuedCall =
|
||||
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
|
||||
| { type: 'identify'; userInfo: Record<string, unknown> };
|
||||
|
||||
const queue: QueuedCall[] = [];
|
||||
|
||||
/** Named events for product analytics. Page views stay on automatic tracker.js. */
|
||||
export const AnalyticsEvents = {
|
||||
LOGIN_SUCCESS: 'Login Success',
|
||||
LOGIN_FAILED: 'Login Failed',
|
||||
SIGNUP_STARTED: 'Sign Up Started',
|
||||
SIGNUP_SUCCESS: 'Sign Up Success',
|
||||
SIGNUP_FAILED: 'Sign Up Failed',
|
||||
LOGOUT: 'Logout',
|
||||
CHECKOUT_STARTED: 'Checkout Started',
|
||||
PAYMENT_SUCCESS: 'Payment Success',
|
||||
PAYMENT_CANCELED: 'Payment Canceled',
|
||||
CONVERSATION_CREATED: 'Conversation Created',
|
||||
MESSAGE_SENT: 'Message Sent',
|
||||
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||
|
||||
/** Custom events + identify require consent. Automatic page views do not. */
|
||||
const isEventTrackingEnabled = (): boolean =>
|
||||
isAnalyticsEnvironment() && hasAnalyticsConsent();
|
||||
|
||||
const runQueuedCall = (call: QueuedCall): void => {
|
||||
if (!window.tianji) return;
|
||||
|
||||
if (call.type === 'track') {
|
||||
window.tianji.track(call.eventName, call.data);
|
||||
return;
|
||||
}
|
||||
|
||||
window.tianji.identify(call.userInfo);
|
||||
};
|
||||
|
||||
export const flushAnalyticsQueue = (): void => {
|
||||
if (!isEventTrackingEnabled() || !window.tianji) return;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const call = queue.shift();
|
||||
if (call) runQueuedCall(call);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackEvent = (
|
||||
eventName: AnalyticsEventName | string,
|
||||
data?: Record<string, unknown>,
|
||||
): void => {
|
||||
if (!isEventTrackingEnabled()) return;
|
||||
|
||||
const payload = data ? { ...data } : undefined;
|
||||
|
||||
if (window.tianji?.track) {
|
||||
window.tianji.track(eventName, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
queue.push({ type: 'track', eventName, data: payload });
|
||||
};
|
||||
|
||||
export const identifyUser = (userInfo: Record<string, unknown>): void => {
|
||||
if (!isEventTrackingEnabled()) return;
|
||||
|
||||
if (window.tianji?.identify) {
|
||||
window.tianji.identify(userInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
queue.push({ type: 'identify', userInfo: { ...userInfo } });
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
getAnalyticsConsent,
|
||||
hasAnalyticsConsent,
|
||||
isAnalyticsConsentResolved,
|
||||
isAnalyticsEnvironment,
|
||||
setAnalyticsConsent,
|
||||
ANALYTICS_CONSENT_CHANGED_EVENT,
|
||||
} from './analyticsConsent';
|
||||
|
||||
const CONSENT_KEY = 'hesychia_analytics_consent';
|
||||
|
||||
describe('isAnalyticsEnvironment', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('returns true for production and beta deploy env', () => {
|
||||
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
|
||||
expect(isAnalyticsEnvironment()).toBe(true);
|
||||
|
||||
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'beta' };
|
||||
expect(isAnalyticsEnvironment()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in development without deploy env', () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
NODE_ENV: 'development',
|
||||
REACT_APP_DEPLOY_ENV: undefined,
|
||||
REACT_APP_TIANJI_WEBSITE_ID: undefined,
|
||||
};
|
||||
expect(isAnalyticsEnvironment()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analytics consent storage', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('defaults to pending in production when unset', () => {
|
||||
expect(getAnalyticsConsent()).toBe('pending');
|
||||
expect(hasAnalyticsConsent()).toBe(false);
|
||||
expect(isAnalyticsConsentResolved()).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-grants consent outside analytics environments', () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
NODE_ENV: 'development',
|
||||
REACT_APP_DEPLOY_ENV: undefined,
|
||||
REACT_APP_TIANJI_WEBSITE_ID: undefined,
|
||||
};
|
||||
expect(getAnalyticsConsent()).toBe('granted');
|
||||
expect(hasAnalyticsConsent()).toBe(true);
|
||||
});
|
||||
|
||||
it('reads granted and denied from localStorage', () => {
|
||||
localStorage.setItem(CONSENT_KEY, 'granted');
|
||||
expect(getAnalyticsConsent()).toBe('granted');
|
||||
expect(isAnalyticsConsentResolved()).toBe(true);
|
||||
|
||||
localStorage.setItem(CONSENT_KEY, 'denied');
|
||||
expect(getAnalyticsConsent()).toBe('denied');
|
||||
expect(hasAnalyticsConsent()).toBe(false);
|
||||
});
|
||||
|
||||
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(listener).toHaveBeenCalled();
|
||||
|
||||
setAnalyticsConsent('granted');
|
||||
expect(localStorage.getItem(CONSENT_KEY)).toBe('granted');
|
||||
|
||||
window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending';
|
||||
|
||||
const 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). */
|
||||
export const isAnalyticsEnvironment = (): boolean => {
|
||||
const deployEnv = process.env.REACT_APP_DEPLOY_ENV;
|
||||
if (deployEnv === 'production' || deployEnv === 'beta') return true;
|
||||
// Fallback: production builds that omit DEPLOY_ENV still load tracker when a website id is set.
|
||||
return (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
Boolean(process.env.REACT_APP_TIANJI_WEBSITE_ID)
|
||||
);
|
||||
};
|
||||
|
||||
export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
|
||||
if (!isAnalyticsEnvironment()) return 'granted';
|
||||
|
||||
try {
|
||||
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
|
||||
if (value === 'granted' || value === 'denied') return value;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted';
|
||||
|
||||
export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() !== 'pending';
|
||||
|
||||
export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_STORAGE_KEY, status);
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ANALYTICS_CONSENT_CHANGED_EVENT, { detail: status }),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user