Files
entscheidomat/components/tools/wheel-of-fortune.tsx
2026-07-24 16:33:44 +02:00

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Plus, RotateCcw, Sparkles, X } from "lucide-react";
import { gsap } from "@/lib/gsap";
import { wheelEase } from "@/lib/motion";
import { celebrateConfetti } from "@/lib/confetti";
import { useSound } from "@/components/providers/sound-provider";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const DEFAULT_OPTIONS = ["1", "2", "3", "4", "5", "6"];
/** Natürliche, gedeckte Farben weiße Schrift bleibt lesbar. */
const PALETTE = [
"#33523f",
"#a8623f",
"#b98a3c",
"#5e7a5e",
"#6b7f8c",
"#8a6b52",
"#5f8a80",
"#9a5b45",
"#4f6b8a",
"#8c7a4f",
"#5c6e4f",
"#3f5e66",
];
const C = 210;
const R = 196;
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 12;
/** Winkel → Punkt auf dem Kreis (0° = oben, im Uhrzeigersinn). */
function polar(angleDeg: number, radius: number): [number, number] {
const a = (angleDeg * Math.PI) / 180;
return [C + radius * Math.sin(a), C - radius * Math.cos(a)];
}
export default function WheelOfFortune() {
const [options, setOptions] = useLocalStorage<string[]>(
"wheel-options",
DEFAULT_OPTIONS,
);
const [spinning, setSpinning] = useState(false);
const [winner, setWinner] = useState<number | null>(null);
const wheelRef = useRef<SVGGElement>(null);
const pointerRef = useRef<HTMLDivElement>(null);
const rotationRef = useRef(0);
const lastTickRef = useRef(-1);
const optionInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const justAddedRef = useRef(false);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
const labels = options.map((o, i) => o.trim() || `Option ${i + 1}`);
const n = labels.length;
const seg = 360 / n;
const spin = () => {
if (spinning || n < MIN_OPTIONS) return;
const winnerIndex = Math.floor(Math.random() * n);
setWinner(null);
setSpinning(true);
play("whoosh");
lastTickRef.current = -1;
// Zufällige Position innerhalb des Gewinner-Segments
const offset = 0.18 + Math.random() * 0.64;
const targetAngle = winnerIndex * seg + seg * offset;
const current = rotationRef.current;
const currentMod = ((current % 360) + 360) % 360;
const delta =
(((360 - targetAngle - currentMod) % 360) + 360) % 360;
const target = current + 360 * 6 + delta;
const dur = reduced ? 0.5 : 5.6;
gsap.to(wheelRef.current, {
rotation: target,
svgOrigin: `${C} ${C}`,
duration: dur,
ease: reduced ? "power1.out" : wheelEase,
onUpdate: () => {
if (reduced) return;
const rot = Number(gsap.getProperty(wheelRef.current, "rotation"));
const m = ((rot % 360) + 360) % 360;
const idx = Math.floor(((360 - m) % 360) / seg);
if (idx !== lastTickRef.current) {
lastTickRef.current = idx;
play("tick");
gsap.fromTo(
pointerRef.current,
{ rotation: -16 },
{
rotation: 0,
duration: 0.22,
ease: "elastic.out(2.2, 0.4)",
transformOrigin: "50% 12%",
},
);
}
},
onComplete: () => {
rotationRef.current = target;
setSpinning(false);
setWinner(winnerIndex);
play("win");
celebrateConfetti();
},
});
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase();
if (activeTag === "input" || activeTag === "textarea") return;
if (e.code === "Space") {
e.preventDefault();
spin();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [spinning, n]);
const updateOption = (i: number, value: string) => {
if (spinning) return;
setOptions((opts) => opts.map((o, idx) => (idx === i ? value : o)));
setWinner(null);
};
const addOption = () => {
if (spinning || n >= MAX_OPTIONS) return;
setOptions((opts) => [...opts, ""]);
setWinner(null);
justAddedRef.current = true;
};
useEffect(() => {
if (!justAddedRef.current) return;
justAddedRef.current = false;
const last = optionInputRefs.current[options.length - 1];
last?.scrollIntoView({ block: "nearest", behavior: "smooth" });
last?.focus();
}, [options.length]);
const removeOption = (i: number) => {
if (spinning || n <= MIN_OPTIONS) return;
setOptions((opts) => opts.filter((_, idx) => idx !== i));
setWinner(null);
};
const resetOptions = () => {
if (spinning) return;
setOptions(DEFAULT_OPTIONS);
setWinner(null);
};
const fontSize = n <= 6 ? 15 : n <= 9 ? 13 : 11.5;
return (
<div className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
{/* Rad */}
<div className="flex flex-col items-center">
<div className="relative w-full max-w-[400px]">
{/* Zeiger */}
<div
ref={pointerRef}
aria-hidden
className="absolute -top-1.5 left-1/2 z-10 h-9 w-7 -translate-x-1/2 bg-primary shadow-[0_6px_16px_rgb(13_122_87/0.35)]"
style={{
clipPath: "polygon(50% 100%, 0 0, 100% 0)",
}}
/>
<svg
viewBox="0 0 420 420"
role="img"
aria-label="Glücksrad mit Zufalls-Optionen"
className="w-full drop-shadow-[0_20px_36px_rgb(23_23_28/0.14)]"
>
{/* äußerer Ring */}
<circle cx={C} cy={C} r={206} fill="#ffffff" stroke="#e9e9e4" />
<g ref={wheelRef}>
{labels.map((label, i) => {
const [x0, y0] = polar(i * seg, R);
const [x1, y1] = polar((i + 1) * seg, R);
const large = seg > 180 ? 1 : 0;
const isWinner = winner === i;
const dimmed = winner !== null && !isWinner;
return (
<g key={i}>
<path
d={`M ${C} ${C} L ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1} Z`}
fill={PALETTE[i % PALETTE.length]}
stroke="#ffffff"
strokeWidth={isWinner ? 3.5 : 1.5}
className="transition-opacity duration-500"
style={{
opacity: dimmed ? 0.4 : 1,
filter: isWinner
? "drop-shadow(0 0 14px rgb(242 179 61 / 0.9))"
: undefined,
}}
/>
<g transform={`rotate(${i * seg + seg / 2} ${C} ${C})`}>
<text
x={C}
y={C - 118}
textAnchor="middle"
dominantBaseline="central"
transform={`rotate(90 ${C} ${C - 118})`}
fill="#ffffff"
fontSize={fontSize}
fontWeight={600}
fontFamily="var(--font-display)"
style={{
textShadow: "0 1px 3px rgb(0 0 0 / 0.35)",
opacity: dimmed ? 0.4 : 1,
}}
className="transition-opacity duration-500"
>
{label.length > 16 ? `${label.slice(0, 15)}` : label}
</text>
</g>
</g>
);
})}
</g>
{/* Nabe */}
<circle cx={C} cy={C} r={30} fill="#ffffff" stroke="#e9e9e4" strokeWidth={2} />
<circle cx={C} cy={C} r={9} fill="#0d7a57" />
</svg>
</div>
<div className="mt-4 h-14" aria-live="polite">
<AnimatePresence mode="wait">
{winner !== null && (
<motion.p
key={`${winner}-${labels[winner]}`}
initial={{ opacity: 0, scale: 0.6, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 380, damping: 22 }}
className="flex items-center gap-2 font-display text-3xl font-semibold tracking-tight"
>
<Sparkles className="h-6 w-6 text-clay" />
<span className="text-primary">{labels[winner]}!</span>
</motion.p>
)}
</AnimatePresence>
</div>
<motion.button
type="button"
onClick={spin}
disabled={spinning || n < MIN_OPTIONS}
whileTap={{ scale: 0.95 }}
className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
>
<RotateCcw className="h-5 w-5" />
{spinning ? "Dreht sich …" : "Drehen!"}
</motion.button>
</div>
{/* Optionen-Editor */}
<div className="rounded-3xl border border-line bg-surface p-5 shadow-soft">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Optionen ({n}/{MAX_OPTIONS})
</h3>
<button
type="button"
onClick={resetOptions}
disabled={spinning}
className="cursor-pointer text-xs font-medium text-ink-soft underline-offset-2 transition-colors hover:text-[#0d7a57] hover:underline disabled:cursor-not-allowed disabled:opacity-40"
>
Zurücksetzen
</button>
</div>
<div className="mt-4 max-h-[300px] space-y-2 overflow-y-auto pr-1">
{options.map((option, i) => (
<div key={i} className="flex items-center gap-2.5">
<span
aria-hidden
className="h-3.5 w-3.5 shrink-0 rounded-full ring-2 ring-white"
style={{ background: PALETTE[i % PALETTE.length] }}
/>
<input
ref={(el) => {
optionInputRefs.current[i] = el;
}}
value={option}
onChange={(e) => updateOption(i, e.target.value)}
disabled={spinning}
maxLength={24}
placeholder={`Option ${i + 1}`}
aria-label={`Option ${i + 1}`}
className="w-full rounded-xl border border-line bg-paper/50 px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-[#0d7a57] focus:bg-surface focus:ring-2 focus:ring-[#0d7a57]/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => removeOption(i)}
disabled={spinning || n <= MIN_OPTIONS}
aria-label={`Option ${i + 1} entfernen`}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-xl text-ink-soft transition-colors hover:bg-clay-soft hover:text-clay disabled:cursor-not-allowed disabled:opacity-30"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addOption}
disabled={spinning || n >= MAX_OPTIONS}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-[#0d7a57] hover:bg-[#e6f7f0]/50 hover:text-[#0d7a57] disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-4 w-4" />
Option hinzufügen
</button>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
{MIN_OPTIONS}{MAX_OPTIONS} Optionen. Deine Liste bleibt automatisch
gespeichert.
</p>
</div>
</div>
);
}