+
+
+ {isOpen && (
+ <>
+
setIsOpen(false)}
+ aria-hidden="true"
+ />
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/TipVolumeChart.jsx b/frontend/src/components/TipVolumeChart.jsx
new file mode 100644
index 00000000..ea104ad3
--- /dev/null
+++ b/frontend/src/components/TipVolumeChart.jsx
@@ -0,0 +1,373 @@
+import { formatAmount } from '../services/analytics';
+import { useState, useEffect, useMemo } from 'react';
+
+/**
+ * Custom Premium SVG-based Chart to avoid external charting library dependencies (like Recharts)
+ * which can cause installation timeouts/failures in offline or restricted environments.
+ *
+ * Provides interactive tooltips, line/bar toggle, and mobile responsiveness.
+ */
+export default function TipVolumeChart({ data }) {
+ const [chartType, setChartType] = useState('line');
+ const [isMobile, setIsMobile] = useState(false);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, []);
+
+ if (!data || data.length === 0) {
+ return (
+
+
Tip Volume Over Time
+
No data available
+
+ );
+ }
+
+ const chartData = useMemo(() => {
+ return data.map(item => ({
+ date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ tips: item.count,
+ volume: Number(formatAmount(item.volume)),
+ }));
+ }, [data]);
+
+ const maxTips = useMemo(() => {
+ const vals = chartData.map(d => d.tips);
+ return Math.max(...vals, 1);
+ }, [chartData]);
+
+ const maxVolume = useMemo(() => {
+ const vals = chartData.map(d => d.volume);
+ return Math.max(...vals, 1);
+ }, [chartData]);
+
+ const svgWidth = 800;
+ const svgHeight = 350;
+ const paddingLeft = 50;
+ const paddingRight = 50;
+ const paddingTop = 30;
+ const paddingBottom = 40;
+
+ const chartWidth = svgWidth - paddingLeft - paddingRight;
+ const chartHeight = svgHeight - paddingTop - paddingBottom;
+
+ const points = useMemo(() => {
+ if (chartData.length === 0) return [];
+
+ return chartData.map((d, index) => {
+ const x = paddingLeft + (index / Math.max(chartData.length - 1, 1)) * chartWidth;
+
+ const yTips = paddingTop + chartHeight - (d.tips / maxTips) * chartHeight;
+ const yVolume = paddingTop + chartHeight - (d.volume / maxVolume) * chartHeight;
+
+ return { x, yTips, yVolume, ...d };
+ });
+ }, [chartData, chartWidth, chartHeight, maxTips, maxVolume]);
+
+ const linePathTips = useMemo(() => {
+ if (points.length < 2) return '';
+ return points.reduce((path, p, i) => {
+ return i === 0 ? `M ${p.x} ${p.yTips}` : `${path} L ${p.x} ${p.yTips}`;
+ }, '');
+ }, [points]);
+
+ const linePathVolume = useMemo(() => {
+ if (points.length < 2) return '';
+ return points.reduce((path, p, i) => {
+ return i === 0 ? `M ${p.x} ${p.yVolume}` : `${path} L ${p.x} ${p.yVolume}`;
+ }, '');
+ }, [points]);
+
+ const areaPathTips = useMemo(() => {
+ if (points.length < 2) return '';
+ const baseLine = paddingTop + chartHeight;
+ return `${linePathTips} L ${points[points.length - 1].x} ${baseLine} L ${points[0].x} ${baseLine} Z`;
+ }, [points, linePathTips, chartHeight]);
+
+ const areaPathVolume = useMemo(() => {
+ if (points.length < 2) return '';
+ const baseLine = paddingTop + chartHeight;
+ return `${linePathVolume} L ${points[points.length - 1].x} ${baseLine} L ${points[0].x} ${baseLine} Z`;
+ }, [points, linePathVolume, chartHeight]);
+
+ const selectedPoint = hoveredIndex !== null ? points[hoveredIndex] : null;
+
+ return (
+
+
+
Tip Volume Over Time
+
+
+
+
+
+
+ {selectedPoint && (
+
+
{selectedPoint.date}
+
+ Tips: {selectedPoint.tips}
+ {' | '}
+ Volume: {selectedPoint.volume.toFixed(2)} STX
+
+
+ )}
+
+
+
+
+
+
+
+
+ Number of Tips
+
+
+
+ Volume (STX)
+
+
+
+ );
+}
diff --git a/frontend/src/components/TopRecipientsChart.jsx b/frontend/src/components/TopRecipientsChart.jsx
new file mode 100644
index 00000000..9da30e33
--- /dev/null
+++ b/frontend/src/components/TopRecipientsChart.jsx
@@ -0,0 +1,135 @@
+import { formatAmount, formatAddress } from '../services/analytics';
+import { useState, useEffect, useMemo } from 'react';
+
+/**
+ * Custom Premium SVG/CSS-based Chart to avoid external charting library dependencies (like Recharts)
+ * which can cause installation timeouts/failures in offline or restricted environments.
+ *
+ * Provides interactive rankings, hover tooltips, and high mobile responsiveness.
+ */
+export default function TopRecipientsChart({ data }) {
+ const [isMobile, setIsMobile] = useState(false);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, []);
+
+ if (!data || data.length === 0) {
+ return (
+
+
Top Recipients
+
No data available
+
+ );
+ }
+
+ const chartData = useMemo(() => {
+ return data.map(item => ({
+ address: formatAddress(item.address),
+ fullAddress: item.address,
+ volume: Number(formatAmount(item.volume)),
+ count: item.count,
+ }));
+ }, [data]);
+
+ const displayData = useMemo(() => {
+ return isMobile ? chartData.slice(0, 5) : chartData;
+ }, [chartData, isMobile]);
+
+ const maxVolume = useMemo(() => {
+ const vals = displayData.map(d => d.volume);
+ return Math.max(...vals, 1);
+ }, [displayData]);
+
+ const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null;
+
+ return (
+
+
Top Recipients
+
+ {selectedPoint && (
+
+
{selectedPoint.fullAddress}
+
+ Volume: {selectedPoint.volume.toFixed(2)} STX
+ {' | '}
+ Tips: {selectedPoint.count}
+
+
+ )}
+
+
+
+ {displayData.map((d, index) => {
+ const isHovered = hoveredIndex === index;
+ const percentage = (d.volume / maxVolume) * 100;
+ return (
+
setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ >
+
+
+ {d.address}
+
+
+
+
+
+ {percentage > 15 && (
+
+ {d.volume.toFixed(1)} STX
+
+ )}
+
+ {percentage <= 15 && (
+
+ {d.volume.toFixed(1)} STX
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
Detailed Rankings
+
+ {data.slice(0, 5).map((recipient, index) => (
+
setHoveredIndex(index)}
+ >
+
+
+ {index + 1}
+
+ {formatAddress(recipient.address)}
+
+
+
{formatAmount(recipient.volume)} STX
+
{recipient.count} tips
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/TopSendersChart.jsx b/frontend/src/components/TopSendersChart.jsx
new file mode 100644
index 00000000..ae91a802
--- /dev/null
+++ b/frontend/src/components/TopSendersChart.jsx
@@ -0,0 +1,135 @@
+import { formatAmount, formatAddress } from '../services/analytics';
+import { useState, useEffect, useMemo } from 'react';
+
+/**
+ * Custom Premium SVG/CSS-based Chart to avoid external charting library dependencies (like Recharts)
+ * which can cause installation timeouts/failures in offline or restricted environments.
+ *
+ * Provides interactive rankings, hover tooltips, and high mobile responsiveness.
+ */
+export default function TopSendersChart({ data }) {
+ const [isMobile, setIsMobile] = useState(false);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, []);
+
+ if (!data || data.length === 0) {
+ return (
+
+
Top Senders
+
No data available
+
+ );
+ }
+
+ const chartData = useMemo(() => {
+ return data.map(item => ({
+ address: formatAddress(item.address),
+ fullAddress: item.address,
+ volume: Number(formatAmount(item.volume)),
+ count: item.count,
+ }));
+ }, [data]);
+
+ const displayData = useMemo(() => {
+ return isMobile ? chartData.slice(0, 5) : chartData;
+ }, [chartData, isMobile]);
+
+ const maxVolume = useMemo(() => {
+ const vals = displayData.map(d => d.volume);
+ return Math.max(...vals, 1);
+ }, [displayData]);
+
+ const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null;
+
+ return (
+
+
Top Senders
+
+ {selectedPoint && (
+
+
{selectedPoint.fullAddress}
+
+ Volume: {selectedPoint.volume.toFixed(2)} STX
+ {' | '}
+ Tips: {selectedPoint.count}
+
+
+ )}
+
+
+
+ {displayData.map((d, index) => {
+ const isHovered = hoveredIndex === index;
+ const percentage = (d.volume / maxVolume) * 100;
+ return (
+
setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ >
+
+
+ {d.address}
+
+
+
+
+
+ {percentage > 15 && (
+
+ {d.volume.toFixed(1)} STX
+
+ )}
+
+ {percentage <= 15 && (
+
+ {d.volume.toFixed(1)} STX
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
Detailed Rankings
+
+ {data.slice(0, 5).map((sender, index) => (
+
setHoveredIndex(index)}
+ >
+
+
+ {index + 1}
+
+ {formatAddress(sender.address)}
+
+
+
{formatAmount(sender.volume)} STX
+
{sender.count} tips
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js
index 24abad95..5f44ad92 100644
--- a/frontend/src/config/routes.js
+++ b/frontend/src/config/routes.js
@@ -91,6 +91,12 @@ export const ROUTE_BLOCK = '/block';
*/
export const ROUTE_STATS = '/stats';
+/**
+ * Analytics dashboard with charts and trends.
+ * @type {string}
+ */
+export const ROUTE_ANALYTICS = '/analytics';
+
/**
* Admin dashboard (owner-only, guarded by RequireAdmin).
* @type {string}
@@ -146,6 +152,7 @@ export const ROUTE_LABELS = {
[ROUTE_ADDRESS_BOOK]: 'Address Book',
[ROUTE_BLOCK]: 'Block',
[ROUTE_STATS]: 'Stats',
+ [ROUTE_ANALYTICS]: 'Analytics',
[ROUTE_ADMIN]: 'Admin',
[ROUTE_TELEMETRY]: 'Telemetry',
[ROUTE_REFUNDS]: 'Refunds',
@@ -174,6 +181,7 @@ export const ROUTE_TITLES = {
[ROUTE_ADDRESS_BOOK]: 'Address Book -- TipStream',
[ROUTE_BLOCK]: 'Block Manager -- TipStream',
[ROUTE_STATS]: 'Platform Stats -- TipStream',
+ [ROUTE_ANALYTICS]: 'Analytics Dashboard -- TipStream',
[ROUTE_ADMIN]: 'Admin Dashboard -- TipStream',
[ROUTE_TELEMETRY]: 'Telemetry -- TipStream',
[ROUTE_REFUNDS]: 'Refunds -- TipStream',
@@ -258,6 +266,11 @@ export const ROUTE_META = {
requiresAuth: false,
adminOnly: false,
},
+ [ROUTE_ANALYTICS]: {
+ description: 'Analytics dashboard with charts, trends, and insights.',
+ requiresAuth: false,
+ adminOnly: false,
+ },
[ROUTE_ADMIN]: {
description: 'Pause/resume, fee configuration, ownership transfer.',
requiresAuth: true,
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e0986c8e..92594486 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,4 +1,5 @@
@import "tailwindcss";
+@config "../tailwind.config.js";
/* ── Base layer ────────────────────────────────────────────── */
html {
diff --git a/frontend/src/lib/batchTipResults.js b/frontend/src/lib/batchTipResults.js
new file mode 100644
index 00000000..82f7f15e
--- /dev/null
+++ b/frontend/src/lib/batchTipResults.js
@@ -0,0 +1,71 @@
+/**
+ * Utilities for summarizing and building notification messages for batch tip transactions.
+ */
+
+/**
+ * Summarizes the outcome of a batch tip transaction by parsing its event log.
+ *
+ * @param {object} txData - Transaction receipt from Stacks API.
+ * @param {number} totalRecipients - Expected number of recipients in the batch.
+ * @returns {{ successCount: number, failureCount: number, totalCount: number }}
+ */
+export function summarizeBatchTipResult(txData, totalRecipients) {
+ if (!txData) {
+ return {
+ successCount: 0,
+ failureCount: totalRecipients,
+ totalCount: totalRecipients,
+ };
+ }
+
+ if (txData.tx_status !== 'success') {
+ return {
+ successCount: 0,
+ failureCount: totalRecipients,
+ totalCount: totalRecipients,
+ };
+ }
+
+ // If there's no events array, or it's empty, assume all succeeded
+ if (!Array.isArray(txData.events) || txData.events.length === 0) {
+ return {
+ successCount: totalRecipients,
+ failureCount: 0,
+ totalCount: totalRecipients,
+ };
+ }
+
+ // Stacks contract emits "smart_contract_log" events for each successful transfer/tip.
+ // Let's count how many print events are present in the transaction events list.
+ const printEvents = txData.events.filter(e => e.event_type === 'smart_contract_log');
+
+ // Resilient check: if we have print events, use that count. Otherwise, assume full success.
+ const successCount = printEvents.length > 0 ? Math.min(printEvents.length, totalRecipients) : totalRecipients;
+ const failureCount = Math.max(0, totalRecipients - successCount);
+
+ return {
+ successCount,
+ failureCount,
+ totalCount: totalRecipients,
+ };
+}
+
+/**
+ * Builds a user-friendly outcome message for a batch tip transaction.
+ *
+ * @param {{ successCount: number, failureCount: number, totalCount: number }} summary
+ * @returns {string} User-facing result message.
+ */
+export function buildBatchTipOutcomeMessage(summary) {
+ const { successCount, failureCount, totalCount } = summary;
+
+ if (failureCount === 0) {
+ return `Successfully sent all ${totalCount} tip${totalCount !== 1 ? 's' : ''}!`;
+ }
+
+ if (successCount === 0) {
+ return `Failed to send any of the ${totalCount} tip${totalCount !== 1 ? 's' : ''}.`;
+ }
+
+ return `Batch complete: ${successCount} tip${successCount !== 1 ? 's' : ''} succeeded, ${failureCount} failed.`;
+}
diff --git a/frontend/src/services/analytics.js b/frontend/src/services/analytics.js
new file mode 100644
index 00000000..643c6904
--- /dev/null
+++ b/frontend/src/services/analytics.js
@@ -0,0 +1,66 @@
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3100';
+
+export async function fetchAnalytics(startDate, endDate) {
+ const params = new URLSearchParams();
+ if (startDate) {
+ params.append('startDate', startDate);
+ }
+ if (endDate) {
+ params.append('endDate', endDate);
+ }
+
+ const url = `${API_BASE_URL}/api/analytics${params.toString() ? '?' + params.toString() : ''}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch analytics data');
+ }
+
+ return response.json();
+}
+
+export async function fetchStats() {
+ const response = await fetch(`${API_BASE_URL}/api/stats`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch stats');
+ }
+
+ return response.json();
+}
+
+export function formatAmount(amount) {
+ return (Number(amount) / 1000000).toFixed(2);
+}
+
+export function formatAddress(address) {
+ if (!address || address.length < 10) return address;
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+}
+
+export function exportToCSV(data, filename) {
+ const headers = Object.keys(data[0] || {});
+ const csvContent = [
+ headers.join(','),
+ ...data.map(row => headers.map(header => row[header]).join(','))
+ ].join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ window.URL.revokeObjectURL(url);
+}
+
+export function exportToJSON(data, filename) {
+ const jsonContent = JSON.stringify(data, null, 2);
+ const blob = new Blob([jsonContent], { type: 'application/json' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ window.URL.revokeObjectURL(url);
+}
diff --git a/frontend/src/styles/analytics.css b/frontend/src/styles/analytics.css
new file mode 100644
index 00000000..9228b1a9
--- /dev/null
+++ b/frontend/src/styles/analytics.css
@@ -0,0 +1,78 @@
+@media (max-width: 640px) {
+ .recharts-wrapper {
+ font-size: 12px;
+ }
+
+ .recharts-cartesian-axis-tick-value {
+ font-size: 10px;
+ }
+
+ .recharts-legend-wrapper {
+ font-size: 11px;
+ }
+}
+
+@media (max-width: 768px) {
+ .analytics-summary-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .analytics-chart-container {
+ padding: 1rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .analytics-summary-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+.analytics-card {
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.analytics-card:hover {
+ transform: translateY(-2px);
+}
+
+.analytics-loading {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.analytics-export-menu {
+ animation: slideDown 0.2s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.analytics-tooltip {
+ animation: fadeIn 0.15s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}