## Summary Closes #35. - **Flip deploy workflows** (mirror `dta_webapp`): push/merge to `master` → auto-deploy **beta**; **Deploy Prod** is `workflow_dispatch` only - **Tianji**: `REACT_APP_TIANJI_WEBSITE_ID` + `REACT_APP_DEPLOY_ENV` in `.env.production` / `.env.beta`; Tracker loads page views always on prod/beta - **Consent policy**: page views always live; custom events / identify gated via `analyticsConsent` + `AnalyticsConsentBanner` + `trackEvent`/`identifyUser` helpers (call sites remain #36) - Remove invalid Tianji `<script>` from `index.tsx` (Tracker component owns injection) - README documents beta auto / prod button + Tianji policy ## Ops note — beta Tianji website ID Prod ID unchanged: `cm7x7m52m03kbddswbswrt17y`. `.env.beta` has `REACT_APP_TIANJI_WEBSITE_ID=` empty until a **Hesychia Beta** website is created in Tianji (workspace `cm7w8087y020lddswyhamadj2`). Paste the new id into `.env.beta` before beta page views show under a distinct site. ## Test plan - [x] `npm run test:ci` (106 tests) - [ ] Create Hesychia Beta Tianji website; set `REACT_APP_TIANJI_WEBSITE_ID` in `.env.beta` - [ ] Merge to master → Deploy Beta runs; prod does **not** auto-deploy - [ ] Manual Run workflow on Deploy Prod still works - [ ] Beta build hits `beta.chatbackend` REST/WS; static root `/var/www/beta.chat.aimloperations/html` (8083) - [ ] Prod/beta: tracker.js loads without accepting consent; banner shows until Accept/Decline; `trackEvent` only fires after AcceptReviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
name: Deploy Beta
|
name: Deploy Beta
|
||||||
|
|
||||||
|
# Auto-deploy beta after push to master (mirrors dta_webapp).
|
||||||
on:
|
on:
|
||||||
workflow_dispatch: {}
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit-tests:
|
unit-tests:
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
name: Deploy Prod
|
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:
|
on:
|
||||||
workflow_run:
|
workflow_dispatch: {}
|
||||||
workflows: [Unit Tests]
|
|
||||||
types: [completed]
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
unit-tests:
|
||||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
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
|
runs-on: self-hosted
|
||||||
env:
|
env:
|
||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
@@ -19,4 +35,4 @@ jobs:
|
|||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
--app chat_web_app \
|
--app chat_web_app \
|
||||||
--env prod \
|
--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) |
|
| File | Used when | Backend (current) |
|
||||||
|------|-----------|-------------------|
|
|------|-----------|-------------------|
|
||||||
| `.env.development` | `npm start` | `http://127.0.0.1:8001` |
|
| `.env.development` | `npm start` | `http://127.0.0.1:8001` |
|
||||||
| `.env.production` | `npm run build` / `build:prod` | `https://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` |
|
| `.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) |
|
| `.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):
|
Required keys (already set in the committed env files):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -184,20 +186,22 @@ npm run test:ci # one-shot (matches Gitea Unit Tests)
|
|||||||
|
|
||||||
## CI / deployment
|
## CI / deployment
|
||||||
|
|
||||||
Gitea Actions workflows in `.gitea/workflows/`:
|
Gitea Actions workflows in `.gitea/workflows/` (same pattern as `dta_webapp`):
|
||||||
|
|
||||||
| Workflow | Trigger | Behavior |
|
| Workflow | Trigger | Behavior |
|
||||||
|----------|---------|----------|
|
|----------|---------|----------|
|
||||||
| `unit-tests.yml` | push/PR to `master` | `npm ci` + `npm run test:ci` in `llm-fe/` |
|
| `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` | **push** to `master` (after unit tests in-workflow) | `server-infra/scripts/deploy.sh --app chat_web_app --env beta --ref <sha>` |
|
||||||
| `deploy-beta.yml` | `workflow_dispatch` | tests, then deploy `--env beta` |
|
| `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`):
|
Deploy flow (on each webserver listed in `host_apps`):
|
||||||
|
|
||||||
1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_<env>`
|
1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_<env>`
|
||||||
2. `npm ci` in `llm-fe/`
|
2. `npm ci` in `llm-fe/`
|
||||||
3. `npm run build:<env>` → writes the static tree under `/var/www/...`
|
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:
|
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:
|
NPM (not managed by Ansible) should balance public Hesychia domains to:
|
||||||
|
|
||||||
- `adama:8082` + `roslin:8082` + `ai-server-4080:8082` (prod)
|
- `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).
|
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
|
## Related repos
|
||||||
|
|
||||||
- **Backend:** [chat_backend](https://git.aimloperations.com/ai_ml_operations/chat_backend) — Django API + WebSocket
|
- **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
|
# Beta API hosts (current). Target public product domain: hesychia.ai
|
||||||
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
|
# 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_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/
|
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=cms38bw671mf9n5jjw3xp1j3q
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# Production API hosts (current). Target public product domain: hesychia.ai
|
# Production API hosts (current). Target public product domain: hesychia.ai
|
||||||
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
|
# 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_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
|
||||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
|
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 PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset';
|
||||||
import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper';
|
import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper';
|
||||||
import Tracker from './llm-fe/components/Tracker/Tracker';
|
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 ProtectedRoutes = () => {
|
||||||
const { authenticated, loading } = useContext(AuthContext);
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
@@ -40,7 +42,9 @@ class App extends Component {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<GlobalThemeWrapper>
|
<GlobalThemeWrapper>
|
||||||
|
<AnalyticsConsentProvider>
|
||||||
<Tracker />
|
<Tracker />
|
||||||
|
<AnalyticsConsentBanner />
|
||||||
<div className='site'>
|
<div className='site'>
|
||||||
<main>
|
<main>
|
||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
@@ -76,6 +80,7 @@ class App extends Component {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</AnalyticsConsentProvider>
|
||||||
</GlobalThemeWrapper>
|
</GlobalThemeWrapper>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const root = ReactDOM.createRoot(
|
|||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AppRouter>
|
<AppRouter>
|
||||||
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AccountProvider>
|
<AccountProvider>
|
||||||
<WebSocketProvider>
|
<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 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 = () => {
|
const Tracker: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (!isAnalyticsEnvironment()) return;
|
||||||
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');
|
|
||||||
|
|
||||||
document.body.appendChild(script);
|
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
|
||||||
|
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
|
||||||
|
|
||||||
return () => {
|
const script = document.createElement('script');
|
||||||
document.body.removeChild(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;
|
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