From 501e4b251f2ada7ed1baeafbc707e7b7fbb5c996 Mon Sep 17 00:00:00 2001 From: OlufunbiIK Date: Mon, 27 Apr 2026 16:38:15 +0100 Subject: [PATCH 1/2] feat --- .gitignore | 6 + package-lock.json | 171 +++++++++++++++ package.json | 12 + src/components/notificationcenter.tsx | 189 ++++++++++++++++ src/components/virtualizedcourselist.tsx | 231 ++++++++++++++++++++ src/components/virtualizedmessagethread.tsx | 180 +++++++++++++++ src/components/virtualizedsearchresults.tsx | 144 ++++++++++++ src/hooks/useAnalytics.tsx | 89 ++++++++ src/providers/Notificationprovider.tsx | 231 ++++++++++++++++++++ src/testing/utils/fixtures.ts | 174 +++++++++++++++ src/utils/analytics.ts | 198 +++++++++++++++++ 11 files changed, 1625 insertions(+) create mode 100644 src/components/notificationcenter.tsx create mode 100644 src/components/virtualizedcourselist.tsx create mode 100644 src/components/virtualizedmessagethread.tsx create mode 100644 src/components/virtualizedsearchresults.tsx create mode 100644 src/hooks/useAnalytics.tsx create mode 100644 src/providers/Notificationprovider.tsx create mode 100644 src/testing/utils/fixtures.ts create mode 100644 src/utils/analytics.ts diff --git a/.gitignore b/.gitignore index e69de29b..0f1d4bde 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +.env +dist/ +build/ +coverage/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 29aec966..5401ea30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", +<<<<<<< HEAD +======= + "dompurify": "^3.2.4", +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "framer-motion": "^12.23.0", "idb": "^8.0.0", "lucide-react": "^0.462.0", @@ -37,6 +41,11 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", +<<<<<<< HEAD +======= + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.9", +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "recharts": "^2.15.4", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", @@ -55,6 +64,10 @@ "@types/chai": "^5.2.3", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", +<<<<<<< HEAD +======= + "@types/dompurify": "^3.0.5", +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "@types/node": "^20", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", @@ -3362,6 +3375,10 @@ "cpu": [ "arm" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3375,6 +3392,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3388,6 +3409,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3401,6 +3426,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3414,6 +3443,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3427,6 +3460,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3440,6 +3477,10 @@ "cpu": [ "arm" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3453,6 +3494,10 @@ "cpu": [ "arm" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3466,6 +3511,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3479,6 +3528,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3492,6 +3545,10 @@ "cpu": [ "loong64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3505,6 +3562,10 @@ "cpu": [ "loong64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3518,6 +3579,10 @@ "cpu": [ "ppc64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3531,6 +3596,10 @@ "cpu": [ "ppc64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3544,6 +3613,10 @@ "cpu": [ "riscv64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3557,6 +3630,10 @@ "cpu": [ "riscv64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3570,6 +3647,10 @@ "cpu": [ "s390x" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3583,6 +3664,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3596,6 +3681,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3609,6 +3698,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3622,6 +3715,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3635,6 +3732,10 @@ "cpu": [ "arm64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3648,6 +3749,10 @@ "cpu": [ "ia32" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3661,6 +3766,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -3674,6 +3783,10 @@ "cpu": [ "x64" ], +<<<<<<< HEAD +======= + "dev": true, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "license": "MIT", "optional": true, "os": [ @@ -5432,6 +5545,19 @@ "dev": true, "license": "MIT" }, +<<<<<<< HEAD +======= + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -7623,7 +7749,10 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", +<<<<<<< HEAD "peer": true, +======= +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -10724,6 +10853,15 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, +<<<<<<< HEAD +======= + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11938,6 +12076,39 @@ "react-dom": ">=16.6.0" } }, +<<<<<<< HEAD +======= + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz", + "integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==", + "license": "MIT", + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc" + } + }, + "node_modules/react-window": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz", + "integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", diff --git a/package.json b/package.json index 81e9e576..46972eb6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", +<<<<<<< HEAD +======= + "dompurify": "^3.2.4", +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "framer-motion": "^12.23.0", "idb": "^8.0.0", "lucide-react": "^0.462.0", @@ -53,12 +57,20 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", +<<<<<<< HEAD +======= + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.9", +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "recharts": "^2.15.4", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", "workbox-webpack-plugin": "^7.0.0", +<<<<<<< HEAD "dompurify": "^3.2.4", +======= +>>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "zod": "^3.25.75", "zustand": "^5.0.10" }, diff --git a/src/components/notificationcenter.tsx b/src/components/notificationcenter.tsx new file mode 100644 index 00000000..27310a07 --- /dev/null +++ b/src/components/notificationcenter.tsx @@ -0,0 +1,189 @@ +import React, { useState, useRef, useEffect } from "react"; +import { + Notification, + NotificationType, + useNotifications, +} from "@/providers/Notificationprovider"; + +// ────────────────────────────────────────────────────────────────────────────── +// Icon helpers +// ────────────────────────────────────────────────────────────────────────────── + +const TYPE_ICON: Record = { + info: "ℹ️", + success: "✅", + warning: "⚠️", + error: "❌", + message: "💬", + course: "📚", + system: "🔔", +}; + +function timeAgo(date: Date): string { + const diff = (Date.now() - date.getTime()) / 1000; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// ────────────────────────────────────────────────────────────────────────────── +// NotificationItem +// ────────────────────────────────────────────────────────────────────────────── + +interface NotificationItemProps { + notification: Notification; + onRead: (id: string) => void; + onClear: (id: string) => void; +} + +function NotificationItem({ notification, onRead, onClear }: NotificationItemProps) { + const { id, type, title, body, timestamp, read, actionUrl, avatarUrl } = notification; + + function handleClick() { + if (!read) onRead(id); + if (actionUrl) window.location.href = actionUrl; + } + + return ( +
e.key === "Enter" && handleClick()} + > +
+ {avatarUrl ? ( + + ) : ( + + )} +
+
+

{title}

+ {body &&

{body}

} + +
+ {!read && } + +
+ ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// NotificationBadge +// ────────────────────────────────────────────────────────────────────────────── + +interface NotificationBadgeProps { + count: number; + onClick: () => void; +} + +export function NotificationBadge({ count, onClick }: NotificationBadgeProps) { + return ( + + ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// NotificationCenter (dropdown panel) +// ────────────────────────────────────────────────────────────────────────────── + +export function NotificationCenter() { + const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotification, clearAll } = + useNotifications(); + + const [open, setOpen] = useState(false); + const panelRef = useRef(null); + + // Close on outside click + useEffect(() => { + function handleOutside(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener("mousedown", handleOutside); + return () => document.removeEventListener("mousedown", handleOutside); + }, [open]); + + // Mark visible unread as read after 3 s when panel is open + useEffect(() => { + if (!open) return; + const timer = setTimeout(() => markAllAsRead(), 3000); + return () => clearTimeout(timer); + }, [open, markAllAsRead]); + + return ( +
+ setOpen((v) => !v)} /> + + {open && ( +
+
+

Notifications

+
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ +
+ {notifications.length === 0 ? ( +
+ +

You’re all caught up!

+
+ ) : ( + notifications.map((n) => ( + + )) + )} +
+
+ )} +
+ ); +} + +export default NotificationCenter; \ No newline at end of file diff --git a/src/components/virtualizedcourselist.tsx b/src/components/virtualizedcourselist.tsx new file mode 100644 index 00000000..ca0fed89 --- /dev/null +++ b/src/components/virtualizedcourselist.tsx @@ -0,0 +1,231 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, + useCallback, + ReactNode, + } from "react"; + + export type NotificationType = + | "info" + | "success" + | "warning" + | "error" + | "message" + | "course" + | "system"; + + export interface Notification { + id: string; + type: NotificationType; + title: string; + body?: string; + timestamp: Date; + read: boolean; + actionUrl?: string; + avatarUrl?: string; + metadata?: Record; + } + + type NotificationEventType = "notification" | "notification_read" | "notification_clear"; + + interface NotificationEvent { + event: NotificationEventType; + payload: unknown; + } + + // ────────────────────────────────────────────────────────────────────────────── + // WebSocket service (singleton per URL) + // ────────────────────────────────────────────────────────────────────────────── + + type Listener = (notification: Notification) => void; + + class NotificationSocketService { + private ws: WebSocket | null = null; + private listeners: Set = new Set(); + private reconnectTimer: ReturnType | null = null; + private reconnectDelay = 1000; + private maxReconnectDelay = 30_000; + private intentionallyClosed = false; + + constructor(private url: string) {} + + connect() { + this.intentionallyClosed = false; + this.open(); + } + + private open() { + if (this.ws) return; + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.info("[NotificationSocket] Connected"); + this.reconnectDelay = 1000; // reset back-off + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const data: NotificationEvent = JSON.parse(event.data as string); + if (data.event === "notification") { + const notification = data.payload as Notification; + notification.timestamp = new Date(notification.timestamp); + this.listeners.forEach((cb) => cb(notification)); + } + } catch { + console.warn("[NotificationSocket] Failed to parse message", event.data); + } + }; + + this.ws.onclose = () => { + this.ws = null; + if (!this.intentionallyClosed) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (err) => { + console.error("[NotificationSocket] Error", err); + this.ws?.close(); + }; + } catch (err) { + console.error("[NotificationSocket] Failed to open", err); + this.scheduleReconnect(); + } + } + + private scheduleReconnect() { + this.reconnectTimer = setTimeout(() => { + console.info(`[NotificationSocket] Reconnecting…`); + this.open(); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, this.reconnectDelay); + } + + disconnect() { + this.intentionallyClosed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } + + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + send(event: NotificationEventType, payload: unknown) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ event, payload })); + } + } + } + + // ────────────────────────────────────────────────────────────────────────────── + // Context + // ────────────────────────────────────────────────────────────────────────────── + + interface NotificationContextValue { + notifications: Notification[]; + unreadCount: number; + markAsRead: (id: string) => void; + markAllAsRead: () => void; + clearNotification: (id: string) => void; + clearAll: () => void; + } + + const NotificationContext = createContext(null); + + export function useNotifications(): NotificationContextValue { + const ctx = useContext(NotificationContext); + if (!ctx) throw new Error("useNotifications must be used inside "); + return ctx; + } + + // ────────────────────────────────────────────────────────────────────────────── + // Provider + // ────────────────────────────────────────────────────────────────────────────── + + interface NotificationProviderProps { + children: ReactNode; + /** WebSocket endpoint, e.g. "wss://api.example.com/notifications" */ + wsUrl: string; + /** Initial notifications fetched server-side / from cache */ + initialNotifications?: Notification[]; + /** Max number of notifications to keep in memory */ + maxNotifications?: number; + } + + export function NotificationProvider({ + children, + wsUrl, + initialNotifications = [], + maxNotifications = 200, + }: NotificationProviderProps) { + const [notifications, setNotifications] = useState(initialNotifications); + const serviceRef = useRef(null); + + const unreadCount = notifications.filter((n) => !n.read).length; + + const addNotification = useCallback( + (notification: Notification) => { + setNotifications((prev) => { + const next = [notification, ...prev]; + return next.slice(0, maxNotifications); + }); + }, + [maxNotifications] + ); + + useEffect(() => { + const svc = new NotificationSocketService(wsUrl); + serviceRef.current = svc; + svc.connect(); + + const unsubscribe = svc.subscribe(addNotification); + return () => { + unsubscribe(); + svc.disconnect(); + }; + }, [wsUrl, addNotification]); + + const markAsRead = useCallback((id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)) + ); + serviceRef.current?.send("notification_read", { id }); + }, []); + + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + serviceRef.current?.send("notification_read", { all: true }); + }, []); + + const clearNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + serviceRef.current?.send("notification_clear", { id }); + }, []); + + const clearAll = useCallback(() => { + setNotifications([]); + serviceRef.current?.send("notification_clear", { all: true }); + }, []); + + return ( + + {children} + + ); + } \ No newline at end of file diff --git a/src/components/virtualizedmessagethread.tsx b/src/components/virtualizedmessagethread.tsx new file mode 100644 index 00000000..dd0a22b8 --- /dev/null +++ b/src/components/virtualizedmessagethread.tsx @@ -0,0 +1,180 @@ +import React, { + useCallback, + useEffect, + useRef, + memo, + useMemo, + } from "react"; + import { VariableSizeList as List, ListChildComponentProps } from "react-window"; + import AutoSizer from "react-virtualized-auto-sizer"; + + export interface Message { + id: string; + senderId: string; + senderName: string; + senderAvatar?: string; + content: string; + timestamp: string | Date; + isOwn?: boolean; + attachments?: { name: string; url: string; type: string }[]; + reactions?: { emoji: string; count: number }[]; + status?: "sending" | "sent" | "delivered" | "read" | "failed"; + } + + const ESTIMATED_MESSAGE_HEIGHT = 72; + const ATTACHMENT_EXTRA = 60; + const REACTIONS_EXTRA = 32; + + function estimateMessageHeight(message: Message): number { + let height = ESTIMATED_MESSAGE_HEIGHT; + const contentLines = Math.ceil(message.content.length / 60); + height += Math.max(0, contentLines - 2) * 20; + if (message.attachments?.length) height += ATTACHMENT_EXTRA; + if (message.reactions?.length) height += REACTIONS_EXTRA; + return height; + } + + interface MessageBubbleProps { + message: Message; + style: React.CSSProperties; + } + + const MessageBubble = memo(({ message, style }: MessageBubbleProps) => { + const time = + message.timestamp instanceof Date + ? message.timestamp + : new Date(message.timestamp); + + return ( +
+
+ {!message.isOwn && ( +
+ {message.senderAvatar ? ( + {message.senderName} + ) : ( + + {message.senderName[0]} + + )} +
+ )} +
+ {!message.isOwn && ( + {message.senderName} + )} +
+

