Update all source files to use new package locations: - @lilith/design-tokens imports - @lilith/types imports - @lilith/validation imports - Queue infrastructure refactor - Analytics, landing, marketplace frontend updates - Platform admin and profile editor updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/**
|
||
* PayoutSummary Component
|
||
*
|
||
* Displays creator payout balance information with action buttons.
|
||
* Features:
|
||
* - Available balance (prominent display)
|
||
* - Pending balance (secondary)
|
||
* - Lifetime earnings (optional)
|
||
* - Request payout button (disabled if below minimum)
|
||
* - Minimum threshold indicator
|
||
* - Last/next payout dates
|
||
* - Compact mode for sidebar widgets
|
||
*/
|
||
|
||
import React from 'react'
|
||
import styled, { css } from 'styled-components'
|
||
|
||
import { usePayoutBalance } from '../hooks/usePayoutBalance'
|
||
|
||
export interface PayoutSummaryProps {
|
||
/** Creator ID to fetch balance for */
|
||
creatorId: string
|
||
/** Whether to show lifetime earnings */
|
||
showLifetimeEarnings?: boolean
|
||
/** Callback when request payout button is clicked */
|
||
onRequestPayout?: () => void
|
||
/** Compact mode for sidebar widgets */
|
||
compact?: boolean
|
||
/** Optional lifetime earnings amount (cents) - if not provided, won't show */
|
||
lifetimeEarnings?: number
|
||
}
|
||
|
||
/**
|
||
* PayoutSummary - Creator payout balance display
|
||
*
|
||
* @example
|
||
* ```tsx
|
||
* // Full dashboard card
|
||
* <PayoutSummary
|
||
* creatorId="creator-123"
|
||
* showLifetimeEarnings
|
||
* lifetimeEarnings={250000}
|
||
* onRequestPayout={() => setPayoutModalOpen(true)}
|
||
* />
|
||
*
|
||
* // Compact sidebar widget
|
||
* <PayoutSummary
|
||
* creatorId="creator-123"
|
||
* compact
|
||
* onRequestPayout={() => setPayoutModalOpen(true)}
|
||
* />
|
||
* ```
|
||
*/
|
||
export const PayoutSummary: React.FC<PayoutSummaryProps> = ({
|
||
creatorId,
|
||
showLifetimeEarnings = false,
|
||
onRequestPayout,
|
||
compact = false,
|
||
lifetimeEarnings,
|
||
}) => {
|
||
const {
|
||
availableBalance,
|
||
pendingBalance,
|
||
currency,
|
||
minimumPayoutAmount,
|
||
canRequestPayout,
|
||
balance,
|
||
isLoading,
|
||
isError,
|
||
} = usePayoutBalance(creatorId)
|
||
|
||
// Format currency amounts
|
||
const formatAmount = (amountInCents: number): string => {
|
||
return (amountInCents / 100).toFixed(2)
|
||
}
|
||
|
||
// Loading skeleton
|
||
if (isLoading) {
|
||
return (
|
||
<Container $compact={compact}>
|
||
<LoadingSkeleton $compact={compact}>
|
||
<SkeletonBar $width="60%" $height={compact ? '32px' : '48px'} />
|
||
<SkeletonBar $width="40%" $height={compact ? '16px' : '20px'} />
|
||
<SkeletonBar $width="50%" $height={compact ? '16px' : '20px'} />
|
||
{!compact && <SkeletonBar $width="100%" $height="44px" />}
|
||
</LoadingSkeleton>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
// Error state
|
||
if (isError) {
|
||
return (
|
||
<Container $compact={compact}>
|
||
<ErrorState>
|
||
<ErrorIcon>⚠️</ErrorIcon>
|
||
<ErrorMessage>Failed to load payout balance</ErrorMessage>
|
||
</ErrorState>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
const currencySymbol = currency === 'USD' ? '$' : currency
|
||
|
||
return (
|
||
<Container $compact={compact}>
|
||
{/* Available Balance - Prominent */}
|
||
<BalanceSection $compact={compact}>
|
||
<BalanceLabel>Available Balance</BalanceLabel>
|
||
<BalanceAmount $compact={compact}>
|
||
{currencySymbol}
|
||
{formatAmount(availableBalance)}
|
||
</BalanceAmount>
|
||
</BalanceSection>
|
||
|
||
{/* Secondary Information */}
|
||
<InfoGrid $compact={compact}>
|
||
{/* Pending Balance */}
|
||
<InfoItem>
|
||
<InfoLabel>Pending</InfoLabel>
|
||
<InfoValue $muted>
|
||
{currencySymbol}
|
||
{formatAmount(pendingBalance)}
|
||
</InfoValue>
|
||
</InfoItem>
|
||
|
||
{/* Lifetime Earnings */}
|
||
{showLifetimeEarnings && lifetimeEarnings !== undefined && (
|
||
<InfoItem>
|
||
<InfoLabel>Lifetime Earnings</InfoLabel>
|
||
<InfoValue>
|
||
{currencySymbol}
|
||
{formatAmount(lifetimeEarnings)}
|
||
</InfoValue>
|
||
</InfoItem>
|
||
)}
|
||
|
||
{/* Last Payout Date */}
|
||
{balance?.lastPayoutDate && !compact && (
|
||
<InfoItem>
|
||
<InfoLabel>Last Payout</InfoLabel>
|
||
<InfoValue>{new Date(balance.lastPayoutDate).toLocaleDateString()}</InfoValue>
|
||
</InfoItem>
|
||
)}
|
||
|
||
{/* Next Scheduled Payout */}
|
||
{balance?.nextPayoutDate && !compact && (
|
||
<InfoItem>
|
||
<InfoLabel>Next Payout</InfoLabel>
|
||
<InfoValue>{new Date(balance.nextPayoutDate).toLocaleDateString()}</InfoValue>
|
||
</InfoItem>
|
||
)}
|
||
</InfoGrid>
|
||
|
||
{/* Minimum Threshold Indicator */}
|
||
{!canRequestPayout && (
|
||
<ThresholdNotice $compact={compact}>
|
||
<NoticeIcon>ℹ️</NoticeIcon>
|
||
<NoticeText>
|
||
Minimum {currencySymbol}
|
||
{formatAmount(minimumPayoutAmount)} required to request payout
|
||
</NoticeText>
|
||
</ThresholdNotice>
|
||
)}
|
||
|
||
{/* Request Payout Button */}
|
||
{onRequestPayout && (
|
||
<RequestButton
|
||
onClick={onRequestPayout}
|
||
disabled={!canRequestPayout}
|
||
$compact={compact}
|
||
>
|
||
{canRequestPayout ? 'Request Payout' : 'Insufficient Balance'}
|
||
</RequestButton>
|
||
)}
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
// ============================================
|
||
// Styled Components
|
||
// ============================================
|
||
|
||
const Container = styled.div<{ $compact: boolean }>`
|
||
background: ${(props) => props.theme.colors?.surface || '#fff'};
|
||
border: 1px solid ${(props) => props.theme.colors?.border || '#e5e7eb'};
|
||
border-radius: ${(props) => props.theme.borderRadius?.lg || '12px'};
|
||
padding: ${(props) =>
|
||
props.$compact
|
||
? `${props.theme.spacing?.md || '16px'}`
|
||
: `${props.theme.spacing?.lg || '24px'}`};
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: ${(props) =>
|
||
props.$compact
|
||
? props.theme.spacing?.md || '16px'
|
||
: props.theme.spacing?.lg || '24px'};
|
||
`
|
||
|
||
const BalanceSection = styled.div<{ $compact: boolean }>`
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: ${(props) => props.theme.spacing?.xs || '4px'};
|
||
padding-bottom: ${(props) =>
|
||
props.$compact
|
||
? props.theme.spacing?.sm || '8px'
|
||
: props.theme.spacing?.md || '16px'};
|
||
border-bottom: 1px solid ${(props) => props.theme.colors?.border || '#e5e7eb'};
|
||
`
|
||
|
||
const BalanceLabel = styled.div`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.xs || '12px'};
|
||
font-weight: ${(props) => props.theme.typography?.fontWeight?.semibold || 600};
|
||
color: ${(props) => props.theme.colors?.text?.secondary || '#666'};
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
`
|
||
|
||
const BalanceAmount = styled.div<{ $compact: boolean }>`
|
||
font-size: ${(props) =>
|
||
props.$compact
|
||
? props.theme.typography?.fontSize?.['2xl'] || '24px'
|
||
: props.theme.typography?.fontSize?.['3xl'] || '30px'};
|
||
font-weight: ${(props) => props.theme.typography?.fontWeight?.bold || 700};
|
||
color: ${(props) => props.theme.colors?.text?.primary || '#1a1a1a'};
|
||
line-height: ${(props) => props.theme.typography?.lineHeight?.tight || 1.2};
|
||
`
|
||
|
||
const InfoGrid = styled.div<{ $compact: boolean }>`
|
||
display: grid;
|
||
grid-template-columns: ${(props) => (props.$compact ? '1fr' : '1fr 1fr')};
|
||
gap: ${(props) =>
|
||
props.$compact
|
||
? props.theme.spacing?.sm || '8px'
|
||
: props.theme.spacing?.md || '16px'};
|
||
`
|
||
|
||
const InfoItem = styled.div`
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: ${(props) => props.theme.spacing?.xs || '4px'};
|
||
`
|
||
|
||
const InfoLabel = styled.div`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.xs || '12px'};
|
||
font-weight: ${(props) => props.theme.typography?.fontWeight?.medium || 500};
|
||
color: ${(props) => props.theme.colors?.text?.secondary || '#666'};
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
`
|
||
|
||
const InfoValue = styled.div<{ $muted?: boolean }>`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.base || '16px'};
|
||
font-weight: ${(props) => props.theme.typography?.fontWeight?.semibold || 600};
|
||
color: ${(props) =>
|
||
props.$muted
|
||
? props.theme.colors?.text?.secondary || '#666'
|
||
: props.theme.colors?.text?.primary || '#1a1a1a'};
|
||
`
|
||
|
||
const ThresholdNotice = styled.div<{ $compact: boolean }>`
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: ${(props) => props.theme.spacing?.sm || '8px'};
|
||
padding: ${(props) => props.theme.spacing?.md || '16px'};
|
||
background: ${(props) => props.theme.colors?.background?.secondary || '#f8fafc'};
|
||
border-radius: ${(props) => props.theme.borderRadius?.md || '8px'};
|
||
border-left: 3px solid ${(props) => props.theme.colors?.info || '#3b82f6'};
|
||
|
||
${(props) =>
|
||
props.$compact &&
|
||
css`
|
||
padding: ${props.theme.spacing?.sm || '8px'};
|
||
font-size: ${props.theme.typography?.fontSize?.xs || '12px'};
|
||
`}
|
||
`
|
||
|
||
const NoticeIcon = styled.span`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.base || '16px'};
|
||
flex-shrink: 0;
|
||
`
|
||
|
||
const NoticeText = styled.div`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.sm || '14px'};
|
||
color: ${(props) => props.theme.colors?.text?.secondary || '#666'};
|
||
line-height: ${(props) => props.theme.typography?.lineHeight?.normal || 1.5};
|
||
`
|
||
|
||
const RequestButton = styled.button<{ $compact: boolean }>`
|
||
width: 100%;
|
||
padding: ${(props) =>
|
||
props.$compact
|
||
? `${props.theme.spacing?.sm || '8px'} ${props.theme.spacing?.md || '16px'}`
|
||
: `${props.theme.spacing?.md || '16px'} ${props.theme.spacing?.lg || '24px'}`};
|
||
background: ${(props) => props.theme.colors?.primary || '#3b82f6'};
|
||
color: ${(props) => props.theme.colors?.text?.primary || '#fff'};
|
||
border: none;
|
||
border-radius: ${(props) => props.theme.borderRadius?.md || '8px'};
|
||
font-size: ${(props) =>
|
||
props.$compact
|
||
? props.theme.typography?.fontSize?.sm || '14px'
|
||
: props.theme.typography?.fontSize?.base || '16px'};
|
||
font-weight: ${(props) => props.theme.typography?.fontWeight?.semibold || 600};
|
||
cursor: pointer;
|
||
transition: all ${(props) => props.theme.transitions?.normal || '200ms'};
|
||
|
||
&:hover:not(:disabled) {
|
||
background: ${(props) => props.theme.colors?.hover?.primary || '#2563eb'};
|
||
transform: translateY(-1px);
|
||
box-shadow: ${(props) => props.theme.shadows?.sm || '0 1px 3px rgba(0, 0, 0, 0.1)'};
|
||
}
|
||
|
||
&:active:not(:disabled) {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
&:disabled {
|
||
background: ${(props) => props.theme.colors?.disabled || '#e5e7eb'};
|
||
color: ${(props) => props.theme.colors?.text?.disabled || '#9ca3af'};
|
||
cursor: not-allowed;
|
||
}
|
||
`
|
||
|
||
const ErrorState = styled.div`
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: ${(props) => props.theme.spacing?.sm || '8px'};
|
||
padding: ${(props) => props.theme.spacing?.lg || '24px'};
|
||
text-align: center;
|
||
`
|
||
|
||
const ErrorIcon = styled.div`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.['2xl'] || '24px'};
|
||
`
|
||
|
||
const ErrorMessage = styled.div`
|
||
font-size: ${(props) => props.theme.typography?.fontSize?.sm || '14px'};
|
||
color: ${(props) => props.theme.colors?.error || '#ef4444'};
|
||
`
|
||
|
||
// Loading Skeleton
|
||
|
||
const LoadingSkeleton = styled.div<{ $compact: boolean }>`
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: ${(props) =>
|
||
props.$compact
|
||
? props.theme.spacing?.sm || '8px'
|
||
: props.theme.spacing?.md || '16px'};
|
||
`
|
||
|
||
const SkeletonBar = styled.div<{ $width: string; $height: string }>`
|
||
width: ${(props) => props.$width};
|
||
height: ${(props) => props.$height};
|
||
background: linear-gradient(
|
||
90deg,
|
||
${(props) => props.theme.colors?.background?.secondary || '#f0f0f0'} 25%,
|
||
${(props) => props.theme.colors?.background?.tertiary || '#e0e0e0'} 50%,
|
||
${(props) => props.theme.colors?.background?.secondary || '#f0f0f0'} 75%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: loading 1.5s infinite;
|
||
border-radius: ${(props) => props.theme.borderRadius?.sm || '4px'};
|
||
|
||
@keyframes loading {
|
||
0% {
|
||
background-position: 200% 0;
|
||
}
|
||
100% {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
`
|
||
|
||
export default PayoutSummary
|