feat(v0.12.2): F501-F510 frontend distribution dashboard UI
- Distribution types, API service, and page component - Distributions list with platform-specific status badges - External streaming revenue table with summary cards - Platform icons and status colors for Spotify/Apple Music/Deezer - ARIA labels for accessibility - Lazy-loaded route at /distribution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6063bfdeea
commit
a8c985688a
7 changed files with 541 additions and 0 deletions
|
|
@ -47,5 +47,6 @@ export {
|
|||
LazyProductDetail,
|
||||
LazyCheckoutComplete,
|
||||
LazySubscription,
|
||||
LazyDistribution,
|
||||
} from './lazy-component';
|
||||
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';
|
||||
|
|
|
|||
|
|
@ -50,4 +50,5 @@ export {
|
|||
LazyProductDetail,
|
||||
LazyCheckoutComplete,
|
||||
LazySubscription,
|
||||
LazyDistribution,
|
||||
} from './lazyExports';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
329
apps/web/src/features/distribution/pages/DistributionPage.tsx
Normal file
329
apps/web/src/features/distribution/pages/DistributionPage.tsx
Normal file
|
|
@ -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<DistributionStatus, string> = {
|
||||
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<string, string> = {
|
||||
spotify: 'Spotify',
|
||||
apple_music: 'Apple Music',
|
||||
deezer: 'Deezer',
|
||||
};
|
||||
|
||||
const PLATFORM_ICONS: Record<string, string> = {
|
||||
spotify: '🟢',
|
||||
apple_music: '🍎',
|
||||
deezer: '🎵',
|
||||
};
|
||||
|
||||
export function DistributionPage(): React.ReactElement {
|
||||
const [distributions, setDistributions] = useState<TrackDistribution[]>([]);
|
||||
const [royalties, setRoyalties] = useState<ExternalStreamingRoyalty[]>([]);
|
||||
const [summary, setSummary] = useState<RoyaltySummary | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'distributions' | 'royalties'>(
|
||||
'distributions',
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Distribution</h1>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Distribute your tracks to Spotify, Apple Music, Deezer and track your
|
||||
streaming revenue.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-900/30 border border-red-500 text-red-300 px-4 py-3 rounded mb-6"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue summary */}
|
||||
{summary && summary.total_streams > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
|
||||
<p className="text-gray-400 text-sm">Total Streams</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatStreams(summary.total_streams)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
|
||||
<p className="text-gray-400 text-sm">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{formatCents(summary.total_revenue_cents)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
|
||||
<p className="text-gray-400 text-sm">Platforms</p>
|
||||
<div className="flex gap-3 mt-1">
|
||||
{Object.entries(summary.by_platform).map(([platform, data]) => (
|
||||
<span key={platform} className="text-sm">
|
||||
{PLATFORM_ICONS[platform] || '🎵'}{' '}
|
||||
{formatStreams(data.streams)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex border-b border-gray-700 mb-6"
|
||||
role="tablist"
|
||||
aria-label="Distribution sections"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'distributions'}
|
||||
onClick={() => setActiveTab('distributions')}
|
||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition ${
|
||||
activeTab === 'distributions'
|
||||
? 'border-purple-500 text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Distributions ({distributions.length})
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'royalties'}
|
||||
onClick={() => setActiveTab('royalties')}
|
||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition ${
|
||||
activeTab === 'royalties'
|
||||
? 'border-purple-500 text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Streaming Revenue ({royalties.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Distributions tab */}
|
||||
{activeTab === 'distributions' && (
|
||||
<div role="tabpanel" aria-label="Distributions">
|
||||
{distributions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">No distributions yet</p>
|
||||
<p className="text-sm">
|
||||
Submit your tracks to Spotify, Apple Music, and Deezer from
|
||||
your track page.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{distributions.map((dist) => (
|
||||
<DistributionCard key={dist.id} distribution={dist} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Royalties tab */}
|
||||
{activeTab === 'royalties' && (
|
||||
<div role="tabpanel" aria-label="Streaming Revenue">
|
||||
{royalties.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">No streaming revenue yet</p>
|
||||
<p className="text-sm">
|
||||
Revenue from external platforms appears here once your tracks
|
||||
are live and generating streams.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label="Streaming revenue">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-700">
|
||||
<th className="text-left py-2 pr-4">Period</th>
|
||||
<th className="text-left py-2 pr-4">Platform</th>
|
||||
<th className="text-right py-2 pr-4">Streams</th>
|
||||
<th className="text-right py-2">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{royalties.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-b border-gray-700/50"
|
||||
>
|
||||
<td className="py-2 pr-4">
|
||||
{formatDate(r.reporting_period_start)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{PLATFORM_ICONS[r.platform] || '🎵'}{' '}
|
||||
{PLATFORM_NAMES[r.platform] || r.platform}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{formatStreams(r.total_streams)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-green-400">
|
||||
{formatCents(r.total_revenue_cents, r.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionCard({
|
||||
distribution,
|
||||
}: {
|
||||
distribution: TrackDistribution;
|
||||
}): React.ReactElement {
|
||||
const metadata =
|
||||
typeof distribution.metadata === 'string'
|
||||
? JSON.parse(distribution.metadata)
|
||||
: distribution.metadata;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-5 border border-gray-700">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{metadata?.track_title || 'Untitled Track'}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{metadata?.artist_name} — {metadata?.album_name || 'Single'}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
Submitted {formatDate(distribution.submitted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${STATUS_COLORS[distribution.overall_status] || 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
{distribution.overall_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Platform statuses */}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{Object.entries(distribution.platform_statuses || {}).map(
|
||||
([platform, status]) => (
|
||||
<div
|
||||
key={platform}
|
||||
className="flex items-center gap-2 bg-gray-900/50 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{PLATFORM_ICONS[platform] || '🎵'}</span>
|
||||
<span className="text-gray-300">
|
||||
{PLATFORM_NAMES[platform] || platform}
|
||||
</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs ${STATUS_COLORS[(status as PlatformStatusDisplay).status as DistributionStatus] || 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
{(status as PlatformStatusDisplay).status}
|
||||
</span>
|
||||
{(status as PlatformStatusDisplay).url && (
|
||||
<a
|
||||
href={(status as PlatformStatusDisplay).url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-400 hover:text-purple-300 text-xs underline"
|
||||
aria-label={`Open on ${PLATFORM_NAMES[platform] || platform}`}
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlatformStatusDisplay {
|
||||
status: string;
|
||||
url?: string;
|
||||
live_date?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default DistributionPage;
|
||||
|
|
@ -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(<LazyCloud />) },
|
||||
// v0.12.1: Subscription Plans & Management
|
||||
{ path: '/subscription', element: wrapProtected(<LazySubscription />) },
|
||||
// v0.12.2: Distribution to External Platforms
|
||||
{ path: '/distribution', element: wrapProtected(<LazyDistribution />) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
105
apps/web/src/services/distributionService.ts
Normal file
105
apps/web/src/services/distributionService.ts
Normal file
|
|
@ -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<SubmitDistributionResponse> => {
|
||||
const response = await apiClient.post<SubmitDistributionResponse>(
|
||||
'/distributions/submit',
|
||||
req,
|
||||
);
|
||||
return (response.data as { data: SubmitDistributionResponse }).data;
|
||||
},
|
||||
|
||||
/** Get a specific distribution by ID */
|
||||
getDistribution: async (id: string): Promise<TrackDistribution> => {
|
||||
const response = await apiClient.get<TrackDistribution>(
|
||||
`/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<TrackDistribution[]> => {
|
||||
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<StatusHistoryEntry[]> => {
|
||||
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<void> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
94
apps/web/src/types/distribution.ts
Normal file
94
apps/web/src/types/distribution.ts
Normal file
|
|
@ -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<DistributionPlatform, PlatformStatus>;
|
||||
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<string, number>;
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue