import React, { useEffect, useMemo, useRef, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { RotateCw, Shuffle, EyeOff, Download, Upload, Trash2, Lock, Unlock } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
// Secret Spin Wheel – designed for:
// - 5 people, 5 options (unique)
// - each option can be claimed once
// - each person spins/claims privately (others can’t see what’s gone)
// - host view can be toggled with a PIN to see assignments
// - state can be saved/loaded (JSON) so you can send it to yourself or keep across devices
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function polarToCartesian(cx, cy, r, angleDeg) {
const a = (Math.PI / 180) * angleDeg;
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
}
function describeArc(cx, cy, r, startAngle, endAngle) {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
}
const palette = [
"#2563eb", // blue
"#16a34a", // green
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // violet
"#06b6d4", // cyan
"#f97316", // orange
"#10b981", // emerald
"#e11d48", // rose
"#64748b", // slate
];
const defaultOptions = ["Starter", "Side", "Dessert", "Salad", "Drinks"];
export default function SecretSpinWheelDinnerParty() {
const [people, setPeople] = useState(["Person 1", "Person 2", "Person 3", "Person 4", "Person 5"]);
const [options, setOptions] = useState(defaultOptions);
const [currentPerson, setCurrentPerson] = useState(people[0]);
// Core state
const [remaining, setRemaining] = useState(() => defaultOptions.map((label, i) => ({ id: `${i}-${label}`, label })));
const [assignments, setAssignments] = useState({}); // { personName: optionLabel }
// Spin UX
const [spinning, setSpinning] = useState(false);
const [rotation, setRotation] = useState(0);
const [result, setResult] = useState(null); // { person, option }
const [toast, setToast] = useState(null);
// Privacy
const [revealMode, setRevealMode] = useState(false);
const [pin, setPin] = useState("1234");
const [pinEntry, setPinEntry] = useState("");
// Locking (prevents edits mid-game)
const [locked, setLocked] = useState(false);
// SVG refs
const svgRef = useRef(null);
// Derived
const remainingLabels = useMemo(() => remaining.map((r) => r.label), [remaining]);
const usedCount = useMemo(() => Object.keys(assignments).length, [assignments]);
useEffect(() => {
// Keep currentPerson valid
if (!people.includes(currentPerson)) setCurrentPerson(people[0] || "");
}, [people, currentPerson]);
useEffect(() => {
// Sync remaining when options change or after reset
setRemaining(options.map((label, i) => ({ id: `${i}-${label}`, label })));
setAssignments({});
setResult(null);
setRotation(0);
setSpinning(false);
}, [options]);
const segments = useMemo(() => {
const segs = remaining.length ? remaining : [{ id: "none", label: "No options" }];
return segs;
}, [remaining]);
const canSpin = useMemo(() => {
return !spinning && !!currentPerson && !assignments[currentPerson] && remaining.length > 0;
}, [spinning, currentPerson, assignments, remaining.length]);
function showToast(message) {
setToast(message);
window.clearTimeout(showToast._t);
showToast._t = window.setTimeout(() => setToast(null), 2800);
}
function resetAll() {
setRemaining(options.map((label, i) => ({ id: `${i}-${label}`, label })));
setAssignments({});
setResult(null);
setRotation(0);
setSpinning(false);
setRevealMode(false);
setPinEntry("");
showToast("Reset complete.");
}
function shuffleOptions() {
const arr = [...options];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
setOptions(arr);
showToast("Options shuffled.");
}
function handleSpin() {
if (!canSpin) return;
// Pick a random remaining option
const idx = randomInt(0, remaining.length - 1);
const chosen = remaining[idx];
// Calculate wheel landing rotation for visual effect
// The wheel is drawn with segments starting at 0° on the right, we place pointer at top (-90°).
const n = remaining.length;
const segmentAngle = 360 / n;
// Choose a target angle in the chosen segment (with some jitter)
const segmentStart = idx * segmentAngle;
const jitter = segmentAngle * (0.15 + Math.random() * 0.7);
const target = segmentStart + jitter;
// Add several full spins + target alignment to pointer
const extraSpins = randomInt(4, 7) * 360;
// Pointer at top means we want the chosen segment to end up at 270° relative orientation.
// We rotate the wheel so that target angle aligns with 270°.
const desired = 270;
const delta = desired - target;
const newRotation = rotation + extraSpins + delta;
setSpinning(true);
setResult(null);
// Animate roughly 3 seconds
setRotation(newRotation);
window.setTimeout(() => {
// Commit assignment after spin
setAssignments((prev) => ({ ...prev, [currentPerson]: chosen.label }));
setRemaining((prev) => prev.filter((_, i) => i !== idx));
setResult({ person: currentPerson, option: chosen.label });
setSpinning(false);
showToast("Assigned! Tap ‘Hide result’ before handing to the next person.");
}, 3200);
}
function hideResult() {
setResult(null);
showToast("Result hidden.");
}
function toggleReveal() {
if (revealMode) {
setRevealMode(false);
setPinEntry("");
showToast("Host view hidden.");
return;
}
if (pinEntry === pin) {
setRevealMode(true);
setPinEntry("");
showToast("Host view enabled.");
} else {
showToast("Incorrect PIN.");
}
}
function exportState() {
const payload = {
v: 1,
people,
options,
remaining,
assignments,
locked,
pin,
ts: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `secret-spinwheel-state-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importState(file) {
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(String(reader.result || "{}"));
if (!data || data.v !== 1) throw new Error("Unsupported file");
setPeople(Array.isArray(data.people) ? data.people : people);
setOptions(Array.isArray(data.options) ? data.options : options);
setRemaining(Array.isArray(data.remaining) ? data.remaining : remaining);
setAssignments(typeof data.assignments === "object" && data.assignments ? data.assignments : {});
setLocked(!!data.locked);
setPin(typeof data.pin === "string" ? data.pin : pin);
setResult(null);
setRotation(0);
setRevealMode(false);
setPinEntry("");
showToast("State loaded.");
} catch (e) {
showToast("Could not load that file.");
}
};
reader.readAsText(file);
}
const wheelSize = 320;
const cx = wheelSize / 2;
const cy = wheelSize / 2;
const r = wheelSize / 2 - 10;
const nSeg = segments.length;
const segAngle = 360 / nSeg;
const pointer = (
<div className="absolute left-1/2 -translate-x-1/2 -top-1">
<div className="w-0 h-0 border-l-[12px] border-l-transparent border-r-[12px] border-r-transparent border-b-[20px] border-b-zinc-900" />
</div>
);
const completed = usedCount === people.length || remaining.length === 0;
return (
<div className="min-h-screen w-full bg-gradient-to-b from-zinc-50 to-zinc-100 p-4 md:p-8">
<div className="mx-auto max-w-5xl space-y-4">
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="flex flex-col gap-2">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">Secret Spin Wheel (5 people / 5 dishes)</h1>
<p className="text-sm md:text-base text-zinc-600">
Each person spins once. Their result shows only on this screen until you tap <span className="font-medium">Hide result</span>. Others cannot see what’s
gone. Host can unlock a private list with a PIN.
</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2 rounded-2xl shadow-sm">
<CardContent className="p-4 md:p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full">{usedCount}/{people.length} assigned</Badge>
<Badge variant="secondary" className="rounded-full">{remaining.length} remaining</Badge>
{completed && <Badge className="rounded-full">Complete</Badge>}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="rounded-2xl" onClick={hideResult} disabled={!result || spinning}>
<EyeOff className="w-4 h-4 mr-2" /> Hide result
</Button>
<Button className="rounded-2xl" onClick={handleSpin} disabled={!canSpin}>
<RotateCw className={`w-4 h-4 mr-2 ${spinning ? "animate-spin" : ""}`} /> Spin
</Button>
</div>
</div>
<div className="mt-4 flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<div className="flex items-center gap-2">
<div className="text-sm text-zinc-600">Current person</div>
<select
className="h-10 rounded-xl border border-zinc-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-zinc-300"
value={currentPerson}
onChange={(e) => setCurrentPerson(e.target.value)}
disabled={spinning}
>
{people.map((p) => (
<option key={p} value={p}>
{p}{assignments[p] ? " (done)" : ""}
</option>
))}
</select>
{assignments[currentPerson] && (
<Badge variant="secondary" className="rounded-full">Already spun</Badge>
)}
</div>
<div className="text-sm text-zinc-600">
Tip: After they see their result, tap <span className="font-medium">Hide result</span> before passing the phone.
</div>
</div>
<div className="mt-6 relative flex justify-center">
{pointer}
<div className="relative">
<motion.div
className="rounded-full"
animate={{ rotate: rotation }}
transition={{ duration: 3.1, ease: [0.12, 0, 0.11, 1] }}
style={{ width: wheelSize, height: wheelSize }}
>
<svg ref={svgRef} width={wheelSize} height={wheelSize} viewBox={`0 0 ${wheelSize} ${wheelSize}`}>
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor="#000" floodOpacity="0.12" />
</filter>
</defs>
<circle cx={cx} cy={cy} r={r} fill="#fff" filter="url(#shadow)" />
{segments.map((seg, i) => {
const start = i * segAngle;
const end = (i + 1) * segAngle;
const color = palette[i % palette.length];
const mid = (start + end) / 2;
const textPos = polarToCartesian(cx, cy, r * 0.62, mid);
return (
<g key={seg.id}>
<path d={describeArc(cx, cy, r, start, end)} fill={color} opacity={0.92} />
<text
x={textPos.x}
y={textPos.y}
fill="#fff"
fontSize="14"
fontWeight="600"
textAnchor="middle"
dominantBaseline="middle"
transform={`rotate(${mid} ${textPos.x} ${textPos.y})`}
>
{seg.label}
</text>
</g>
);
})}
<circle cx={cx} cy={cy} r={38} fill="#111827" opacity={0.95} />
<text x={cx} y={cy} fill="#fff" fontSize="12" fontWeight="700" textAnchor="middle" dominantBaseline="middle">
SPIN
</text>
</svg>
</motion.div>
</div>
</div>
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="mt-6 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="text-sm text-zinc-600">Private result for</div>
<div className="mt-1 text-xl font-semibold">{result.person}</div>
<div className="mt-2 text-sm text-zinc-600">You are bringing:</div>
<div className="mt-1 text-2xl font-semibold">{result.option}</div>
<div className="mt-3 text-sm text-zinc-500">Tap <span className="font-medium">Hide result</span> before handing the device to the next person.</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="fixed left-1/2 bottom-6 -translate-x-1/2 rounded-full bg-zinc-900 text-white px-4 py-2 text-sm shadow-lg"
>
{toast}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
<Card className="rounded-2xl shadow-sm">
<CardContent className="p-4 md:p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-lg font-semibold">Setup</div>
<div className="text-sm text-zinc-600">Edit names & options, then lock.</div>
</div>
<Button
variant={locked ? "secondary" : "outline"}
className="rounded-2xl"
onClick={() => setLocked((v) => !v)}
>
{locked ? <Lock className="w-4 h-4 mr-2" /> : <Unlock className="w-4 h-4 mr-2" />}
{locked ? "Locked" : "Lock"}
</Button>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">People (5)</div>
<div className="grid grid-cols-1 gap-2">
{people.map((p, idx) => (
<Input
key={idx}
value={p}
disabled={locked || usedCount > 0}
onChange={(e) => {
const v = e.target.value;
setPeople((prev) => prev.map((x, i) => (i === idx ? v : x)));
}}
className="rounded-xl"
/>
))}
</div>
<div className="text-xs text-zinc-500">Editing disabled after anyone spins (to keep it fair).</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Dish options (5, unique)</div>
<div className="grid grid-cols-1 gap-2">
{options.map((o, idx) => (
<Input
key={idx}
value={o}
disabled={locked || usedCount > 0}
onChange={(e) => {
const v = e.target.value;
setOptions((prev) => prev.map((x, i) => (i === idx ? v : x)));
}}
className="rounded-xl"
/>
))}
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Button variant="outline" className="rounded-2xl" onClick={shuffleOptions} disabled={locked || usedCount > 0}>
<Shuffle className="w-4 h-4 mr-2" /> Shuffle options
</Button>
<Button variant="outline" className="rounded-2xl" onClick={resetAll}>
<Trash2 className="w-4 h-4 mr-2" /> Reset
</Button>
</div>
</div>
<div className="border-t border-zinc-200 pt-4 space-y-2">
<div className="text-sm font-medium">Host view (PIN protected)</div>
<div className="text-xs text-zinc-500">People won’t see what’s taken unless you unlock this.</div>
<div className="flex gap-2">
<Input
type="password"
value={pinEntry}
onChange={(e) => setPinEntry(e.target.value)}
placeholder="Enter PIN"
className="rounded-xl"
/>
<Button className="rounded-2xl" onClick={toggleReveal}>
{revealMode ? "Hide" : "Unlock"}
</Button>
</div>
<div className="flex gap-2">
<Input
type="password"
value={pin}
onChange={(e) => setPin(e.target.value)}
placeholder="Set PIN (default 1234)"
className="rounded-xl"
disabled={locked && usedCount > 0}
/>
</div>
<AnimatePresence>
{revealMode && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="mt-2 rounded-2xl border border-zinc-200 bg-white p-3"
>
<div className="text-sm font-semibold">Assignments</div>
<div className="mt-2 space-y-1">
{people.map((p) => (
<div key={p} className="flex items-center justify-between text-sm">
<span className="text-zinc-700">{p}</span>
<span className="font-medium text-zinc-900">{assignments[p] || "—"}</span>
</div>
))}
</div>
<div className="mt-3 text-xs text-zinc-500">Keep this hidden during the game.</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="border-t border-zinc-200 pt-4 space-y-2">
<div className="text-sm font-medium">Save / Load (optional)</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" className="rounded-2xl" onClick={exportState}>
<Download className="w-4 h-4 mr-2" /> Export
</Button>
<label className="inline-flex">
<input
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importState(f);
e.target.value = "";
}}
/>
<span className="inline-flex">
<Button variant="outline" className="rounded-2xl" asChild>
<span>
<Upload className="w-4 h-4 mr-2" /> Import
</span>
</Button>
</span>
</label>
</div>
<div className="text-xs text-zinc-500">
Export lets you keep the same wheel state if you move devices. Import restores it.
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="rounded-2xl shadow-sm">
<CardContent className="p-4 md:p-6">
<div className="text-lg font-semibold">How to use (in 30 seconds)</div>
<ol className="mt-2 list-decimal pl-5 space-y-1 text-sm text-zinc-700">
<li>Enter your 5 people and 5 dish options (left panel). (Defaults are already set.)</li>
<li>Hand the phone to Person 1, choose their name, tap <span className="font-medium">Spin</span>.</li>
<li>They remember their dish. You tap <span className="font-medium">Hide result</span>.</li>
<li>Repeat for each person. Nobody sees what’s already gone.</li>
<li>If you need to check, unlock Host view with the PIN.</li>
</ol>
<div className="mt-4 text-sm text-zinc-600">
Want this embedded in a Microsoft Form flow? The clean approach is: use this wheel for selection, then each person submits their result via a Form (one question:
“What did you get?”). I can also help you wire a Power Automate flow to capture results in a SharePoint list.
</div>
</CardContent>
</Card>
</div>
</div>
);
}
0 comments on “” Add yours →