1161 lines
45 KiB
TypeScript
1161 lines
45 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
|
||
import { createPortal } from 'react-dom';
|
||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||
|
||
// ============================================================================
|
||
// INFO FLYOUT COMPONENT
|
||
// ============================================================================
|
||
|
||
interface InfoFlyoutProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
title: string;
|
||
children: React.ReactNode;
|
||
anchorRef: React.RefObject<HTMLElement>;
|
||
}
|
||
|
||
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
|
||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||
const [mounted, setMounted] = useState(false);
|
||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isOpen && anchorRef.current && mounted) {
|
||
const rect = anchorRef.current.getBoundingClientRect();
|
||
const flyoutWidth = Math.min(380, window.innerWidth - 40);
|
||
const flyoutHeight = 450;
|
||
const padding = 16;
|
||
|
||
// Try to position to the right first
|
||
let left = rect.right + 12;
|
||
let top = rect.top;
|
||
|
||
// If goes off right edge, try left side
|
||
if (left + flyoutWidth > window.innerWidth - padding) {
|
||
left = rect.left - flyoutWidth - 12;
|
||
}
|
||
|
||
// If still off screen (left side), center horizontally
|
||
if (left < padding) {
|
||
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
|
||
}
|
||
|
||
// Vertical positioning - try to center on button, but stay in viewport
|
||
top = rect.top - flyoutHeight / 3;
|
||
|
||
// Adjust if goes off bottom edge
|
||
if (top + flyoutHeight > window.innerHeight - padding) {
|
||
top = window.innerHeight - flyoutHeight - padding;
|
||
}
|
||
|
||
// Ensure not above viewport
|
||
if (top < padding) top = padding;
|
||
|
||
// Final bounds check
|
||
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
|
||
|
||
setPosition({ top, left });
|
||
}
|
||
}, [isOpen, anchorRef, mounted]);
|
||
|
||
useEffect(() => {
|
||
const handleEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('keydown', handleEscape);
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
return () => {
|
||
document.removeEventListener('keydown', handleEscape);
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isOpen, onClose]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={flyoutRef}
|
||
className="fixed z-50 w-[380px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
|
||
style={{ top: position.top, left: position.left }}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
|
||
<div className="flex items-center gap-2">
|
||
<HelpCircle className="w-4 h-4 text-primary-blue" />
|
||
<span className="text-sm font-semibold text-white">{title}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
|
||
>
|
||
<X className="w-4 h-4 text-gray-400" />
|
||
</button>
|
||
</div>
|
||
{/* Content */}
|
||
<div className="p-4">
|
||
{children}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
|
||
return (
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
onClick={onClick}
|
||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
|
||
>
|
||
<HelpCircle className="w-3.5 h-3.5" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// MOCKUP COMPONENTS FOR FLYOUTS
|
||
// ============================================================================
|
||
|
||
function PointsSystemMockup() {
|
||
const positions = [
|
||
{ pos: 1, pts: 25, color: 'bg-yellow-500' },
|
||
{ pos: 2, pts: 18, color: 'bg-gray-300' },
|
||
{ pos: 3, pts: 15, color: 'bg-amber-600' },
|
||
{ pos: 4, pts: 12, color: 'bg-charcoal-outline' },
|
||
{ pos: 5, pts: 10, color: 'bg-charcoal-outline' },
|
||
];
|
||
|
||
return (
|
||
<div className="bg-deep-graphite rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center justify-between text-[10px] text-gray-500 uppercase tracking-wide px-1">
|
||
<span>Position</span>
|
||
<span>Points</span>
|
||
</div>
|
||
{positions.map((p) => (
|
||
<div key={p.pos} className="flex items-center gap-3">
|
||
<div className={`h-8 w-8 rounded-lg ${p.color} flex items-center justify-center text-sm font-bold ${p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}`}>
|
||
P{p.pos}
|
||
</div>
|
||
<div className="flex-1 h-2 bg-charcoal-outline/50 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
|
||
style={{ width: `${(p.pts / 25) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<div className="w-8 text-right">
|
||
<span className="text-sm font-mono font-semibold text-white">{p.pts}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="flex items-center justify-center gap-1 pt-2 text-[10px] text-gray-500">
|
||
<span>...</span>
|
||
<span>down to P10 = 1 point</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BonusPointsMockup() {
|
||
const bonuses = [
|
||
{ emoji: '🏎️', label: 'Pole Position', pts: '+1', desc: 'Fastest in qualifying' },
|
||
{ emoji: '⚡', label: 'Fastest Lap', pts: '+1', desc: 'Best race lap time' },
|
||
{ emoji: '🥇', label: 'Led a Lap', pts: '+1', desc: 'Led at least one lap' },
|
||
];
|
||
|
||
return (
|
||
<div className="bg-deep-graphite rounded-lg p-4 space-y-2">
|
||
{bonuses.map((b, i) => (
|
||
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline/30">
|
||
<span className="text-xl">{b.emoji}</span>
|
||
<div className="flex-1">
|
||
<div className="text-xs font-medium text-white">{b.label}</div>
|
||
<div className="text-[10px] text-gray-500">{b.desc}</div>
|
||
</div>
|
||
<span className="text-sm font-mono font-semibold text-performance-green">{b.pts}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChampionshipMockup() {
|
||
const standings = [
|
||
{ pos: 1, name: 'M. Verstappen', pts: 575, delta: null },
|
||
{ pos: 2, name: 'L. Norris', pts: 412, delta: -163 },
|
||
{ pos: 3, name: 'C. Leclerc', pts: 389, delta: -186 },
|
||
];
|
||
|
||
return (
|
||
<div className="bg-deep-graphite rounded-lg p-4">
|
||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
|
||
<Trophy className="w-4 h-4 text-yellow-500" />
|
||
<span className="text-xs font-semibold text-white">Driver Championship</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{standings.map((s) => (
|
||
<div key={s.pos} className="flex items-center gap-2">
|
||
<div className={`h-6 w-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||
s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'
|
||
}`}>
|
||
{s.pos}
|
||
</div>
|
||
<div className="flex-1 text-xs text-white truncate">{s.name}</div>
|
||
<div className="text-xs font-mono font-semibold text-white">{s.pts}</div>
|
||
{s.delta && (
|
||
<div className="text-[10px] font-mono text-gray-500">{s.delta}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 pt-2 border-t border-charcoal-outline/50 text-[10px] text-gray-500 text-center">
|
||
Points accumulated across all races
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DropRulesMockup() {
|
||
const results = [
|
||
{ round: 'R1', pts: 25, dropped: false },
|
||
{ round: 'R2', pts: 18, dropped: false },
|
||
{ round: 'R3', pts: 4, dropped: true },
|
||
{ round: 'R4', pts: 15, dropped: false },
|
||
{ round: 'R5', pts: 12, dropped: false },
|
||
{ round: 'R6', pts: 0, dropped: true },
|
||
];
|
||
|
||
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
|
||
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
|
||
|
||
return (
|
||
<div className="bg-deep-graphite rounded-lg p-4">
|
||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
|
||
<span className="text-xs font-semibold text-white">Best 4 of 6 Results</span>
|
||
</div>
|
||
<div className="flex gap-1 mb-3">
|
||
{results.map((r, i) => (
|
||
<div
|
||
key={i}
|
||
className={`flex-1 p-2 rounded-lg text-center border transition-all ${
|
||
r.dropped
|
||
? 'bg-charcoal-outline/20 border-dashed border-charcoal-outline/50 opacity-50'
|
||
: 'bg-performance-green/10 border-performance-green/30'
|
||
}`}
|
||
>
|
||
<div className="text-[9px] text-gray-500">{r.round}</div>
|
||
<div className={`text-xs font-mono font-semibold ${r.dropped ? 'text-gray-500 line-through' : 'text-white'}`}>
|
||
{r.pts}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex justify-between items-center text-xs">
|
||
<span className="text-gray-500">Total counted:</span>
|
||
<span className="font-mono font-semibold text-performance-green">{total} pts</span>
|
||
</div>
|
||
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1">
|
||
<span>Without drops:</span>
|
||
<span className="font-mono">{wouldBe} pts</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface LeagueScoringSectionProps {
|
||
form: LeagueConfigFormModel;
|
||
presets: LeagueScoringPresetViewModel[];
|
||
onChange?: (form: LeagueConfigFormModel) => void;
|
||
readOnly?: boolean;
|
||
/**
|
||
* When true, only render the scoring pattern panel.
|
||
*/
|
||
patternOnly?: boolean;
|
||
/**
|
||
* When true, only render the championships panel.
|
||
*/
|
||
championshipsOnly?: boolean;
|
||
}
|
||
|
||
interface ScoringPatternSectionProps {
|
||
scoring: LeagueConfigFormModel['scoring'];
|
||
presets: LeagueScoringPresetViewModel[];
|
||
readOnly?: boolean;
|
||
patternError?: string;
|
||
onChangePatternId?: (patternId: string) => void;
|
||
onToggleCustomScoring?: () => void;
|
||
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
|
||
}
|
||
|
||
// Custom points configuration for inline editor
|
||
export interface CustomPointsConfig {
|
||
racePoints: number[];
|
||
poleBonusPoints: number;
|
||
fastestLapPoints: number;
|
||
leaderLapPoints: number;
|
||
}
|
||
|
||
const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = {
|
||
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||
poleBonusPoints: 1,
|
||
fastestLapPoints: 1,
|
||
leaderLapPoints: 0,
|
||
};
|
||
|
||
interface ChampionshipsSectionProps {
|
||
form: LeagueConfigFormModel;
|
||
onChange?: (form: LeagueConfigFormModel) => void;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
export function LeagueScoringSection({
|
||
form,
|
||
presets,
|
||
onChange,
|
||
readOnly,
|
||
patternOnly,
|
||
championshipsOnly,
|
||
}: LeagueScoringSectionProps) {
|
||
const disabled = readOnly || !onChange;
|
||
|
||
const handleSelectPreset = (presetId: string) => {
|
||
if (disabled || !onChange) return;
|
||
onChange({
|
||
...form,
|
||
scoring: {
|
||
...form.scoring,
|
||
patternId: presetId,
|
||
customScoringEnabled: false,
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleToggleCustomScoring = () => {
|
||
if (disabled || !onChange) return;
|
||
onChange({
|
||
...form,
|
||
scoring: {
|
||
...form.scoring,
|
||
customScoringEnabled: !form.scoring.customScoringEnabled,
|
||
},
|
||
});
|
||
};
|
||
|
||
const patternProps: ScoringPatternSectionProps = {
|
||
scoring: form.scoring,
|
||
presets,
|
||
readOnly: !!readOnly,
|
||
};
|
||
|
||
if (!readOnly && onChange) {
|
||
patternProps.onChangePatternId = handleSelectPreset;
|
||
}
|
||
|
||
if (!disabled) {
|
||
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
|
||
}
|
||
|
||
const patternPanel = <ScoringPatternSection {...patternProps} />;
|
||
|
||
const championshipsProps: ChampionshipsSectionProps = {
|
||
form,
|
||
readOnly: !!readOnly,
|
||
};
|
||
|
||
if (onChange) {
|
||
championshipsProps.onChange = onChange;
|
||
}
|
||
|
||
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
|
||
|
||
if (patternOnly) {
|
||
return <div>{patternPanel}</div>;
|
||
}
|
||
|
||
if (championshipsOnly) {
|
||
return <div>{championshipsPanel}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
|
||
{patternPanel}
|
||
{championshipsPanel}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Step 4 – Super simple scoring preset picker.
|
||
* Flat layout, no collapsibles, easy to understand.
|
||
*/
|
||
// Preset info content for individual presets
|
||
function getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
|
||
const name = presetName.toLowerCase();
|
||
if (name.includes('sprint')) {
|
||
return {
|
||
title: 'Sprint + Feature Format',
|
||
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
|
||
details: [
|
||
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
|
||
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
|
||
'Grid for feature often based on sprint results',
|
||
'Great for competitive leagues with time for multiple races',
|
||
],
|
||
};
|
||
}
|
||
if (name.includes('endurance') || name.includes('long')) {
|
||
return {
|
||
title: 'Endurance Format',
|
||
description: 'Long-form racing focused on consistency and strategy over raw pace.',
|
||
details: [
|
||
'Single race per weekend, longer duration (60-90+ minutes)',
|
||
'Higher points for finishing (rewards reliability)',
|
||
'Often includes mandatory pit stops',
|
||
'Best for serious leagues with dedicated racers',
|
||
],
|
||
};
|
||
}
|
||
if (name.includes('club') || name.includes('casual')) {
|
||
return {
|
||
title: 'Club/Casual Format',
|
||
description: 'Relaxed format perfect for community leagues and casual racing.',
|
||
details: [
|
||
'Simple points structure, easy to understand',
|
||
'Typically single race per weekend',
|
||
'Lower stakes, focus on participation',
|
||
'Great for beginners or mixed-skill leagues',
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
title: 'Standard Race Format',
|
||
description: 'Traditional single-race weekend with standard F1-style points.',
|
||
details: [
|
||
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
|
||
'Bonus points for pole position and fastest lap',
|
||
'One race per weekend',
|
||
'The most common format used in sim racing',
|
||
],
|
||
};
|
||
}
|
||
|
||
export function ScoringPatternSection({
|
||
scoring,
|
||
presets,
|
||
readOnly,
|
||
patternError,
|
||
onChangePatternId,
|
||
onToggleCustomScoring,
|
||
onUpdateCustomPoints,
|
||
}: ScoringPatternSectionProps) {
|
||
const disabled = readOnly || !onChangePatternId;
|
||
const currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null;
|
||
const isCustom = scoring.customScoringEnabled;
|
||
|
||
// Local state for custom points editor
|
||
const [customPoints, setCustomPoints] = useState<CustomPointsConfig>(DEFAULT_CUSTOM_POINTS);
|
||
|
||
const updateRacePoints = (index: number, delta: number) => {
|
||
setCustomPoints((prev) => {
|
||
const newPoints = [...prev.racePoints];
|
||
newPoints[index] = Math.max(0, (newPoints[index] ?? 0) + delta);
|
||
const updated = { ...prev, racePoints: newPoints };
|
||
onUpdateCustomPoints?.(updated);
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const addPosition = () => {
|
||
setCustomPoints((prev) => {
|
||
const lastPoints = prev.racePoints[prev.racePoints.length - 1] ?? 0;
|
||
const updated = { ...prev, racePoints: [...prev.racePoints, Math.max(0, lastPoints - 1)] };
|
||
onUpdateCustomPoints?.(updated);
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const removePosition = () => {
|
||
if (customPoints.racePoints.length <= 3) return;
|
||
setCustomPoints((prev) => {
|
||
const updated = { ...prev, racePoints: prev.racePoints.slice(0, -1) };
|
||
onUpdateCustomPoints?.(updated);
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const updateBonus = (key: keyof Omit<CustomPointsConfig, 'racePoints'>, delta: number) => {
|
||
setCustomPoints((prev) => {
|
||
const updated = { ...prev, [key]: Math.max(0, prev[key] + delta) };
|
||
onUpdateCustomPoints?.(updated);
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const resetToDefaults = () => {
|
||
setCustomPoints(DEFAULT_CUSTOM_POINTS);
|
||
onUpdateCustomPoints?.(DEFAULT_CUSTOM_POINTS);
|
||
};
|
||
|
||
const getPresetEmoji = (preset: LeagueScoringPresetViewModel) => {
|
||
const name = preset.name.toLowerCase();
|
||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||
return '🏁';
|
||
};
|
||
|
||
const getPresetDescription = (preset: LeagueScoringPresetViewModel) => {
|
||
const name = preset.name.toLowerCase();
|
||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||
if (name.includes('endurance')) return 'Long-form endurance';
|
||
if (name.includes('club')) return 'Casual league format';
|
||
return preset.sessionSummary;
|
||
};
|
||
|
||
// Flyout state
|
||
const [showPointsFlyout, setShowPointsFlyout] = useState(false);
|
||
const [showBonusFlyout, setShowBonusFlyout] = useState(false);
|
||
const [activePresetFlyout, setActivePresetFlyout] = useState<string | null>(null);
|
||
const pointsInfoRef = useRef<HTMLButtonElement>(null!);
|
||
const bonusInfoRef = useRef<HTMLButtonElement>(null!);
|
||
const presetInfoRefs = useRef<Record<string, HTMLElement | null>>({});
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Section header */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
|
||
<Trophy className="w-5 h-5 text-primary-blue" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-white">Points System</h3>
|
||
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
|
||
</div>
|
||
<p className="text-xs text-gray-500">Choose how points are awarded</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Points System Flyout */}
|
||
<InfoFlyout
|
||
isOpen={showPointsFlyout}
|
||
onClose={() => setShowPointsFlyout(false)}
|
||
title="How Points Work"
|
||
anchorRef={pointsInfoRef}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-400">
|
||
Points are awarded based on finishing position. Higher positions earn more points,
|
||
which accumulate across the season to determine championship standings.
|
||
</p>
|
||
|
||
<div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Example: F1-Style Points</div>
|
||
<PointsSystemMockup />
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
|
||
<div className="text-[11px] text-gray-400">
|
||
<span className="font-medium text-primary-blue">Pro tip:</span> Sprint formats
|
||
award points in both races, typically with reduced points for the sprint.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</InfoFlyout>
|
||
|
||
{/* Two-column layout: Presets | Custom */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-4">
|
||
{/* Preset options */}
|
||
<div className="space-y-2">
|
||
{presets.length === 0 ? (
|
||
<p className="text-sm text-gray-400 p-4 border border-dashed border-charcoal-outline rounded-lg">
|
||
Loading presets...
|
||
</p>
|
||
) : (
|
||
presets.map((preset) => {
|
||
const isSelected = !isCustom && scoring.patternId === preset.id;
|
||
const presetInfo = getPresetInfoContent(preset.name);
|
||
return (
|
||
<div key={preset.id} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChangePatternId?.(preset.id)}
|
||
disabled={disabled}
|
||
className={`
|
||
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200
|
||
${isSelected
|
||
? 'border-primary-blue bg-primary-blue/10'
|
||
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/40'
|
||
}
|
||
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
|
||
`}
|
||
>
|
||
{/* Radio indicator */}
|
||
<div className={`
|
||
flex h-5 w-5 items-center justify-center rounded-full border-2 shrink-0 transition-colors
|
||
${isSelected ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'}
|
||
`}>
|
||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||
</div>
|
||
|
||
{/* Emoji */}
|
||
<span className="text-xl">{getPresetEmoji(preset)}</span>
|
||
|
||
{/* Text */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium text-white">{preset.name}</div>
|
||
<div className="text-xs text-gray-500">{getPresetDescription(preset)}</div>
|
||
</div>
|
||
|
||
{/* Bonus badge */}
|
||
{preset.bonusSummary && (
|
||
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400">
|
||
<Zap className="w-3 h-3" />
|
||
{preset.bonusSummary}
|
||
</span>
|
||
)}
|
||
|
||
{/* Info button */}
|
||
<div
|
||
ref={(el) => { presetInfoRefs.current[preset.id] = el; }}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
|
||
}
|
||
}}
|
||
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
|
||
>
|
||
<HelpCircle className="w-3.5 h-3.5" />
|
||
</div>
|
||
</button>
|
||
|
||
{/* Preset Info Flyout */}
|
||
<InfoFlyout
|
||
isOpen={activePresetFlyout === preset.id}
|
||
onClose={() => setActivePresetFlyout(null)}
|
||
title={presetInfo.title}
|
||
anchorRef={{ current: presetInfoRefs.current[preset.id] ?? pointsInfoRef.current }}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-400">{presetInfo.description}</p>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Key Features</div>
|
||
<ul className="space-y-1.5">
|
||
{presetInfo.details.map((detail, idx) => (
|
||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
|
||
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
|
||
<span>{detail}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
|
||
{preset.bonusSummary && (
|
||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
|
||
<div className="text-[11px] text-gray-400">
|
||
<span className="font-medium text-primary-blue">Bonus points:</span> {preset.bonusSummary}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</InfoFlyout>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Custom scoring option */}
|
||
<div className="lg:w-48">
|
||
<button
|
||
type="button"
|
||
onClick={onToggleCustomScoring}
|
||
disabled={!onToggleCustomScoring || readOnly}
|
||
className={`
|
||
w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
|
||
${isCustom
|
||
? 'border-primary-blue bg-primary-blue/10'
|
||
: 'border-dashed border-charcoal-outline/50 bg-iron-gray/10 hover:border-charcoal-outline hover:bg-iron-gray/20'
|
||
}
|
||
${readOnly ? 'cursor-default opacity-60' : 'cursor-pointer'}
|
||
`}
|
||
>
|
||
<div className={`
|
||
flex h-10 w-10 items-center justify-center rounded-xl transition-colors
|
||
${isCustom ? 'bg-primary-blue/20' : 'bg-charcoal-outline/30'}
|
||
`}>
|
||
<Settings className={`w-5 h-5 ${isCustom ? 'text-primary-blue' : 'text-gray-500'}`} />
|
||
</div>
|
||
<div className="text-center">
|
||
<div className={`text-sm font-medium ${isCustom ? 'text-white' : 'text-gray-400'}`}>
|
||
Custom
|
||
</div>
|
||
<div className="text-[10px] text-gray-500">
|
||
Define your own
|
||
</div>
|
||
</div>
|
||
{isCustom && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary-blue/20 text-[10px] font-medium text-primary-blue">
|
||
<Check className="w-2.5 h-2.5" />
|
||
Active
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error message */}
|
||
{patternError && (
|
||
<p className="text-xs text-warning-amber">{patternError}</p>
|
||
)}
|
||
|
||
{/* Custom scoring editor - inline, no placeholder */}
|
||
{isCustom && (
|
||
<div className="space-y-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline/50">
|
||
{/* Header with reset button */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Settings className="w-4 h-4 text-primary-blue" />
|
||
<span className="text-sm font-medium text-white">Custom Points Table</span>
|
||
<InfoButton buttonRef={bonusInfoRef} onClick={() => setShowBonusFlyout(true)} />
|
||
</div>
|
||
|
||
{/* Bonus Points Flyout */}
|
||
<InfoFlyout
|
||
isOpen={showBonusFlyout}
|
||
onClose={() => setShowBonusFlyout(false)}
|
||
title="Bonus Points Explained"
|
||
anchorRef={bonusInfoRef}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-400">
|
||
Bonus points reward exceptional performances beyond just finishing position.
|
||
They add strategic depth and excitement to your championship.
|
||
</p>
|
||
|
||
<div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Available Bonuses</div>
|
||
<BonusPointsMockup />
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
|
||
<div className="text-[11px] text-gray-400">
|
||
<span className="font-medium text-primary-blue">Example:</span> A driver finishing
|
||
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</InfoFlyout>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={resetToDefaults}
|
||
disabled={readOnly}
|
||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
|
||
>
|
||
<RotateCcw className="w-3 h-3" />
|
||
Reset
|
||
</button>
|
||
</div>
|
||
|
||
{/* Race position points */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-gray-400">Finish position points</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={removePosition}
|
||
disabled={readOnly || customPoints.racePoints.length <= 3}
|
||
className="flex h-5 w-5 items-center justify-center rounded bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white hover:border-primary-blue disabled:opacity-30 transition-colors"
|
||
>
|
||
<Minus className="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={addPosition}
|
||
disabled={readOnly || customPoints.racePoints.length >= 20}
|
||
className="flex h-5 w-5 items-center justify-center rounded bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white hover:border-primary-blue disabled:opacity-30 transition-colors"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-1">
|
||
{customPoints.racePoints.map((pts, idx) => (
|
||
<div key={idx} className="flex flex-col items-center">
|
||
<span className="text-[9px] text-gray-500 mb-0.5">P{idx + 1}</span>
|
||
<div className="flex items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateRacePoints(idx, -1)}
|
||
disabled={readOnly || pts <= 0}
|
||
className="flex h-5 w-4 items-center justify-center rounded-l bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 text-[10px] transition-colors"
|
||
>
|
||
−
|
||
</button>
|
||
<div className="flex h-5 w-6 items-center justify-center bg-deep-graphite border-y border-charcoal-outline">
|
||
<span className="text-[10px] font-medium text-white">{pts}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateRacePoints(idx, 1)}
|
||
disabled={readOnly}
|
||
className="flex h-5 w-4 items-center justify-center rounded-r bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 text-[10px] transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bonus points */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{[
|
||
{ key: 'poleBonusPoints' as const, label: 'Pole', emoji: '🏎️' },
|
||
{ key: 'fastestLapPoints' as const, label: 'Fast lap', emoji: '⚡' },
|
||
{ key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' },
|
||
].map((bonus) => (
|
||
<div key={bonus.key} className="flex flex-col items-center gap-1">
|
||
<span className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</span>
|
||
<div className="flex items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateBonus(bonus.key, -1)}
|
||
disabled={readOnly || customPoints[bonus.key] <= 0}
|
||
className="flex h-6 w-5 items-center justify-center rounded-l bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 transition-colors"
|
||
>
|
||
−
|
||
</button>
|
||
<div className="flex h-6 w-7 items-center justify-center bg-deep-graphite border-y border-charcoal-outline">
|
||
<span className="text-xs font-medium text-white">{customPoints[bonus.key]}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateBonus(bonus.key, 1)}
|
||
disabled={readOnly}
|
||
className="flex h-6 w-5 items-center justify-center rounded-r bg-iron-gray border border-charcoal-outline text-gray-500 hover:text-white disabled:opacity-30 transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Championships section - simple inline toggle list.
|
||
* No collapsibles, flat and easy to scan.
|
||
*/
|
||
// Championship info content
|
||
const CHAMPIONSHIP_INFO: Record<string, { title: string; description: string; details: string[] }> = {
|
||
enableDriverChampionship: {
|
||
title: 'Driver Championship',
|
||
description: 'Track individual driver performance across all races in the season.',
|
||
details: [
|
||
'Each driver accumulates points based on race finishes',
|
||
'The driver with most points at season end wins',
|
||
'Standard in all racing leagues',
|
||
'Shows overall driver skill and consistency',
|
||
],
|
||
},
|
||
enableTeamChampionship: {
|
||
title: 'Team Championship',
|
||
description: 'Combine points from all drivers within a team for team standings.',
|
||
details: [
|
||
'All drivers\' points count toward team total',
|
||
'Rewards having consistent performers across the roster',
|
||
'Creates team strategy opportunities',
|
||
'Only available in Teams mode leagues',
|
||
],
|
||
},
|
||
enableNationsChampionship: {
|
||
title: 'Nations Cup',
|
||
description: 'Group drivers by nationality for international competition.',
|
||
details: [
|
||
'Drivers represent their country automatically',
|
||
'Points pooled by nationality',
|
||
'Adds international rivalry element',
|
||
'Great for diverse, international leagues',
|
||
],
|
||
},
|
||
enableTrophyChampionship: {
|
||
title: 'Trophy Championship',
|
||
description: 'A special category championship for specific classes or groups.',
|
||
details: [
|
||
'Custom category you define (e.g., Am drivers, rookies)',
|
||
'Separate standings from main championship',
|
||
'Encourages participation from all skill levels',
|
||
'Can be used for gentleman drivers, newcomers, etc.',
|
||
],
|
||
},
|
||
};
|
||
|
||
export function ChampionshipsSection({
|
||
form,
|
||
onChange,
|
||
readOnly,
|
||
}: ChampionshipsSectionProps) {
|
||
const disabled = readOnly || !onChange;
|
||
const isTeamsMode = form.structure.mode === 'fixedTeams';
|
||
const [showChampFlyout, setShowChampFlyout] = useState(false);
|
||
const [activeChampFlyout, setActiveChampFlyout] = useState<string | null>(null);
|
||
const champInfoRef = useRef<HTMLButtonElement>(null!);
|
||
const champItemRefs = useRef<Record<string, HTMLElement | null>>({});
|
||
|
||
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
|
||
if (!onChange) return;
|
||
onChange({
|
||
...form,
|
||
championships: {
|
||
...form.championships,
|
||
[key]: value,
|
||
},
|
||
});
|
||
};
|
||
|
||
const championships = [
|
||
{
|
||
key: 'enableDriverChampionship' as const,
|
||
icon: Trophy,
|
||
label: 'Driver Standings',
|
||
description: 'Track individual driver points',
|
||
enabled: form.championships.enableDriverChampionship,
|
||
available: true,
|
||
},
|
||
{
|
||
key: 'enableTeamChampionship' as const,
|
||
icon: Award,
|
||
label: 'Team Standings',
|
||
description: 'Combined team points',
|
||
enabled: form.championships.enableTeamChampionship,
|
||
available: isTeamsMode,
|
||
unavailableHint: 'Teams mode only',
|
||
},
|
||
{
|
||
key: 'enableNationsChampionship' as const,
|
||
icon: Globe,
|
||
label: 'Nations Cup',
|
||
description: 'By nationality',
|
||
enabled: form.championships.enableNationsChampionship,
|
||
available: true,
|
||
},
|
||
{
|
||
key: 'enableTrophyChampionship' as const,
|
||
icon: Medal,
|
||
label: 'Trophy Cup',
|
||
description: 'Special category',
|
||
enabled: form.championships.enableTrophyChampionship,
|
||
available: true,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Section header */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
|
||
<Award className="w-5 h-5 text-primary-blue" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-white">Championships</h3>
|
||
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
|
||
</div>
|
||
<p className="text-xs text-gray-500">What standings to track</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Championships Flyout */}
|
||
<InfoFlyout
|
||
isOpen={showChampFlyout}
|
||
onClose={() => setShowChampFlyout(false)}
|
||
title="Championship Standings"
|
||
anchorRef={champInfoRef}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-400">
|
||
Championships track cumulative points across all races. You can enable multiple
|
||
championship types to run different competitions simultaneously.
|
||
</p>
|
||
|
||
<div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Live Standings Example</div>
|
||
<ChampionshipMockup />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Championship Types</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{[
|
||
{ icon: Trophy, label: 'Driver', desc: 'Individual points' },
|
||
{ icon: Award, label: 'Team', desc: 'Combined drivers' },
|
||
{ icon: Globe, label: 'Nations', desc: 'By country' },
|
||
{ icon: Medal, label: 'Trophy', desc: 'Special class' },
|
||
].map((t, i) => (
|
||
<div key={i} className="flex items-center gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
|
||
<t.icon className="w-3.5 h-3.5 text-primary-blue" />
|
||
<div>
|
||
<div className="text-[10px] font-medium text-white">{t.label}</div>
|
||
<div className="text-[9px] text-gray-500">{t.desc}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</InfoFlyout>
|
||
|
||
{/* Inline toggle grid */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{championships.map((champ) => {
|
||
const Icon = champ.icon;
|
||
const isEnabled = champ.enabled && champ.available;
|
||
const champInfo = CHAMPIONSHIP_INFO[champ.key];
|
||
|
||
return (
|
||
<div key={champ.key} className="relative">
|
||
<button
|
||
type="button"
|
||
disabled={disabled || !champ.available}
|
||
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
|
||
className={`
|
||
w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all duration-200
|
||
${isEnabled
|
||
? 'border-primary-blue/40 bg-primary-blue/10'
|
||
: champ.available
|
||
? 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
|
||
: 'border-charcoal-outline/20 bg-iron-gray/10 opacity-50 cursor-not-allowed'
|
||
}
|
||
`}
|
||
>
|
||
{/* Toggle indicator */}
|
||
<div className={`
|
||
flex h-5 w-5 items-center justify-center rounded-md shrink-0 transition-colors
|
||
${isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline/50'}
|
||
`}>
|
||
{isEnabled && <Check className="w-3 h-3 text-white" />}
|
||
</div>
|
||
|
||
{/* Icon */}
|
||
<Icon className={`w-4 h-4 shrink-0 ${isEnabled ? 'text-primary-blue' : 'text-gray-500'}`} />
|
||
|
||
{/* Text */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}>
|
||
{champ.label}
|
||
</div>
|
||
{!champ.available && champ.unavailableHint && (
|
||
<div className="text-[10px] text-warning-amber/70">{champ.unavailableHint}</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info button */}
|
||
<div
|
||
ref={(el) => { champItemRefs.current[champ.key] = el; }}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
|
||
}
|
||
}}
|
||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
|
||
>
|
||
<HelpCircle className="w-3 h-3" />
|
||
</div>
|
||
</button>
|
||
|
||
{/* Championship Item Info Flyout */}
|
||
{champInfo && (
|
||
<InfoFlyout
|
||
isOpen={activeChampFlyout === champ.key}
|
||
onClose={() => setActiveChampFlyout(null)}
|
||
title={champInfo.title}
|
||
anchorRef={{ current: champItemRefs.current[champ.key] ?? champInfoRef.current }}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-gray-400">{champInfo.description}</p>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wide">How It Works</div>
|
||
<ul className="space-y-1.5">
|
||
{champInfo.details.map((detail, idx) => (
|
||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
|
||
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
|
||
<span>{detail}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
|
||
{!champ.available && (
|
||
<div className="rounded-lg bg-warning-amber/5 border border-warning-amber/20 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<Zap className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" />
|
||
<div className="text-[11px] text-gray-400">
|
||
<span className="font-medium text-warning-amber">Note:</span> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</InfoFlyout>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|