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:
senke 2026-03-10 19:54:45 +01:00
parent a15bdb965d
commit 92babee9e8
7 changed files with 541 additions and 0 deletions

View file

@ -47,5 +47,6 @@ export {
LazyProductDetail,
LazyCheckoutComplete,
LazySubscription,
LazyDistribution,
} from './lazy-component';
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';

View file

@ -50,4 +50,5 @@ export {
LazyProductDetail,
LazyCheckoutComplete,
LazySubscription,
LazyDistribution,
} from './lazyExports';

View file

@ -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',
);

View 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;

View file

@ -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 />) },
];
}

View 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;
},
};

View 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;
}