rewrite predictions form as tap-to-assign with 4 ranked slots
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,58 @@
|
||||
import { useState } from "react"
|
||||
import type { Country, Prediction } from "@celebrate-esc/shared"
|
||||
import type { Entry, Prediction } from "@celebrate-esc/shared"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface PredictionsFormProps {
|
||||
countries: Country[]
|
||||
existingPrediction: Prediction | null
|
||||
locked: boolean
|
||||
onSubmit: (prediction: { predictedWinner: string; top3: string[]; nulPointsPick: string }) => void
|
||||
type SlotKey = "first" | "second" | "third" | "last"
|
||||
|
||||
const SLOTS: { key: SlotKey; label: string }[] = [
|
||||
{ key: "first", label: "1st Place" },
|
||||
{ key: "second", label: "2nd Place" },
|
||||
{ key: "third", label: "3rd Place" },
|
||||
{ key: "last", label: "Last Place" },
|
||||
]
|
||||
|
||||
function formatEntry(entry: Entry): string {
|
||||
return `${entry.country.flag} ${entry.artist} — ${entry.song}`
|
||||
}
|
||||
|
||||
export function PredictionsForm({ countries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
|
||||
const [winner, setWinner] = useState(existingPrediction?.predictedWinner ?? "")
|
||||
const [top3, setTop3] = useState<string[]>(existingPrediction?.top3 ?? [])
|
||||
const [nulPoints, setNulPoints] = useState(existingPrediction?.nulPointsPick ?? "")
|
||||
interface PredictionsFormProps {
|
||||
entries: Entry[]
|
||||
existingPrediction: Prediction | null
|
||||
locked: boolean
|
||||
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
|
||||
}
|
||||
|
||||
export function PredictionsForm({ entries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
|
||||
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
|
||||
if (existingPrediction) {
|
||||
return {
|
||||
first: existingPrediction.first,
|
||||
second: existingPrediction.second,
|
||||
third: existingPrediction.third,
|
||||
last: existingPrediction.last,
|
||||
}
|
||||
}
|
||||
return { first: null, second: null, third: null, last: null }
|
||||
})
|
||||
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
|
||||
|
||||
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
|
||||
const emptySlots = SLOTS.filter((s) => !slots[s.key])
|
||||
const allFilled = SLOTS.every((s) => slots[s.key])
|
||||
|
||||
function findEntry(code: string): Entry | undefined {
|
||||
return entries.find((e) => e.country.code === code)
|
||||
}
|
||||
|
||||
function assignToSlot(entryCode: string, slotKey: SlotKey) {
|
||||
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
|
||||
setPickerForEntry(null)
|
||||
}
|
||||
|
||||
function removeFromSlot(slotKey: SlotKey) {
|
||||
setSlots((prev) => ({ ...prev, [slotKey]: null }))
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
if (!existingPrediction) {
|
||||
@@ -30,97 +69,158 @@ export function PredictionsForm({ countries, existingPrediction, locked, onSubmi
|
||||
<CardHeader>
|
||||
<CardTitle>Your Predictions (locked)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">Winner:</span>{" "}
|
||||
{countries.find((c) => c.code === existingPrediction.predictedWinner)?.name}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Top 3:</span>{" "}
|
||||
{existingPrediction.top3.map((code) => countries.find((c) => c.code === code)?.name).join(", ")}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Nul Points:</span>{" "}
|
||||
{countries.find((c) => c.code === existingPrediction.nulPointsPick)?.name}
|
||||
</p>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const entry = findEntry(existingPrediction[slot.key])
|
||||
return (
|
||||
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function toggleTop3(code: string) {
|
||||
setTop3((prev) => {
|
||||
if (prev.includes(code)) return prev.filter((c) => c !== code)
|
||||
if (prev.length >= 3) return prev
|
||||
return [...prev, code]
|
||||
})
|
||||
// Already submitted — show read-only with option to change
|
||||
if (existingPrediction && allFilled) {
|
||||
const hasChanges = SLOTS.some((s) => slots[s.key] !== existingPrediction[s.key])
|
||||
if (!hasChanges) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Predictions (submitted)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const entry = findEntry(existingPrediction[slot.key])
|
||||
return (
|
||||
<div key={slot.key} className="flex items-center justify-between rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromSlot(slot.key)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
change
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = winner && top3.length === 3 && nulPoints && !top3.includes(winner)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Predictions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Winner</label>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={winner}
|
||||
onChange={(e) => setWinner(e.target.value)}
|
||||
>
|
||||
<option value="">Select a country...</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Slot cards */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{SLOTS.map((slot) => {
|
||||
const code = slots[slot.key]
|
||||
const entry = code ? findEntry(code) : null
|
||||
return (
|
||||
<div
|
||||
key={slot.key}
|
||||
className={`flex items-center justify-between rounded-md border p-2 ${
|
||||
code ? "border-primary/30 bg-primary/5" : "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
||||
{entry ? (
|
||||
<span className="text-sm">{formatEntry(entry)}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Tap an entry below</span>
|
||||
)}
|
||||
</div>
|
||||
{code && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromSlot(slot.key)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${slot.label}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Top 3 (select 3, excl. winner)</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{countries
|
||||
.filter((c) => c.code !== winner)
|
||||
.map((c) => (
|
||||
{/* Submit button */}
|
||||
{allFilled && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
first: slots.first!,
|
||||
second: slots.second!,
|
||||
third: slots.third!,
|
||||
last: slots.last!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Entry list */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
|
||||
{entries.map((entry) => {
|
||||
const isAssigned = assignedCodes.has(entry.country.code)
|
||||
const isPickerOpen = pickerForEntry === entry.country.code
|
||||
return (
|
||||
<div key={entry.country.code}>
|
||||
<button
|
||||
type="button"
|
||||
key={c.code}
|
||||
onClick={() => toggleTop3(c.code)}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
||||
top3.includes(c.code)
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border hover:bg-muted"
|
||||
} ${top3.length >= 3 && !top3.includes(c.code) ? "opacity-40" : ""}`}
|
||||
disabled={isAssigned}
|
||||
onClick={() => {
|
||||
if (emptySlots.length === 1) {
|
||||
assignToSlot(entry.country.code, emptySlots[0]!.key)
|
||||
} else {
|
||||
setPickerForEntry(isPickerOpen ? null : entry.country.code)
|
||||
}
|
||||
}}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||
isAssigned
|
||||
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
|
||||
: isPickerOpen
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
{formatEntry(entry)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isPickerOpen && !isAssigned && (
|
||||
<div className="mt-1 ml-4 flex gap-1">
|
||||
{emptySlots.map((slot) => (
|
||||
<button
|
||||
type="button"
|
||||
key={slot.key}
|
||||
onClick={() => assignToSlot(entry.country.code, slot.key)}
|
||||
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
{slot.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Nul Points (last place)</label>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={nulPoints}
|
||||
onChange={(e) => setNulPoints(e.target.value)}
|
||||
>
|
||||
<option value="">Select a country...</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onSubmit({ predictedWinner: winner, top3, nulPointsPick: nulPoints })} disabled={!canSubmit}>
|
||||
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user