diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts new file mode 100644 index 0000000..0a059cd --- /dev/null +++ b/src/__tests__/analytics.test.ts @@ -0,0 +1,175 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { + POST as postEvent, + GET as getEvents, +} from "@/app/api/v1/analytics/events/route"; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeRequest( + url: string, + options: { method?: string; body?: string; headers?: Record } = {}, +) { + return new NextRequest(new URL(url, "http://localhost"), { + method: options.method ?? "GET", + body: options.body, + headers: { "Content-Type": "application/json", ...options.headers }, + }); +} + +function validEvent(overrides: Record = {}) { + return { + id: `evt_${Date.now()}_0`, + name: "listing_view", + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +// ── POST /api/v1/analytics/events ────────────────────────────────────────── + +describe("POST /api/v1/analytics/events", () => { + it("records a valid event and returns 200", async () => { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent()), + }); + + const res = await postEvent(req); + expect(res.status).toBe(200); + + const json = await res.json(); + expect(json.data.recorded).toBe(true); + expect(json.data.eventId).toBeDefined(); + }); + + it("accepts all valid event names", async () => { + const names = [ + "listing_view", + "listing_create", + "trade_complete", + "match_propose", + "wallet_connect", + "wallet_disconnect", + "page_view", + ]; + + for (const name of names) { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent({ name })), + }); + const res = await postEvent(req); + expect(res.status).toBe(200); + } + }); + + it("rejects missing id", async () => { + const event = validEvent(); + delete (event as Record).id; + + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(event), + }); + + const res = await postEvent(req); + expect(res.status).toBe(400); + }); + + it("rejects an unknown event name", async () => { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent({ name: "hack_attempt" })), + }); + + const res = await postEvent(req); + expect(res.status).toBe(400); + }); + + it("rejects a malformed timestamp", async () => { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent({ timestamp: "not-a-date" })), + }); + + const res = await postEvent(req); + expect(res.status).toBe(400); + }); + + it("rejects non-JSON body", async () => { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: "not-json", + }); + + const res = await postEvent(req); + expect(res.status).toBe(400); + }); + + it("accepts an event with optional properties", async () => { + const req = makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify( + validEvent({ properties: { assetId: "gold", price: 2385 } }), + ), + }); + + const res = await postEvent(req); + expect(res.status).toBe(200); + }); +}); + +// ── GET /api/v1/analytics/events ─────────────────────────────────────────── + +describe("GET /api/v1/analytics/events", () => { + it("returns a list of events", async () => { + // Record one first + await postEvent( + makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent({ name: "trade_complete" })), + }), + ); + + const req = makeRequest("http://localhost/api/v1/analytics/events"); + const res = await getEvents(req); + expect(res.status).toBe(200); + + const json = await res.json(); + expect(Array.isArray(json.data)).toBe(true); + expect(json.metadata.total).toBeGreaterThan(0); + }); + + it("respects the limit parameter", async () => { + const req = makeRequest( + "http://localhost/api/v1/analytics/events?limit=2", + ); + const res = await getEvents(req); + const json = await res.json(); + expect(json.data.length).toBeLessThanOrEqual(2); + }); + + it("filters by event name", async () => { + // Record a wallet_connect event + await postEvent( + makeRequest("http://localhost/api/v1/analytics/events", { + method: "POST", + body: JSON.stringify(validEvent({ name: "wallet_connect" })), + }), + ); + + const req = makeRequest( + "http://localhost/api/v1/analytics/events?name=wallet_connect", + ); + const res = await getEvents(req); + const json = await res.json(); + + for (const event of json.data) { + expect(event.name).toBe("wallet_connect"); + } + }); +}); diff --git a/src/__tests__/analyticsService.test.ts b/src/__tests__/analyticsService.test.ts new file mode 100644 index 0000000..06e1827 --- /dev/null +++ b/src/__tests__/analyticsService.test.ts @@ -0,0 +1,109 @@ +/** + * @jest-environment jsdom + */ + +// analyticsService calls fetch — stub it before importing the module. +const mockFetch = jest.fn().mockResolvedValue({ ok: true }); +global.fetch = mockFetch; + +import { + aggregateMetrics, + getBufferedEvents, + isOptedOut, + optIn, + optOut, + trackEvent, +} from "@/services/analyticsService"; + +beforeEach(() => { + // Reset localStorage and the fetch spy between tests. + localStorage.clear(); + mockFetch.mockClear(); + // Clear the internal buffer by re-importing is tricky; instead we drain it + // by reading and ignoring — real isolation would require module reset, but + // verifying the opt-out and aggregation logic is sufficient here. +}); + +describe("opt-out preference", () => { + it("isOptedOut returns false by default", () => { + expect(isOptedOut()).toBe(false); + }); + + it("optOut persists the preference", () => { + optOut(); + expect(isOptedOut()).toBe(true); + }); + + it("optIn clears the preference", () => { + optOut(); + optIn(); + expect(isOptedOut()).toBe(false); + }); +}); + +describe("trackEvent", () => { + it("does not call fetch when opted out", async () => { + optOut(); + await trackEvent("listing_view", { assetId: "gold" }); + expect(mockFetch).not.toHaveBeenCalled(); + optIn(); + }); + + it("calls fetch when opted in", async () => { + optIn(); + await trackEvent("page_view", { page: "/analytics" }); + expect(mockFetch).toHaveBeenCalledWith( + "/api/v1/analytics/events", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("adds events to the buffer", async () => { + optIn(); + const before = getBufferedEvents().length; + await trackEvent("trade_complete", { asset: "XLM" }); + expect(getBufferedEvents().length).toBeGreaterThan(before); + }); + + it("stored event has expected shape", async () => { + optIn(); + await trackEvent("wallet_connect", { wallet: "GTEST123" }); + const events = getBufferedEvents(); + const last = events[events.length - 1]; + expect(last.name).toBe("wallet_connect"); + expect(last.id).toMatch(/^evt_/); + expect(last.timestamp).toBeTruthy(); + expect(last.properties?.wallet).toBe("GTEST123"); + }); +}); + +describe("aggregateMetrics", () => { + it("returns zero counts when no events are buffered (7-day window)", () => { + // Use 0-day window to get an empty snapshot regardless of prior tests + const metrics = aggregateMetrics(0); + expect(metrics.tradesCompleted).toBeGreaterThanOrEqual(0); + expect(metrics.listingsViewed).toBeGreaterThanOrEqual(0); + expect(metrics.matchesProposed).toBeGreaterThanOrEqual(0); + expect(Array.isArray(metrics.dailyActivity)).toBe(true); + }); + + it("dailyActivity has an entry per day for the requested window", async () => { + const days = 7; + const metrics = aggregateMetrics(days); + expect(metrics.dailyActivity).toHaveLength(days); + }); + + it("counts trade_complete events in the window", async () => { + optIn(); + // Track events so we know exactly what's in the buffer going forward + await trackEvent("trade_complete"); + await trackEvent("trade_complete"); + await trackEvent("listing_view"); + + const metrics = aggregateMetrics(7); + // We can't assert exact counts because other tests also pushed events, + // but we can confirm trades ≥ 2 and listings ≥ 1 + expect(metrics.tradesCompleted).toBeGreaterThanOrEqual(2); + expect(metrics.listingsViewed).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx new file mode 100644 index 0000000..842d537 --- /dev/null +++ b/src/app/analytics/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { AnalyticsDashboard } from "@/components/AnalyticsDashboard"; + +export const metadata: Metadata = { + title: "Analytics · InterChangableTrade", + description: + "Platform engagement metrics — active wallets, trading volume, and listing activity.", +}; + +/** + * Analytics & Metrics dashboard page. + * All data rendering is delegated to the AnalyticsDashboard client component + * so that real-time updates and browser-local opt-out preferences work correctly. + */ +export default function AnalyticsPage() { + return ; +} diff --git a/src/app/api/v1/analytics/events/route.ts b/src/app/api/v1/analytics/events/route.ts new file mode 100644 index 0000000..19775e4 --- /dev/null +++ b/src/app/api/v1/analytics/events/route.ts @@ -0,0 +1,177 @@ +import { NextRequest } from "next/server"; +import { + createErrorResponse, + createSuccessResponse, +} from "@/lib/api-middleware"; +import type { AnalyticsEvent, AnalyticsEventName } from "@/types/analytics"; + +/** + * Valid event names accepted by the collector. + * Kept as a runtime set so unknown event names are rejected early. + */ +const VALID_EVENT_NAMES = new Set([ + "listing_view", + "listing_create", + "trade_complete", + "match_propose", + "wallet_connect", + "wallet_disconnect", + "page_view", +]); + +/** + * In-memory event store for the server process. + * Suitable for development / demo purposes. In production this would be + * forwarded to a dedicated analytics backend (e.g. Segment, Amplitude, or a + * time-series DB). + */ +const serverEventStore: AnalyticsEvent[] = []; +const MAX_STORE_SIZE = 10_000; + +/** + * @openapi + * /api/v1/analytics/events: + * post: + * summary: Record an analytics event + * description: | + * Accepts a single analytics event payload from the browser client. + * Requests without a valid event name are rejected. No authentication + * is required so this endpoint is intentionally light-weight — clients + * that have opted out simply do not call it. + * tags: + * - Analytics + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - id + * - name + * - timestamp + * properties: + * id: + * type: string + * example: "evt_1718000000000_0" + * name: + * type: string + * enum: + * - listing_view + * - listing_create + * - trade_complete + * - match_propose + * - wallet_connect + * - wallet_disconnect + * - page_view + * timestamp: + * type: string + * format: date-time + * properties: + * type: object + * additionalProperties: true + * responses: + * 200: + * description: Event recorded successfully + * 400: + * description: Invalid event payload + */ +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse(400, "Invalid JSON body"); + } + + if (!body || typeof body !== "object") { + return createErrorResponse(400, "Event payload must be a JSON object"); + } + + const payload = body as Record; + + const { id, name, timestamp, properties } = payload; + + if (typeof id !== "string" || !id) { + return createErrorResponse(400, "Missing or invalid field: id"); + } + + if (typeof name !== "string" || !VALID_EVENT_NAMES.has(name as AnalyticsEventName)) { + return createErrorResponse(400, "Missing or invalid field: name", { + valid: [...VALID_EVENT_NAMES], + }); + } + + if (typeof timestamp !== "string" || isNaN(Date.parse(timestamp))) { + return createErrorResponse(400, "Missing or invalid field: timestamp"); + } + + // properties is optional; if present it must be a plain object + if (properties !== undefined && (typeof properties !== "object" || Array.isArray(properties))) { + return createErrorResponse(400, "Field 'properties' must be a plain object"); + } + + const event: AnalyticsEvent = { + id, + name: name as AnalyticsEventName, + timestamp, + properties: (properties as AnalyticsEvent["properties"]) ?? undefined, + }; + + // Bound the in-memory store + if (serverEventStore.length >= MAX_STORE_SIZE) { + serverEventStore.shift(); + } + serverEventStore.push(event); + + return createSuccessResponse({ recorded: true, eventId: event.id }); +} + +/** + * @openapi + * /api/v1/analytics/events: + * get: + * summary: List recorded analytics events + * description: Returns the most-recent analytics events held in memory. + * tags: + * - Analytics + * security: + * - ApiKeyAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * default: 50 + * description: Maximum number of events to return (max 500) + * - in: query + * name: name + * schema: + * type: string + * description: Filter by event name + * responses: + * 200: + * description: Events returned successfully + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const rawLimit = searchParams.get("limit"); + const limit = Math.min(parseInt(rawLimit ?? "50", 10) || 50, 500); + + const nameFilter = searchParams.get("name"); + + let events = [...serverEventStore].reverse(); // newest first + + if (nameFilter) { + events = events.filter((e) => e.name === nameFilter); + } + + const page = events.slice(0, limit); + + return createSuccessResponse(page, { + total: serverEventStore.length, + returned: page.length, + apiVersion: "v1", + }); +} diff --git a/src/app/assets/[id]/page.tsx b/src/app/assets/[id]/page.tsx index 4ce5101..4479994 100644 --- a/src/app/assets/[id]/page.tsx +++ b/src/app/assets/[id]/page.tsx @@ -3,6 +3,7 @@ import { getAsset } from "@/services/assetService"; import { formatCurrency, formatNumber } from "@/lib/format"; import { ChangeBadge } from "@/components/ChangeBadge"; import { TradePanel } from "@/components/TradePanel"; +import { ListingViewTracker } from "@/components/ListingViewTracker"; interface PageProps { params: Promise<{ id: string }>; @@ -16,42 +17,48 @@ export default async function AssetDetailPage({ params }: PageProps) { notFound(); } + // notFound() throws, so `asset` is non-null from here on. + // The explicit cast satisfies TypeScript when Next.js types are unavailable. + const safeAsset = asset!; + return (
+ {/* Silent client-side tracker — fires listing_view analytics event */} +
-

{asset.code}

- +

{safeAsset.code}

+
-

{asset.name}

+

{safeAsset.name}

-

{formatCurrency(asset.price)}

+

{formatCurrency(safeAsset.price)}

-

{asset.description}

+

{safeAsset.description}

Category
- {asset.category.replace("-", " ")} + {safeAsset.category.replace("-", " ")}
Supply
-
{formatNumber(asset.supply)}
+
{formatNumber(safeAsset.supply)}
Issuer
-
- {asset.issuer} +
+ {safeAsset.issuer}
- +
diff --git a/src/components/ActivityChart.tsx b/src/components/ActivityChart.tsx new file mode 100644 index 0000000..27d4ab7 --- /dev/null +++ b/src/components/ActivityChart.tsx @@ -0,0 +1,98 @@ +import type { DailyActivityPoint } from "@/types/analytics"; + +interface ActivityChartProps { + data: DailyActivityPoint[]; + /** Which series to render. Defaults to "trades". */ + series?: "trades" | "activeUsers" | "listings"; + label?: string; +} + +const SERIES_COLORS: Record = { + trades: "#0ea5e9", + activeUsers: "#8b5cf6", + listings: "#10b981", +}; + +const SERIES_LABELS: Record = { + trades: "Trades", + activeUsers: "Active users", + listings: "Listings viewed", +}; + +const CHART_HEIGHT = 120; +const BAR_WIDTH = 28; +const BAR_GAP = 8; + +/** + * Lightweight SVG bar chart — no external charting library required. + * Renders the selected series from the daily activity data. + */ +export function ActivityChart({ + data, + series = "trades", + label, +}: ActivityChartProps) { + const values = data.map((d) => d[series] as number); + const max = Math.max(...values, 1); // prevent division by zero + + const svgWidth = data.length * (BAR_WIDTH + BAR_GAP) - BAR_GAP; + const color = SERIES_COLORS[series]; + const seriesLabel = label ?? SERIES_LABELS[series]; + + return ( +
+

+ {seriesLabel} · last {data.length} days +

+ + {values.every((v) => v === 0) ? ( +
+ No activity yet +
+ ) : ( +
+ + {values.map((val, i) => { + const barH = Math.max((val / max) * CHART_HEIGHT, val > 0 ? 4 : 0); + const x = i * (BAR_WIDTH + BAR_GAP); + const y = CHART_HEIGHT - barH; + const dateLabel = data[i].date.slice(5); // MM-DD + + return ( + + + + {data[i].date}: {val} + + + + {dateLabel} + + + ); + })} + +
+ )} +
+ ); +} diff --git a/src/components/AnalyticsDashboard.tsx b/src/components/AnalyticsDashboard.tsx new file mode 100644 index 0000000..89bbef0 --- /dev/null +++ b/src/components/AnalyticsDashboard.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAnalytics } from "@/hooks/useAnalytics"; +import { MetricCard } from "@/components/MetricCard"; +import { ActivityChart } from "@/components/ActivityChart"; +import { AnalyticsOptOut } from "@/components/AnalyticsOptOut"; +import type { AnalyticsTimeRange } from "@/types/analytics"; + +const TIME_RANGES: AnalyticsTimeRange[] = [ + { label: "7 days", days: 7 }, + { label: "14 days", days: 14 }, + { label: "30 days", days: 30 }, +]; + +/** + * Client component that renders the analytics dashboard. + * Metrics are derived from the in-memory session buffer so they reflect + * actions taken since the page was last loaded. The dashboard auto-refreshes + * every 30 seconds. + */ +export function AnalyticsDashboard() { + const { metrics, refreshMetrics, track, optedOut } = useAnalytics(); + const [range, setRange] = useState(TIME_RANGES[0]); + + // Track page view once + useEffect(() => { + track("page_view", { page: "/analytics" }); + }, [track]); + + // Refresh metrics when the time range changes + useEffect(() => { + refreshMetrics(range.days); + }, [range, refreshMetrics]); + + // Auto-refresh every 30 s + useEffect(() => { + const id = setInterval(() => refreshMetrics(range.days), 30_000); + return () => clearInterval(id); + }, [range, refreshMetrics]); + + return ( +
+ {/* Header */} +
+
+

+ Analytics & Metrics +

+

+ Platform engagement and trading activity overview. +

+
+ + {/* Time-range filter */} +
+ {TIME_RANGES.map((r) => ( + + ))} +
+
+ + {/* Opt-out notice */} + {optedOut && ( +
+ Analytics collection is paused — the metrics below reflect your + current session only, before opt-out was applied. +
+ )} + + {/* KPI cards */} + {metrics ? ( + <> +
+ + + + +
+ + {/* Charts row */} +
+ + + +
+ + ) : ( +
+ {[...Array(4)].map((_, i) => ( + + )} + + {/* Settings section */} +
+

+ Privacy settings +

+ +
+
+ ); +} diff --git a/src/components/AnalyticsOptOut.tsx b/src/components/AnalyticsOptOut.tsx new file mode 100644 index 0000000..4c1fbc3 --- /dev/null +++ b/src/components/AnalyticsOptOut.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useAnalytics } from "@/hooks/useAnalytics"; + +/** + * Toggle that lets users opt in or out of analytics tracking. + * Reads and writes the preference from localStorage. + */ +export function AnalyticsOptOut() { + const { optedOut, setOptOut, setOptIn } = useAnalytics(); + + return ( +
+
+

Usage tracking

+

+ We collect anonymous event data (page views, trades, listings) to + improve the platform. No personally identifiable information is stored. + You can opt out at any time. +

+ {optedOut && ( +

+ You are currently opted out. Events are not being collected. +

+ )} +
+ + +
+ ); +} diff --git a/src/components/ListingViewTracker.tsx b/src/components/ListingViewTracker.tsx new file mode 100644 index 0000000..949a244 --- /dev/null +++ b/src/components/ListingViewTracker.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; +import { trackEvent } from "@/services/analyticsService"; + +interface ListingViewTrackerProps { + assetId: string; + assetCode: string; +} + +/** + * Zero-render client component — mounts silently and fires a `listing_view` + * analytics event. Kept separate from the server page so the async page + * component stays a React Server Component. + */ +export function ListingViewTracker({ + assetId, + assetCode, +}: ListingViewTrackerProps) { + useEffect(() => { + void trackEvent("listing_view", { assetId, assetCode }); + }, [assetId, assetCode]); + + return null; +} diff --git a/src/components/MetricCard.tsx b/src/components/MetricCard.tsx new file mode 100644 index 0000000..8e5a482 --- /dev/null +++ b/src/components/MetricCard.tsx @@ -0,0 +1,30 @@ +interface MetricCardProps { + label: string; + value: number | string; + description?: string; + icon: string; +} + +export function MetricCard({ label, value, description, icon }: MetricCardProps) { + return ( +
+
+
+

+ {label} +

+

{value}

+ {description && ( +

{description}

+ )} +
+ +
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 218bfbb..6b68f1a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -4,6 +4,7 @@ import { WalletButton } from "@/components/WalletButton"; const navLinks = [ { href: "/marketplace", label: "Marketplace" }, { href: "/portfolio", label: "Portfolio" }, + { href: "/analytics", label: "Analytics" }, ]; export function Navbar() { diff --git a/src/components/TradePanel.tsx b/src/components/TradePanel.tsx index 4176d3e..a1d6ea2 100644 --- a/src/components/TradePanel.tsx +++ b/src/components/TradePanel.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import type { Asset, TradeSide } from "@/types/asset"; import { useWallet } from "@/hooks/useWallet"; +import { useAnalytics } from "@/hooks/useAnalytics"; import { placeOrder } from "@/services/tradeService"; import { formatCurrency } from "@/lib/format"; @@ -14,6 +15,7 @@ type Status = export function TradePanel({ asset }: { asset: Asset }) { const { address, isConnected, isConnecting, connect } = useWallet(); + const { track } = useAnalytics(); const [side, setSide] = useState("buy"); const [amount, setAmount] = useState(""); const [status, setStatus] = useState({ kind: "idle" }); @@ -43,6 +45,13 @@ export function TradePanel({ asset }: { asset: Asset }) { hash: result.hash, explorerUrl: result.explorerUrl, }); + track("trade_complete", { + asset: asset.code, + side, + amount: parsedAmount, + price: asset.price, + wallet: address, + }); setAmount(""); } catch (err) { setStatus({ diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts new file mode 100644 index 0000000..adaec55 --- /dev/null +++ b/src/hooks/useAnalytics.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + aggregateMetrics, + isOptedOut, + optIn, + optOut, + trackEvent, +} from "@/services/analyticsService"; +import type { AnalyticsEvent, AnalyticsEventName, AnalyticsMetrics } from "@/types/analytics"; + +interface UseAnalyticsReturn { + /** Track a named event with optional properties. */ + track: ( + name: AnalyticsEventName, + properties?: AnalyticsEvent["properties"], + ) => void; + /** Whether the user has opted out of tracking. */ + optedOut: boolean; + /** Opt the user out of tracking. */ + setOptOut: () => void; + /** Opt the user back in to tracking. */ + setOptIn: () => void; + /** Aggregated metrics over the given time window (default: 7 days). */ + metrics: AnalyticsMetrics | null; + /** Refresh the metrics snapshot from the in-memory buffer. */ + refreshMetrics: (days?: number) => void; +} + +/** + * React hook that exposes analytics event tracking, opt-out controls, + * and a live metrics snapshot aggregated from the session buffer. + */ +export function useAnalytics(): UseAnalyticsReturn { + const [optedOut, setOptedOut] = useState(false); + const [metrics, setMetrics] = useState(null); + + // Read persisted opt-out preference on mount (client-only) + useEffect(() => { + setOptedOut(isOptedOut()); + }, []); + + const refreshMetrics = useCallback((days = 7) => { + setMetrics(aggregateMetrics(days)); + }, []); + + // Compute initial metrics snapshot once mounted + useEffect(() => { + refreshMetrics(); + }, [refreshMetrics]); + + const track = useCallback( + (name: AnalyticsEventName, properties?: AnalyticsEvent["properties"]) => { + void trackEvent(name, properties); + }, + [], + ); + + const handleOptOut = useCallback(() => { + optOut(); + setOptedOut(true); + }, []); + + const handleOptIn = useCallback(() => { + optIn(); + setOptedOut(false); + }, []); + + return { + track, + optedOut, + setOptOut: handleOptOut, + setOptIn: handleOptIn, + metrics, + refreshMetrics, + }; +} diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts new file mode 100644 index 0000000..8ab31b2 --- /dev/null +++ b/src/services/analyticsService.ts @@ -0,0 +1,168 @@ +"use client"; + +import type { + AnalyticsEvent, + AnalyticsEventName, + AnalyticsMetrics, + DailyActivityPoint, +} from "@/types/analytics"; + +const OPT_OUT_KEY = "ict.analytics.optout"; +const MAX_BUFFERED_EVENTS = 200; + +/** + * In-memory event store shared for the current browser session. + * In production this would be forwarded to a real analytics backend. + */ +const eventBuffer: AnalyticsEvent[] = []; + +let eventCounter = 0; + +function generateId(): string { + return `evt_${Date.now()}_${(eventCounter++).toString(36)}`; +} + +/** Returns true when the user has opted out of event tracking. */ +export function isOptedOut(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(OPT_OUT_KEY) === "true"; +} + +/** Opt the current user out of analytics collection. */ +export function optOut(): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(OPT_OUT_KEY, "true"); +} + +/** Opt the current user back in to analytics collection. */ +export function optIn(): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(OPT_OUT_KEY); +} + +/** + * Track an analytics event. + * + * Events are buffered in memory and sent to the API route in a fire-and-forget + * manner. If the user has opted out, or we are running server-side, this is + * a no-op. + */ +export async function trackEvent( + name: AnalyticsEventName, + properties?: AnalyticsEvent["properties"], +): Promise { + if (typeof window === "undefined") return; + if (isOptedOut()) return; + + const event: AnalyticsEvent = { + id: generateId(), + name, + timestamp: new Date().toISOString(), + properties, + }; + + // Keep buffer bounded + if (eventBuffer.length >= MAX_BUFFERED_EVENTS) { + eventBuffer.shift(); + } + eventBuffer.push(event); + + // Fire-and-forget to our API collector + try { + await fetch("/api/v1/analytics/events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + // Use keepalive so the request survives navigation + keepalive: true, + }); + } catch { + // Analytics failures must never break the UI + } +} + +/** Return all events buffered in the current session. */ +export function getBufferedEvents(): ReadonlyArray { + return eventBuffer; +} + +// --------------------------------------------------------------------------- +// Client-side metrics aggregation (over the session buffer) +// Used by the dashboard when there are no server-persisted events yet. +// --------------------------------------------------------------------------- + +function isoDate(iso: string): string { + return iso.slice(0, 10); +} + +/** + * Aggregate the in-memory event buffer into {@link AnalyticsMetrics}. + * `days` controls the rolling window (default: 7). + */ +export function aggregateMetrics(days = 7): AnalyticsMetrics { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffIso = cutoff.toISOString(); + + const window = eventBuffer.filter((e) => e.timestamp >= cutoffIso); + + const uniqueWallets = new Set(); + let tradesCompleted = 0; + let listingsViewed = 0; + let matchesProposed = 0; + + // Build per-day buckets + const dayBuckets = new Map< + string, + { wallets: Set; trades: number; listings: number } + >(); + + for (const event of window) { + const date = isoDate(event.timestamp); + if (!dayBuckets.has(date)) { + dayBuckets.set(date, { wallets: new Set(), trades: 0, listings: 0 }); + } + const bucket = dayBuckets.get(date)!; + + const wallet = String(event.properties?.wallet ?? event.id); + bucket.wallets.add(wallet); + uniqueWallets.add(wallet); + + switch (event.name) { + case "trade_complete": + tradesCompleted++; + bucket.trades++; + break; + case "listing_view": + listingsViewed++; + bucket.listings++; + break; + case "match_propose": + matchesProposed++; + break; + } + } + + // Ensure every day in the window has an entry (fill gaps with zeros) + const dailyActivity: DailyActivityPoint[] = []; + for (let d = days - 1; d >= 0; d--) { + const dt = new Date(); + dt.setDate(dt.getDate() - d); + const date = isoDate(dt.toISOString()); + const bucket = dayBuckets.get(date); + dailyActivity.push({ + date, + activeUsers: bucket?.wallets.size ?? 0, + trades: bucket?.trades ?? 0, + listings: bucket?.listings ?? 0, + }); + } + + return { + weeklyActiveUsers: uniqueWallets.size, + listingsViewed, + tradesCompleted, + matchesProposed, + dailyActivity, + }; +} diff --git a/src/types/analytics.ts b/src/types/analytics.ts new file mode 100644 index 0000000..017ebdc --- /dev/null +++ b/src/types/analytics.ts @@ -0,0 +1,51 @@ +/** Analytics & Metrics types for the InterChangableTrade platform. */ + +export type AnalyticsEventName = + | 'listing_view' + | 'listing_create' + | 'trade_complete' + | 'match_propose' + | 'wallet_connect' + | 'wallet_disconnect' + | 'page_view'; + +export interface AnalyticsEvent { + /** Unique event identifier. */ + id: string; + /** Type of event being recorded. */ + name: AnalyticsEventName; + /** ISO 8601 timestamp of when the event occurred. */ + timestamp: string; + /** Optional extra context (asset id, pair, page path, etc.). */ + properties?: Record; +} + +/** Aggregated metrics returned by the analytics API. */ +export interface AnalyticsMetrics { + /** Weekly active unique wallet addresses. */ + weeklyActiveUsers: number; + /** Total listings (page views on asset detail pages) in the window. */ + listingsViewed: number; + /** Number of completed on-chain trades in the window. */ + tradesCompleted: number; + /** Number of match-propose events in the window. */ + matchesProposed: number; + /** + * Daily breakdown for chart rendering. + * Each entry represents one calendar day. + */ + dailyActivity: DailyActivityPoint[]; +} + +export interface DailyActivityPoint { + /** ISO 8601 date string (YYYY-MM-DD). */ + date: string; + activeUsers: number; + trades: number; + listings: number; +} + +export interface AnalyticsTimeRange { + label: string; + days: number; +} diff --git a/src/types/index.ts b/src/types/index.ts index 2af9efe..0bfa151 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from "./asset"; export * from "./wallet"; export * from "./trading"; +export * from "./analytics"; \ No newline at end of file