This commit is contained in:
2025-12-10 12:38:55 +01:00
parent 0f7fe67d3c
commit fbbcf414a4
87 changed files with 11972 additions and 390 deletions

View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Clock, Check, X, DollarSign, MessageCircle, User, Building } from 'lucide-react';
export interface PendingRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: 'main' | 'secondary';
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
}
interface PendingSponsorshipRequestsProps {
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
entityName: string;
requests: PendingRequestDTO[];
onAccept: (requestId: string) => Promise<void>;
onReject: (requestId: string, reason?: string) => Promise<void>;
isLoading?: boolean;
}
export default function PendingSponsorshipRequests({
entityType,
entityId,
entityName,
requests,
onAccept,
onReject,
isLoading = false,
}: PendingSponsorshipRequestsProps) {
const [processingId, setProcessingId] = useState<string | null>(null);
const [rejectModalId, setRejectModalId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const handleAccept = async (requestId: string) => {
setProcessingId(requestId);
try {
await onAccept(requestId);
} finally {
setProcessingId(null);
}
};
const handleReject = async (requestId: string) => {
setProcessingId(requestId);
try {
await onReject(requestId, rejectReason || undefined);
setRejectModalId(null);
setRejectReason('');
} finally {
setProcessingId(null);
}
};
if (isLoading) {
return (
<div className="text-center py-8 text-gray-400">
<div className="animate-pulse">Loading sponsorship requests...</div>
</div>
);
}
if (requests.length === 0) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Building className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400 text-sm">No pending sponsorship requests</p>
<p className="text-gray-500 text-xs mt-1">
When sponsors apply to sponsor this {entityType}, their requests will appear here.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Sponsorship Requests</h3>
<span className="px-2 py-1 text-xs bg-primary-blue/20 text-primary-blue rounded-full">
{requests.length} pending
</span>
</div>
<div className="space-y-3">
{requests.map((request) => {
const isProcessing = processingId === request.id;
const isRejecting = rejectModalId === request.id;
return (
<div
key={request.id}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
>
{/* Reject Modal */}
{isRejecting && (
<div className="mb-4 p-4 rounded-lg bg-iron-gray/50 border border-red-500/30">
<h4 className="text-sm font-medium text-white mb-2">
Reject sponsorship from {request.sponsorName}?
</h4>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Optional: Provide a reason for rejection..."
rows={2}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray/80 px-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 mb-3"
/>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setRejectModalId(null);
setRejectReason('');
}}
className="px-3 py-1 text-xs"
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => handleReject(request.id)}
disabled={isProcessing}
className="px-3 py-1 text-xs bg-red-600 hover:bg-red-700"
>
{isProcessing ? 'Rejecting...' : 'Confirm Reject'}
</Button>
</div>
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
{/* Sponsor Logo */}
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-iron-gray/50 shrink-0">
{request.sponsorLogo ? (
<img
src={request.sponsorLogo}
alt={request.sponsorName}
className="w-8 h-8 object-contain"
/>
) : (
<Building className="w-6 h-6 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-white truncate">
{request.sponsorName}
</h4>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
request.tier === 'main'
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{request.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
</span>
</div>
{/* Offer Details */}
<div className="flex flex-wrap gap-3 text-xs mb-2">
<div className="flex items-center gap-1 text-performance-green">
<DollarSign className="w-3 h-3" />
<span className="font-semibold">{request.formattedAmount}</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<span>Net: ${(request.netAmount / 100).toFixed(2)}</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<Clock className="w-3 h-3" />
<span>
{new Date(request.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
{/* Message */}
{request.message && (
<div className="flex items-start gap-1.5 text-xs text-gray-400 bg-iron-gray/30 rounded p-2">
<MessageCircle className="w-3 h-3 mt-0.5 shrink-0" />
<span className="line-clamp-2">{request.message}</span>
</div>
)}
</div>
</div>
{/* Actions */}
{!isRejecting && (
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => handleAccept(request.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-xs"
>
<Check className="w-3 h-3 mr-1" />
{isProcessing ? 'Accepting...' : 'Accept'}
</Button>
<Button
variant="secondary"
onClick={() => setRejectModalId(request.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-xs"
>
<X className="w-3 h-3 mr-1" />
Reject
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
<div className="text-xs text-gray-500 mt-4">
<p>
<strong className="text-gray-400">Note:</strong> Accepting a request will activate the sponsorship.
The sponsor will be charged and you'll receive the payment minus 10% platform fee.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,571 @@
'use client';
import React, { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
Eye,
TrendingUp,
Users,
Star,
Target,
DollarSign,
Calendar,
Trophy,
Zap,
ExternalLink,
MessageCircle,
Activity,
Shield,
Check,
Loader2,
} from 'lucide-react';
import { getApplyForSponsorshipUseCase, getSponsorRepository } from '@/lib/di-container';
// ============================================================================
// TYPES
// ============================================================================
export type EntityType = 'league' | 'race' | 'driver' | 'team';
export interface SponsorMetric {
icon: React.ElementType;
label: string;
value: string | number;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
export interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
currency?: string;
benefits: string[];
}
export interface SponsorInsightsProps {
// Entity info
entityType: EntityType;
entityId: string;
entityName: string;
// Tier classification
tier: 'premium' | 'standard' | 'starter';
// Key metrics (shown in grid)
metrics: SponsorMetric[];
// Sponsorship availability
slots: SponsorshipSlot[];
// Optional: additional stats section
additionalStats?: {
label: string;
items: Array<{ label: string; value: string | number }>;
};
// Optional: trust indicators
trustScore?: number;
discordMembers?: number;
monthlyActivity?: number;
// CTA customization
ctaLabel?: string;
ctaHref?: string;
// Optional: current sponsor ID (if logged in as sponsor)
currentSponsorId?: string;
// Optional: callback when sponsorship request is submitted
onSponsorshipRequested?: (tier: 'main' | 'secondary') => void;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getTierStyles(tier: SponsorInsightsProps['tier']) {
switch (tier) {
case 'premium':
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
gradient: 'from-yellow-500/10 via-transparent to-transparent',
};
case 'standard':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
gradient: 'from-blue-500/10 via-transparent to-transparent',
};
default:
return {
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
gradient: 'from-gray-500/10 via-transparent to-transparent',
};
}
}
function getEntityLabel(type: EntityType): string {
switch (type) {
case 'league': return 'League';
case 'race': return 'Race';
case 'driver': return 'Driver';
case 'team': return 'Team';
}
}
function getEntityIcon(type: EntityType) {
switch (type) {
case 'league': return Trophy;
case 'race': return Zap;
case 'driver': return Users;
case 'team': return Users;
}
}
// ============================================================================
// COMPONENT
// ============================================================================
export default function SponsorInsightsCard({
entityType,
entityId,
entityName,
tier,
metrics,
slots,
additionalStats,
trustScore,
discordMembers,
monthlyActivity,
ctaLabel,
ctaHref,
currentSponsorId,
onSponsorshipRequested,
}: SponsorInsightsProps) {
const router = useRouter();
const tierStyles = getTierStyles(tier);
const EntityIcon = getEntityIcon(entityType);
// State for sponsorship application
const [applyingTier, setApplyingTier] = useState<'main' | 'secondary' | null>(null);
const [appliedTiers, setAppliedTiers] = useState<Set<'main' | 'secondary'>>(new Set());
const [error, setError] = useState<string | null>(null);
const mainSlot = slots.find(s => s.tier === 'main');
const secondarySlots = slots.filter(s => s.tier === 'secondary');
const availableSecondary = secondarySlots.filter(s => s.available).length;
// Map EntityType to SponsorableEntityType
const getSponsorableEntityType = useCallback((type: EntityType): 'driver' | 'team' | 'race' | 'season' => {
switch (type) {
case 'league': return 'season'; // Leagues are sponsored via their seasons
case 'race': return 'race';
case 'driver': return 'driver';
case 'team': return 'team';
}
}, []);
const handleSponsorClick = useCallback(async (slotTier: 'main' | 'secondary') => {
// If no sponsor ID, redirect to sponsor signup/login
if (!currentSponsorId) {
const href = ctaHref || `/sponsor/${entityType}s/${entityId}?tier=${slotTier}`;
router.push(href);
return;
}
// If already applied for this tier, show details page
if (appliedTiers.has(slotTier)) {
router.push(`/sponsor/dashboard`);
return;
}
// Apply for sponsorship using use case
setApplyingTier(slotTier);
setError(null);
try {
const applyUseCase = getApplyForSponsorshipUseCase();
const slot = slotTier === 'main' ? mainSlot : secondarySlots[0];
const slotPrice = slot?.price ?? 0;
await applyUseCase.execute({
sponsorId: currentSponsorId,
entityType: getSponsorableEntityType(entityType),
entityId,
tier: slotTier,
offeredAmount: slotPrice * 100, // Convert to cents
currency: (slot?.currency as 'USD' | 'EUR' | 'GBP') ?? 'USD',
message: `Interested in sponsoring ${entityName} as ${slotTier} sponsor.`,
});
// Mark as applied
setAppliedTiers(prev => new Set([...prev, slotTier]));
// Call callback if provided
onSponsorshipRequested?.(slotTier);
} catch (err) {
console.error('Failed to apply for sponsorship:', err);
setError(err instanceof Error ? err.message : 'Failed to submit sponsorship request');
} finally {
setApplyingTier(null);
}
}, [currentSponsorId, ctaHref, entityType, entityId, entityName, router, mainSlot, secondarySlots, appliedTiers, getSponsorableEntityType, onSponsorshipRequested]);
return (
<Card className={`mb-6 border-primary-blue/30 bg-gradient-to-r ${tierStyles.gradient}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Target className="w-5 h-5 text-primary-blue" />
<h3 className="text-lg font-semibold text-white">Sponsorship Opportunity</h3>
</div>
<p className="text-gray-400 text-sm">
Reach engaged sim racers by sponsoring this {entityType}
</p>
</div>
<div className="flex items-center gap-2">
<div className={`px-2 py-1 rounded text-xs font-medium border ${tierStyles.badge}`}>
{tier.charAt(0).toUpperCase() + tier.slice(1)} {getEntityLabel(entityType)}
</div>
</div>
</div>
{/* Key Metrics Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
{metrics.slice(0, 4).map((metric, index) => {
const Icon = metric.icon;
return (
<div
key={index}
className="bg-iron-gray/50 rounded-lg p-3 border border-charcoal-outline"
>
<div className={`flex items-center gap-1.5 ${metric.color || 'text-primary-blue'} mb-1`}>
<Icon className="w-4 h-4" />
<span className="text-xs font-medium">{metric.label}</span>
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-bold text-white">
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
</div>
{metric.trend && (
<span className={`text-xs ${metric.trend.isPositive ? 'text-performance-green' : 'text-red-400'}`}>
{metric.trend.isPositive ? '+' : ''}{metric.trend.value}%
</span>
)}
</div>
</div>
);
})}
</div>
{/* Trust & Activity Indicators */}
{(trustScore !== undefined || discordMembers !== undefined || monthlyActivity !== undefined) && (
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-charcoal-outline/50">
{trustScore !== undefined && (
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-performance-green" />
<span className="text-sm text-gray-400">Trust Score:</span>
<span className="text-sm font-semibold text-white">{trustScore}/100</span>
</div>
)}
{discordMembers !== undefined && (
<div className="flex items-center gap-2">
<MessageCircle className="w-4 h-4 text-purple-400" />
<span className="text-sm text-gray-400">Discord:</span>
<span className="text-sm font-semibold text-white">{discordMembers.toLocaleString()}</span>
</div>
)}
{monthlyActivity !== undefined && (
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-neon-aqua" />
<span className="text-sm text-gray-400">Monthly Activity:</span>
<span className="text-sm font-semibold text-white">{monthlyActivity}%</span>
</div>
)}
</div>
)}
{/* Sponsorship Slots */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
{/* Main Sponsor Slot */}
{mainSlot && (
<div className={`p-3 rounded-lg border ${
mainSlot.available
? 'bg-performance-green/10 border-performance-green/30'
: 'bg-iron-gray/30 border-charcoal-outline'
}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Main Sponsor Slot</span>
<span className={`text-xs ${mainSlot.available ? 'text-performance-green' : 'text-gray-500'}`}>
{mainSlot.available ? 'Available' : 'Taken'}
</span>
</div>
<p className="text-xs text-gray-400 mb-2">{mainSlot.benefits.join(' • ')}</p>
{mainSlot.available && (
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-white">
${mainSlot.price.toLocaleString()}/season
</span>
<Button
variant="primary"
onClick={() => handleSponsorClick('main')}
disabled={applyingTier === 'main'}
className="text-xs px-3 py-1"
>
{applyingTier === 'main' ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Applying...
</>
) : appliedTiers.has('main') ? (
<>
<Check className="w-3 h-3 mr-1" />
Applied
</>
) : (
'Apply to Sponsor'
)}
</Button>
</div>
)}
</div>
)}
{/* Secondary Slots */}
{secondarySlots.length > 0 && (
<div className={`p-3 rounded-lg border ${
availableSecondary > 0
? 'bg-purple-500/10 border-purple-500/30'
: 'bg-iron-gray/30 border-charcoal-outline'
}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Secondary Slots</span>
<span className={`text-xs ${availableSecondary > 0 ? 'text-purple-400' : 'text-gray-500'}`}>
{availableSecondary}/{secondarySlots.length} Available
</span>
</div>
<p className="text-xs text-gray-400 mb-2">
{secondarySlots[0]?.benefits.join(' • ') || 'Logo placement on page'}
</p>
{availableSecondary > 0 && (
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-white">
${secondarySlots[0]?.price.toLocaleString()}/season
</span>
<Button
variant="secondary"
onClick={() => handleSponsorClick('secondary')}
disabled={applyingTier === 'secondary'}
className="text-xs px-3 py-1"
>
{applyingTier === 'secondary' ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Applying...
</>
) : appliedTiers.has('secondary') ? (
<>
<Check className="w-3 h-3 mr-1" />
Applied
</>
) : (
'Apply to Sponsor'
)}
</Button>
</div>
)}
</div>
)}
</div>
{/* Additional Stats */}
{additionalStats && (
<div className="mb-4 pb-4 border-b border-charcoal-outline/50">
<h4 className="text-sm font-medium text-gray-400 mb-2">{additionalStats.label}</h4>
<div className="flex flex-wrap gap-4">
{additionalStats.items.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-sm text-gray-500">{item.label}:</span>
<span className="text-sm font-semibold text-white">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</span>
</div>
))}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500">
10% platform fee applies Logos burned on all liveries
{appliedTiers.size > 0 && ' • Application pending review'}
</p>
<Button
variant="secondary"
onClick={() => router.push(ctaHref || `/sponsor/${entityType}s/${entityId}`)}
className="text-xs"
>
{ctaLabel || 'View Full Details'}
</Button>
</div>
</Card>
);
}
// ============================================================================
// HELPER HOOK: useSponsorMode
// ============================================================================
export function useSponsorMode(): boolean {
const [isSponsor, setIsSponsor] = React.useState(false);
React.useEffect(() => {
if (typeof document !== 'undefined') {
const cookies = document.cookie.split(';');
const demoModeCookie = cookies.find(c => c.trim().startsWith('gridpilot_demo_mode='));
if (demoModeCookie) {
const value = demoModeCookie.split('=')[1]?.trim();
setIsSponsor(value === 'sponsor');
}
}
}, []);
return isSponsor;
}
// ============================================================================
// COMMON METRIC BUILDERS
// ============================================================================
export const MetricBuilders = {
views: (value: number, label = 'Views'): SponsorMetric => ({
icon: Eye,
label,
value,
color: 'text-primary-blue',
}),
engagement: (value: number | string): SponsorMetric => ({
icon: TrendingUp,
label: 'Engagement',
value: typeof value === 'number' ? `${value}%` : value,
color: 'text-performance-green',
}),
reach: (value: number): SponsorMetric => ({
icon: Users,
label: 'Est. Reach',
value,
color: 'text-purple-400',
}),
rating: (value: number | string, label = 'Rating'): SponsorMetric => ({
icon: Star,
label,
value,
color: 'text-warning-amber',
}),
races: (value: number): SponsorMetric => ({
icon: Calendar,
label: 'Races',
value,
color: 'text-neon-aqua',
}),
members: (value: number): SponsorMetric => ({
icon: Users,
label: 'Members',
value,
color: 'text-purple-400',
}),
impressions: (value: number): SponsorMetric => ({
icon: Eye,
label: 'Impressions',
value,
color: 'text-primary-blue',
}),
sof: (value: number | string): SponsorMetric => ({
icon: Zap,
label: 'Avg SOF',
value,
color: 'text-warning-amber',
}),
};
// ============================================================================
// SLOT TEMPLATES
// ============================================================================
export const SlotTemplates = {
league: (mainAvailable: boolean, secondaryAvailable: number, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
},
{
tier: 'secondary',
available: secondaryAvailable > 0,
price: secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
{
tier: 'secondary',
available: secondaryAvailable > 1,
price: secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
],
race: (mainAvailable: boolean, mainPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
},
],
driver: (available: boolean, price: number): SponsorshipSlot[] => [
{
tier: 'main',
available,
price,
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
},
],
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
},
{
tier: 'secondary',
available: secondaryAvailable,
price: secondaryPrice,
benefits: ['Team page logo', 'Minor livery placement'],
},
],
};