diff --git a/app/settings/account/page.tsx b/app/settings/account/page.tsx
index 7b7cefec..dff3ba9e 100644
--- a/app/settings/account/page.tsx
+++ b/app/settings/account/page.tsx
@@ -27,6 +27,7 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
+import { useConsentStore } from "@/app/state/consent"
// ─────────────────────────────────────────────────────────────
// Types
@@ -47,7 +48,8 @@ export default function AccountSettingsPage() {
// Privacy controls
const [publicProfile, setPublicProfile] = useState(true)
const [showActivity, setShowActivity] = useState(true)
- const [allowAnalytics, setAllowAnalytics] = useState(false)
+ const analyticsConsent = useConsentStore((s) => s.analyticsConsent)
+ const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent)
const [showLeaderboard, setShowLeaderboard] = useState(true)
// Save state for live-region announcement
@@ -288,8 +290,8 @@ export default function AccountSettingsPage() {
id="allow-analytics"
label="Allow usage analytics"
description="Help improve Predictify by sharing anonymised interaction data. No wallet data is included."
- checked={allowAnalytics}
- onCheckedChange={setAllowAnalytics}
+ checked={analyticsConsent}
+ onCheckedChange={setAnalyticsConsent}
/>
diff --git a/app/settings/privacy/__tests__/privacy.a11y.test.tsx b/app/settings/privacy/__tests__/privacy.a11y.test.tsx
index e586950b..32184fe1 100644
--- a/app/settings/privacy/__tests__/privacy.a11y.test.tsx
+++ b/app/settings/privacy/__tests__/privacy.a11y.test.tsx
@@ -1,9 +1,21 @@
import React from "react"
-import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import PrivacySettingsPage from "../page"
+import { useConsentStore } from "@/app/state/consent"
describe("Settings → Privacy page", () => {
+ beforeEach(() => {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: false })
+ })
+ localStorage.clear()
+ })
+
+ afterEach(() => {
+ localStorage.clear()
+ })
+
it("renders the page heading and description", () => {
render()
@@ -114,8 +126,10 @@ describe("Settings → Privacy page", () => {
it("each switch row displays an icon and a badge in the summary card", () => {
render()
- const summaryBadges = screen.getAllByText(/visible|hidden/i)
- expect(summaryBadges.length).toBe(5)
+ // Anchored to the exact badge values so surrounding copy (e.g. "...information is
+ // visible to other users...") can never inflate the count.
+ const summaryBadges = screen.getAllByText(/^(visible|hidden)$/i)
+ expect(summaryBadges).toHaveLength(5)
})
it("all switches have unique ids matching their labels", () => {
@@ -129,4 +143,20 @@ describe("Settings → Privacy page", () => {
expect(label).toBeInTheDocument()
})
})
+
+ it("the analytics toggle is backed by the consent store (single source of truth)", () => {
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+
+ render()
+
+ const analytics = screen.getByRole("switch", { name: /allow usage analytics/i })
+ fireEvent.click(analytics)
+
+ expect(useConsentStore.getState().analyticsConsent).toBe(true)
+ expect(analytics).toBeChecked()
+
+ fireEvent.click(analytics)
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+ expect(analytics).not.toBeChecked()
+ })
})
\ No newline at end of file
diff --git a/app/settings/privacy/page.tsx b/app/settings/privacy/page.tsx
index bb6d3d20..3694873d 100644
--- a/app/settings/privacy/page.tsx
+++ b/app/settings/privacy/page.tsx
@@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
+import { useConsentStore } from "@/app/state/consent"
type SaveState = "idle" | "saving" | "saved" | "error"
@@ -24,7 +25,8 @@ export default function PrivacySettingsPage() {
const [showActivity, setShowActivity] = useState(true)
const [showLeaderboard, setShowLeaderboard] = useState(true)
const [showBalance, setShowBalance] = useState(true)
- const [allowAnalytics, setAllowAnalytics] = useState(false)
+ const analyticsConsent = useConsentStore((s) => s.analyticsConsent)
+ const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent)
const [saveState, setSaveState] = useState("idle")
@@ -75,8 +77,8 @@ export default function PrivacySettingsPage() {
icon: Eye,
label: "Allow usage analytics",
description: "Help improve Predictify by sharing anonymised interaction data. No wallet data is included.",
- checked: allowAnalytics,
- onChange: setAllowAnalytics,
+ checked: analyticsConsent,
+ onChange: setAnalyticsConsent,
},
]
@@ -211,7 +213,7 @@ export default function PrivacySettingsPage() {
setShowActivity(true)
setShowLeaderboard(true)
setShowBalance(true)
- setAllowAnalytics(false)
+ setAnalyticsConsent(false)
}}
>
Reset defaults
diff --git a/app/state/__tests__/consent.test.ts b/app/state/__tests__/consent.test.ts
new file mode 100644
index 00000000..c8207287
--- /dev/null
+++ b/app/state/__tests__/consent.test.ts
@@ -0,0 +1,127 @@
+/**
+ * consent.test.ts
+ *
+ * Unit tests for the useConsentStore (app/state/consent.ts).
+ * Covers: default state, explicit opt-in/opt-out, strict boolean coercion,
+ * persistence, and boundary/invalid inputs.
+ */
+
+import { act } from "@testing-library/react"
+import {
+ useConsentStore,
+ getAnalyticsConsent,
+ parseConsent,
+ CONSENT_STORAGE_KEY,
+} from "../consent"
+
+/** Reset store to the optimistic default between tests. */
+function resetStore() {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: false })
+ })
+}
+
+describe("useConsentStore", () => {
+ beforeEach(() => {
+ resetStore()
+ localStorage.clear()
+ })
+
+ afterEach(() => {
+ localStorage.clear()
+ })
+
+ // ── Initial state ──────────────────────────────────────────────────────────
+
+ it("starts with telemetry consent disabled (optimistic default)", () => {
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+ expect(getAnalyticsConsent()).toBe(false)
+ })
+
+ // ── setAnalyticsConsent ────────────────────────────────────────────────────
+
+ it("setAnalyticsConsent(true) opt-in is persisted", () => {
+ act(() => useConsentStore.getState().setAnalyticsConsent(true))
+ expect(useConsentStore.getState().analyticsConsent).toBe(true)
+ expect(getAnalyticsConsent()).toBe(true)
+ })
+
+ it("setAnalyticsConsent(false) opt-out is respected", () => {
+ act(() => useConsentStore.getState().setAnalyticsConsent(true))
+ act(() => useConsentStore.getState().setAnalyticsConsent(false))
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+ })
+
+ it("coerces non-boolean opt-in values to false (no telemetry without explicit consent)", () => {
+ // @ts-expect-error – intentionally passing invalid runtime input
+ act(() => useConsentStore.getState().setAnalyticsConsent("yes"))
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+
+ // @ts-expect-error – intentionally passing invalid runtime input
+ act(() => useConsentStore.getState().setAnalyticsConsent(1))
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+
+ // @ts-expect-error – intentionally passing invalid runtime input
+ act(() => useConsentStore.getState().setAnalyticsConsent(null))
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+ })
+
+ // ── toggleAnalyticsConsent ─────────────────────────────────────────────────
+
+ it("toggleAnalyticsConsent flips state and returns the new value", () => {
+ let result1: boolean
+ act(() => {
+ result1 = useConsentStore.getState().toggleAnalyticsConsent()
+ })
+ expect(result1!).toBe(true)
+ expect(useConsentStore.getState().analyticsConsent).toBe(true)
+
+ let result2: boolean
+ act(() => {
+ result2 = useConsentStore.getState().toggleAnalyticsConsent()
+ })
+ expect(result2!).toBe(false)
+ expect(useConsentStore.getState().analyticsConsent).toBe(false)
+ })
+
+ // ── persistence ────────────────────────────────────────────────────────────
+
+ it("persists opt-in consent to localStorage under the consent key", () => {
+ act(() => useConsentStore.getState().setAnalyticsConsent(true))
+
+ const stored = localStorage.getItem(CONSENT_STORAGE_KEY)
+ expect(stored).toBeTruthy()
+ const parsed = JSON.parse(stored!)
+ expect(parsed.state.analyticsConsent).toBe(true)
+ })
+
+ it("persists opt-out so telemetry stays disabled across reloads", () => {
+ act(() => useConsentStore.getState().setAnalyticsConsent(true))
+ act(() => useConsentStore.getState().setAnalyticsConsent(false))
+
+ const stored = localStorage.getItem(CONSENT_STORAGE_KEY)
+ const parsed = JSON.parse(stored!)
+ expect(parsed.state.analyticsConsent).toBe(false)
+ })
+
+ // ── parseConsent boundary inputs ───────────────────────────────────────────
+
+ it("parseConsent accepts only the literal boolean true", () => {
+ expect(parseConsent(true)).toBe(true)
+ expect(parseConsent(false)).toBe(false)
+ })
+
+ it("parseConsent rejects every coercion-flavoured value", () => {
+ expect(parseConsent("true")).toBe(false)
+ expect(parseConsent("1")).toBe(false)
+ expect(parseConsent(1)).toBe(false)
+ expect(parseConsent(0)).toBe(false)
+ expect(parseConsent("")).toBe(false)
+ expect(parseConsent(null)).toBe(false)
+ expect(parseConsent(undefined)).toBe(false)
+ expect(parseConsent([])).toBe(false)
+ expect(parseConsent({})).toBe(false)
+ expect(parseConsent("TRUE")).toBe(false)
+ expect(parseConsent(1 as unknown)).toBe(false)
+ })
+})
\ No newline at end of file
diff --git a/app/state/consent.ts b/app/state/consent.ts
new file mode 100644
index 00000000..146bab9f
--- /dev/null
+++ b/app/state/consent.ts
@@ -0,0 +1,99 @@
+/**
+ * consent.ts
+ *
+ * Client-side store for the user's usage-analytics consent preference.
+ * Gated in optimistic-default style: consent is `false` until the user
+ * explicitly opts in from the privacy settings page. All recommendation
+ * telemetry is suppressed while consent is `false`.
+ *
+ * Invariants
+ * ----------
+ * - `analyticsConsent` is a strict boolean. Any invalid value written to
+ * localStorage (e.g. a corrupted payload) is coerced via `parseConsent`
+ * so it can never put the store into an inconsistent state.
+ * - Consent defaults to `false` ("no telemetry") and can only become `true`
+ * through an explicit user action (`setAnalyticsConsent`).
+ * - Reads and writes are failure-tolerant: a blocked or throwing
+ * localStorage never crashes the app — telemetry simply stays disabled.
+ */
+
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
+
+export const CONSENT_STORAGE_KEY = "predictify-consent";
+
+interface ConsentState {
+ /** Whether the user has opted in to usage analytics. */
+ analyticsConsent: boolean;
+ /** Set the consent value. Call from an explicit opt-in/opt-out UI action. */
+ setAnalyticsConsent: (consent: boolean) => void;
+ /** Toggle consent; returns the new value. */
+ toggleAnalyticsConsent: () => boolean;
+}
+
+/**
+ * Coerce an unknown persisted value into a strict boolean. Anything that is
+ * not exactly `true` maps to `false` so the optimistic-default invariant
+ * always holds, even against corrupted or malicious localStorage payloads.
+ */
+export function parseConsent(value: unknown): boolean {
+ return value === true;
+}
+
+/**
+ * A no-op storage used on the server (or when localStorage is unavailable)
+ * so persistence never throws during SSR or privacy-restricted sessions.
+ */
+const noopStorage = {
+ getItem: () => null,
+ setItem: () => {},
+ removeItem: () => {},
+};
+
+function createSafeStorage(): ReturnType {
+ if (typeof window === "undefined") {
+ return createJSONStorage(() => noopStorage);
+ }
+ try {
+ return createJSONStorage(() => window.localStorage);
+ } catch {
+ return createJSONStorage(() => noopStorage);
+ }
+}
+
+export const useConsentStore = create()(
+ persist(
+ (set, get) => ({
+ analyticsConsent: false,
+
+ setAnalyticsConsent: (consent) =>
+ set({ analyticsConsent: parseConsent(consent) }),
+
+ toggleAnalyticsConsent: () => {
+ const next = !get().analyticsConsent;
+ set({ analyticsConsent: next });
+ return next;
+ },
+ }),
+ {
+ name: CONSENT_STORAGE_KEY,
+ storage: createSafeStorage(),
+ merge: (persisted, current) => ({
+ ...current,
+ ...(persisted as Partial),
+ // Enforce the strict-boolean invariant on any persisted payload so a
+ // stale or corrupted value can never turn telemetry on without an
+ // explicit opt-in.
+ analyticsConsent: parseConsent(
+ (persisted as Partial | undefined)?.analyticsConsent
+ ),
+ }),
+ partialize: (state) => ({ analyticsConsent: state.analyticsConsent }),
+ }
+ )
+);
+
+/** Non-hook accessor for reads outside React (e.g. before render). */
+export function getAnalyticsConsent(): boolean {
+ return parseConsent(useConsentStore.getState().analyticsConsent);
+}
\ No newline at end of file
diff --git a/components/dashboard/RecommendationsStrip.tsx b/components/dashboard/RecommendationsStrip.tsx
index d5ad41d0..6a05b575 100644
--- a/components/dashboard/RecommendationsStrip.tsx
+++ b/components/dashboard/RecommendationsStrip.tsx
@@ -13,6 +13,11 @@ import {
getTopCategories,
type RecommendedMarket,
} from "@/lib/recommendations"
+import {
+ trackRecommendation,
+ trackRecommendationImpression,
+} from "@/lib/telemetry"
+import { useConsentStore } from "@/app/state/consent"
import { mockActiveBets } from "@/lib/mock-data"
import type { Bet, CategoryColor } from "@/lib/types"
@@ -28,10 +33,19 @@ interface RecommendationsStripProps {
* category chip — this is a lower-commitment discovery surface, not a bet
* the user is tracking.
*/
-function RecommendationCard({ market }: { market: RecommendedMarket }) {
+function RecommendationCard({
+ market,
+ position,
+ onSelect,
+}: {
+ market: RecommendedMarket
+ position: number
+ onSelect: (market: RecommendedMarket, position: number) => void
+}) {
return (
onSelect(market, position)}
className={cn(
"flex-shrink-0 snap-start rounded-xl border border-border/30 bg-card/20 p-4",
"w-[calc(100vw-3rem)] max-w-[260px] sm:w-[260px]",
@@ -108,10 +122,33 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom
const scrollContainerRef = useRef(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
+ const analyticsConsent = useConsentStore((s) => s.analyticsConsent)
const categories = manualCategories ?? getTopCategories(bets)
const markets = useMemo(() => getRecommendedMarkets(categories), [categories])
+ // Consent-gated impression telemetry: fires once per rendered market set.
+ // When consent is revoked, no further events are emitted.
+ useEffect(() => {
+ if (!analyticsConsent) return
+ markets.forEach((market, index) => {
+ trackRecommendationImpression({
+ marketId: market.id,
+ category: market.category,
+ position: index,
+ })
+ })
+ }, [markets, analyticsConsent])
+
+ const handleSelectMarket = (market: RecommendedMarket, position: number) => {
+ trackRecommendation({
+ type: "recommendation_click",
+ marketId: market.id,
+ category: market.category,
+ position,
+ })
+ }
+
const updateScrollState = () => {
const el = scrollContainerRef.current
if (!el) return
@@ -184,8 +221,13 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom
role="region"
aria-label="Markets you might like carousel"
>
- {markets.map((market) => (
-
+ {markets.map((market, index) => (
+
))}
diff --git a/components/dashboard/__tests__/RecommendationsStrip.test.tsx b/components/dashboard/__tests__/RecommendationsStrip.test.tsx
index fa9d4085..8c10fd2a 100644
--- a/components/dashboard/__tests__/RecommendationsStrip.test.tsx
+++ b/components/dashboard/__tests__/RecommendationsStrip.test.tsx
@@ -1,6 +1,8 @@
import React from "react"
-import { render, screen, fireEvent } from "@testing-library/react"
+import { render, screen, fireEvent, act } from "@testing-library/react"
import { RecommendationsStrip } from "../RecommendationsStrip"
+import { setTelemetrySink } from "@/lib/telemetry"
+import { useConsentStore } from "@/app/state/consent"
import type { Bet } from "@/lib/types"
function betIn(color: Bet["category"]["color"]): Pick {
@@ -94,4 +96,92 @@ describe("RecommendationsStrip", () => {
expect(screen.getByRole("region", { name: /carousel/i })).toHaveAttribute("tabIndex", "0")
})
})
+
+ describe("consent-gated telemetry", () => {
+ const sink = jest.fn()
+
+ beforeEach(() => {
+ sink.mockClear()
+ setTelemetrySink(sink)
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: false })
+ })
+ })
+
+ afterEach(() => {
+ setTelemetrySink(null)
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: false })
+ })
+ })
+
+ it("emits no telemetry while analytics consent is disabled", () => {
+ render()
+ expect(screen.getByRole("region", { name: /carousel/i })).toBeInTheDocument()
+ expect(sink).not.toHaveBeenCalled()
+ })
+
+ it("emits one impression per recommended market after consent is granted", () => {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: true })
+ })
+ render()
+
+ const impressions = sink.mock.calls.filter(
+ ([event]) => event.type === "recommendation_impression"
+ )
+ expect(impressions.length).toBeGreaterThan(0)
+ impressions.forEach(([event]) => {
+ expect(event).toMatchObject({
+ type: "recommendation_impression",
+ category: "crypto",
+ })
+ expect(typeof event.marketId).toBe("string")
+ expect(event.marketId.length).toBeGreaterThan(0)
+ expect(event.sessionId).toBeTruthy()
+ })
+ })
+
+ it("emits a click event with position when a recommendation card is selected", () => {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: true })
+ })
+ render()
+
+ const card = screen.getAllByRole("link")[0]
+ fireEvent.click(card)
+
+ const clickEvents = sink.mock.calls.filter(
+ ([event]) => event.type === "recommendation_click"
+ )
+ expect(clickEvents.length).toBe(1)
+ expect(clickEvents[0][0]).toMatchObject({
+ type: "recommendation_click",
+ category: "crypto",
+ position: 0,
+ })
+ expect(clickEvents[0][0].marketId).toBeTruthy()
+ })
+
+ it("stops emitting impressions once consent is revoked", () => {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: true })
+ })
+ const { unmount } = render()
+ const impressionsBefore = sink.mock.calls.filter(
+ ([event]) => event.type === "recommendation_impression"
+ )
+ expect(impressionsBefore.length).toBeGreaterThan(0)
+
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: false })
+ })
+ const impressionsAfter = sink.mock.calls.filter(
+ ([event]) => event.type === "recommendation_impression"
+ )
+ // Revoking consent never adds events; the count is unchanged.
+ expect(impressionsAfter.length).toBe(impressionsBefore.length)
+ unmount()
+ })
+ })
})
diff --git a/lib/__tests__/telemetry.test.ts b/lib/__tests__/telemetry.test.ts
new file mode 100644
index 00000000..5e51a370
--- /dev/null
+++ b/lib/__tests__/telemetry.test.ts
@@ -0,0 +1,309 @@
+/**
+ * telemetry.test.ts
+ *
+ * Unit tests for lib/telemetry.ts — the consent-gated recommendation
+ * telemetry primitive. Covers consent gating, strict input validation,
+ * boundary clamping, duplicate suppression, sink failure recovery, and
+ * determinism of the emitted event shape.
+ */
+
+import { act } from "@testing-library/react"
+import { useConsentStore } from "@/app/state/consent"
+import {
+ trackRecommendation,
+ trackRecommendationImpression,
+ sanitizeTelemetryEvent,
+ setTelemetrySink,
+ resetTelemetrySession,
+ MAX_MARKET_ID_LENGTH,
+ MAX_CATEGORY_LENGTH,
+} from "../telemetry"
+
+const sink = jest.fn()
+
+function grantConsent(enabled: boolean) {
+ act(() => {
+ useConsentStore.setState({ analyticsConsent: enabled })
+ })
+}
+
+describe("sanitizeTelemetryEvent", () => {
+ beforeEach(() => {
+ grantConsent(true)
+ resetTelemetrySession()
+ })
+
+ it("builds a well-formed event for valid input", () => {
+ const event = sanitizeTelemetryEvent({
+ type: "recommendation_click",
+ marketId: "rec-crypto-1",
+ category: "crypto",
+ position: 2,
+ })
+ expect(event).not.toBeNull()
+ expect(event!.type).toBe("recommendation_click")
+ expect(event!.marketId).toBe("rec-crypto-1")
+ expect(event!.category).toBe("crypto")
+ expect(event!.position).toBe(2)
+ expect(typeof event!.timestamp).toBe("number")
+ expect(typeof event!.sessionId).toBe("string")
+ expect(event!.sessionId.length).toBeGreaterThan(0)
+ })
+
+ it("rejects non-object input", () => {
+ expect(sanitizeTelemetryEvent(null)).toBeNull()
+ expect(sanitizeTelemetryEvent(undefined)).toBeNull()
+ expect(sanitizeTelemetryEvent("event")).toBeNull()
+ expect(sanitizeTelemetryEvent(42)).toBeNull()
+ })
+
+ it("rejects unknown event types", () => {
+ expect(
+ sanitizeTelemetryEvent({ type: "page_view", marketId: "x" })
+ ).toBeNull()
+ })
+
+ it("rejects missing, empty, or whitespace-only marketId", () => {
+ expect(sanitizeTelemetryEvent({ type: "recommendation_click" })).toBeNull()
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "" })
+ ).toBeNull()
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: " " })
+ ).toBeNull()
+ })
+
+ it("trims and bounds the marketId and category lengths", () => {
+ const event = sanitizeTelemetryEvent({
+ type: "recommendation_impression",
+ marketId: ` ${"a".repeat(MAX_MARKET_ID_LENGTH + 50)} `,
+ category: ` ${"b".repeat(MAX_CATEGORY_LENGTH + 50)} `,
+ })
+ expect(event!.marketId).toBe("a".repeat(MAX_MARKET_ID_LENGTH))
+ expect(event!.category).toBe("b".repeat(MAX_CATEGORY_LENGTH))
+ })
+
+ it("drops the category when it is missing or collapses to empty", () => {
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x" })
+ ?.category
+ ).toBeUndefined()
+ expect(
+ sanitizeTelemetryEvent({
+ type: "recommendation_click",
+ marketId: "x",
+ category: " ",
+ })?.category
+ ).toBeUndefined()
+ expect(
+ sanitizeTelemetryEvent({
+ type: "recommendation_click",
+ marketId: "x",
+ category: 7,
+ })?.category
+ ).toBeUndefined()
+ })
+
+ it("clamps position into [0, MAX_POSITION] deterministically", () => {
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 0 })!
+ .position
+ ).toBe(0)
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 3.9 })!
+ .position
+ ).toBe(3)
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: -5 })!
+ .position
+ ).toBe(0)
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 100000 })!
+ .position
+ ).toBe(999)
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: NaN })!
+ .position
+ ).toBe(0)
+ expect(
+ sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: Infinity })!
+ .position
+ ).toBe(0)
+ })
+
+ it("falls back to the current timestamp for invalid timestamps", () => {
+ const before = Date.now()
+ const event = sanitizeTelemetryEvent({
+ type: "recommendation_click",
+ marketId: "x",
+ timestamp: -1,
+ })
+ expect(event!.timestamp).toBeGreaterThanOrEqual(before)
+ })
+})
+
+describe("trackRecommendation (consent gate)", () => {
+ beforeEach(() => {
+ sink.mockClear()
+ setTelemetrySink(sink)
+ resetTelemetrySession()
+ grantConsent(false)
+ })
+
+ afterEach(() => {
+ setTelemetrySink(null)
+ grantConsent(false)
+ })
+
+ it("emits nothing and returns false when consent is disabled", () => {
+ const result = trackRecommendation({
+ type: "recommendation_click",
+ marketId: "rec-crypto-1",
+ })
+ expect(result).toBe(false)
+ expect(sink).not.toHaveBeenCalled()
+ })
+
+ it("emits a valid event and returns true when consent is granted", () => {
+ grantConsent(true)
+ const result = trackRecommendation({
+ type: "recommendation_click",
+ marketId: "rec-crypto-1",
+ category: "crypto",
+ position: 4,
+ })
+ expect(result).toBe(true)
+ expect(sink).toHaveBeenCalledTimes(1)
+ const dispatched = sink.mock.calls[0][0]
+ expect(dispatched.marketId).toBe("rec-crypto-1")
+ expect(dispatched.category).toBe("crypto")
+ expect(dispatched.position).toBe(4)
+ // No sensitive data is ever forwarded (there is no identity field).
+ expect(dispatched).not.toHaveProperty("wallet")
+ expect(dispatched).not.toHaveProperty("userId")
+ expect(dispatched).not.toHaveProperty("address")
+ })
+
+ it("does not dispatch invalid input even when consent is granted", () => {
+ grantConsent(true)
+ expect(trackRecommendation({ type: "recommendation_click" })).toBe(false)
+ expect(trackRecommendation({ type: "recommendation_click", marketId: "" })).toBe(false)
+ expect(trackRecommendation("garbage")).toBe(false)
+ expect(sink).not.toHaveBeenCalled()
+ })
+
+ it("is deterministic for duplicate click input (clicks are not de-duplicated)", () => {
+ grantConsent(true)
+ const input = { type: "recommendation_click" as const, marketId: "rec-crypto-1" }
+ expect(trackRecommendation(input)).toBe(true)
+ expect(trackRecommendation(input)).toBe(true)
+ expect(sink).toHaveBeenCalledTimes(2)
+ })
+
+ it("swallows sink failures so callers are never broken", () => {
+ grantConsent(true)
+ const error = new Error("sink is down")
+ setTelemetrySink(() => {
+ throw error
+ })
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {})
+ let result: boolean
+ expect(() => {
+ result = trackRecommendation({ type: "recommendation_click", marketId: "x" })
+ }).not.toThrow()
+ expect(result!).toBe(true)
+ expect(spy).toHaveBeenCalled()
+ spy.mockRestore()
+ })
+
+ it("recovers on the next call after a transient sink failure", () => {
+ grantConsent(true)
+ let shouldThrow = true
+ setTelemetrySink(() => {
+ if (shouldThrow) throw new Error("boom")
+ })
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {})
+ const input = { type: "recommendation_click" as const, marketId: "x" }
+ expect(trackRecommendation(input)).toBe(true)
+ shouldThrow = false
+ expect(trackRecommendation(input)).toBe(true)
+ spy.mockRestore()
+ })
+})
+
+describe("trackRecommendationImpression (dedup)", () => {
+ beforeEach(() => {
+ sink.mockClear()
+ setTelemetrySink(sink)
+ resetTelemetrySession()
+ })
+
+ afterEach(() => {
+ setTelemetrySink(null)
+ grantConsent(false)
+ })
+
+ it("dispatches an impression for each distinct market in the session", () => {
+ grantConsent(true)
+ expect(
+ trackRecommendationImpression({ marketId: "rec-crypto-1", category: "crypto", position: 0 })
+ ).toBe(true)
+ expect(
+ trackRecommendationImpression({ marketId: "rec-football-1", category: "football", position: 1 })
+ ).toBe(true)
+ expect(sink).toHaveBeenCalledTimes(2)
+ })
+
+ it("suppresses a duplicate impression of the same market in the same session", () => {
+ grantConsent(true)
+ const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 }
+ expect(trackRecommendationImpression(input)).toBe(true)
+ expect(trackRecommendationImpression(input)).toBe(false)
+ expect(sink).toHaveBeenCalledTimes(1)
+ })
+
+ it("is suppressed entirely without consent and never reaches the sink", () => {
+ grantConsent(false)
+ expect(
+ trackRecommendationImpression({ marketId: "rec-crypto-1" })
+ ).toBe(false)
+ expect(sink).not.toHaveBeenCalled()
+ })
+
+ it("does not dedup invalid impressions (they are simply rejected)", () => {
+ grantConsent(true)
+ expect(trackRecommendationImpression({ marketId: "" })).toBe(false)
+ expect(trackRecommendationImpression({ marketId: " " })).toBe(false)
+ expect(sink).not.toHaveBeenCalled()
+ })
+
+ it("a fresh session re-enables impressions for the same market", () => {
+ grantConsent(true)
+ const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 }
+ expect(trackRecommendationImpression(input)).toBe(true)
+ resetTelemetrySession()
+ expect(trackRecommendationImpression(input)).toBe(true)
+ expect(sink).toHaveBeenCalledTimes(2)
+ })
+
+ it("keeps the dedup set size-bounded (oldest evicted past the cap)", () => {
+ grantConsent(true)
+ const marketIds = Array.from({ length: 300 }, (_, i) => `market-${i}`)
+ marketIds.forEach((marketId, i) => {
+ trackRecommendationImpression({ marketId, position: i % 6 })
+ })
+ // Cap is 256 → the first 300-256=44 keys were evicted.
+ expect(trackRecommendationImpression({ marketId: "market-0" })).toBe(true)
+ expect(trackRecommendationImpression({ marketId: "market-299" })).toBe(false)
+ })
+
+ it("does not corrupt the store or sink state after a batch of impressions", () => {
+ grantConsent(true)
+ for (let i = 0; i < 50; i++) {
+ trackRecommendationImpression({ marketId: `m-${i % 5}`, position: i })
+ }
+ // Only 5 unique markets are seen even after 50 calls.
+ expect(sink).toHaveBeenCalledTimes(5)
+ expect(useConsentStore.getState().analyticsConsent).toBe(true)
+ })
+})
\ No newline at end of file
diff --git a/lib/telemetry.ts b/lib/telemetry.ts
new file mode 100644
index 00000000..2d0577fe
--- /dev/null
+++ b/lib/telemetry.ts
@@ -0,0 +1,206 @@
+/**
+ * telemetry.ts
+ *
+ * Recommendation telemetry, gated on the user's usage-analytics consent.
+ *
+ * Every event emitted from here is (1) validated and sanitized against a
+ * strict schema, (2) suppressed entirely while consent is `false`, and
+ * (3) forwarded to a pluggable sink that never receives sensitive data
+ * (no wallet addresses, no user identity — only market/category metadata).
+ *
+ * Invariants
+ * ----------
+ * - No telemetry is emitted unless `getAnalyticsConsent()` is true.
+ * - Invalid input is rejected (returns `false`) and never reaches the sink.
+ * - Boundary inputs are clamped deterministically (see sanitize* helpers).
+ * - Sink failures are swallowed: a throwing sink never crashes the caller,
+ * and telemetry silently degrades to a no-op instead of failing the UI.
+ * - Impression duplicates within the same session are de-duplicated and the
+ * dedup set is size-bounded so memory cannot grow without limit.
+ */
+
+import { getAnalyticsConsent } from "@/app/state/consent"
+
+export type RecommendationEventType =
+ | "recommendation_impression"
+ | "recommendation_click"
+
+export interface RecommendationTelemetryInput {
+ type: RecommendationEventType
+ marketId: string
+ category?: string
+ position?: number
+ timestamp?: number
+}
+
+export interface RecommendationTelemetryEvent {
+ type: RecommendationEventType
+ marketId: string
+ category?: string
+ position: number
+ timestamp: number
+ /** Auto-generated, stable for the lifetime of the tab (page session). */
+ sessionId: string
+}
+
+export const MAX_MARKET_ID_LENGTH = 128
+export const MAX_CATEGORY_LENGTH = 32
+export const MAX_POSITION = 999
+export const IMPRESSION_DEDUP_CAP = 256
+
+type TelemetrySink = (event: RecommendationTelemetryEvent) => void
+
+/** Default: dev-only console output; production emits nothing until wired to a real sink. */
+const defaultSink: TelemetrySink = (event) => {
+ if (process.env.NODE_ENV !== "production") {
+ console.debug("[telemetry]", event)
+ }
+}
+
+let sink: TelemetrySink = defaultSink
+
+/** Replace the sink (used to point telemetry at a real backend or a test spy). */
+export function setTelemetrySink(next: TelemetrySink | null): void {
+ sink = next ?? defaultSink
+}
+
+export function getTelemetrySink(): TelemetrySink {
+ return sink
+}
+
+/** Non-sensitive module-scoped page-session id, stable per module lifetime. */
+let sessionId = createSessionId()
+
+function createSessionId(): string {
+ return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
+}
+
+/** Reset module-scoped state (used by tests between cases). */
+export function resetTelemetrySession(): void {
+ sessionId = createSessionId()
+ seenImpressions.clear()
+ impressionFifo = []
+}
+
+// ── sanitization ─────────────────────────────────────────────────────────────
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
+function isEventType(value: unknown): value is RecommendationEventType {
+ return value === "recommendation_impression" || value === "recommendation_click"
+}
+
+function sanitizeString(value: unknown, maxLength: number): string | undefined {
+ if (typeof value !== "string") return undefined
+ const trimmed = value.trim().slice(0, maxLength)
+ return trimmed.length > 0 ? trimmed : undefined
+}
+
+/** Position is clamped into [0, MAX_POSITION]; invalid input defaults to 0. */
+function sanitizePosition(value: unknown): number {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
+ return 0
+ }
+ return Math.min(Math.floor(value), MAX_POSITION)
+}
+
+function sanitizeTimestamp(value: unknown): number {
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
+ return Date.now()
+ }
+ return Math.floor(value)
+}
+
+/**
+ * Validate and coerce arbitrary input into a well-formed telemetry event.
+ * Returns `null` for invalid input so callers can deterministically treat
+ * malformed events as "not emitted". Never throws.
+ */
+export function sanitizeTelemetryEvent(
+ input: unknown
+): RecommendationTelemetryEvent | null {
+ if (!isRecord(input)) return null
+
+ const type = input.type
+ if (!isEventType(type)) return null
+
+ const marketId = sanitizeString(input.marketId, MAX_MARKET_ID_LENGTH)
+ if (!marketId) return null
+
+ const category = sanitizeString(input.category, MAX_CATEGORY_LENGTH)
+
+ return {
+ type,
+ marketId,
+ category,
+ position: sanitizePosition(input.position),
+ timestamp: sanitizeTimestamp(input.timestamp),
+ sessionId,
+ }
+}
+
+// ── impression de-duplication (bounded, FIFO) ───────────────────────────────
+
+const seenImpressions = new Set()
+let impressionFifo: string[] = []
+
+function rememberImpression(key: string): void {
+ if (seenImpressions.size >= IMPRESSION_DEDUP_CAP) {
+ const oldest = impressionFifo.shift()
+ if (oldest !== undefined) seenImpressions.delete(oldest)
+ }
+ seenImpressions.add(key)
+ impressionFifo.push(key)
+}
+
+/**
+ * Emit a recommendation telemetry event.
+ *
+ * - Returns `true` when the event was dispatched to the sink.
+ * - Returns `false` when consent is missing or the input is invalid — the
+ * caller can rely on this for deterministic behaviour in tests/retries.
+ *
+ * Sink errors are swallowed so telemetry can never break the caller.
+ */
+export function trackRecommendation(
+ input: RecommendationTelemetryInput | unknown
+): boolean {
+ if (!getAnalyticsConsent()) return false
+
+ const event = sanitizeTelemetryEvent(input)
+ if (!event) return false
+
+ try {
+ sink(event)
+ } catch (error) {
+ // Failure-tolerant by design: an unreachable/slow/erroring sink must not
+ // corrupt UI state or crash the client. Log locally without payload data.
+ console.error("[telemetry] sink error", error instanceof Error ? error.message : "unknown")
+ }
+ return true
+}
+
+/**
+ * Emit a recommendation *impression*, de-duplicated per market per session.
+ * A second impression of the same market in the same tab is suppressed so
+ * re-renders / retries cannot double-count.
+ */
+export function trackRecommendationImpression(
+ input: Omit | unknown
+): boolean {
+ if (!getAnalyticsConsent()) return false
+
+ const event = sanitizeTelemetryEvent({
+ ...(isRecord(input) ? input : {}),
+ type: "recommendation_impression",
+ })
+ if (!event) return false
+
+ const key = `${event.sessionId}:${event.marketId}`
+ if (seenImpressions.has(key)) return false
+
+ rememberImpression(key)
+ return trackRecommendation(event)
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index a2f2a476..a7337d68 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,6 @@
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^1.7.3",
"@hookform/resolvers": "^3.9.1",
- "canvas-confetti": "^1.9.3",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.1",
@@ -54,6 +53,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-virtual": "^3.10.8",
"autoprefixer": "^10.4.20",
+ "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
@@ -82,10 +82,10 @@
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
- "@testing-library/jest-dom": "^6.1.4",
+ "@testing-library/jest-dom": "^6",
+ "@testing-library/react": "^16",
+ "@testing-library/user-event": "^14",
"@types/canvas-confetti": "^1.6.4",
- "@testing-library/react": "^16.0.0",
- "@testing-library/user-event": "^14.5.1",
"@types/jest": "^29.5.8",
"@types/node": "^22",
"@types/qrcode": "^1.5.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 32654fda..3fa37b02 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -184,13 +184,13 @@ importers:
specifier: ^10.4.1
version: 10.4.1
'@testing-library/jest-dom':
- specifier: ^6.1.4
+ specifier: ^6
version: 6.9.1
'@testing-library/react':
- specifier: ^16.0.0
+ specifier: ^16
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@testing-library/user-event':
- specifier: ^14.5.1
+ specifier: ^14
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/canvas-confetti':
specifier: ^1.6.4
@@ -9926,8 +9926,8 @@ snapshots:
'@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -9946,7 +9946,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -9957,22 +9957,22 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -9983,7 +9983,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3