Files
entscheidomat/components/tools/wheel-of-fortune.tsx
2026-07-23 21:59:15 +02:00

300 lines
11 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 { 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 { 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();
},
});
};
const updateOption = (i: number, value: string) => {
setOptions((opts) => opts.map((o, idx) => (idx === i ? value : o)));
setWinner(null);
};
const addOption = () => {
if (n >= MAX_OPTIONS) return;
setOptions((opts) => [...opts, ""]);
setWinner(null);
};
const removeOption = (i: number) => {
if (n <= MIN_OPTIONS) return;
setOptions((opts) => opts.filter((_, idx) => idx !== i));
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(108_92_231/0.5)]"
style={{
clipPath: "polygon(50% 100%, 0 0, 100% 0)",
}}
/>
<svg viewBox="0 0 420 420" 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="#6c5ce7" />
</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-cream/70 p-5">
<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={() => {
setOptions(DEFAULT_OPTIONS);
setWinner(null);
}}
className="cursor-pointer text-xs font-medium text-ink-soft underline-offset-2 transition-colors hover:text-primary hover:underline"
>
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
value={option}
onChange={(e) => updateOption(i, e.target.value)}
maxLength={24}
placeholder={`Option ${i + 1}`}
aria-label={`Option ${i + 1}`}
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
<button
type="button"
onClick={() => removeOption(i)}
disabled={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={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-primary/50 hover:text-primary 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>
);
}