veza/apps/web/src/components/seller/SellerDashboardView.tsx
senke 7b39efa176 fix: stabilize frontend — 98 TS errors to 0, align API endpoints, optimize bundle
- Fix 98 TypeScript errors across 37 files:
  - Service layer double-unwrapping (subscriptionService, distributionService, gearService)
  - Self-referencing variables in SearchPageResults
  - FeedView/ExploreView .posts→.items alignment
  - useQueueSync Zustand subscribe API
  - AdminAuditLogsView missing interface fields
  - Toast proxy type, interceptor type narrowing
  - 22 unused imports/variables removed
  - 5 storybook mock data fixes

- Align frontend API calls with backend endpoints:
  - Analytics: useAnalyticsView now calls /creator/analytics/dashboard (was /analytics)
  - Chat: chatService uses /conversations (was mock data), WS URL from backend token
  - Dashboard StatsSection: uses real /dashboard API data (was hardcoded zeros)
  - Settings: suppress 2FA toast error when endpoint unavailable

- Fix marketplace products: seed uses 'active' status (was 'published')
- Enrich seed: admin follows all creators (feed has content)

- Optimize bundle: vendor catch-all 793KB→318KB gzip (-60%)
  Split into vendor-charts, vendor-emoji, vendor-swagger, vendor-media, etc.

- Clean repo: remove ~100 orphaned screenshots, audit reports, logs from root

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:18:49 +01:00

