Skip to content

Commit e2966dc

Browse files
Merge pull request #64 from Juliusolsson05/feat/cookies-and-posthog-analytics
feat: add privacy controls and tighten analytics
2 parents 62fda05 + 747e492 commit e2966dc

39 files changed

Lines changed: 1428 additions & 147 deletions

File tree

instrumentation-client.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import posthog from 'posthog-js';
22

3-
const posthogToken =
4-
process.env.NEXT_PUBLIC_POSTHOG_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_TOKEN;
3+
import { SHOW_COOKIE_CONTROLS } from './src/shared/config/privacy';
54

6-
if (posthogToken) {
7-
posthog.init(posthogToken, {
5+
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_TOKEN;
6+
7+
if (posthogKey) {
8+
posthog.init(posthogKey, {
89
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com',
10+
autocapture: false,
11+
capture_pageleave: true,
12+
capture_pageview: false,
913
defaults: '2026-01-30',
14+
opt_out_capturing_by_default: SHOW_COOKIE_CONTROLS,
15+
opt_out_persistence_by_default: SHOW_COOKIE_CONTROLS,
1016
person_profiles: 'identified_only',
11-
capture_pageview: false,
12-
capture_pageleave: true,
13-
autocapture: true,
14-
});
17+
} as Parameters<typeof posthog.init>[1]);
1518
}

src/app/(dashboard)/dashboard/data/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import Link from 'next/link';
44

5+
import { trackNavigationClicked } from '@/shared/lib/analytics';
6+
import { useAnalyticsLayoutMode } from '@/shared/hooks/use-analytics-layout-mode';
57
import { useIsLandscapePhone } from '@/shared/hooks/use-is-landscape-phone';
68
import { useLandscapeScrollEmitter } from '@/shared/hooks/use-landscape-scroll-emitter';
79

@@ -43,6 +45,7 @@ const DATA_SOURCES = [
4345
export default function DataIndexPage() {
4446
const isLandscapePhone = useIsLandscapePhone();
4547
const onLandscapeScroll = useLandscapeScrollEmitter(isLandscapePhone);
48+
const layoutMode = useAnalyticsLayoutMode();
4649

4750
return (
4851
<div
@@ -60,6 +63,16 @@ export default function DataIndexPage() {
6063
<Link
6164
key={source.label}
6265
href={source.href}
66+
onClick={() => {
67+
trackNavigationClicked({
68+
component: 'source_card',
69+
data_source_id: source.href.replace('/dashboard/data/', ''),
70+
destination_path: source.href,
71+
layout_mode: layoutMode,
72+
pathname: '/dashboard/data',
73+
surface: 'dashboard_data',
74+
});
75+
}}
6376
className={`
6477
no-underline block p-5 border transition-colors
6578
${source.status === 'LIVE'

src/app/api/v1/chat/route.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ async function readBody(req: NextRequest): Promise<ChatBody | Response> {
2121
}
2222
}
2323

24-
async function resolveSession(conflictId: string) {
25-
const visitor = await resolveAnonymousVisitor();
24+
function readVisitorOptions(req: NextRequest) {
25+
return {
26+
persistVisitor: req.headers.get('x-pharos-persist-visitor') !== '0',
27+
visitorToken: req.headers.get('x-pharos-visitor-token'),
28+
};
29+
}
30+
31+
async function resolveSession(req: NextRequest, conflictId: string) {
32+
const visitor = await resolveAnonymousVisitor(readVisitorOptions(req));
2633
const session = await getOrCreateChatSession(conflictId, visitor.id);
2734
return { session, visitor };
2835
}
@@ -37,7 +44,7 @@ export async function GET(req: NextRequest) {
3744
const invalid = validateConflictId(conflictId);
3845
if (invalid) return invalid;
3946

40-
const { session } = await resolveSession(conflictId as string);
47+
const { session } = await resolveSession(req, conflictId as string);
4148
const messages = await listChatMessages(session.id);
4249
return ok({ sessionId: session.id, messages });
4350
}
@@ -47,7 +54,7 @@ export async function DELETE(req: NextRequest) {
4754
const invalid = validateConflictId(conflictId);
4855
if (invalid) return invalid;
4956

50-
const visitor = await resolveAnonymousVisitor();
57+
const visitor = await resolveAnonymousVisitor(readVisitorOptions(req));
5158
const deletedSessionId = await clearCurrentChatSession(conflictId as string, visitor.id);
5259
return ok({ deletedSessionId });
5360
}
@@ -71,7 +78,7 @@ export async function POST(req: NextRequest) {
7178
return err('NOT_FOUND', `Conflict ${conflictId} not found`, 404);
7279
}
7380

74-
const { session } = await resolveSession(conflictId);
81+
const { session } = await resolveSession(req, conflictId);
7582
await appendChatMessage(session.id, ChatMessageRole.USER, input);
7683
const messages = await listChatMessages(session.id);
7784

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ok } from '@/server/lib/api-utils';
2+
import { clearAnonymousVisitorCookie } from '@/server/lib/chat/visitor';
3+
4+
export async function DELETE() {
5+
await clearAnonymousVisitorCookie();
6+
return ok({ cleared: true });
7+
}

src/app/cookies/page.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { LegalPage } from '@/features/legal/components/LegalPage';
2+
import { OpenCookiePreferencesButton } from '@/shared/components/privacy/OpenCookiePreferencesButton';
3+
4+
export default function CookiesPage() {
5+
return (
6+
<LegalPage
7+
title="Cookie Policy"
8+
description="What storage Conflicts.app uses, which categories are necessary, and how to manage consent."
9+
>
10+
<section className="flex flex-col gap-3">
11+
<h2 className="text-xl font-semibold text-[var(--t1)]">Storage categories</h2>
12+
<p>
13+
We separate storage into necessary functionality, optional preferences, and optional analytics. Necessary storage stays enabled because the product depends on it.
14+
Preference storage and analytics may be controlled separately where consent controls are available.
15+
</p>
16+
<div>
17+
<OpenCookiePreferencesButton
18+
label="Manage cookie settings"
19+
className="border-[var(--bd)] bg-[var(--bg-2)] text-[var(--t1)] hover:bg-[var(--bg-3)]"
20+
/>
21+
</div>
22+
</section>
23+
24+
<section className="flex flex-col gap-3">
25+
<h2 className="text-xl font-semibold text-[var(--t1)]">Necessary</h2>
26+
<p>
27+
Necessary storage is limited to core request handling, security, and basic application delivery.
28+
It does not include remembered preferences, analytics, or optional chat continuity.
29+
</p>
30+
</section>
31+
32+
<section className="flex flex-col gap-3">
33+
<h2 className="text-xl font-semibold text-[var(--t1)]">Preferences</h2>
34+
<p>
35+
Preference storage covers remembered chat continuity, dashboard layout persistence, map preferences, and similar convenience features.
36+
If optional storage is turned off, those features can still work in-session but may no longer be remembered on device.
37+
</p>
38+
</section>
39+
40+
<section className="flex flex-col gap-3">
41+
<h2 className="text-xl font-semibold text-[var(--t1)]">Analytics</h2>
42+
<p>
43+
When accepted, PostHog and Vercel Analytics help us understand product usage patterns such as page views, navigation flow,
44+
and feature engagement. We use this information to improve performance, prioritize product work, and verify adoption of new features.
45+
</p>
46+
</section>
47+
48+
<section className="flex flex-col gap-3">
49+
<h2 className="text-xl font-semibold text-[var(--t1)]">Changing your decision</h2>
50+
<p>
51+
When cookie controls are enabled in the product, you can reopen settings from the dashboard menu, public footer, or the button on this page.
52+
If optional storage is turned off later, future analytics capture is disabled and remembered preference storage is cleared where supported.
53+
</p>
54+
</section>
55+
</LegalPage>
56+
);
57+
}

src/app/layout.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { Analytics } from '@vercel/analytics/next';
21
import type { Metadata, Viewport } from 'next';
32

43
import { Toaster } from '@/components/ui/sonner';
54

65
import { SITE_URL } from '@/features/browse/constants';
6+
import { CookieConsentProvider } from '@/shared/components/privacy/CookieConsentProvider';
77

8-
import { PostHogPageView } from '@/shared/lib/posthog-provider';
98
import { QueryProvider } from '@/shared/lib/query-provider';
109
import { ReduxProvider } from '@/shared/state/redux-provider';
1110

@@ -56,14 +55,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
5655
return (
5756
<html lang="en">
5857
<body>
59-
<PostHogPageView />
60-
<ReduxProvider>
61-
<QueryProvider>
62-
{children}
63-
<Toaster theme="dark" position="bottom-right" />
64-
</QueryProvider>
65-
</ReduxProvider>
66-
<Analytics />
58+
<CookieConsentProvider>
59+
<ReduxProvider>
60+
<QueryProvider>
61+
{children}
62+
<Toaster theme="dark" position="bottom-right" />
63+
</QueryProvider>
64+
</ReduxProvider>
65+
</CookieConsentProvider>
6766
</body>
6867
</html>
6968
);

src/app/privacy/page.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { LegalPage } from '@/features/legal/components/LegalPage';
2+
3+
export default function PrivacyPage() {
4+
return (
5+
<LegalPage
6+
title="Privacy Policy"
7+
description="How Conflicts.app handles analytics, chat continuity, and local device storage."
8+
>
9+
<section className="flex flex-col gap-3">
10+
<h2 className="text-xl font-semibold text-[var(--t1)]">Overview</h2>
11+
<p>
12+
Conflicts.app uses a small amount of device storage to keep core product behavior working and, if you allow it,
13+
to understand product usage through analytics. We aim to keep this collection limited and tied to improving the product.
14+
</p>
15+
</section>
16+
17+
<section className="flex flex-col gap-3">
18+
<h2 className="text-xl font-semibold text-[var(--t1)]">Necessary storage</h2>
19+
<p>
20+
Conflicts.app uses a small amount of strictly necessary storage for core request handling, security, and basic app delivery.
21+
This category does not include optional remembered preferences or analytics.
22+
</p>
23+
</section>
24+
25+
<section className="flex flex-col gap-3">
26+
<h2 className="text-xl font-semibold text-[var(--t1)]">Preference storage</h2>
27+
<p>
28+
Preference storage covers optional remembered behavior like anonymous chat continuity, workspace layout persistence,
29+
map UI preferences, and dismissible interface state. These improve convenience, but they are separate from strictly necessary storage.
30+
</p>
31+
</section>
32+
33+
<section className="flex flex-col gap-3">
34+
<h2 className="text-xl font-semibold text-[var(--t1)]">Analytics</h2>
35+
<p>
36+
If you accept analytics cookies, we enable PostHog and Vercel Analytics to measure page views and feature usage.
37+
This helps us understand what users open, which views are most useful, and where product friction exists after releases.
38+
</p>
39+
<p>
40+
We use analytics to improve navigation, prioritize fixes, and understand which features are being actively used.
41+
If you reject analytics, those tools remain disabled in your browser.
42+
</p>
43+
</section>
44+
45+
<section className="flex flex-col gap-3">
46+
<h2 className="text-xl font-semibold text-[var(--t1)]">Local storage and cookies</h2>
47+
<p>
48+
Conflicts.app uses both browser local storage and cookies. Local storage is used for interface state such as layouts
49+
and filters. A chat visitor cookie may be used when remembered chat continuity is enabled. Analytics storage is separate from preference storage.
50+
</p>
51+
</section>
52+
53+
<section className="flex flex-col gap-3">
54+
<h2 className="text-xl font-semibold text-[var(--t1)]">Your choices</h2>
55+
<p>
56+
Where consent controls are enabled, you can accept or reject optional storage and revisit those choices later.
57+
In environments where those controls are not currently shown, the app may operate with analytics and preference storage enabled by default.
58+
You can also review our dedicated Cookie Policy for a more explicit breakdown of storage categories.
59+
</p>
60+
</section>
61+
</LegalPage>
62+
);
63+
}

src/app/terms/page.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { LegalPage } from '@/features/legal/components/LegalPage';
2+
3+
export default function TermsPage() {
4+
return (
5+
<LegalPage
6+
title="Terms of Use"
7+
description="Baseline terms for using Conflicts.app, its public intelligence views, and the open-source product."
8+
>
9+
<section className="flex flex-col gap-3">
10+
<h2 className="text-xl font-semibold text-[var(--t1)]">Use of the service</h2>
11+
<p>
12+
Conflicts.app provides geopolitical intelligence, summaries, event timelines, and related research tooling.
13+
You may use the service for lawful research, monitoring, and product evaluation purposes.
14+
</p>
15+
</section>
16+
17+
<section className="flex flex-col gap-3">
18+
<h2 className="text-xl font-semibold text-[var(--t1)]">No operational guarantee</h2>
19+
<p>
20+
The product is informational and may contain delays, gaps, or evolving assessments. It should not be treated as guaranteed,
21+
real-time operational advice, investment advice, or life-safety guidance.
22+
</p>
23+
</section>
24+
25+
<section className="flex flex-col gap-3">
26+
<h2 className="text-xl font-semibold text-[var(--t1)]">Open-source and content</h2>
27+
<p>
28+
Conflicts.app includes open-source code and references external reporting, datasets, and public information sources.
29+
Rights to third-party content remain with their respective owners.
30+
</p>
31+
</section>
32+
33+
<section className="flex flex-col gap-3">
34+
<h2 className="text-xl font-semibold text-[var(--t1)]">Changes</h2>
35+
<p>
36+
We may update the product, these terms, or related legal pages as the platform evolves. Continued use after updates means you accept the revised terms.
37+
</p>
38+
</section>
39+
</LegalPage>
40+
);
41+
}

src/features/actors/components/ActorsContent.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,17 @@ export function ActorsContent() {
7777
<DaySelector currentDay={currentDay} onDayChange={setDay} />
7878
</div>
7979
</div>
80-
<ActorDossier actor={selected} tab={tab} onTabChange={setTab} currentDay={currentDay} compact={usePageScroll} pageScroll={usePageScroll} />
80+
<ActorDossier
81+
actor={selected}
82+
tab={tab}
83+
onTabChange={nextTab => {
84+
setTab(nextTab);
85+
track('actor_tab_changed', { actor_id: selected.id, tab: nextTab });
86+
}}
87+
currentDay={currentDay}
88+
compact={usePageScroll}
89+
pageScroll={usePageScroll}
90+
/>
8191
</>
8292
) : (
8393
<ActorList

src/features/brief/components/BriefContent.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { BriefScreenSkeleton } from '@/shared/components/loading/screen-skeleton
1010
import { DaySelector } from '@/shared/components/shared/DaySelector';
1111
import { Flag } from '@/shared/components/shared/Flag';
1212

13+
import { getAnalyticsLayoutMode, trackBriefViewChanged } from '@/shared/lib/analytics';
1314
import { useConflictDay } from '@/shared/hooks/use-conflict-day';
1415
import { useIsLandscapePhone } from '@/shared/hooks/use-is-landscape-phone';
16+
import { useIsMobile } from '@/shared/hooks/use-is-mobile';
1517
import { useLandscapeScrollEmitter } from '@/shared/hooks/use-landscape-scroll-emitter';
1618

1719
import { ACT_C, STA_C } from '@/data/iran-actors';
@@ -23,7 +25,9 @@ export function BriefContent() {
2325
const { data: snapshot, isLoading: snapshotLoading } = useConflictDaySnapshot(undefined, currentDay || undefined);
2426
const { data: actors, isLoading: actorsLoading } = useActors(undefined, currentDay || undefined);
2527
const isLandscapePhone = useIsLandscapePhone();
28+
const isMobile = useIsMobile(1024);
2629
const onLandscapeScroll = useLandscapeScrollEmitter(isLandscapePhone);
30+
const layoutMode = getAnalyticsLayoutMode({ isLandscapePhone, isMobile });
2731

2832
const majorActors = actors?.filter(a => MAJOR_IDS.includes(a.id)) ?? [];
2933

@@ -52,7 +56,22 @@ export function BriefContent() {
5256
<span className="mono text-[10px] text-[var(--t3)]">DAY {dayIndex + 1} OF OPERATIONS</span>
5357
</div>
5458
<div className="flex justify-center">
55-
<DaySelector currentDay={currentDay} onDayChange={setDay} />
59+
<DaySelector
60+
currentDay={currentDay}
61+
onDayChange={day => {
62+
if (day === currentDay) return;
63+
64+
setDay(day);
65+
trackBriefViewChanged({
66+
control: 'day',
67+
day,
68+
layout_mode: layoutMode,
69+
pathname: '/dashboard/brief',
70+
surface: 'dashboard_brief',
71+
value: day,
72+
});
73+
}}
74+
/>
5675
</div>
5776
</div>
5877

0 commit comments

Comments
 (0)