diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 6820b6bf7..193871d98 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -47,5 +47,6 @@ export { LazyProductDetail, LazyCheckoutComplete, LazySubscription, + LazyDistribution, } from './lazy-component'; export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component'; diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index 2420d10b5..902715840 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -50,4 +50,5 @@ export { LazyProductDetail, LazyCheckoutComplete, LazySubscription, + LazyDistribution, } from './lazyExports'; diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index d68ba1f65..d21c67972 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -323,3 +323,11 @@ export const LazySubscription = createLazyComponent( undefined, 'Subscription', ); +export const LazyDistribution = createLazyComponent( + () => + import('@/features/distribution/pages/DistributionPage').then((m) => ({ + default: m.DistributionPage, + })), + undefined, + 'Distribution', +); diff --git a/apps/web/src/features/distribution/pages/DistributionPage.tsx b/apps/web/src/features/distribution/pages/DistributionPage.tsx new file mode 100644 index 000000000..57aebbe66 --- /dev/null +++ b/apps/web/src/features/distribution/pages/DistributionPage.tsx @@ -0,0 +1,329 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { distributionService } from '@/services/distributionService'; +import type { + TrackDistribution, + ExternalStreamingRoyalty, + RoyaltySummary, + DistributionStatus, +} from '@/types/distribution'; + +function formatCents(cents: number, currency = 'USD'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }).format(cents / 100); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function formatStreams(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); +} + +const STATUS_COLORS: Record = { + submitted: 'bg-blue-900/50 text-blue-300', + processing: 'bg-yellow-900/50 text-yellow-300', + live: 'bg-green-900/50 text-green-300', + rejected: 'bg-red-900/50 text-red-300', + removed: 'bg-gray-700 text-gray-400', + failed: 'bg-red-900/50 text-red-300', +}; + +const PLATFORM_NAMES: Record = { + spotify: 'Spotify', + apple_music: 'Apple Music', + deezer: 'Deezer', +}; + +const PLATFORM_ICONS: Record = { + spotify: '🟢', + apple_music: '🍎', + deezer: '🎵', +}; + +export function DistributionPage(): React.ReactElement { + const [distributions, setDistributions] = useState([]); + const [royalties, setRoyalties] = useState([]); + const [summary, setSummary] = useState(null); + const [activeTab, setActiveTab] = useState<'distributions' | 'royalties'>( + 'distributions', + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const [distResult, royaltyResult] = await Promise.all([ + distributionService + .listDistributions({ limit: 50 }) + .catch(() => ({ data: [], pagination: { page: 1, limit: 50, total: 0, total_pages: 0 } })), + distributionService + .getExternalRoyalties({ limit: 50 }) + .catch(() => ({ + data: [], + summary: { + total_streams: 0, + total_revenue_cents: 0, + by_platform: {}, + }, + })), + ]); + setDistributions(distResult.data); + setRoyalties(royaltyResult.data); + setSummary(royaltyResult.summary); + } catch { + setError('Failed to load distribution data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+

Distribution

+

+ Distribute your tracks to Spotify, Apple Music, Deezer and track your + streaming revenue. +