563 lines
22 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { EmptyState } from '../ui/empty-state';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import {
Plus,
TrendingUp,
DollarSign,
Package,
Users,
Eye,
MoreHorizontal,
Zap,
Loader2,
RefreshCcw,
Wallet,
CreditCard,
ArrowDownLeft,
} from 'lucide-react';
import { FlashSaleModal } from './modals/FlashSaleModal';
import { RefundRequestModal } from '../commerce/modals/RefundRequestModal';
import { useToast } from '../../components/feedback/ToastProvider';
import { Product } from '../../types';
import { marketplaceService } from '../../services/marketplaceService';
import { commerceService, type SellerTransfer } from '../../services/commerceService';
import { logger } from '@/utils/logger';
interface SellerDashboardProps {
onCreateProduct: () => void;
}
export const SellerDashboardView: React.FC<SellerDashboardProps> = ({
onCreateProduct,
}) => {
const { addToast } = useToast();
const [showFlashSale, setShowFlashSale] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [sales, setSales] = useState<any[]>([]);
const [stats, setStats] = useState<any>({});
const [evolution, setEvolution] = useState<{ date: string; revenue: number; sales_count: number }[]>([]);
const [topProducts, setTopProducts] = useState<{ product_id: string; title: string; revenue: number; sales_count: number }[]>([]);
const [chartPeriod, setChartPeriod] = useState<'day' | 'week' | 'month'>('week');
const [refundOrderId, setRefundOrderId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [balance, setBalance] = useState<{ connected: boolean; available: number; pending: number } | null>(null);
const [stripeConnectAvailable, setStripeConnectAvailable] = useState<boolean | null>(null);
const [transfers, setTransfers] = useState<SellerTransfer[]>([]);
// v0.12.0 F254: Marketplace balance
const [marketplaceBalance, setMarketplaceBalance] = useState<{
available_cents: number;
pending_cents: number;
total_earned_cents: number;
total_paid_out_cents: number;
currency: string;
} | null>(null);
const [payoutLoading, setPayoutLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [prods, salesData, statsData, evolutionData, topData] = await Promise.all([
marketplaceService.listProducts({ seller_id: 'me' }).catch(() => ({ products: [] })),
commerceService.getSales().catch(() => []),
commerceService.getSellerStats().catch(() => ({})),
commerceService.getSellerStatsEvolution(chartPeriod).catch(() => []),
commerceService.getSellerTopProducts(10).catch(() => []),
]);
setProducts(prods.products || []);
setSales(salesData || []);
setStats(statsData || {});
setEvolution(evolutionData || []);
setTopProducts(topData || []);
// v0.602 P3: Fetch balance separately (may 503 if Stripe Connect not configured)
try {
const balanceData = await commerceService.getSellerBalance();
setBalance(balanceData);
setStripeConnectAvailable(true);
} catch (balErr: unknown) {
const status = (balErr as { response?: { status?: number } })?.response?.status;
setStripeConnectAvailable(status === 503 ? false : true);
setBalance(null);
}
// v0.603: Fetch transfer history
try {
const transfersData = await commerceService.getSellerTransfers();
setTransfers(transfersData);
} catch {
setTransfers([]);
}
// v0.12.0 F254: Fetch marketplace balance
try {
const mktBalance = await marketplaceService.getSellerBalance();
setMarketplaceBalance(mktBalance);
} catch {
setMarketplaceBalance(null);
}
} catch (e) {
logger.error('Error loading seller dashboard data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
}, [chartPeriod]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRequestPayout = useCallback(async () => {
setPayoutLoading(true);
try {
await marketplaceService.requestPayout();
addToast('Payout requested successfully', 'success');
fetchData();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to request payout';
addToast(msg, 'error');
} finally {
setPayoutLoading(false);
}
}, [addToast, fetchData]);
const handleConnectPayments = useCallback(async () => {
try {
const { onboarding_url } = await commerceService.connectStripeOnboard();
if (onboarding_url) {
window.location.href = onboarding_url;
} else {
addToast('Could not get onboarding link', 'error');
}
} catch (err) {
logger.error('Connect Stripe onboard failed', { error: err });
addToast('Failed to start payment setup', 'error');
}
}, [addToast]);
if (loading)
return (
<div className="flex justify-center py-24">
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin" />
</div>
);
if (error) {
return (
<div className="pb-20">
<ErrorDisplay
error={error}
onRetry={fetchData}
title="Failed to load seller dashboard"
context={{ action: 'loading', resource: 'seller dashboard' }}
variant="card"
/>
</div>
);
}
if (products.length === 0 && sales.length === 0) {
return (
<div className="animate-fadeIn space-y-8 pb-20">
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
<div>
<h2 className="text-2xl font-heading font-bold text-foreground mb-2">
SELLER DASHBOARD
</h2>
<p className="text-muted-foreground font-mono text-sm">
Manage your products, sales, and analytics.
</p>
</div>
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={onCreateProduct}
>
CREATE PRODUCT
</Button>
</div>
<EmptyState
icon={<Package className="w-full h-full" />}
title="No products yet"
description="Create your first product to start selling and see your dashboard stats."
action={{
label: 'Create Product',
onClick: onCreateProduct,
}}
size="lg"
className="min-h-96"
/>
</div>
);
}
return (
<div className="animate-fadeIn space-y-8 pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
<div>
<h2 className="text-2xl font-heading font-bold text-foreground mb-2">
SELLER DASHBOARD
</h2>
<p className="text-muted-foreground font-mono text-sm">
Manage your products, sales, and analytics.
</p>
</div>
<div className="flex gap-4">
<Button
variant="glass"
icon={<Zap className="w-4 h-4" />}
onClick={() => setShowFlashSale(true)}
>
FLASH SALE
</Button>
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={onCreateProduct}
>
CREATE PRODUCT
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* v0.602 P3: Stripe Connect balance card */}
{stripeConnectAvailable !== false && (
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Wallet className="w-16 h-16 text-primary" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Payout Balance
</div>
{balance?.connected ? (
<>
<div className="text-3xl font-mono font-bold text-foreground mb-1">
{(balance.available + balance.pending).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
<div className="text-xs text-muted-foreground">
Available: {balance.available.toFixed(2)} · Pending: {balance.pending.toFixed(2)}
</div>
</>
) : (
<>
<div className="text-sm text-muted-foreground mb-3">
Configure Stripe to receive payouts
</div>
<Button
variant="outline"
size="sm"
icon={<CreditCard className="w-4 h-4" />}
onClick={handleConnectPayments}
>
Configurer les paiements
</Button>
</>
)}
</Card>
)}
{stripeConnectAvailable !== false && (
<Card variant="default" className="p-6 relative overflow-hidden group lg:col-span-2">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowDownLeft className="w-16 h-16 text-primary" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-3">
Transfer History
</div>
{transfers.length === 0 ? (
<div className="text-sm text-muted-foreground">No transfers yet</div>
) : (
<div className="space-y-3 max-h-48 overflow-y-auto">
{transfers.slice(0, 8).map((t) => (
<div
key={t.id}
className="flex items-center justify-between text-sm py-2 border-b border-border last:border-0"
>
<div>
<span className="font-mono text-foreground">
{((t.amount_cents + t.platform_fee_cents) / 100).toFixed(2)}
</span>
<span className="text-muted-foreground ml-2">
(fee: {(t.platform_fee_cents / 100).toFixed(2)})
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded ${
t.status === 'completed'
? 'bg-success/20 text-success'
: t.status === 'failed'
? 'bg-destructive/20 text-destructive'
: 'bg-muted text-muted-foreground'
}`}
>
{t.status}
</span>
<span className="text-xs text-muted-foreground">
{new Date(t.created_at).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* v0.12.0 F254: Marketplace Balance Card */}
{marketplaceBalance && (
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<DollarSign className="w-16 h-16 text-success" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Marketplace Balance
</div>
<div className="text-3xl font-mono font-bold text-foreground mb-1">
{(marketplaceBalance.available_cents / 100).toFixed(2)}
</div>
<div className="text-xs text-muted-foreground mb-3">
Pending: {(marketplaceBalance.pending_cents / 100).toFixed(2)} · Total earned: {(marketplaceBalance.total_earned_cents / 100).toFixed(2)}
</div>
{marketplaceBalance.available_cents >= 10000 && (
<Button
variant="outline"
size="sm"
onClick={handleRequestPayout}
disabled={payoutLoading}
>
{payoutLoading ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Request Payout
</Button>
)}
{marketplaceBalance.available_cents > 0 && marketplaceBalance.available_cents < 10000 && (
<div className="text-xs text-muted-foreground">
Min. 100.00 for manual payout · Auto-payout at 50.00 weekly
</div>
)}
</Card>
)}
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<DollarSign className="w-16 h-16 text-warning" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Total Revenue
</div>
<div className="text-3xl font-mono font-bold text-foreground mb-2">
{stats.revenue?.toLocaleString() ?? '0'}
</div>
<div className="text-xs text-success flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> +12.5% this month
</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Package className="w-16 h-16 text-muted-foreground" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Total Sales
</div>
<div className="text-3xl font-mono font-bold text-foreground mb-2">
{stats.sales}
</div>
<div className="text-xs text-success flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> +5.0% this month
</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Eye className="w-16 h-16 text-destructive" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Page Views
</div>
<div className="text-3xl font-mono font-bold text-foreground mb-2">
{stats.views > 1000
? `${(stats.views / 1000).toFixed(1)}K`
: stats.views}
</div>
<div className="text-xs text-destructive flex items-center gap-1">
<TrendingUp className="w-3 h-3 rotate-180" /> -2.4% this month
</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Users className="w-16 h-16 text-foreground" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Conversion Rate
</div>
<div className="text-3xl font-mono font-bold text-foreground mb-2">
{stats.conversion != null ? `${stats.conversion}%` : 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{stats.conversion != null ? 'Views → purchases' : 'Tracking not available'}
</div>
</Card>
</div>
{/* Sales Evolution Chart (v0.401 M3) */}
{evolution.length > 0 && (
<Card variant="default" className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-foreground">Sales Evolution</h3>
<div className="flex gap-2">
{(['day', 'week', 'month'] as const).map((p) => (
<Button
key={p}
variant={chartPeriod === p ? 'default' : 'outline'}
size="sm"
onClick={() => setChartPeriod(p)}
>
{p.charAt(0).toUpperCase() + p.slice(1)}
</Button>
))}
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={evolution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="date" className="text-xs" tick={{ fill: 'currentColor' }} />
<YAxis className="text-xs" tick={{ fill: 'currentColor' }} />
<Tooltip contentStyle={{ backgroundColor: 'hsl(var(--card))', border: '1px solid hsl(var(--border))' }} />
<Line type="monotone" dataKey="revenue" stroke="hsl(var(--primary))" strokeWidth={2} name="Revenue (€)" />
<Line type="monotone" dataKey="sales_count" stroke="hsl(var(--muted-foreground))" strokeWidth={2} name="Sales" />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Top Products (v0.401 M3: real data from API) */}
<div className="lg:col-span-2">
<Card variant="default" className="h-full">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-foreground">Top Products</h3>
<Button variant="ghost" size="sm">
View All
</Button>
</div>
<div className="space-y-4">
{(topProducts.length > 0 ? topProducts : products.slice(0, 5).map((p) => ({ product_id: p.id, title: p.title, revenue: 0, sales_count: 0 }))).map((item, i) => {
const prod = products.find((p) => p.id === item.product_id);
return (
<div
key={item.product_id}
className="flex items-center gap-4 p-4 bg-card rounded-lg border border-transparent hover:border-border transition-all"
>
<div className="w-8 text-center font-mono text-muted-foreground">
{i + 1}
</div>
<img
src={prod?.coverUrl ?? prod?.cover_url ?? ''}
alt=""
className="w-12 h-12 rounded object-cover bg-muted"
/>
<div className="flex-1 min-w-0">
<div className="font-bold text-foreground truncate">
{item.title}
</div>
<div className="text-xs text-muted-foreground">
{item.sales_count} sales
</div>
</div>
<div className="text-right">
<div className="font-bold text-foreground">{item.revenue.toFixed(2)}</div>
<div className="text-xs text-muted-foreground">
revenue
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
);
})}
</div>
</Card>
</div>
{/* Recent Sales */}
<div>
<Card variant="default" className="h-full">
<h3 className="font-bold text-foreground mb-6">Recent Sales</h3>
<div className="space-y-4 relative">
<div className="absolute left-2.5 top-2 bottom-2 w-px bg-muted"></div>
{sales.map((sale) => (
<div key={sale.id} className="relative pl-8">
<div className="absolute left-0 top-1.5 w-5 h-5 bg-muted border border-success rounded-full flex items-center justify-center">
<div className="w-2 h-2 bg-success rounded-full"></div>
</div>
<div className="text-sm text-foreground font-bold">
{sale.product}
</div>
<div className="text-xs text-muted-foreground flex justify-between mt-1">
<span>{sale.buyer}</span>
<span>{typeof sale.amount === 'number' ? sale.amount.toFixed(2) : sale.amount}</span>
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center justify-between">
<span>{sale.date}</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-warning"
onClick={() => setRefundOrderId(sale.id)}
>
<RefreshCcw className="w-3 h-3" /> Refund
</Button>
</div>
</div>
))}
</div>
</Card>
</div>
</div>
{showFlashSale && (
<FlashSaleModal
products={products}
onClose={() => setShowFlashSale(false)}
onStart={(config) =>
addToast(
`Flash Sale started for ${config.productIds.length} products!`,
'success',
)
}
/>
)}
{refundOrderId && (
<RefundRequestModal
orderId={refundOrderId}
onClose={() => setRefundOrderId(null)}
onSuccess={fetchData}
/>
)}
</div>
);
};