diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5e2897a2..7a373bb9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import OfflineBanner from './components/OfflineBanner'; import Onboarding from './components/Onboarding'; import { AnimatedHero } from './components/ui/animated-hero'; import { ToastContainer, useToast } from './components/ui/toast'; +import { analytics } from './lib/analytics'; const TipHistory = lazy(() => import('./components/TipHistory')); const PlatformStats = lazy(() => import('./components/PlatformStats')); @@ -27,18 +28,26 @@ function App() { if (userSession.isUserSignedIn()) { setUserData(userSession.loadUserData()); } + analytics.trackSession(); }, []); + useEffect(() => { + analytics.trackPageView(location.pathname); + analytics.trackTabNavigation(location.pathname); + }, [location.pathname]); + const handleAuth = async () => { if (userData) { disconnect(); setUserData(null); + analytics.trackWalletDisconnect(); return; } setAuthLoading(true); try { await authenticate(); + analytics.trackWalletConnect(); } catch (error) { console.error('Authentication failed:', error.message || error); addToast(error.message || 'Failed to connect wallet. Please try again.', 'error'); diff --git a/frontend/src/components/AdminDashboard.jsx b/frontend/src/components/AdminDashboard.jsx index bb84025f..d09e145c 100644 --- a/frontend/src/components/AdminDashboard.jsx +++ b/frontend/src/components/AdminDashboard.jsx @@ -10,6 +10,7 @@ import { import { network, appDetails, userSession } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { formatSTX } from '../lib/utils'; +import { analytics } from '../lib/analytics'; export default function AdminDashboard({ addToast }) { const [stats, setStats] = useState(null); @@ -19,6 +20,7 @@ export default function AdminDashboard({ addToast }) { const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); const [isOwner, setIsOwner] = useState(false); + const [analyticsData, setAnalyticsData] = useState(null); const userAddress = userSession.isUserSignedIn() ? userSession.loadUserData().profile.stxAddress.mainnet @@ -72,6 +74,7 @@ export default function AdminDashboard({ addToast }) { useEffect(() => { fetchAdminData(); + setAnalyticsData(analytics.getSummary()); }, [fetchAdminData]); const handlePauseToggle = async () => { @@ -157,6 +160,104 @@ export default function AdminDashboard({ addToast }) { ); } + const AnalyticsPanel = () => { + if (!analyticsData) return null; + const a = analyticsData; + + return ( +
+
+

Usage Analytics

+ +
+ +
+
+

{a.totalPageViews}

+

Page Views

+
+
+

{a.walletConnections}

+

Wallet Connects

+
+
+

{a.sessions}

+

Sessions

+
+
+

{a.totalErrors}

+

Errors

+
+
+ +
+

Tip Funnel

+
+ {[ + ['Started', a.tipsStarted], + ['Submitted', a.tipsSubmitted], + ['Confirmed', a.tipsConfirmed], + ['Cancelled', a.tipsCancelled], + ['Failed', a.tipsFailed], + ].map(([label, count]) => ( +
+ {label} + {count} +
+ ))} +
+ Completion Rate + {a.tipCompletionRate}% +
+
+ Drop-off Rate + {a.tipDropOffRate}% +
+
+
+ + {a.sortedTabs.length > 0 && ( +
+

Tab Navigation

+
+ {a.sortedTabs.map(([tab, count]) => ( +
+ {tab} + {count} +
+ ))} +
+
+ )} + + {a.sortedErrors.length > 0 && ( +
+

Top Errors

+
+ {a.sortedErrors.map(([error, count]) => ( +
+ {error} + {count} +
+ ))} +
+
+ )} + + {a.firstSeen && ( +

+ Tracking since {new Date(a.firstSeen).toLocaleDateString()} +

+ )} +
+ ); + }; + return (

Admin Dashboard

@@ -223,6 +324,8 @@ export default function AdminDashboard({ addToast }) {
+ + ); } diff --git a/frontend/src/components/BatchTip.jsx b/frontend/src/components/BatchTip.jsx index 4938c64a..7b7af10d 100644 --- a/frontend/src/components/BatchTip.jsx +++ b/frontend/src/components/BatchTip.jsx @@ -13,6 +13,7 @@ import { network, appDetails, userSession } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { toMicroSTX, formatSTX } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; +import { analytics } from '../lib/analytics'; const MAX_RECIPIENTS = 50; const MIN_TIP_STX = 0.001; @@ -94,6 +95,7 @@ export default function BatchTip({ addToast }) { const handleSubmit = async () => { if (!validate()) return; setSending(true); + analytics.trackBatchTipStarted(); try { const totalMicro = toMicroSTX(totalAmount + totalFee); @@ -123,6 +125,7 @@ export default function BatchTip({ addToast }) { ], onFinish: () => { notifyTipSent(); + analytics.trackBatchTipSubmitted(); addToast(`Batch of ${validEntries.length} tips submitted`, 'success'); setEntries([emptyEntry(), emptyEntry()]); }, @@ -131,6 +134,7 @@ export default function BatchTip({ addToast }) { }, }); } catch (err) { + analytics.trackError('BatchTip', err.message || 'Unknown error'); addToast(err.message || 'Failed to send batch tips', 'error'); } finally { setSending(false); diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index a2760a90..86955fab 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -1,4 +1,5 @@ import { Component } from 'react'; +import { analytics } from '../lib/analytics'; export default class ErrorBoundary extends Component { constructor(props) { @@ -12,6 +13,7 @@ export default class ErrorBoundary extends Component { componentDidCatch(error, info) { console.error('Uncaught error:', error, info.componentStack); + analytics.trackError('ErrorBoundary', error.message || 'Unknown error'); } handleReset = () => { diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 53dc4b7b..ced5c2ff 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -13,6 +13,7 @@ import { toMicroSTX, formatSTX } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; import { useBalance } from '../hooks/useBalance'; import { useStxPrice } from '../hooks/useStxPrice'; +import { analytics } from '../lib/analytics'; import ConfirmDialog from './ui/confirm-dialog'; import TxStatus from './ui/tx-status'; @@ -148,10 +149,12 @@ export default function SendTip({ addToast }) { } setShowConfirm(true); + analytics.trackTipStarted(); }; const handleSendTip = async () => { setShowConfirm(false); + analytics.trackTipSubmitted(); setLoading(true); @@ -191,11 +194,13 @@ export default function SendTip({ addToast }) { notifyTipSent(); refetchBalance(); startCooldown(); + analytics.trackTipConfirmed(); addToast('Tip sent successfully! Transaction: ' + data.txId, 'success'); }, onCancel: () => { console.info('Transaction cancelled by user'); setLoading(false); + analytics.trackTipCancelled(); addToast('Transaction cancelled. Your funds were not transferred.', 'info'); } }; @@ -203,6 +208,8 @@ export default function SendTip({ addToast }) { await openContractCall(options); } catch (error) { console.error('Failed to send tip:', error.message || error); + analytics.trackTipFailed(); + analytics.trackError('SendTip', error.message || 'Unknown error'); addToast('Failed to send tip. Please try again.', 'error'); setLoading(false); } diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js new file mode 100644 index 00000000..3ecce8b4 --- /dev/null +++ b/frontend/src/lib/analytics.js @@ -0,0 +1,162 @@ +const STORAGE_KEY = 'tipstream_analytics'; + +const DEFAULT_METRICS = { + pageViews: {}, + walletConnections: 0, + walletDisconnections: 0, + tipsStarted: 0, + tipsSubmitted: 0, + tipsConfirmed: 0, + tipsCancelled: 0, + tipsFailed: 0, + batchTipsStarted: 0, + batchTipsSubmitted: 0, + tabNavigations: {}, + errors: {}, + sessions: 0, + firstSeen: null, + lastSeen: null, +}; + +function loadMetrics() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_METRICS, firstSeen: Date.now(), lastSeen: Date.now(), sessions: 1 }; + return JSON.parse(raw); + } catch { + return { ...DEFAULT_METRICS, firstSeen: Date.now(), lastSeen: Date.now(), sessions: 1 }; + } +} + +function saveMetrics(metrics) { + try { + metrics.lastSeen = Date.now(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(metrics)); + } catch { + // storage full or unavailable + } +} + +function increment(field) { + const metrics = loadMetrics(); + if (typeof metrics[field] === 'number') { + metrics[field] += 1; + } + saveMetrics(metrics); +} + +function incrementMap(field, key) { + const metrics = loadMetrics(); + if (!metrics[field] || typeof metrics[field] !== 'object') { + metrics[field] = {}; + } + metrics[field][key] = (metrics[field][key] || 0) + 1; + saveMetrics(metrics); +} + +export const analytics = { + trackPageView(path) { + incrementMap('pageViews', path); + }, + + trackWalletConnect() { + increment('walletConnections'); + }, + + trackWalletDisconnect() { + increment('walletDisconnections'); + }, + + trackTipStarted() { + increment('tipsStarted'); + }, + + trackTipSubmitted() { + increment('tipsSubmitted'); + }, + + trackTipConfirmed() { + increment('tipsConfirmed'); + }, + + trackTipCancelled() { + increment('tipsCancelled'); + }, + + trackTipFailed() { + increment('tipsFailed'); + }, + + trackBatchTipStarted() { + increment('batchTipsStarted'); + }, + + trackBatchTipSubmitted() { + increment('batchTipsSubmitted'); + }, + + trackTabNavigation(tab) { + incrementMap('tabNavigations', tab); + }, + + trackError(component, message) { + const key = `${component}:${message}`.slice(0, 200); + incrementMap('errors', key); + }, + + trackSession() { + increment('sessions'); + }, + + getMetrics() { + return loadMetrics(); + }, + + getSummary() { + const m = loadMetrics(); + const tipCompletionRate = m.tipsStarted > 0 + ? ((m.tipsConfirmed / m.tipsStarted) * 100).toFixed(1) + : '0.0'; + const tipDropOffRate = m.tipsStarted > 0 + ? (((m.tipsStarted - m.tipsConfirmed) / m.tipsStarted) * 100).toFixed(1) + : '0.0'; + + const sortedTabs = Object.entries(m.tabNavigations || {}) + .sort((a, b) => b[1] - a[1]); + + const sortedPages = Object.entries(m.pageViews || {}) + .sort((a, b) => b[1] - a[1]); + + const sortedErrors = Object.entries(m.errors || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + const totalPageViews = Object.values(m.pageViews || {}).reduce((a, b) => a + b, 0); + const totalErrors = Object.values(m.errors || {}).reduce((a, b) => a + b, 0); + + return { + totalPageViews, + walletConnections: m.walletConnections, + tipsStarted: m.tipsStarted, + tipsSubmitted: m.tipsSubmitted, + tipsConfirmed: m.tipsConfirmed, + tipsCancelled: m.tipsCancelled, + tipsFailed: m.tipsFailed, + tipCompletionRate, + tipDropOffRate, + batchTipsStarted: m.batchTipsStarted, + batchTipsSubmitted: m.batchTipsSubmitted, + sortedTabs, + sortedPages, + sortedErrors, + totalErrors, + sessions: m.sessions, + firstSeen: m.firstSeen, + lastSeen: m.lastSeen, + }; + }, + + reset() { + localStorage.removeItem(STORAGE_KEY); + }, +}; diff --git a/frontend/src/test/analytics.test.js b/frontend/src/test/analytics.test.js new file mode 100644 index 00000000..217d56ef --- /dev/null +++ b/frontend/src/test/analytics.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { analytics } from '../lib/analytics'; + +describe('Analytics', () => { + beforeEach(() => { + analytics.reset(); + }); + + it('tracks sessions', () => { + analytics.trackSession(); + analytics.trackSession(); + const summary = analytics.getSummary(); + expect(summary.sessions).toBeGreaterThanOrEqual(2); + }); + + it('tracks page views', () => { + analytics.trackPageView('/send'); + analytics.trackPageView('/send'); + analytics.trackPageView('/activity'); + const summary = analytics.getSummary(); + expect(summary.totalPageViews).toBe(3); + expect(summary.sortedPages[0]).toEqual(['/send', 2]); + }); + + it('tracks wallet connections', () => { + analytics.trackWalletConnect(); + analytics.trackWalletConnect(); + analytics.trackWalletDisconnect(); + const summary = analytics.getSummary(); + expect(summary.walletConnections).toBe(2); + }); + + it('tracks tip funnel', () => { + analytics.trackTipStarted(); + analytics.trackTipStarted(); + analytics.trackTipSubmitted(); + analytics.trackTipSubmitted(); + analytics.trackTipConfirmed(); + analytics.trackTipCancelled(); + const summary = analytics.getSummary(); + expect(summary.tipsStarted).toBe(2); + expect(summary.tipsSubmitted).toBe(2); + expect(summary.tipsConfirmed).toBe(1); + expect(summary.tipsCancelled).toBe(1); + expect(summary.tipCompletionRate).toBe('50.0'); + expect(summary.tipDropOffRate).toBe('50.0'); + }); + + it('tracks tab navigation', () => { + analytics.trackTabNavigation('/send'); + analytics.trackTabNavigation('/send'); + analytics.trackTabNavigation('/stats'); + const summary = analytics.getSummary(); + expect(summary.sortedTabs[0]).toEqual(['/send', 2]); + expect(summary.sortedTabs[1]).toEqual(['/stats', 1]); + }); + + it('tracks errors by component', () => { + analytics.trackError('SendTip', 'Network error'); + analytics.trackError('SendTip', 'Network error'); + analytics.trackError('BatchTip', 'Timeout'); + const summary = analytics.getSummary(); + expect(summary.totalErrors).toBe(3); + expect(summary.sortedErrors[0]).toEqual(['SendTip:Network error', 2]); + }); + + it('tracks batch tip events', () => { + analytics.trackBatchTipStarted(); + analytics.trackBatchTipSubmitted(); + const summary = analytics.getSummary(); + expect(summary.batchTipsStarted).toBe(1); + expect(summary.batchTipsSubmitted).toBe(1); + }); + + it('computes zero rates when no tips started', () => { + const summary = analytics.getSummary(); + expect(summary.tipCompletionRate).toBe('0.0'); + expect(summary.tipDropOffRate).toBe('0.0'); + }); + + it('records firstSeen timestamp', () => { + analytics.trackSession(); + const summary = analytics.getSummary(); + expect(summary.firstSeen).toBeTruthy(); + expect(typeof summary.firstSeen).toBe('number'); + }); + + it('resets all metrics', () => { + analytics.trackTipStarted(); + analytics.trackTipStarted(); + analytics.trackTipConfirmed(); + const before = analytics.getSummary(); + expect(before.tipsStarted).toBeGreaterThanOrEqual(2); + expect(before.tipsConfirmed).toBeGreaterThanOrEqual(1); + analytics.reset(); + const after = analytics.getSummary(); + expect(after.tipsStarted).toBe(0); + expect(after.tipsConfirmed).toBe(0); + }); +});