{message.content}

+ {message.attachments?.map((att) => ( + + 📎 {att.name} + + ))} +
+
+ + {message.isOwn && message.status && ( + + {message.status} + + )} +
+ {message.reactions && message.reactions.length > 0 && ( +
+ {message.reactions.map((r) => ( + + {r.emoji} {r.count} + + ))} +
+ )} +
+
+
+ ); + }); + + MessageBubble.displayName = "MessageBubble"; + + interface VirtualizedMessageThreadProps { + messages: Message[]; + className?: string; + /** Scroll to bottom when new messages arrive */ + autoScrollToBottom?: boolean; + } + + const VirtualizedMessageThread: React.FC = ({ + messages, + className, + autoScrollToBottom = true, + }) => { + const listRef = useRef(null); + const heightCache = useRef>(new Map()); + + const getItemSize = useCallback( + (index: number) => { + const msg = messages[index]; + if (!msg) return ESTIMATED_MESSAGE_HEIGHT; + const cached = heightCache.current.get(msg.id); + if (cached) return cached; + const h = estimateMessageHeight(msg); + heightCache.current.set(msg.id, h); + return h; + }, + [messages] + ); + + // Scroll to bottom when messages change (new message arrives) + useEffect(() => { + if (autoScrollToBottom && listRef.current && messages.length > 0) { + listRef.current.scrollToItem(messages.length - 1, "end"); + } + }, [messages.length, autoScrollToBottom]); + + // Reset height cache when messages change significantly + const messageIds = useMemo(() => messages.map((m) => m.id).join(","), [messages]); + useEffect(() => { + heightCache.current.clear(); + listRef.current?.resetAfterIndex(0); + }, [messageIds]); + + const Row = useCallback( + ({ index, style }: ListChildComponentProps) => ( + + ), + [messages] + ); + + return ( +
+ + {({ height, width }: { height: number; width: number }) => ( + + {Row} + + )} + +
+ ); + }; + + export default VirtualizedMessageThread; \ No newline at end of file diff --git a/src/components/virtualizedsearchresults.tsx b/src/components/virtualizedsearchresults.tsx new file mode 100644 index 00000000..a26c1595 --- /dev/null +++ b/src/components/virtualizedsearchresults.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, memo, useMemo } from "react"; +import { VariableSizeList as List, ListChildComponentProps } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; + +export type SearchResultType = "course" | "user" | "post" | "file"; + +export interface SearchResult { + id: string; + type: SearchResultType; + title: string; + subtitle?: string; + description?: string; + icon?: string; + url?: string; + metadata?: Record; +} + +const ITEM_HEIGHT_MAP: Record = { + course: 96, + user: 72, + post: 110, + file: 64, +}; + +interface SearchResultItemProps { + result: SearchResult; + style: React.CSSProperties; + onSelect?: (result: SearchResult) => void; +} + +const SearchResultItem = memo( + ({ result, style, onSelect }: SearchResultItemProps) => ( +
onSelect?.(result)} + role="option" + aria-selected="false" + > +
+ {result.icon ? ( + + ) : ( + {result.type[0].toUpperCase()} + )} +
+
+ {result.title} + {result.subtitle && ( + {result.subtitle} + )} + {result.description && ( +

{result.description}

+ )} +
+ {result.type} +
+ ) +); + +SearchResultItem.displayName = "SearchResultItem"; + +interface VirtualizedSearchResultsProps { + results: SearchResult[]; + onResultSelect?: (result: SearchResult) => void; + isLoading?: boolean; + query?: string; + className?: string; +} + +const VirtualizedSearchResults: React.FC = ({ + results, + onResultSelect, + isLoading, + query, + className, +}) => { + const getItemSize = useCallback( + (index: number) => ITEM_HEIGHT_MAP[results[index]?.type] ?? 80, + [results] + ); + + const Row = useCallback( + ({ index, style }: ListChildComponentProps) => ( + + ), + [results, onResultSelect] + ); + + // Compute total estimated height for small result sets (avoids AutoSizer overhead) + const estimatedTotalHeight = useMemo( + () => results.reduce((sum, r) => sum + (ITEM_HEIGHT_MAP[r.type] ?? 80), 0), + [results] + ); + + if (isLoading) { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (results.length === 0 && query) { + return ( +
+

No results found for “{query}”

+
+ ); + } + + return ( +
+ + {({ height, width }) => ( + + {Row} + + )} + +
+ ); +}; + +export default VirtualizedSearchResults; \ No newline at end of file diff --git a/src/hooks/useAnalytics.tsx b/src/hooks/useAnalytics.tsx new file mode 100644 index 00000000..e3c6d926 --- /dev/null +++ b/src/hooks/useAnalytics.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef } from "react"; +import analytics, { EventName, EventProperties } from "@/utils/analytics"; + +/** + * useAnalytics – React hook for consistent event tracking. + * + * Automatically fires a `page_view` event on mount (opt-out via `trackPageView: false`). + * Provides a `track` helper that merges any page-level context automatically. + * + * @example + * ```tsx + * function CoursePage({ course }) { + * const { track } = useAnalytics({ page: "course_detail", courseId: course.id }); + * + * function handleEnroll() { + * track("course_started", { courseId: course.id }); + * enroll(course.id); + * } + * } + * ``` + */ +export interface UseAnalyticsOptions { + /** Additional properties attached to every event in this component */ + context?: EventProperties; + /** + * Whether to auto-track a page_view on mount. + * Default: true when used at the page/route level; pass false for sub-components. + */ + trackPageView?: boolean; + /** Extra properties for the auto page_view event */ + pageViewProperties?: EventProperties; +} + +export interface UseAnalyticsReturn { + /** Track an event with optional extra properties */ + track: (name: EventName, properties?: EventProperties) => void; + /** Manually fire a page_view (useful for SPA route changes) */ + trackPageView: (overrides?: EventProperties) => void; +} + +export function useAnalytics(options: UseAnalyticsOptions = {}): UseAnalyticsReturn { + const { context = {}, trackPageView: autoTrack = false, pageViewProperties = {} } = options; + + // Keep a stable ref to context so track() callbacks don't go stale + const contextRef = useRef(context); + useEffect(() => { + contextRef.current = context; + }, [context]); + + // Auto page_view on mount + useEffect(() => { + if (autoTrack) { + analytics.trackPageView({ ...contextRef.current, ...pageViewProperties }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // intentionally run once on mount + + const track = useCallback( + (name: EventName, properties: EventProperties = {}) => { + analytics.track(name, { ...contextRef.current, ...properties }); + }, + [] // stable — context accessed via ref + ); + + const trackPageView = useCallback((overrides: EventProperties = {}) => { + analytics.trackPageView({ ...contextRef.current, ...overrides }); + }, []); + + return { track, trackPageView }; +} + +/** + * Higher-order helper: attach analytics tracking to any onClick handler. + * + * @example + * + */ +export function trackClick( + eventName: EventName, + properties: EventProperties, + handler?: (e: T) => void +): (e: T) => void { + return (e: T) => { + analytics.track(eventName, properties); + handler?.(e); + }; +} \ No newline at end of file diff --git a/src/providers/Notificationprovider.tsx b/src/providers/Notificationprovider.tsx new file mode 100644 index 00000000..ca0fed89 --- /dev/null +++ b/src/providers/Notificationprovider.tsx @@ -0,0 +1,231 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, + useCallback, + ReactNode, + } from "react"; + + export type NotificationType = + | "info" + | "success" + | "warning" + | "error" + | "message" + | "course" + | "system"; + + export interface Notification { + id: string; + type: NotificationType; + title: string; + body?: string; + timestamp: Date; + read: boolean; + actionUrl?: string; + avatarUrl?: string; + metadata?: Record; + } + + type NotificationEventType = "notification" | "notification_read" | "notification_clear"; + + interface NotificationEvent { + event: NotificationEventType; + payload: unknown; + } + + // ────────────────────────────────────────────────────────────────────────────── + // WebSocket service (singleton per URL) + // ────────────────────────────────────────────────────────────────────────────── + + type Listener = (notification: Notification) => void; + + class NotificationSocketService { + private ws: WebSocket | null = null; + private listeners: Set = new Set(); + private reconnectTimer: ReturnType | null = null; + private reconnectDelay = 1000; + private maxReconnectDelay = 30_000; + private intentionallyClosed = false; + + constructor(private url: string) {} + + connect() { + this.intentionallyClosed = false; + this.open(); + } + + private open() { + if (this.ws) return; + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.info("[NotificationSocket] Connected"); + this.reconnectDelay = 1000; // reset back-off + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const data: NotificationEvent = JSON.parse(event.data as string); + if (data.event === "notification") { + const notification = data.payload as Notification; + notification.timestamp = new Date(notification.timestamp); + this.listeners.forEach((cb) => cb(notification)); + } + } catch { + console.warn("[NotificationSocket] Failed to parse message", event.data); + } + }; + + this.ws.onclose = () => { + this.ws = null; + if (!this.intentionallyClosed) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (err) => { + console.error("[NotificationSocket] Error", err); + this.ws?.close(); + }; + } catch (err) { + console.error("[NotificationSocket] Failed to open", err); + this.scheduleReconnect(); + } + } + + private scheduleReconnect() { + this.reconnectTimer = setTimeout(() => { + console.info(`[NotificationSocket] Reconnecting…`); + this.open(); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, this.reconnectDelay); + } + + disconnect() { + this.intentionallyClosed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } + + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + send(event: NotificationEventType, payload: unknown) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ event, payload })); + } + } + } + + // ────────────────────────────────────────────────────────────────────────────── + // Context + // ────────────────────────────────────────────────────────────────────────────── + + interface NotificationContextValue { + notifications: Notification[]; + unreadCount: number; + markAsRead: (id: string) => void; + markAllAsRead: () => void; + clearNotification: (id: string) => void; + clearAll: () => void; + } + + const NotificationContext = createContext(null); + + export function useNotifications(): NotificationContextValue { + const ctx = useContext(NotificationContext); + if (!ctx) throw new Error("useNotifications must be used inside "); + return ctx; + } + + // ────────────────────────────────────────────────────────────────────────────── + // Provider + // ────────────────────────────────────────────────────────────────────────────── + + interface NotificationProviderProps { + children: ReactNode; + /** WebSocket endpoint, e.g. "wss://api.example.com/notifications" */ + wsUrl: string; + /** Initial notifications fetched server-side / from cache */ + initialNotifications?: Notification[]; + /** Max number of notifications to keep in memory */ + maxNotifications?: number; + } + + export function NotificationProvider({ + children, + wsUrl, + initialNotifications = [], + maxNotifications = 200, + }: NotificationProviderProps) { + const [notifications, setNotifications] = useState(initialNotifications); + const serviceRef = useRef(null); + + const unreadCount = notifications.filter((n) => !n.read).length; + + const addNotification = useCallback( + (notification: Notification) => { + setNotifications((prev) => { + const next = [notification, ...prev]; + return next.slice(0, maxNotifications); + }); + }, + [maxNotifications] + ); + + useEffect(() => { + const svc = new NotificationSocketService(wsUrl); + serviceRef.current = svc; + svc.connect(); + + const unsubscribe = svc.subscribe(addNotification); + return () => { + unsubscribe(); + svc.disconnect(); + }; + }, [wsUrl, addNotification]); + + const markAsRead = useCallback((id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)) + ); + serviceRef.current?.send("notification_read", { id }); + }, []); + + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + serviceRef.current?.send("notification_read", { all: true }); + }, []); + + const clearNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + serviceRef.current?.send("notification_clear", { id }); + }, []); + + const clearAll = useCallback(() => { + setNotifications([]); + serviceRef.current?.send("notification_clear", { all: true }); + }, []); + + return ( + + {children} + + ); + } \ No newline at end of file diff --git a/src/testing/utils/fixtures.ts b/src/testing/utils/fixtures.ts new file mode 100644 index 00000000..c164248b --- /dev/null +++ b/src/testing/utils/fixtures.ts @@ -0,0 +1,174 @@ +// ────────────────────────────────────────────────────────────────────────────── +// src/testing/utils/fixtures.ts +// +// Centralised, reusable test data. +// Use the factory functions to generate single objects, or the preset arrays +// (COURSES, USERS, etc.) for collection-level tests. +// ────────────────────────────────────────────────────────────────────────────── + +import { SearchResult } from "@/components/virtualizedsearchresults"; +import { Course } from "@/types"; + + +// ────────────────────────────────────────────────────────────────────────────── +// Utility +// ────────────────────────────────────────────────────────────────────────────── + +let _seq = 0; +function seq(prefix = "") { + return `${prefix}${++_seq}`; +} + +/** Reset auto-increment counter between test suites */ +export function resetFixtureCounter() { + _seq = 0; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Course fixtures +// ────────────────────────────────────────────────────────────────────────────── + +export function makeCourse(overrides: Partial = {}): Course { + const id = seq("course-"); + return { + id, + title: `Test Course ${id}`, + instructor: "Jane Doe", + thumbnailUrl: `https://picsum.photos/seed/${id}/320/180`, + progress: 0, + duration: "4h 30m", + category: "Engineering", + ...overrides, + }; +} + +export function makeCourses(count: number, overrides: Partial = {}): Course[] { + return Array.from({ length: count }, () => makeCourse(overrides)); +} + +export const COURSES: Course[] = [ + makeCourse({ title: "Introduction to TypeScript", instructor: "Alice Smith", progress: 100}), + makeCourse({ title: "React Performance Patterns", instructor: "Bob Lee", progress: 42, }), + makeCourse({ title: "Node.js Microservices", instructor: "Carol White", progress: 0,}), + makeCourse({ title: "CSS Architecture", instructor: "Dan Brown", progress: 75,}), + makeCourse({ title: "Testing with Vitest", instructor: "Eve Davis", progress: 20,}), +]; + +// ────────────────────────────────────────────────────────────────────────────── +// Search result fixtures +// ────────────────────────────────────────────────────────────────────────────── + +export function makeSearchResult(overrides: Partial = {}): SearchResult { + const id = seq("result-"); + return { + id, + type: "course", + title: `Search Result ${id}`, + subtitle: "Subtitle text", + description: "Short description for the result item.", + ...overrides, + }; +} + +export function makeSearchResults( + count: number, + overrides: Partial = {} +): SearchResult[] { + return Array.from({ length: count }, () => makeSearchResult(overrides)); +} + +export const SEARCH_RESULTS: SearchResult[] = [ + makeSearchResult({ type: "course", title: "TypeScript Fundamentals" }), + makeSearchResult({ type: "user", title: "Alice Smith", subtitle: "Frontend Engineer" }), + makeSearchResult({ type: "post", title: "How to optimise React re-renders", description: "A deep-dive post." }), + makeSearchResult({ type: "file", title: "Q3 Report.pdf", subtitle: "Uploaded 3 days ago" }), +]; + +// ────────────────────────────────────────────────────────────────────────────── +// Message fixtures +// ────────────────────────────────────────────────────────────────────────────── + +export function makeMessage(overrides: Partial = {}): Message { + const id = seq("msg-"); + return { + id, + senderId: "user-1", + senderName: "Alice", + content: `Hello, this is message ${id}.`, + timestamp: new Date(Date.now() - Math.random() * 3_600_000), + isOwn: false, + status: "read", + ...overrides, + }; +} + +export function makeMessages(count: number, overrides: Partial = {}): Message[] { + return Array.from({ length: count }, () => makeMessage(overrides)); +} + +export const MESSAGES: Message[] = [ + makeMessage({ senderId: "user-2", senderName: "Bob", content: "Hey, how are you?" }), + makeMessage({ senderId: "user-1", senderName: "Alice", content: "I'm great, thanks!", isOwn: true, status: "read" }), + makeMessage({ senderId: "user-2", senderName: "Bob", content: "Can you review my PR?" }), + makeMessage({ senderId: "user-1", senderName: "Alice", content: "Sure, sending comments now.", isOwn: true, status: "delivered" }), +]; + +// ────────────────────────────────────────────────────────────────────────────── +// Notification fixtures +// ────────────────────────────────────────────────────────────────────────────── + +export function makeNotification(overrides: Partial = {}): Notification { + const id = seq("notif-"); + return { + id, + type: "info", + title: `Notification ${id}`, + body: "This is a test notification body.", + timestamp: new Date(Date.now() - Math.random() * 86_400_000), + read: false, + ...overrides, + }; +} + +export function makeNotifications( + count: number, + overrides: Partial = {} +): Notification[] { + return Array.from({ length: count }, () => makeNotification(overrides)); +} + +export const NOTIFICATIONS: Notification[] = [ + makeNotification({ title: "Course completed!", body: "You finished TypeScript Fundamentals." }), + makeNotification({ title: "New message from Bob", body: "Can you review my PR?" }), + makeNotification({ title: "Subscription expiring soon", body: "Your plan expires in 3 days.", read: true }), + makeNotification({ title: "Scheduled maintenance", body: "Downtime on Saturday 02:00–04:00 UTC." }), +]; + +// ────────────────────────────────────────────────────────────────────────────── +// Common user fixture (reused across domain objects) +// ────────────────────────────────────────────────────────────────────────────── + +export interface UserFixture { + id: string; + name: string; + email: string; + avatarUrl?: string; + role: "student" | "instructor" | "admin"; +} + +export function makeUser(overrides: Partial = {}): UserFixture { + const id = seq("user-"); + return { + id, + name: `Test User ${id}`, + email: `user${id}@example.com`, + role: "student", + ...overrides, + }; +} + +export const USERS: UserFixture[] = [ + makeUser({ name: "Alice Smith", email: "alice@example.com", role: "instructor" }), + makeUser({ name: "Bob Lee", email: "bob@example.com", role: "student" }), + makeUser({ name: "Carol Admin", email: "carol@example.com", role: "admin" }), +]; \ No newline at end of file diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts new file mode 100644 index 00000000..b3c601b8 --- /dev/null +++ b/src/utils/analytics.ts @@ -0,0 +1,198 @@ +// ────────────────────────────────────────────────────────────────────────────── +// analytics.ts – Centralised event-tracking service +// ────────────────────────────────────────────────────────────────────────────── +// Usage: +// import analytics from "@/utils/analytics"; +// analytics.track("course_started", { courseId: "abc" }); +// +// Or via hook (auto-attaches page metadata): +// import { useAnalytics } from "@/hooks/useAnalytics"; +// const { track } = useAnalytics(); +// track("button_clicked", { label: "Enroll" }); +// ────────────────────────────────────────────────────────────────────────────── + +export type EventName = + // Navigation + | "page_view" + | "page_exit" + // Auth + | "login" + | "logout" + | "signup" + // Courses + | "course_view" + | "course_started" + | "course_completed" + | "lesson_started" + | "lesson_completed" + | "lesson_skipped" + // Search + | "search_performed" + | "search_result_clicked" + | "search_no_results" + // Messaging + | "message_sent" + | "thread_opened" + // Notifications + | "notification_received" + | "notification_clicked" + | "notification_dismissed" + // UI interactions + | "button_clicked" + | "link_clicked" + | "modal_opened" + | "modal_closed" + | "filter_applied" + | "sort_changed" + // Errors + | "error_boundary_triggered" + | "api_error" + // Custom / escape hatch + | (string & Record); + +export interface EventProperties { + [key: string]: string | number | boolean | null | undefined; +} + +export interface AnalyticsEvent { + name: EventName; + properties: EventProperties; + timestamp: string; // ISO-8601 + sessionId: string; + userId?: string; + anonymousId: string; +} + +export type AnalyticsAdapter = (event: AnalyticsEvent) => void | Promise; + +// ────────────────────────────────────────────────────────────────────────────── +// Built-in adapters +// ────────────────────────────────────────────────────────────────────────────── + +/** Logs to console in development */ +export const consoleAdapter: AnalyticsAdapter = (event) => { + if (process.env.NODE_ENV !== "production") { + console.info(`[Analytics] ${event.name}`, event.properties); + } +}; + +/** Sends events to your own backend */ +export function createApiAdapter(endpoint: string): AnalyticsAdapter { + return async (event) => { + try { + await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + keepalive: true, // survive page unload + }); + } catch (err) { + console.warn("[Analytics] Failed to send event", err); + } + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +function generateId(prefix = ""): string { + return `${prefix}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +} + +const SESSION_KEY = "__analytics_session__"; +const ANON_KEY = "__analytics_anon__"; + +function getOrCreate(key: string, factory: () => string): string { + try { + return sessionStorage.getItem(key) ?? (() => { + const id = factory(); + sessionStorage.setItem(key, id); + return id; + })(); + } catch { + return factory(); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Analytics class +// ────────────────────────────────────────────────────────────────────────────── + +class Analytics { + private adapters: AnalyticsAdapter[] = [consoleAdapter]; + private userId: string | undefined; + private globalProperties: EventProperties = {}; + + private get sessionId(): string { + return getOrCreate(SESSION_KEY, () => generateId("s_")); + } + + private get anonymousId(): string { + try { + const stored = localStorage.getItem(ANON_KEY); + if (stored) return stored; + const id = generateId("a_"); + localStorage.setItem(ANON_KEY, id); + return id; + } catch { + return generateId("a_"); + } + } + + /** Register an adapter (e.g. Segment, Mixpanel, your own API) */ + addAdapter(adapter: AnalyticsAdapter): this { + this.adapters.push(adapter); + return this; + } + + /** Attach a user identity after login */ + identify(userId: string, traits?: EventProperties): void { + this.userId = userId; + if (traits) this.setGlobalProperties(traits); + } + + /** Clear identity on logout */ + reset(): void { + this.userId = undefined; + this.globalProperties = {}; + } + + /** Properties merged into every subsequent event */ + setGlobalProperties(properties: EventProperties): void { + this.globalProperties = { ...this.globalProperties, ...properties }; + } + + track(name: EventName, properties: EventProperties = {}): void { + const event: AnalyticsEvent = { + name, + properties: { ...this.globalProperties, ...properties }, + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + anonymousId: this.anonymousId, + userId: this.userId, + }; + + for (const adapter of this.adapters) { + try { + adapter(event); + } catch (err) { + console.warn(`[Analytics] Adapter error for "${name}"`, err); + } + } + } + + /** Convenience: track a page view with current URL metadata */ + trackPageView(overrides: EventProperties = {}): void { + this.track("page_view", { + url: window.location.href, + path: window.location.pathname, + referrer: document.referrer || null, + title: document.title, + ...overrides, + }); + } +} + +const analytics = new Analytics(); +export default analytics; \ No newline at end of file From 6efa91387185feb0077ee806274a60d588570931 Mon Sep 17 00:00:00 2001 From: OlufunbiIK Date: Mon, 27 Apr 2026 16:32:18 +0100 Subject: [PATCH 2/2] feat --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0f1d4bde..46031f77 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules/ .env dist/ build/ -coverage/ \ No newline at end of file +coverage/node_modules/