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:
2026-03-12 17:39:47 +01:00
parent f9e01f18fd
commit 4489c774e5

View File

@@ -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>
)