+ + {error && ( +
+ {error} +
+ )} + + {/* Revenue summary */} + {summary && summary.total_streams > 0 && ( +
+
+

Total Streams

+

+ {formatStreams(summary.total_streams)} +

+
+
+

Total Revenue

+

+ {formatCents(summary.total_revenue_cents)} +

+
+
+

Platforms

+
+ {Object.entries(summary.by_platform).map(([platform, data]) => ( + + {PLATFORM_ICONS[platform] || '🎵'}{' '} + {formatStreams(data.streams)} + + ))} +
+
+
+ )} + + {/* Tabs */} +
+ + +
+ + {/* Distributions tab */} + {activeTab === 'distributions' && ( +
+ {distributions.length === 0 ? ( +
+

No distributions yet

+

+ Submit your tracks to Spotify, Apple Music, and Deezer from + your track page. +

+
+ ) : ( +
+ {distributions.map((dist) => ( + + ))} +
+ )} +
+ )} + + {/* Royalties tab */} + {activeTab === 'royalties' && ( +
+ {royalties.length === 0 ? ( +
+

No streaming revenue yet

+

+ Revenue from external platforms appears here once your tracks + are live and generating streams. +

+
+ ) : ( +
+ + + + + + + + + + + {royalties.map((r) => ( + + + + + + + ))} + +
PeriodPlatformStreamsRevenue
+ {formatDate(r.reporting_period_start)} + + {PLATFORM_ICONS[r.platform] || '🎵'}{' '} + {PLATFORM_NAMES[r.platform] || r.platform} + + {formatStreams(r.total_streams)} + + {formatCents(r.total_revenue_cents, r.currency)} +
+
+ )} +
+ )} +
+ ); +} + +function DistributionCard({ + distribution, +}: { + distribution: TrackDistribution; +}): React.ReactElement { + const metadata = + typeof distribution.metadata === 'string' + ? JSON.parse(distribution.metadata) + : distribution.metadata; + + return ( +
+
+
+

+ {metadata?.track_title || 'Untitled Track'} +

+

+ {metadata?.artist_name} — {metadata?.album_name || 'Single'} +

+

+ Submitted {formatDate(distribution.submitted_at)} +

+
+ + {distribution.overall_status} + +
+ + {/* Platform statuses */} +
+ {Object.entries(distribution.platform_statuses || {}).map( + ([platform, status]) => ( +
+ {PLATFORM_ICONS[platform] || '🎵'} + + {PLATFORM_NAMES[platform] || platform} + + + {(status as PlatformStatusDisplay).status} + + {(status as PlatformStatusDisplay).url && ( + + Open + + )} +
+ ), + )} +
+
+ ); +} + +interface PlatformStatusDisplay { + status: string; + url?: string; + live_date?: string; + error?: string; +} + +export default DistributionPage; diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index e7e875c21..c7189adf5 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -47,6 +47,7 @@ import { LazyListenTogether, LazyCloud, LazySubscription, + LazyDistribution, } from '@/components/ui/LazyComponent'; import { PublicRoute } from './PublicRoute'; import { ProtectedLayoutRoute } from './ProtectedLayoutRoute'; @@ -140,6 +141,8 @@ export function getProtectedRoutes(): RouteEntry[] { { path: '/cloud', element: wrapProtected() }, // v0.12.1: Subscription Plans & Management { path: '/subscription', element: wrapProtected() }, + // v0.12.2: Distribution to External Platforms + { path: '/distribution', element: wrapProtected() }, ]; } diff --git a/apps/web/src/services/distributionService.ts b/apps/web/src/services/distributionService.ts new file mode 100644 index 000000000..819446491 --- /dev/null +++ b/apps/web/src/services/distributionService.ts @@ -0,0 +1,105 @@ +import { apiClient } from './api/client'; +import type { + TrackDistribution, + StatusHistoryEntry, + ExternalStreamingRoyalty, + RoyaltySummary, + SubmitDistributionRequest, + SubmitDistributionResponse, + DistributionStatus, +} from '../types/distribution'; + +export const distributionService = { + /** Submit a track for distribution to external platforms */ + submit: async ( + req: SubmitDistributionRequest, + ): Promise => { + const response = await apiClient.post( + '/distributions/submit', + req, + ); + return (response.data as { data: SubmitDistributionResponse }).data; + }, + + /** Get a specific distribution by ID */ + getDistribution: async (id: string): Promise => { + const response = await apiClient.get( + `/distributions/${id}`, + ); + return (response.data as { data: TrackDistribution }).data; + }, + + /** List all distributions for the current user */ + listDistributions: async (params?: { + track_id?: string; + status?: DistributionStatus; + limit?: number; + offset?: number; + }): Promise<{ + data: TrackDistribution[]; + pagination: { page: number; limit: number; total: number; total_pages: number }; + }> => { + const response = await apiClient.get('/distributions', { params }); + return ( + response.data as { + data: { + data: TrackDistribution[]; + pagination: { page: number; limit: number; total: number; total_pages: number }; + }; + } + ).data; + }, + + /** Get distributions for a specific track */ + getTrackDistributions: async ( + trackId: string, + ): Promise => { + const response = await apiClient.get( + `/tracks/${trackId}/distributions`, + ); + return ( + response.data as { data: { distributions: TrackDistribution[] } } + ).data.distributions; + }, + + /** Get status change history for a distribution */ + getStatusHistory: async ( + distributionId: string, + ): Promise => { + const response = await apiClient.get( + `/distributions/${distributionId}/status-history`, + ); + return ( + response.data as { data: { history: StatusHistoryEntry[] } } + ).data.history; + }, + + /** Remove a live distribution */ + removeDistribution: async (distributionId: string): Promise => { + await apiClient.post(`/distributions/${distributionId}/remove`); + }, + + /** Get external streaming royalties for the current user */ + getExternalRoyalties: async (params?: { + start_date?: string; + end_date?: string; + platform?: string; + limit?: number; + offset?: number; + }): Promise<{ + data: ExternalStreamingRoyalty[]; + summary: RoyaltySummary; + }> => { + const response = await apiClient.get('/creators/me/external-royalties', { + params, + }); + return ( + response.data as { + data: { + data: ExternalStreamingRoyalty[]; + summary: RoyaltySummary; + }; + } + ).data; + }, +}; diff --git a/apps/web/src/types/distribution.ts b/apps/web/src/types/distribution.ts new file mode 100644 index 000000000..fe3e4ef34 --- /dev/null +++ b/apps/web/src/types/distribution.ts @@ -0,0 +1,94 @@ +export type DistributionStatus = + | 'submitted' + | 'processing' + | 'live' + | 'rejected' + | 'removed' + | 'failed'; + +export type DistributionPlatform = 'spotify' | 'apple_music' | 'deezer'; + +export type ImportStatus = 'pending' | 'completed' | 'failed' | 'partial'; + +export interface PlatformStatus { + status: string; + url?: string; + live_date?: string; + error?: string; +} + +export interface DistributionMetadata { + artist_name: string; + album_name: string; + genre: string; + release_date: string; + track_title: string; + featuring_artists?: string[]; + upc?: string; + isrc?: string; +} + +export interface TrackDistribution { + id: string; + track_id: string; + creator_id: string; + distributor: string; + submission_id: string; + submission_upc?: string; + submission_isrc?: string; + metadata: DistributionMetadata; + overall_status: DistributionStatus; + platform_statuses: Record; + submitted_at: string; + first_live_at?: string; + last_status_check_at?: string; + created_at: string; + updated_at: string; +} + +export interface StatusHistoryEntry { + id: string; + distribution_id: string; + platform: string; + old_status?: string; + new_status: string; + note?: string; + created_at: string; +} + +export interface ExternalStreamingRoyalty { + id: string; + track_id: string; + creator_id: string; + reporting_period_start: string; + reporting_period_end: string; + platform: string; + total_streams: number; + total_revenue_cents: number; + currency: string; + streams_breakdown?: Record; + distributor: string; + import_status: ImportStatus; + imported_at?: string; + created_at: string; +} + +export interface RoyaltySummary { + total_streams: number; + total_revenue_cents: number; + by_platform: Record< + string, + { streams: number; revenue_cents: number } + >; +} + +export interface SubmitDistributionRequest { + track_id: string; + platforms: DistributionPlatform[]; + metadata: DistributionMetadata; +} + +export interface SubmitDistributionResponse { + distribution: TrackDistribution; + estimated_live_days: number; +}