import React from 'react'; import superagent from 'superagent'; import { Header, Segment, Button, Icon, Table, Label, Statistic, Loader, Dropdown, Grid, Message } from 'semantic-ui-react'; import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { formatDistanceToNow, parseISO } from 'date-fns'; import ExperimentalWrapper from './ExperimentalWrapper.jsx'; const STATUS_COLORS = { DELIVERED: '#21ba45', IN_TRANSIT: '#2185d0', PRE_TRANSIT: '#fbbd08', RETURNED: '#db2828', FAILURE: '#a04050', UNKNOWN: '#767676', }; const ALL_STATUSES_COLOR = '#999999'; const DATE_RANGE_OPTIONS = [ { key: '7', text: 'Last 7 days', value: 7 }, { key: '14', text: 'Last 14 days', value: 14 }, { key: '30', text: 'Last 30 days', value: 30 }, { key: '90', text: 'Last 90 days', value: 90 }, ]; function getStatusColor(status) { return STATUS_COLORS[status] || ALL_STATUSES_COLOR; } export default class ShippoAnalyticsPage extends React.Component { constructor(props) { super(props); this.state = { // Data cacheStatus: null, analytics: null, transactions: [], transactionsTotal: 0, // UI state loading: false, refreshing: false, loadingTransactions: false, // Filters statusFilter: '', chartDays: 30, txOffset: 0, txLimit: 50, // Error error: null, }; } componentDidMount() { this.fetchCacheStatus(); this.fetchAnalytics(); this.fetchTransactions(); } async fetchCacheStatus() { try { const res = await superagent.get('/api/admin/shippo/analytics/cache-status'); this.setState({ cacheStatus: res.body }); } catch (err) { if (err.status !== 404) { console.error('Failed to fetch cache status', err); } this.setState({ cacheStatus: null }); } } async fetchAnalytics() { this.setState({ loading: true }); try { const res = await superagent.get('/api/admin/shippo/analytics'); this.setState({ analytics: res.body, loading: false, error: null }); } catch (err) { if (err.status === 404) { this.setState({ analytics: null, loading: false }); } else { this.setState({ loading: false, error: 'Failed to load analytics.' }); } } } async fetchTransactions() { const { statusFilter, txOffset, txLimit } = this.state; this.setState({ loadingTransactions: true }); try { let req = superagent.get('/api/admin/shippo/analytics/transactions') .query({ limit: txLimit, offset: txOffset }); if (statusFilter) { req = req.query({ status: statusFilter }); } const res = await req; this.setState({ transactions: res.body.transactions, transactionsTotal: res.body.total, loadingTransactions: false, }); } catch (err) { console.error('Failed to fetch transactions', err); this.setState({ loadingTransactions: false }); } } async refreshCache() { this.setState({ refreshing: true }); try { await superagent.post('/api/admin/shippo/analytics/refresh'); this.setState({ refreshing: false }); // Refetch after a short delay to let the worker start setTimeout(() => { this.fetchCacheStatus(); this.fetchAnalytics(); this.fetchTransactions(); }, 2000); } catch (err) { console.error('Failed to refresh cache', err); this.setState({ refreshing: false, error: 'Failed to start refresh.' }); } } handleStatusFilterChange(value) { this.setState({ statusFilter: value, txOffset: 0 }, () => this.fetchTransactions()); } handlePageChange(direction) { const { txOffset, txLimit, transactionsTotal } = this.state; let newOffset = txOffset; if (direction === 'next') { newOffset = Math.min(txOffset + txLimit, transactionsTotal - 1); } else { newOffset = Math.max(txOffset - txLimit, 0); } this.setState({ txOffset: newOffset }, () => this.fetchTransactions()); } renderCacheStatus() { const { cacheStatus, refreshing } = this.state; return (
Cache Status
{cacheStatus ? (

Last refreshed: {formatDistanceToNow(parseISO(cacheStatus.metadata.fetchedAt), { addSuffix: true })}

TTL: {cacheStatus.ttlSeconds}s remaining

) : (

No cache available. Click refresh to populate.

)}
{cacheStatus && ( {cacheStatus.metadata.totalCached.toLocaleString()} Cached Transactions )}
); } renderSummary() { const { analytics } = this.state; if (!analytics) return null; const { summary, statusBreakdown } = analytics; return (
Summary
{summary.totalTransactions.toLocaleString()} Total Transactions {(summary.byShippoStatus['SUCCESS'] || 0).toLocaleString()} Successful Labels {(summary.byShippoStatus['ERROR'] || 0).toLocaleString()} Error Labels {Object.keys(statusBreakdown).length} Unique Statuses
); } renderStatusBreakdown() { const { analytics } = this.state; if (!analytics) return null; const { statusBreakdown, summary } = analytics; const pieData = Object.entries(statusBreakdown).map(([status, data]) => ({ name: status, value: data.count, })); return (
Status Breakdown
`${name} (${(percent * 100).toFixed(1)}%)`} > {pieData.map((entry, index) => ( ))} Status Count % {Object.entries(statusBreakdown) .sort(([, a], [, b]) => b.count - a.count) .map(([status, data]) => ( {data.count.toLocaleString()} {((data.count / summary.totalTransactions) * 100).toFixed(1)}% ))}
); } renderTimeSeries() { const { analytics, chartDays } = this.state; if (!analytics || !analytics.timeSeries) return null; // Filter to selected day range const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - chartDays); const cutoffStr = cutoff.toISOString().split('T')[0]; const filtered = analytics.timeSeries.filter(d => d.date >= cutoffStr); // Collect all status keys from the data const allStatuses = new Set(); for (const entry of filtered) { for (const key of Object.keys(entry)) { if (key !== 'date') allStatuses.add(key); } } return (
Transactions Over Time
this.setState({ chartDays: value })} />
{Array.from(allStatuses).map(status => ( ))}
); } renderTransactionsTable() { const { transactions, transactionsTotal, loadingTransactions, txOffset, txLimit, statusFilter, analytics } = this.state; // Build status options from analytics const statusOptions = [{ key: '', text: 'All Statuses', value: '' }]; if (analytics && analytics.statusBreakdown) { for (const status of Object.keys(analytics.statusBreakdown).sort()) { statusOptions.push({ key: status, text: status, value: status }); } } const currentPage = Math.floor(txOffset / txLimit) + 1; const totalPages = Math.ceil(transactionsTotal / txLimit); return (
Transactions
this.handleStatusFilterChange(value)} />
{loadingTransactions ? ( ) : ( <> Tracking Number Tracking Status Shippo Status Created At Tracking URL {transactions.map((tx, i) => ( {tx.trackingNumber || tx._trackingNumber} {tx.status} {tx.createdAt ? new Date(tx.createdAt).toLocaleDateString() : '-'} {tx.trackingUrlProvider ? ( Track ) : '-'} ))} {transactions.length === 0 && ( No transactions found. )}
{transactionsTotal.toLocaleString()} total
Page {currentPage} of {totalPages || 1}
)}
); } render() { const { loading, error, analytics } = this.state; return (
Shippo Analytics Shipping transaction analytics from Shippo
{error && ( Error

{error}

)} {this.renderCacheStatus()} {loading ? ( ) : !analytics ? ( No Analytics Available

Click "Refresh Cache" above to compute analytics from Shippo data.

) : ( <> {this.renderSummary()} {this.renderStatusBreakdown()} {this.renderTimeSeries()} )} {this.renderTransactionsTable()}
); } }