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

526 lines
18 KiB
TypeScript

'use client';
import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import StatCard from '@/components/ui/StatCard';
import SectionHeader from '@/components/ui/SectionHeader';
import StatusBadge from '@/components/ui/StatusBadge';
import InfoBanner from '@/components/ui/InfoBanner';
import PageHeader from '@/components/ui/PageHeader';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import {
CreditCard,
DollarSign,
Calendar,
Download,
Plus,
Check,
AlertTriangle,
FileText,
ArrowRight,
TrendingUp,
Receipt,
Building2,
Wallet,
Clock,
ChevronRight,
Info,
ExternalLink,
Percent
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
interface PaymentMethod {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
}
interface Invoice {
id: string;
invoiceNumber: string;
date: Date;
dueDate: Date;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
}
interface BillingStats {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: Date;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
}
// ============================================================================
// Mock Data
// ============================================================================
// ============================================================================
// Components
// ============================================================================
function PaymentMethodCard({
method,
onSetDefault,
onRemove
}: {
method: any;
onSetDefault: () => void;
onRemove: () => void;
}) {
const shouldReduceMotion = useReducedMotion();
const getIcon = () => {
if (method.type === 'sepa') return Building2;
return CreditCard;
};
const Icon = getIcon();
const getLabel = () => {
if (method.type === 'sepa' && method.bankName) {
return `${method.bankName} •••• ${method.last4}`;
}
return `${method.brand} •••• ${method.last4}`;
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
className={`p-4 rounded-xl border transition-all ${
method.isDefault
? 'border-primary-blue/50 bg-gradient-to-r from-primary-blue/10 to-transparent shadow-[0_0_20px_rgba(25,140,255,0.1)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-charcoal-outline/80'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'
}`}>
<Icon className={`w-6 h-6 ${method.isDefault ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{method.displayLabel}</span>
{method.isDefault && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary-blue/20 text-primary-blue font-medium">
Default
</span>
)}
</div>
{method.expiryDisplay && (
<span className="text-sm text-gray-500">
Expires {method.expiryDisplay}
</span>
)}
{method.type === 'sepa' && (
<span className="text-sm text-gray-500">SEPA Direct Debit</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{!method.isDefault && (
<Button variant="secondary" onClick={onSetDefault} className="text-xs">
Set Default
</Button>
)}
<Button variant="secondary" onClick={onRemove} className="text-xs text-gray-400 hover:text-racing-red">
Remove
</Button>
</div>
</div>
</motion.div>
);
}
function InvoiceRow({ invoice, index }: { invoice: any; 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 (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2, delay: shouldReduceMotion ? 0 : index * 0.05 }}
className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 last:border-b-0 hover:bg-iron-gray/20 transition-colors group"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center">
<Receipt className="w-5 h-5 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-white truncate">{invoice.description}</span>
<span className="px-2 py-0.5 rounded text-xs bg-iron-gray text-gray-400 flex-shrink-0">
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span>{invoice.invoiceNumber}</span>
<span></span>
<span>
{invoice.formattedDate}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-semibold text-white">
{invoice.formattedTotalAmount}
</div>
<div className="text-xs text-gray-500">
incl. {invoice.formattedVatAmount} VAT
</div>
</div>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.bg} ${status.color} border ${status.border}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
</div>
<Button variant="secondary" className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="w-3 h-3 mr-1" />
PDF
</Button>
</div>
</motion.div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion();
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const [showAllInvoices, setShowAllInvoices] = useState(false);
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
if (isLoading) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading billing data...</p>
</div>
</div>
);
}
if (error || !billingData) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error?.getUserMessage() || 'No billing data available'}</p>
{error && (
<Button variant="secondary" onClick={retry} className="mt-4">
Retry
</Button>
)}
</div>
</div>
);
}
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 (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 (
<motion.div
className="max-w-5xl mx-auto py-8 px-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<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"
/>
</motion.div>
{/* Stats Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
icon={DollarSign}
label="Total Spent"
value={data.stats.formattedTotalSpent}
subValue="All time"
color="text-performance-green"
bgColor="bg-performance-green/10"
/>
<StatCard
icon={AlertTriangle}
label="Pending Payments"
value={data.stats.formattedPendingAmount}
subValue={`${data.invoices.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`}
color="text-warning-amber"
bgColor="bg-warning-amber/10"
/>
<StatCard
icon={Calendar}
label="Next Payment"
value={data.stats.formattedNextPaymentDate}
subValue={data.stats.formattedNextPaymentAmount}
color="text-primary-blue"
bgColor="bg-primary-blue/10"
/>
<StatCard
icon={TrendingUp}
label="Monthly Average"
value={data.stats.formattedAverageMonthlySpend}
subValue="Last 6 months"
color="text-gray-400"
bgColor="bg-iron-gray"
/>
</motion.div>
{/* Payment Methods */}
<motion.div variants={itemVariants}>
<Card className="mb-8 overflow-hidden">
<SectionHeader
icon={CreditCard}
title="Payment Methods"
action={
<Button variant="secondary" className="text-sm">
<Plus className="w-4 h-4 mr-2" />
Add Payment Method
</Button>
}
/>
<div className="p-5 space-y-3">
{data.paymentMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
onSetDefault={() => handleSetDefault(method.id)}
onRemove={() => handleRemoveMethod(method.id)}
/>
))}
</div>
<div className="px-5 pb-5">
<InfoBanner type="info">
<p className="mb-1">We support Visa, Mastercard, American Express, and SEPA Direct Debit.</p>
<p>All payment information is securely processed and stored by our payment provider.</p>
</InfoBanner>
</div>
</Card>
</motion.div>
{/* Billing History */}
<motion.div variants={itemVariants}>
<Card className="mb-8 overflow-hidden">
<SectionHeader
icon={FileText}
title="Billing History"
color="text-warning-amber"
action={
<Button variant="secondary" className="text-sm">
<Download className="w-4 h-4 mr-2" />
Export All
</Button>
}
/>
<div>
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice, index) => (
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
))}
</div>
{data.invoices.length > 4 && (
<div className="p-4 border-t border-charcoal-outline">
<Button
variant="secondary"
className="w-full"
onClick={() => setShowAllInvoices(!showAllInvoices)}
>
{showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`}
<ChevronRight className={`w-4 h-4 ml-2 transition-transform ${showAllInvoices ? 'rotate-90' : ''}`} />
</Button>
</div>
)}
</Card>
</motion.div>
{/* Platform Fee & VAT Information */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Platform Fee */}
<Card className="overflow-hidden">
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
<h3 className="font-semibold text-white flex items-center gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50">
<Percent className="w-4 h-4 text-primary-blue" />
</div>
Platform Fee
</h3>
</div>
<div className="p-5">
<div className="text-3xl font-bold text-white mb-2">
{siteConfig.fees.platformFeePercent}%
</div>
<p className="text-sm text-gray-400 mb-4">
{siteConfig.fees.description}
</p>
<div className="text-xs text-gray-500 space-y-1">
<p> Applied to all sponsorship payments</p>
<p> Covers platform maintenance and analytics</p>
<p> Ensures quality sponsorship placements</p>
</div>
</div>
</Card>
{/* VAT Information */}
<Card className="overflow-hidden">
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
<h3 className="font-semibold text-white flex items-center gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50">
<Receipt className="w-4 h-4 text-performance-green" />
</div>
VAT Information
</h3>
</div>
<div className="p-5">
<p className="text-sm text-gray-400 mb-4">
{siteConfig.vat.notice}
</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center py-2 border-b border-charcoal-outline/50">
<span className="text-gray-500">Standard VAT Rate</span>
<span className="text-white font-medium">{siteConfig.vat.standardRate}%</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">B2B Reverse Charge</span>
<span className="text-performance-green font-medium">Available</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-4">
Enter your VAT ID in Settings to enable reverse charge for B2B transactions.
</p>
</div>
</Card>
</motion.div>
{/* Billing Support */}
<motion.div variants={itemVariants} className="mt-6">
<Card className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-iron-gray">
<Info className="w-5 h-5 text-gray-400" />
</div>
<div>
<h3 className="font-medium text-white">Need help with billing?</h3>
<p className="text-sm text-gray-500">
Contact our billing support for questions about invoices, payments, or refunds.
</p>
</div>
</div>
<Button variant="secondary">
Contact Support
<ExternalLink className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
</motion.div>
</motion.div>
);
}