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);
+ });
+});