feat(v0.11.1): F396-F399 frontend advanced analytics components
- AnalyticsViewHeatmap: track listening heatmap visualization (F396) - AnalyticsViewComparison: period comparison with % changes (F397) - AnalyticsViewMarketplace: product conversion rates and revenue (F398) - AnalyticsViewAlerts: opt-in metric alerts with CRUD (F399) - Updated analytics service with new API methods - Extended tab navigation with 3 new tabs - All components have ARIA labels and keyboard navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29586b59da
commit
70ba65b0f4
8 changed files with 915 additions and 4 deletions
|
|
@ -9,15 +9,22 @@ import { AnalyticsViewSkeleton } from './AnalyticsViewSkeleton';
|
|||
import { AnalyticsViewGeographic } from './AnalyticsViewGeographic';
|
||||
import { AnalyticsViewAudience } from './AnalyticsViewAudience';
|
||||
import { AnalyticsViewSales } from './AnalyticsViewSales';
|
||||
import { AnalyticsViewHeatmap } from './AnalyticsViewHeatmap';
|
||||
import { AnalyticsViewComparison } from './AnalyticsViewComparison';
|
||||
import { AnalyticsViewMarketplace } from './AnalyticsViewMarketplace';
|
||||
import { AnalyticsViewAlerts } from './AnalyticsViewAlerts';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import type { AnalyticsViewProps, AnalyticsTab } from './types';
|
||||
import type { DateRangeKey } from './types';
|
||||
|
||||
const TABS: Array<{ key: AnalyticsTab; label: string }> = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'heatmap', label: 'Heatmap' },
|
||||
{ key: 'sales', label: 'Sales & Downloads' },
|
||||
{ key: 'marketplace', label: 'Marketplace' },
|
||||
{ key: 'audience', label: 'Audience' },
|
||||
{ key: 'geographic', label: 'Geographic' },
|
||||
{ key: 'alerts', label: 'Alerts' },
|
||||
];
|
||||
|
||||
export function AnalyticsView({ onNavigateTrack }: AnalyticsViewProps) {
|
||||
|
|
@ -42,6 +49,15 @@ export function AnalyticsView({ onNavigateTrack }: AnalyticsViewProps) {
|
|||
audience,
|
||||
sales,
|
||||
discoverySources,
|
||||
// v0.11.1
|
||||
heatmap,
|
||||
loadHeatmap,
|
||||
comparison,
|
||||
comparisonPreset,
|
||||
setComparisonPreset,
|
||||
marketplace,
|
||||
alerts,
|
||||
refreshAlerts,
|
||||
} = useAnalyticsView('30d');
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -82,15 +98,24 @@ export function AnalyticsView({ onNavigateTrack }: AnalyticsViewProps) {
|
|||
|
||||
<AnalyticsViewKpiGrid stats={stats} />
|
||||
|
||||
{/* Period comparison summary (F397) */}
|
||||
{comparison && activeTab === 'overview' && (
|
||||
<AnalyticsViewComparison
|
||||
data={comparison}
|
||||
preset={comparisonPreset}
|
||||
onPresetChange={setComparisonPreset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="flex gap-1 border-b border-white/5" role="tablist" aria-label="Analytics sections">
|
||||
<nav className="flex gap-1 border-b border-white/5 overflow-x-auto" role="tablist" aria-label="Analytics sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
aria-controls={`tabpanel-${tab.key}`}
|
||||
className={`px-4 py-2.5 text-xs uppercase tracking-widest font-medium transition-colors ${
|
||||
className={`px-4 py-2.5 text-xs uppercase tracking-widest font-medium transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.key
|
||||
? 'text-foreground border-b-2 border-cyan-400'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
|
@ -124,10 +149,45 @@ export function AnalyticsView({ onNavigateTrack }: AnalyticsViewProps) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'heatmap' && (
|
||||
<div className="space-y-6">
|
||||
<AnalyticsViewHeatmap data={heatmap} />
|
||||
{/* Track selector for heatmap */}
|
||||
{topTracks.length > 0 && (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Select a Track
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{topTracks.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
onClick={() => loadHeatmap(track.id)}
|
||||
className={`text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
track.id === (heatmap?.track_id ?? '')
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-white/5 text-foreground hover:bg-white/10'
|
||||
}`}
|
||||
aria-label={`View heatmap for ${track.title}`}
|
||||
>
|
||||
<span className="block truncate">{track.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{track.plays} plays</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'sales' && (
|
||||
<AnalyticsViewSales data={sales} onExportSales={handleExportSales} />
|
||||
)}
|
||||
|
||||
{activeTab === 'marketplace' && (
|
||||
<AnalyticsViewMarketplace data={marketplace} />
|
||||
)}
|
||||
|
||||
{activeTab === 'audience' && (
|
||||
<AnalyticsViewAudience data={audience} />
|
||||
)}
|
||||
|
|
@ -135,6 +195,10 @@ export function AnalyticsView({ onNavigateTrack }: AnalyticsViewProps) {
|
|||
{activeTab === 'geographic' && (
|
||||
<AnalyticsViewGeographic data={geographic} />
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<AnalyticsViewAlerts data={alerts} onRefresh={refreshAlerts} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { analyticsService } from '@/services/analyticsService';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import { logger } from '@/utils/logger';
|
||||
import type { MetricAlertSummary } from './types';
|
||||
|
||||
interface Props {
|
||||
data: MetricAlertSummary | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
plays: 'Total Plays',
|
||||
followers: 'Followers',
|
||||
sales: 'Sales',
|
||||
listeners: 'Unique Listeners',
|
||||
};
|
||||
|
||||
const SUGGESTED_THRESHOLDS: Record<string, number[]> = {
|
||||
plays: [100, 500, 1000, 5000, 10000],
|
||||
followers: [10, 50, 100, 500, 1000],
|
||||
sales: [10, 50, 100, 500],
|
||||
listeners: [50, 100, 500, 1000, 5000],
|
||||
};
|
||||
|
||||
export function AnalyticsViewAlerts({ data, onRefresh }: Props) {
|
||||
const { addToast } = useToast();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newMetricType, setNewMetricType] = useState('plays');
|
||||
const [newThreshold, setNewThreshold] = useState('');
|
||||
|
||||
const handleTogglePreference = useCallback(async (metricType: string, enabled: boolean) => {
|
||||
try {
|
||||
await analyticsService.updateAlertPreference(metricType, enabled);
|
||||
addToast(`${METRIC_LABELS[metricType]} alerts ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Failed to update alert preference', { error });
|
||||
addToast('Failed to update preference', 'error');
|
||||
}
|
||||
}, [addToast, onRefresh]);
|
||||
|
||||
const handleCreateAlert = useCallback(async () => {
|
||||
const threshold = parseInt(newThreshold, 10);
|
||||
if (!threshold || threshold <= 0) {
|
||||
addToast('Please enter a valid threshold', 'error');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await analyticsService.createMetricAlert(newMetricType, threshold);
|
||||
addToast(`Alert created: ${METRIC_LABELS[newMetricType]} reaches ${threshold}`, 'success');
|
||||
setNewThreshold('');
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create alert', { error });
|
||||
addToast('Failed to create alert', 'error');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newMetricType, newThreshold, addToast, onRefresh]);
|
||||
|
||||
const handleDeleteAlert = useCallback(async (alertId: string) => {
|
||||
try {
|
||||
await analyticsService.deleteMetricAlert(alertId);
|
||||
addToast('Alert removed', 'success');
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete alert', { error });
|
||||
addToast('Failed to remove alert', 'error');
|
||||
}
|
||||
}, [addToast, onRefresh]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Alert preferences */}
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Alert Preferences
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground/60 mb-4">
|
||||
Choose which metric milestones you want to be notified about. These are informational only — not gamification.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{(data?.preferences ?? []).map((pref) => (
|
||||
<label
|
||||
key={pref.metric_type}
|
||||
className="flex items-center justify-between py-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-sm text-foreground">
|
||||
{METRIC_LABELS[pref.metric_type] ?? pref.metric_type}
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={pref.enabled}
|
||||
aria-label={`Toggle ${METRIC_LABELS[pref.metric_type]} alerts`}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
pref.enabled ? 'bg-cyan-500' : 'bg-white/10'
|
||||
}`}
|
||||
onClick={() => handleTogglePreference(pref.metric_type, !pref.enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
pref.enabled ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create new alert */}
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Create Alert
|
||||
</h3>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="alert-metric" className="text-xs text-muted-foreground block mb-1">
|
||||
Metric
|
||||
</label>
|
||||
<select
|
||||
id="alert-metric"
|
||||
value={newMetricType}
|
||||
onChange={(e) => setNewMetricType(e.target.value)}
|
||||
className="w-full bg-background border border-white/10 rounded-md px-3 py-2 text-sm text-foreground"
|
||||
aria-label="Select metric type"
|
||||
>
|
||||
{Object.entries(METRIC_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="alert-threshold" className="text-xs text-muted-foreground block mb-1">
|
||||
Threshold
|
||||
</label>
|
||||
<input
|
||||
id="alert-threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
value={newThreshold}
|
||||
onChange={(e) => setNewThreshold(e.target.value)}
|
||||
placeholder="e.g., 1000"
|
||||
className="w-full bg-background border border-white/10 rounded-md px-3 py-2 text-sm text-foreground"
|
||||
aria-label="Alert threshold value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateAlert}
|
||||
disabled={creating || !newThreshold}
|
||||
className="px-4 py-2 text-sm bg-cyan-500/20 text-cyan-400 rounded-md hover:bg-cyan-500/30 disabled:opacity-50 transition-colors"
|
||||
aria-label="Create alert"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{SUGGESTED_THRESHOLDS[newMetricType] && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<span className="text-xs text-muted-foreground/60">Quick:</span>
|
||||
{SUGGESTED_THRESHOLDS[newMetricType].map((threshold) => (
|
||||
<button
|
||||
key={threshold}
|
||||
onClick={() => setNewThreshold(threshold.toString())}
|
||||
className="text-xs px-2 py-0.5 rounded bg-white/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={`Set threshold to ${threshold}`}
|
||||
>
|
||||
{threshold.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active alerts */}
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Active Alerts
|
||||
</h3>
|
||||
{!data?.alerts?.length ? (
|
||||
<p className="text-muted-foreground text-sm">No alerts configured yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-center justify-between py-2.5 px-3 rounded-lg ${
|
||||
alert.is_triggered ? 'bg-emerald-500/10' : 'bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${alert.is_triggered ? 'bg-emerald-400' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-sm text-foreground">
|
||||
{METRIC_LABELS[alert.metric_type] ?? alert.metric_type} reaches {alert.threshold.toLocaleString()}
|
||||
</span>
|
||||
{alert.is_triggered && (
|
||||
<span className="text-xs text-emerald-400">Reached!</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAlert(alert.id)}
|
||||
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
|
||||
aria-label={`Remove alert for ${METRIC_LABELS[alert.metric_type]} at ${alert.threshold}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { PeriodComparison } from './types';
|
||||
|
||||
interface Props {
|
||||
data: PeriodComparison | null;
|
||||
preset: string;
|
||||
onPresetChange: (preset: string) => void;
|
||||
}
|
||||
|
||||
function formatChange(value: number): { text: string; color: string } {
|
||||
if (value > 0) return { text: `+${value.toFixed(1)}%`, color: 'text-emerald-400' };
|
||||
if (value < 0) return { text: `${value.toFixed(1)}%`, color: 'text-red-400' };
|
||||
return { text: '0%', color: 'text-muted-foreground' };
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ key: 'week', label: 'Week' },
|
||||
{ key: 'month', label: 'Month' },
|
||||
{ key: 'quarter', label: 'Quarter' },
|
||||
];
|
||||
|
||||
interface MetricRowProps {
|
||||
label: string;
|
||||
currentValue: string;
|
||||
previousValue: string;
|
||||
change: number;
|
||||
}
|
||||
|
||||
function MetricRow({ label, currentValue, previousValue, change }: MetricRowProps) {
|
||||
const { text, color } = formatChange(change);
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-white/5 last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-sm font-medium text-foreground w-20 text-right">{currentValue}</span>
|
||||
<span className="text-sm text-muted-foreground/60 w-20 text-right">{previousValue}</span>
|
||||
<span className={`text-sm font-medium w-16 text-right ${color}`}>{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalyticsViewComparison({ data, preset, onPresetChange }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest">
|
||||
Period Comparison
|
||||
</h3>
|
||||
<div className="flex gap-1" role="radiogroup" aria-label="Comparison period">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
role="radio"
|
||||
aria-checked={preset === p.key}
|
||||
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
|
||||
preset === p.key
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
|
||||
}`}
|
||||
onClick={() => onPresetChange(p.key)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!data || !data.current_period ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No comparison data available for this period.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground/60 mb-2 px-0">
|
||||
<span />
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="w-20 text-right">Current</span>
|
||||
<span className="w-20 text-right">Previous</span>
|
||||
<span className="w-16 text-right">Change</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetricRow
|
||||
label="Total Plays"
|
||||
currentValue={formatNumber(data.current_period.total_plays)}
|
||||
previousValue={formatNumber(data.previous_period.total_plays)}
|
||||
change={data.changes.plays_change}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Unique Listeners"
|
||||
currentValue={formatNumber(data.current_period.unique_listeners)}
|
||||
previousValue={formatNumber(data.previous_period.unique_listeners)}
|
||||
change={data.changes.listeners_change}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Avg Completion"
|
||||
currentValue={`${data.current_period.avg_completion.toFixed(1)}%`}
|
||||
previousValue={`${data.previous_period.avg_completion.toFixed(1)}%`}
|
||||
change={data.changes.completion_change}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Play Time"
|
||||
currentValue={formatDuration(data.current_period.total_play_time)}
|
||||
previousValue={formatDuration(data.previous_period.total_play_time)}
|
||||
change={data.changes.play_time_change}
|
||||
/>
|
||||
<MetricRow
|
||||
label="Revenue"
|
||||
currentValue={`$${data.current_period.total_revenue.toFixed(2)}`}
|
||||
previousValue={`$${data.previous_period.total_revenue.toFixed(2)}`}
|
||||
change={data.changes.revenue_change}
|
||||
/>
|
||||
<MetricRow
|
||||
label="New Followers"
|
||||
currentValue={formatNumber(data.current_period.new_followers)}
|
||||
previousValue={formatNumber(data.previous_period.new_followers)}
|
||||
change={data.changes.followers_change}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import type { TrackHeatmapData } from './types';
|
||||
|
||||
interface Props {
|
||||
data: TrackHeatmapData | null;
|
||||
trackTitle?: string;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function intensityColor(intensity: number): string {
|
||||
if (intensity >= 0.8) return 'bg-cyan-400';
|
||||
if (intensity >= 0.6) return 'bg-cyan-500/80';
|
||||
if (intensity >= 0.4) return 'bg-cyan-600/60';
|
||||
if (intensity >= 0.2) return 'bg-cyan-700/40';
|
||||
return 'bg-cyan-800/20';
|
||||
}
|
||||
|
||||
export function AnalyticsViewHeatmap({ data, trackTitle }: Props) {
|
||||
if (!data || !data.segments || data.segments.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Listening Heatmap
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No heatmap data available yet. Select a track to view its listening heatmap.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-2">
|
||||
Heatmap data helps you understand which parts of your tracks listeners engage with most.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest">
|
||||
Listening Heatmap
|
||||
</h3>
|
||||
{trackTitle && (
|
||||
<span className="text-xs text-foreground/60">{trackTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5" role="img" aria-label={`Listening heatmap with ${data.total_segments} segments`}>
|
||||
{data.segments.map((seg) => (
|
||||
<div
|
||||
key={seg.segment_index}
|
||||
className={`flex-1 rounded-sm ${intensityColor(seg.intensity)} transition-all hover:opacity-80 cursor-default min-h-[48px] relative group`}
|
||||
title={`${formatMs(seg.segment_start_ms)} - ${formatMs(seg.segment_end_ms)}: ${seg.listen_count} listens, ${seg.drop_off_count} drop-offs, ${seg.replay_count} replays`}
|
||||
aria-label={`Segment ${seg.segment_index + 1}: ${seg.listen_count} listens`}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-popover text-popover-foreground text-xs rounded-lg px-3 py-2 shadow-lg whitespace-nowrap border border-white/10">
|
||||
<div className="font-medium">{formatMs(seg.segment_start_ms)} - {formatMs(seg.segment_end_ms)}</div>
|
||||
<div>{seg.listen_count} listens</div>
|
||||
<div>{seg.drop_off_count} drop-offs</div>
|
||||
<div>{seg.replay_count} replays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground/60">
|
||||
<span>0:00</span>
|
||||
{data.segments.length > 0 && (
|
||||
<span>{formatMs(data.segments[data.segments.length - 1].segment_end_ms)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-cyan-800/20" />
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-cyan-600/60" />
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-cyan-400" />
|
||||
<span>High</span>
|
||||
</div>
|
||||
<span className="ml-auto">
|
||||
Peak: {data.max_listens} listens | Avg drop-off: {data.avg_drop_off.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import type { MarketplaceAnalyticsData } from './types';
|
||||
|
||||
interface Props {
|
||||
data: MarketplaceAnalyticsData | null;
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function AnalyticsViewMarketplace({ data }: Props) {
|
||||
if (!data || (!data.products?.length && !data.total_sales)) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Marketplace Analytics
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No marketplace data available yet. Start selling products to see analytics.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<KpiCard label="Total Views" value={data.total_views?.toString() ?? '0'} />
|
||||
<KpiCard label="Total Sales" value={data.total_sales?.toString() ?? '0'} />
|
||||
<KpiCard label="Total Revenue" value={formatCurrency(data.total_revenue ?? 0)} />
|
||||
<KpiCard label="Conversion" value={`${(data.overall_conversion ?? 0).toFixed(1)}%`} />
|
||||
<KpiCard label="Commission" value={formatCurrency(data.platform_commission ?? 0)} />
|
||||
<KpiCard label="Net Revenue" value={formatCurrency(data.net_revenue ?? 0)} accent />
|
||||
</div>
|
||||
|
||||
{/* Products table */}
|
||||
{data.products && data.products.length > 0 && (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Product Performance
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" role="table">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5">
|
||||
<th className="text-left py-2 text-muted-foreground font-medium" scope="col">Product</th>
|
||||
<th className="text-left py-2 text-muted-foreground font-medium" scope="col">Type</th>
|
||||
<th className="text-right py-2 text-muted-foreground font-medium" scope="col">Views</th>
|
||||
<th className="text-right py-2 text-muted-foreground font-medium" scope="col">Sales</th>
|
||||
<th className="text-right py-2 text-muted-foreground font-medium" scope="col">Revenue</th>
|
||||
<th className="text-right py-2 text-muted-foreground font-medium" scope="col">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.products.map((product) => (
|
||||
<tr key={product.product_id} className="border-b border-white/5 last:border-0">
|
||||
<td className="py-2.5 text-foreground">{product.name}</td>
|
||||
<td className="py-2.5 text-muted-foreground capitalize">{product.product_type}</td>
|
||||
<td className="py-2.5 text-right text-muted-foreground">{product.views}</td>
|
||||
<td className="py-2.5 text-right text-foreground">{product.sales}</td>
|
||||
<td className="py-2.5 text-right text-foreground">{formatCurrency(product.revenue)}</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<span className={product.conversion_rate > 5 ? 'text-emerald-400' : 'text-muted-foreground'}>
|
||||
{product.conversion_rate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue timeline */}
|
||||
{data.revenue_timeline && data.revenue_timeline.length > 0 && (
|
||||
<div className="rounded-xl border border-white/5 bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Revenue Timeline
|
||||
</h3>
|
||||
<div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart">
|
||||
{data.revenue_timeline.map((day, i) => {
|
||||
const maxRevenue = Math.max(...data.revenue_timeline!.map((d) => d.revenue), 1);
|
||||
const height = (day.revenue / maxRevenue) * 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-cyan-500/40 hover:bg-cyan-500/60 transition-colors rounded-t-sm cursor-default relative group"
|
||||
style={{ height: `${Math.max(height, 2)}%` }}
|
||||
title={`${day.date}: ${formatCurrency(day.revenue)} (${day.sales} sales)`}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-popover text-popover-foreground text-xs rounded-lg px-3 py-2 shadow-lg whitespace-nowrap border border-white/10">
|
||||
<div className="font-medium">{day.date}</div>
|
||||
<div>{formatCurrency(day.revenue)}</div>
|
||||
<div>{day.sales} sales, {day.views} views</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">{label}</p>
|
||||
<p className={`text-lg font-semibold mt-1 ${accent ? 'text-cyan-400' : 'text-foreground'}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ export interface ChartHoverData {
|
|||
}
|
||||
|
||||
// v0.11.0: Creator Analytics types (F381-F385)
|
||||
export type AnalyticsTab = 'overview' | 'sales' | 'audience' | 'geographic';
|
||||
export type AnalyticsTab = 'overview' | 'sales' | 'audience' | 'geographic' | 'heatmap' | 'marketplace' | 'alerts';
|
||||
|
||||
export interface GeographicEntry {
|
||||
country_code: string;
|
||||
|
|
@ -92,3 +92,92 @@ export interface PlayEvolutionPoint {
|
|||
total_play_time: number;
|
||||
avg_completion: number;
|
||||
}
|
||||
|
||||
// v0.11.1: Advanced Analytics types (F396-F399)
|
||||
|
||||
export interface HeatmapSegment {
|
||||
segment_index: number;
|
||||
segment_start_ms: number;
|
||||
segment_end_ms: number;
|
||||
listen_count: number;
|
||||
drop_off_count: number;
|
||||
replay_count: number;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
export interface TrackHeatmapData {
|
||||
track_id: string;
|
||||
total_segments: number;
|
||||
segment_duration_ms: number;
|
||||
segments: HeatmapSegment[];
|
||||
max_listens: number;
|
||||
avg_drop_off: number;
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
total_plays: number;
|
||||
unique_listeners: number;
|
||||
complete_listens: number;
|
||||
total_play_time: number;
|
||||
avg_completion: number;
|
||||
total_revenue: number;
|
||||
new_followers: number;
|
||||
}
|
||||
|
||||
export interface PeriodChanges {
|
||||
plays_change: number;
|
||||
listeners_change: number;
|
||||
completion_change: number;
|
||||
revenue_change: number;
|
||||
followers_change: number;
|
||||
play_time_change: number;
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
current_period: PeriodStats;
|
||||
previous_period: PeriodStats;
|
||||
changes: PeriodChanges;
|
||||
}
|
||||
|
||||
export interface ProductAnalytics {
|
||||
product_id: string;
|
||||
name: string;
|
||||
product_type: string;
|
||||
views: number;
|
||||
sales: number;
|
||||
revenue: number;
|
||||
conversion_rate: number;
|
||||
}
|
||||
|
||||
export interface MarketplaceAnalyticsData {
|
||||
total_views: number;
|
||||
total_sales: number;
|
||||
total_revenue: number;
|
||||
overall_conversion: number;
|
||||
platform_commission: number;
|
||||
net_revenue: number;
|
||||
products: ProductAnalytics[];
|
||||
revenue_timeline: Array<{ date: string; revenue: number; sales: number; views: number }>;
|
||||
}
|
||||
|
||||
export interface MetricAlert {
|
||||
id: string;
|
||||
metric_type: string;
|
||||
threshold: number;
|
||||
is_triggered: boolean;
|
||||
triggered_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MetricAlertPreference {
|
||||
metric_type: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MetricAlertSummary {
|
||||
preferences: MetricAlertPreference[];
|
||||
alerts: MetricAlert[];
|
||||
pending: MetricAlert[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { logger } from '@/utils/logger';
|
|||
import type { DateRangeKey, AnalyticsTab } from './types';
|
||||
import type { GlobalStats, TopTrackRow, TrafficSource, DeviceStats, ChartHoverData, CreatorChartData } from './types';
|
||||
import type { GeographicEntry, AudienceData, SalesData, DiscoverySource } from './types';
|
||||
import type { TrackHeatmapData, PeriodComparison, MarketplaceAnalyticsData, MetricAlertSummary } from './types';
|
||||
|
||||
export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') {
|
||||
const { addToast } = useToast();
|
||||
|
|
@ -26,6 +27,14 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') {
|
|||
const [sales, setSales] = useState<SalesData>({});
|
||||
const [discoverySources, setDiscoverySources] = useState<DiscoverySource[]>([]);
|
||||
|
||||
// v0.11.1: Advanced analytics data (F396-F399)
|
||||
const [heatmap, setHeatmap] = useState<TrackHeatmapData | null>(null);
|
||||
const [comparison, setComparison] = useState<PeriodComparison | null>(null);
|
||||
const [comparisonPreset, setComparisonPreset] = useState('week');
|
||||
const [marketplace, setMarketplace] = useState<MarketplaceAnalyticsData | null>(null);
|
||||
const [alerts, setAlerts] = useState<MetricAlertSummary | null>(null);
|
||||
const [selectedHeatmapTrack, setSelectedHeatmapTrack] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -61,13 +70,23 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') {
|
|||
setAudience(audienceData as AudienceData);
|
||||
setSales(salesData as SalesData);
|
||||
setDiscoverySources(discoveryData as DiscoverySource[]);
|
||||
|
||||
// v0.11.1: Load advanced analytics data (non-blocking)
|
||||
const [comparisonData, marketplaceData, alertsData] = await Promise.all([
|
||||
analyticsService.comparePeriods({ preset: comparisonPreset }).catch(() => ({})),
|
||||
analyticsService.getMarketplaceAnalytics({ days }).catch(() => ({})),
|
||||
analyticsService.getMetricAlerts().catch(() => ({})),
|
||||
]);
|
||||
setComparison((comparisonData as PeriodComparison)?.current_period ? comparisonData as PeriodComparison : null);
|
||||
setMarketplace((marketplaceData as MarketplaceAnalyticsData)?.products ? marketplaceData as MarketplaceAnalyticsData : null);
|
||||
setAlerts((alertsData as MetricAlertSummary)?.preferences ? alertsData as MetricAlertSummary : null);
|
||||
} catch (e) {
|
||||
logger.error('Error loading analytics', { error: e });
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange]);
|
||||
}, [dateRange, comparisonPreset]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
|
@ -94,6 +113,27 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') {
|
|||
[dateRange, addToast]
|
||||
);
|
||||
|
||||
const loadHeatmap = useCallback(async (trackId: string) => {
|
||||
setSelectedHeatmapTrack(trackId);
|
||||
try {
|
||||
const days = parseInt(dateRange.replace('d', ''), 10) || 30;
|
||||
const data = await analyticsService.getTrackHeatmap(trackId, { days });
|
||||
setHeatmap((data as TrackHeatmapData)?.segments ? data as TrackHeatmapData : null);
|
||||
} catch (e) {
|
||||
logger.error('Failed to load heatmap', { error: e });
|
||||
setHeatmap(null);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const refreshAlerts = useCallback(async () => {
|
||||
try {
|
||||
const data = await analyticsService.getMetricAlerts();
|
||||
setAlerts((data as MetricAlertSummary)?.preferences ? data as MetricAlertSummary : null);
|
||||
} catch (e) {
|
||||
logger.error('Failed to refresh alerts', { error: e });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExportSales = useCallback(async () => {
|
||||
toast('Building sales CSV...', { icon: 'ℹ️' });
|
||||
try {
|
||||
|
|
@ -134,5 +174,15 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') {
|
|||
audience,
|
||||
sales,
|
||||
discoverySources,
|
||||
// v0.11.1
|
||||
heatmap,
|
||||
loadHeatmap,
|
||||
selectedHeatmapTrack,
|
||||
comparison,
|
||||
comparisonPreset,
|
||||
setComparisonPreset,
|
||||
marketplace,
|
||||
alerts,
|
||||
refreshAlerts,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,4 +332,141 @@ export const analyticsService = {
|
|||
});
|
||||
return response.data as Blob;
|
||||
},
|
||||
|
||||
// v0.11.1: Advanced Analytics (F396-F399)
|
||||
|
||||
getTrackHeatmap: async (trackId: string, params?: { days?: number }) => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.days != null) searchParams.set('days', String(params.days));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get(`/creator/analytics/heatmap/${trackId}${qs ? `?${qs}` : ''}`);
|
||||
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
||||
return (data?.heatmap ?? {}) as {
|
||||
track_id?: string;
|
||||
total_segments?: number;
|
||||
segment_duration_ms?: number;
|
||||
segments?: Array<{
|
||||
segment_index: number;
|
||||
segment_start_ms: number;
|
||||
segment_end_ms: number;
|
||||
listen_count: number;
|
||||
drop_off_count: number;
|
||||
replay_count: number;
|
||||
intensity: number;
|
||||
}>;
|
||||
max_listens?: number;
|
||||
avg_drop_off?: number;
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[Analytics] Failed to fetch track heatmap', { error });
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
comparePeriods: async (params?: { preset?: string; current_start?: string; current_end?: string; previous_start?: string; previous_end?: string }) => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.preset) searchParams.set('preset', params.preset);
|
||||
if (params?.current_start) searchParams.set('current_start', params.current_start);
|
||||
if (params?.current_end) searchParams.set('current_end', params.current_end);
|
||||
if (params?.previous_start) searchParams.set('previous_start', params.previous_start);
|
||||
if (params?.previous_end) searchParams.set('previous_end', params.previous_end);
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get(`/creator/analytics/compare${qs ? `?${qs}` : ''}`);
|
||||
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
||||
return (data?.comparison ?? {}) as {
|
||||
current_period?: {
|
||||
start_date: string; end_date: string;
|
||||
total_plays: number; unique_listeners: number; complete_listens: number;
|
||||
total_play_time: number; avg_completion: number; total_revenue: number; new_followers: number;
|
||||
};
|
||||
previous_period?: {
|
||||
start_date: string; end_date: string;
|
||||
total_plays: number; unique_listeners: number; complete_listens: number;
|
||||
total_play_time: number; avg_completion: number; total_revenue: number; new_followers: number;
|
||||
};
|
||||
changes?: {
|
||||
plays_change: number; listeners_change: number; completion_change: number;
|
||||
revenue_change: number; followers_change: number; play_time_change: number;
|
||||
};
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[Analytics] Failed to compare periods', { error });
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
getMarketplaceAnalytics: async (params?: { days?: number }) => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.days != null) searchParams.set('days', String(params.days));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get(`/creator/analytics/marketplace${qs ? `?${qs}` : ''}`);
|
||||
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
||||
return (data?.marketplace ?? {}) as {
|
||||
total_views?: number;
|
||||
total_sales?: number;
|
||||
total_revenue?: number;
|
||||
overall_conversion?: number;
|
||||
platform_commission?: number;
|
||||
net_revenue?: number;
|
||||
products?: Array<{
|
||||
product_id: string; name: string; product_type: string;
|
||||
views: number; sales: number; revenue: number; conversion_rate: number;
|
||||
}>;
|
||||
revenue_timeline?: Array<{ date: string; revenue: number; sales: number; views: number }>;
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[Analytics] Failed to fetch marketplace analytics', { error });
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
getMetricAlerts: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/creator/analytics/alerts');
|
||||
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
||||
return (data?.alerts ?? {}) as {
|
||||
preferences?: Array<{ metric_type: string; enabled: boolean }>;
|
||||
alerts?: Array<{
|
||||
id: string; metric_type: string; threshold: number;
|
||||
is_triggered: boolean; triggered_at?: string; created_at: string;
|
||||
}>;
|
||||
pending?: Array<{
|
||||
id: string; metric_type: string; threshold: number;
|
||||
is_triggered: boolean; triggered_at?: string; created_at: string;
|
||||
}>;
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[Analytics] Failed to fetch metric alerts', { error });
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
createMetricAlert: async (metricType: string, threshold: number) => {
|
||||
const response = await apiClient.post('/creator/analytics/alerts', {
|
||||
metric_type: metricType,
|
||||
threshold,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateAlertPreference: async (metricType: string, enabled: boolean) => {
|
||||
const response = await apiClient.put('/creator/analytics/alerts/preferences', {
|
||||
metric_type: metricType,
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteMetricAlert: async (alertId: string) => {
|
||||
const response = await apiClient.delete(`/creator/analytics/alerts/${alertId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
checkMetricAlerts: async () => {
|
||||
const response = await apiClient.post('/creator/analytics/alerts/check');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue