Files
gridpilot.gg/apps/website/app/sponsor/billing/page.tsx
2026-01-16 01:00:03 +01:00

502 lines
18 KiB
TypeScript

'use client';
import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { StatCard } from '@/ui/StatCard';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { SectionHeader } from '@/ui/SectionHeader';
import { InfoBanner } from '@/ui/InfoBanner';
import { PageHeader } from '@/ui/PageHeader';
import { Icon } from '@/ui/Icon';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
import {
CreditCard,
DollarSign,
Calendar,
Download,
Plus,
Check,
AlertTriangle,
FileText,
TrendingUp,
Receipt,
Building2,
Wallet,
Clock,
ChevronRight,
Info,
ExternalLink,
Percent,
Loader2
} from 'lucide-react';
import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO';
// ============================================================================
// Components
// ============================================================================
function PaymentMethodCardComponent({
method,
onSetDefault,
onRemove
}: {
method: PaymentMethodDTO;
onSetDefault: () => void;
onRemove: () => void;
}) {
const shouldReduceMotion = useReducedMotion();
const getIcon = () => {
if (method.type === 'sepa') return Building2;
return CreditCard;
};
const MethodIcon = getIcon();
const displayLabel = method.type === 'sepa' && method.bankName
? `${method.bankName} •••• ${method.last4}`
: `${method.brand} •••• ${method.last4}`;
const expiryDisplay = method.expiryMonth && method.expiryYear
? `${method.expiryMonth}/${method.expiryYear}`
: null;
return (
<Box
as={motion.div}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
p={4}
rounded="xl"
border
borderColor={method.isDefault ? 'border-primary-blue/50' : 'border-charcoal-outline'}
bg={method.isDefault ? 'bg-gradient-to-r from-primary-blue/10 to-transparent' : 'bg-iron-gray/30'}
shadow={method.isDefault ? '0_0_20px_rgba(25,140,255,0.1)' : undefined}
hoverBorderColor={!method.isDefault ? 'border-charcoal-outline/80' : undefined}
transition-all
>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={4}>
<Box
w="12"
h="12"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
bg={method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'}
>
<Icon icon={MethodIcon} size={6} color={method.isDefault ? 'text-primary-blue' : 'text-gray-400'} />
</Box>
<Box>
<Box display="flex" alignItems="center" gap={2}>
<Text weight="medium" color="text-white">{displayLabel}</Text>
{method.isDefault && (
<Box px={2} py={0.5} rounded="full" bg="bg-primary-blue/20">
<Text size="xs" color="text-primary-blue" weight="medium">
Default
</Text>
</Box>
)}
</Box>
{expiryDisplay && (
<Text size="sm" color="text-gray-500" block>
Expires {expiryDisplay}
</Text>
)}
{method.type === 'sepa' && (
<Text size="sm" color="text-gray-500" block>SEPA Direct Debit</Text>
)}
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
{!method.isDefault && (
<Button variant="secondary" onClick={onSetDefault} size="sm">
Set Default
</Button>
)}
<Button variant="secondary" onClick={onRemove} size="sm" color="text-gray-400" hoverTextColor="text-racing-red">
Remove
</Button>
</Box>
</Box>
</Box>
);
}
function InvoiceRowComponent({ invoice, index }: { invoice: InvoiceDTO; index: number }) {
const shouldReduceMotion = useReducedMotion();
const statusConfig = {
paid: {
icon: Check,
label: 'Paid',
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30'
},
pending: {
icon: Clock,
label: 'Pending',
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30'
},
overdue: {
icon: AlertTriangle,
label: 'Overdue',
color: 'text-racing-red',
bg: 'bg-racing-red/10',
border: 'border-racing-red/30'
},
failed: {
icon: AlertTriangle,
label: 'Failed',
color: 'text-racing-red',
bg: 'bg-racing-red/10',
border: 'border-racing-red/30'
},
};
const typeLabels = {
league: 'League',
team: 'Team',
driver: 'Driver',
race: 'Race',
platform: 'Platform',
};
const status = statusConfig[invoice.status as keyof typeof statusConfig];
const StatusIcon = status.icon;
return (
<Box
as={motion.div}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2, delay: shouldReduceMotion ? 0 : index * 0.05 }}
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
hoverBg="bg-iron-gray/20"
transition-colors
group
>
<Box display="flex" alignItems="center" gap={4} flexGrow={1}>
<Box w="10" h="10" rounded="lg" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center">
<Icon icon={Receipt} size={5} color="text-gray-400" />
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} mb={0.5}>
<Text weight="medium" color="text-white" truncate>{invoice.description}</Text>
<Box px={2} py={0.5} rounded bg="bg-iron-gray">
<Text size="xs" color="text-gray-400">
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
</Text>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={3}>
<Text size="sm" color="text-gray-500">{invoice.invoiceNumber}</Text>
<Text size="sm" color="text-gray-500"></Text>
<Text size="sm" color="text-gray-500">
{new globalThis.Date(invoice.date).toLocaleDateString()}
</Text>
</Box>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>
${invoice.totalAmount.toFixed(2)}
</Text>
<Text size="xs" color="text-gray-500" block>
incl. ${invoice.vatAmount.toFixed(2)} VAT
</Text>
</Box>
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" bg={status.bg} border borderColor={status.border}>
<Icon icon={StatusIcon} size={3} color={status.color} />
<Text size="xs" weight="medium" color={status.color}>{status.label}</Text>
</Box>
<Button variant="secondary" size="sm" opacity={0} groupHoverTextColor="opacity-100" transition-opacity icon={<Icon icon={Download} size={3} />}>
PDF
</Button>
</Box>
</Box>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion();
const [showAllInvoices, setShowAllInvoices] = useState(false);
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
if (isLoading) {
return (
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
<Text color="text-gray-400">Loading billing data...</Text>
</Box>
</Box>
);
}
if (error || !billingData) {
return (
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Text color="text-gray-400" block>{error?.message || 'No billing data available'}</Text>
{error && (
<Button variant="secondary" onClick={retry} mt={4}>
Retry
</Button>
)}
</Box>
</Box>
);
}
const data = billingData;
const handleSetDefault = (methodId: string) => {
// In a real app, this would call an API
console.log('Setting default payment method:', methodId);
};
const handleRemoveMethod = (methodId: string) => {
if (window.confirm('Remove this payment method?')) {
// In a real app, this would call an API
console.log('Removing payment method:', methodId);
}
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: shouldReduceMotion ? 0 : 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
return (
<Box
maxWidth="5xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<Box as={motion.div} variants={itemVariants}>
<PageHeader
icon={Wallet}
title="Billing & Payments"
description="Manage payment methods, view invoices, and track your sponsorship spending"
iconGradient="from-warning-amber/20 to-warning-amber/5"
iconBorder="border-warning-amber/30"
/>
</Box>
{/* Stats Grid */}
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2, lg: 4 }} gap={4} mb={8}>
<StatCard
icon={DollarSign}
label="Total Spent"
value={`$${data.stats.totalSpent.toFixed(2)}`}
subValue="All time"
variant="green"
/>
<StatCard
icon={AlertTriangle}
label="Pending Payments"
value={`$${data.stats.pendingAmount.toFixed(2)}`}
subValue={`${data.invoices.filter((i: { status: string }) => i.status === 'pending' || i.status === 'overdue').length} invoices`}
variant="orange"
/>
<StatCard
icon={Calendar}
label="Next Payment"
value={new globalThis.Date(data.stats.nextPaymentDate).toLocaleDateString()}
subValue={`$${data.stats.nextPaymentAmount.toFixed(2)}`}
variant="blue"
/>
<StatCard
icon={TrendingUp}
label="Monthly Average"
value={`$${data.stats.averageMonthlySpend.toFixed(2)}`}
subValue="Last 6 months"
variant="blue"
/>
</Box>
{/* Payment Methods */}
<Box as={motion.div} variants={itemVariants}>
<Card mb={8} overflow="hidden">
<SectionHeader
icon={CreditCard}
title="Payment Methods"
action={
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Add Payment Method
</Button>
}
/>
<Box p={5} display="flex" flexDirection="col" gap={3}>
{data.paymentMethods.map((method: PaymentMethodDTO) => (
<PaymentMethodCardComponent
key={method.id}
method={method}
onSetDefault={() => handleSetDefault(method.id)}
onRemove={() => handleRemoveMethod(method.id)}
/>
))}
</Box>
<Box px={5} pb={5}>
<InfoBanner type="info">
<Text block mb={1}>We support Visa, Mastercard, American Express, and SEPA Direct Debit.</Text>
<Text block>All payment information is securely processed and stored by our payment provider.</Text>
</InfoBanner>
</Box>
</Card>
</Box>
{/* Billing History */}
<Box as={motion.div} variants={itemVariants}>
<Card mb={8} overflow="hidden">
<SectionHeader
icon={FileText}
title="Billing History"
color="text-warning-amber"
action={
<Button variant="secondary" size="sm" icon={<Icon icon={Download} size={4} />}>
Export All
</Button>
}
/>
<Box>
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: InvoiceDTO, index: number) => (
<InvoiceRowComponent key={invoice.id} invoice={invoice} index={index} />
))}
</Box>
{data.invoices.length > 4 && (
<Box p={4} borderTop borderColor="border-charcoal-outline">
<Button
variant="secondary"
fullWidth
onClick={() => setShowAllInvoices(!showAllInvoices)}
icon={<Icon icon={ChevronRight} size={4} className={showAllInvoices ? 'rotate-90' : ''} />}
flexDirection="row-reverse"
>
{showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`}
</Button>
</Box>
)}
</Card>
</Box>
{/* Platform Fee & VAT Information */}
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
{/* Platform Fee */}
<Card overflow="hidden">
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Percent} size={4} color="text-primary-blue" /></Box>}>
Platform Fee
</Heading>
</Box>
<Box p={5}>
<Text size="3xl" weight="bold" color="text-white" block mb={2}>
{siteConfig.fees.platformFeePercent}%
</Text>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.fees.description}
</Text>
<Box display="flex" flexDirection="col" gap={1}>
<Text size="xs" color="text-gray-500" block> Applied to all sponsorship payments</Text>
<Text size="xs" color="text-gray-500" block> Covers platform maintenance and analytics</Text>
<Text size="xs" color="text-gray-500" block> Ensures quality sponsorship placements</Text>
</Box>
</Box>
</Card>
{/* VAT Information */}
<Card overflow="hidden">
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Receipt} size={4} color="text-performance-green" /></Box>}>
VAT Information
</Heading>
</Box>
<Box p={5}>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.vat.notice}
</Text>
<Box display="flex" flexDirection="col" gap={3}>
<Box display="flex" justifyContent="between" alignItems="center" py={2} borderBottom borderColor="border-charcoal-outline/50">
<Text color="text-gray-500">Standard VAT Rate</Text>
<Text color="text-white" weight="medium">{siteConfig.vat.standardRate}%</Text>
</Box>
<Box display="flex" justifyContent="between" alignItems="center" py={2}>
<Text color="text-gray-500">B2B Reverse Charge</Text>
<Text color="text-performance-green" weight="medium">Available</Text>
</Box>
</Box>
<Text size="xs" color="text-gray-500" block mt={4}>
Enter your VAT ID in Settings to enable reverse charge for B2B transactions.
</Text>
</Box>
</Card>
</Box>
{/* Billing Support */}
<Box as={motion.div} variants={itemVariants} mt={6}>
<Card p={5}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={4}>
<Box p={3} rounded="xl" bg="bg-iron-gray">
<Icon icon={Info} size={5} color="text-gray-400" />
</Box>
<Box>
<Heading level={3} fontSize="base" weight="medium" color="text-white">Need help with billing?</Heading>
<Text size="sm" color="text-gray-500" block>
Contact our billing support for questions about invoices, payments, or refunds.
</Text>
</Box>
</Stack>
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
Contact Support
</Button>
</Box>
</Card>
</Box>
</Box>
);
}