+ {/* Alert preferences */}
+
+
+ Alert Preferences
+
+
+ Choose which metric milestones you want to be notified about. These are informational only — not gamification.
+
+
+ {(data?.preferences ?? []).map((pref) => (
+
+ ))}
+
+
+
+ {/* Create new alert */}
+
+
+ Create Alert
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+ {/* Quick suggestions */}
+ {SUGGESTED_THRESHOLDS[newMetricType] && (
+
+ Quick:
+ {SUGGESTED_THRESHOLDS[newMetricType].map((threshold) => (
+
+ ))}
+
+ )}
+
+
+ {/* Active alerts */}
+
+
+ Active Alerts
+
+ {!data?.alerts?.length ? (
+
No alerts configured yet.
+ ) : (
+
+ {data.alerts.map((alert) => (
+
+
+
+
+ {METRIC_LABELS[alert.metric_type] ?? alert.metric_type} reaches {alert.threshold.toLocaleString()}
+
+ {alert.is_triggered && (
+ Reached!
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewComparison.tsx b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewComparison.tsx
new file mode 100644
index 000000000..df3baff07
--- /dev/null
+++ b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewComparison.tsx
@@ -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 (
+
+
+ Listening Heatmap
+
+
+ No heatmap data available yet. Select a track to view its listening heatmap.
+
+
+ Heatmap data helps you understand which parts of your tracks listeners engage with most.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Listening Heatmap
+
+ {trackTitle && (
+ {trackTitle}
+ )}
+
+
+
+ {data.segments.map((seg) => (
+
+
+
+
{formatMs(seg.segment_start_ms)} - {formatMs(seg.segment_end_ms)}
+
{seg.listen_count} listens
+
{seg.drop_off_count} drop-offs
+
{seg.replay_count} replays
+
+
+
+ ))}
+
+
+
+ 0:00
+ {data.segments.length > 0 && (
+ {formatMs(data.segments[data.segments.length - 1].segment_end_ms)}
+ )}
+
+
+
+
+
+
+
+ Peak: {data.max_listens} listens | Avg drop-off: {data.avg_drop_off.toFixed(1)}
+
+
+
+ );
+}
diff --git a/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewMarketplace.tsx b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewMarketplace.tsx
new file mode 100644
index 000000000..1e4a4646c
--- /dev/null
+++ b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewMarketplace.tsx
@@ -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 (
+
+ {/* KPI Cards */}
+
+
+
+
+
+
+
+
+
+ {/* Products table */}
+ {data.products && data.products.length > 0 && (
+
+
+ Product Performance
+
+
+
+
+
+ | Product |
+ Type |
+ Views |
+ Sales |
+ Revenue |
+ Conversion |
+
+
+
+ {data.products.map((product) => (
+
+ | {product.name} |
+ {product.product_type} |
+ {product.views} |
+ {product.sales} |
+ {formatCurrency(product.revenue)} |
+
+ 5 ? 'text-emerald-400' : 'text-muted-foreground'}>
+ {product.conversion_rate.toFixed(1)}%
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Revenue timeline */}
+ {data.revenue_timeline && data.revenue_timeline.length > 0 && (
+
+
+ Revenue Timeline
+
+
+ {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 (
+
+
+
+
{day.date}
+
{formatCurrency(day.revenue)}
+
{day.sales} sales, {day.views} views
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+function KpiCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
+ return (
+