540 lines
21 KiB
TypeScript
540 lines
21 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from "@/components/ui/card";
|
|
import { Plus, Minus, Trash2, ArrowLeft } from "lucide-react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Progress } from "@/components/ui/progress";
|
|
|
|
interface ParticipantSetup {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface Prize {
|
|
id: string;
|
|
name: string;
|
|
points: number;
|
|
}
|
|
|
|
interface Participant {
|
|
id: number;
|
|
name: string;
|
|
pointsAvailable: number;
|
|
prizes: Prize[];
|
|
}
|
|
|
|
interface StoredData {
|
|
stage: Stage;
|
|
numParticipants: number;
|
|
participantSetup: ParticipantSetup[];
|
|
pointInputs: number[];
|
|
participants: Participant[];
|
|
totalPoints: number;
|
|
}
|
|
|
|
type Stage = 'setup' | 'points' | 'tracking';
|
|
|
|
const STORAGE_KEY = 'arcadeTrackerData';
|
|
|
|
const ArcadeTracker: React.FC = () => {
|
|
const [stage, setStage] = useState<Stage>('setup');
|
|
const [numParticipants, setNumParticipants] = useState<number>(2);
|
|
const [pointInputs, setPointInputs] = useState<number[]>([]);
|
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
const [totalPoints, setTotalPoints] = useState<number>(0);
|
|
const [newPrizeName, setNewPrizeName] = useState<string>('');
|
|
const [newPrizePoints, setNewPrizePoints] = useState<string>('');
|
|
const [participantSetup, setParticipantSetup] = useState<ParticipantSetup[]>([]);
|
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
|
const [editingParticipantId, setEditingParticipantId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Initialize participant setup when component mounts
|
|
if (participantSetup.length === 0) {
|
|
setParticipantSetup([
|
|
{ id: 1, name: 'Player 1' },
|
|
{ id: 2, name: 'Player 2' }
|
|
]);
|
|
}
|
|
}, [participantSetup.length]); // Add the dependency
|
|
|
|
useEffect(() => {
|
|
const savedData = localStorage.getItem(STORAGE_KEY);
|
|
if (savedData) {
|
|
try {
|
|
const parsed = JSON.parse(savedData) as StoredData;
|
|
setStage(parsed.stage);
|
|
setNumParticipants(parsed.numParticipants);
|
|
setParticipantSetup(parsed.participantSetup || []);
|
|
setPointInputs(parsed.pointInputs);
|
|
setParticipants(parsed.participants);
|
|
setTotalPoints(parsed.totalPoints);
|
|
} catch (error) {
|
|
console.error('Error parsing saved data:', error);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const dataToSave: StoredData = {
|
|
stage,
|
|
numParticipants,
|
|
participantSetup,
|
|
pointInputs,
|
|
participants,
|
|
totalPoints,
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave));
|
|
}, [stage, numParticipants, pointInputs, participants, totalPoints, participantSetup]); // Add participantSetup
|
|
|
|
const handleParticipantNameChange = (id: number, name: string) => {
|
|
setParticipantSetup(prev =>
|
|
prev.map(p => p.id === id ? { ...p, name } : p)
|
|
);
|
|
};
|
|
|
|
const handleNumParticipantsChange = (change: number) => {
|
|
const newNum = Math.max(2, Math.min(10, numParticipants + change));
|
|
setNumParticipants(newNum);
|
|
|
|
// Update participant setup array
|
|
setParticipantSetup(prev => {
|
|
const newSetup = Array(newNum).fill(null).map((_, i) => ({
|
|
id: i + 1,
|
|
name: prev[i]?.name || `Player ${i + 1}`
|
|
}));
|
|
return newSetup;
|
|
});
|
|
};
|
|
|
|
const handleUpdatePoints = () => {
|
|
const total = pointInputs.reduce((sum, points) => sum + points, 0);
|
|
const pointsPerPerson = Math.floor(total / numParticipants);
|
|
|
|
// Update each participant's points while preserving prizes
|
|
const updatedParticipants = participantSetup.map((setup) => {
|
|
const existingParticipant = participants.find(p => p.id === setup.id);
|
|
const currentPrizePoints = existingParticipant?.prizes.reduce((sum, prize) => sum + prize.points, 0) || 0;
|
|
|
|
return {
|
|
id: setup.id,
|
|
name: setup.name,
|
|
pointsAvailable: pointsPerPerson - currentPrizePoints,
|
|
prizes: existingParticipant?.prizes || []
|
|
};
|
|
});
|
|
setTotalPoints(total);
|
|
setParticipants(updatedParticipants);
|
|
setStage('tracking');
|
|
};
|
|
|
|
const handleAddPrize = (participantId: number) => {
|
|
// Just add the prize, no validation
|
|
const points = parseInt(newPrizePoints) || 0;
|
|
|
|
setParticipants(current =>
|
|
current.map(p => {
|
|
if (p.id === participantId) {
|
|
return {
|
|
...p,
|
|
pointsAvailable: p.pointsAvailable - points,
|
|
prizes: [
|
|
...p.prizes,
|
|
{
|
|
id: Date.now().toString(),
|
|
name: newPrizeName || 'Prize',
|
|
points
|
|
}
|
|
]
|
|
};
|
|
}
|
|
return p;
|
|
})
|
|
);
|
|
|
|
// Clear inputs and close dialog
|
|
setNewPrizeName('');
|
|
setNewPrizePoints('');
|
|
setEditingParticipantId(null);
|
|
};
|
|
|
|
const removePrize = (participantId: number, prizeId: string) => {
|
|
setParticipants(participants.map(p => {
|
|
if (p.id === participantId) {
|
|
const prizeToRemove = p.prizes.find(prize => prize.id === prizeId);
|
|
return {
|
|
...p,
|
|
pointsAvailable: p.pointsAvailable + (prizeToRemove?.points || 0),
|
|
prizes: p.prizes.filter(prize => prize.id !== prizeId)
|
|
};
|
|
}
|
|
return p;
|
|
}));
|
|
};
|
|
|
|
const handlePointInput = (index: number, value: string) => {
|
|
const newInputs = [...pointInputs];
|
|
newInputs[index] = value === '' ? 0 : parseInt(value) || 0;
|
|
setPointInputs(newInputs);
|
|
};
|
|
|
|
const resetApp = () => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
setStage('setup');
|
|
setNumParticipants(2);
|
|
setParticipantSetup([
|
|
{ id: 1, name: 'Player 1' },
|
|
{ id: 2, name: 'Player 2' }
|
|
]);
|
|
setPointInputs([]);
|
|
setParticipants([]);
|
|
setTotalPoints(0);
|
|
setShowResetDialog(false);
|
|
};
|
|
|
|
const renderSetupStage = () => (
|
|
<div className="p-4 max-w-md mx-auto">
|
|
<Card className="shadow-lg">
|
|
<CardHeader>
|
|
<CardTitle>Arcade Point Tracker</CardTitle>
|
|
<CardDescription>Set up your group's point tracking</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Label>Number of Participants (2-10)</Label>
|
|
<div className="flex items-center gap-4 mt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => handleNumParticipantsChange(-1)}
|
|
disabled={numParticipants <= 2}
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<div className="text-2xl font-medium w-8 text-center">
|
|
{numParticipants}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => handleNumParticipantsChange(1)}
|
|
disabled={numParticipants >= 10}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<Label>Participant Names</Label>
|
|
{participantSetup.map((p, index) => (
|
|
<div key={p.id} className="flex gap-2">
|
|
<Input
|
|
value={p.name}
|
|
onChange={(e) => handleParticipantNameChange(p.id, e.target.value)}
|
|
placeholder={`Player ${index + 1}`}
|
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
|
className="transition-colors focus:bg-accent focus:text-accent-foreground"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
onClick={() => {
|
|
setPointInputs(Array(numParticipants).fill(0));
|
|
setStage('points');
|
|
}}
|
|
disabled={participantSetup.some(p => !p.name.trim())}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
|
|
const renderPointsStage = () => (
|
|
<div className="p-4 max-w-md mx-auto">
|
|
<Card className="shadow-lg">
|
|
<CardHeader>
|
|
<CardTitle>Enter Points</CardTitle>
|
|
<CardDescription>Input the points from each card</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{pointInputs.map((points, index) => (
|
|
<div key={index}>
|
|
<Label htmlFor={`card-${index}`}>Card {index + 1} Points</Label>
|
|
<Input
|
|
key={`card-${index}`} // Key helps ensure clean re-render
|
|
id={`card-${index}`}
|
|
type="text" // Changed from number
|
|
inputMode="numeric" // Still shows number keyboard on mobile
|
|
value={pointInputs[index] === 0 ? '' : pointInputs[index]} // Empty if 0
|
|
onChange={(e) => handlePointInput(index, e.target.value)}
|
|
className="mt-1"
|
|
placeholder="Enter points"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="flex justify-between">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setStage('setup')}
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={handleUpdatePoints} // Changed from calculatePoints
|
|
disabled={pointInputs.some(p => !p)}
|
|
>
|
|
Calculate
|
|
</Button> </CardFooter>
|
|
</Card>
|
|
</div>
|
|
);
|
|
|
|
const renderTrackingStage = () => (
|
|
<>
|
|
<div className="fixed top-0 left-0 right-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="max-w-md mx-auto p-4">
|
|
<Card className="shadow-lg">
|
|
<CardHeader className="space-y-2">
|
|
<div className="flex justify-between items-start gap-4">
|
|
<CardTitle className="flex flex-col gap-1">
|
|
<div>Total Points: {totalPoints}</div>
|
|
<div className="text-base font-normal text-muted-foreground">
|
|
Points Used: {participants.reduce((total, p) =>
|
|
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
|
, 0)}
|
|
</div>
|
|
</CardTitle>
|
|
<div className="space-x-2 flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setStage('points')}
|
|
>
|
|
Edit Points
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setShowResetDialog(true)}
|
|
>
|
|
Reset App
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Progress
|
|
value={(participants.reduce((total, p) =>
|
|
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
|
, 0) / totalPoints) * 100}
|
|
className={`h-2 ${(participants.reduce((total, p) =>
|
|
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
|
, 0) > totalPoints) ? '[&>div]:bg-destructive' : ''}`}
|
|
/>
|
|
<CardDescription className={`text-right text-xs ${(participants.reduce((total, p) =>
|
|
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
|
, 0) > totalPoints) ? 'text-destructive font-medium' : ''}`}>
|
|
{((participants.reduce((total, p) =>
|
|
total + p.prizes.reduce((sum, prize) => sum + prize.points, 0)
|
|
, 0) / totalPoints) * 100).toFixed(1)}% used
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-md mx-auto">
|
|
<div className="h-[150px]" /> {/* Spacer for fixed header */}
|
|
|
|
<div className="p-4 space-y-4">
|
|
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reset Application</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to reset all data? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowResetDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={resetApp}>
|
|
Reset
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AnimatePresence>
|
|
{participants.map((participant, index) => (
|
|
<motion.div
|
|
key={participant.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
transition={{ duration: 0.2, delay: index * 0.1 }}
|
|
>
|
|
<Card className="shadow-lg">
|
|
<CardHeader>
|
|
<div className="flex justify-between items-center">
|
|
<CardTitle>{participant.name}</CardTitle>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="text-sm text-muted-foreground flex justify-between items-center">
|
|
<span>Points Available: {participant.pointsAvailable} / {Math.floor(totalPoints / participants.length)}</span>
|
|
<span className={`text-xs ${participant.pointsAvailable < 0 ? 'text-destructive font-medium' : ''}`}>
|
|
{((participant.pointsAvailable / (totalPoints / participants.length)) * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<Progress
|
|
value={100 - ((participant.pointsAvailable / (totalPoints / participants.length)) * 100)}
|
|
className={`h-2 ${participant.pointsAvailable < 0 ? '[&>div]:bg-destructive' : ''}`}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="space-y-2">
|
|
<AnimatePresence mode="popLayout">
|
|
{participant.prizes.map((prize) => (
|
|
<motion.div
|
|
key={`${participant.id}-${prize.id}`}
|
|
layout
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 500,
|
|
damping: 30,
|
|
opacity: { duration: 0.2 }
|
|
}}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="flex justify-between items-center p-2 bg-secondary rounded">
|
|
<div>
|
|
<div className="font-medium">{prize.name}</div>
|
|
<div className="text-sm text-muted-foreground">{prize.points} points</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removePrize(participant.id, prize.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
|
|
{!participant.prizes.length && (
|
|
<Alert variant="default" className="bg-muted">
|
|
<AlertDescription>
|
|
"No prizes added yet. Click "Add Prize" to get started."
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Dialog open={editingParticipantId === participant.id} onOpenChange={(open) => {
|
|
if (!open) setEditingParticipantId(null);
|
|
}}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
onClick={() => setEditingParticipantId(participant.id)}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Prize
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add Prize for {participant.name}</DialogTitle>
|
|
<DialogDescription>
|
|
Available Points: {participant.pointsAvailable}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="prize-name">Prize Name</Label>
|
|
<Input
|
|
id="prize-name"
|
|
value={newPrizeName}
|
|
onChange={(e) => setNewPrizeName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="prize-points">Points Cost</Label>
|
|
<Input
|
|
id="prize-points"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={newPrizePoints}
|
|
onChange={(e) => setNewPrizePoints(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setEditingParticipantId(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleAddPrize(participant.id)}
|
|
disabled={!newPrizeName}
|
|
>
|
|
Add Prize
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
switch (stage) {
|
|
case 'setup':
|
|
return renderSetupStage();
|
|
case 'points':
|
|
return renderPointsStage();
|
|
case 'tracking':
|
|
return renderTrackingStage();
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export default ArcadeTracker; |