Files
entscheidomat/components/tools/coin-flip.tsx
2026-07-24 16:33:44 +02:00

188 lines
6.9 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 { Coins } from "lucide-react";
import Coin3D from "@/components/coin-3d";
import { gsap } from "@/lib/gsap";
import { coinEase } from "@/lib/motion";
import { popConfetti } 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";
export default function CoinFlip() {
const [frontLabel, setFrontLabel] = useLocalStorage("coin-front", "Kopf");
const [backLabel, setBackLabel] = useLocalStorage("coin-back", "Zahl");
const [history, setHistory] = useState<string[]>([]);
const [flipping, setFlipping] = useState(false);
const [result, setResult] = useState<string | null>(null);
const coinRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const rotationRef = useRef(0);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
const front = frontLabel.trim() || "Kopf";
const back = backLabel.trim() || "Zahl";
const flip = () => {
if (flipping) return;
const isBack = Math.random() < 0.5;
const label = isBack ? back : front;
setFlipping(true);
setResult(null);
play("coin");
const current = rotationRef.current;
const base = Math.ceil(current / 360) * 360;
let target = base + 360 * 5 + (isBack ? 180 : 0);
if (target - current < 360 * 4) target += 360;
const dur = reduced ? 0.3 : 2.6;
gsap.to(coinRef.current, {
rotationX: target,
duration: dur,
ease: reduced ? "power1.out" : coinEase,
onComplete: () => {
rotationRef.current = target;
setFlipping(false);
setResult(label);
setHistory((h) => [label, ...h].slice(0, 10));
play("pop");
popConfetti();
},
});
if (!reduced) {
gsap.to(stageRef.current, {
y: -54,
duration: dur / 2,
yoyo: true,
repeat: 1,
ease: "power2.out",
});
}
};
return (
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
{/* Bühne */}
<div className="flex flex-col items-center">
<div ref={stageRef} className="coin-scene p-4">
<button
type="button"
onClick={flip}
aria-label="Münze werfen"
className="block cursor-pointer rounded-full drop-shadow-[0_10px_18px_rgba(17,20,18,0.12)] transition-transform duration-200 hover:scale-[1.04] active:scale-95"
>
<Coin3D ref={coinRef} frontLabel={front} backLabel={back} size={188} />
</button>
</div>
<div className="mt-2 h-14" aria-live="polite">
<AnimatePresence mode="wait">
{result && (
<motion.p
key={result + history.length}
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="font-display text-3xl font-semibold tracking-tight"
>
<span className="text-primary">{result}!</span>
</motion.p>
)}
</AnimatePresence>
</div>
<motion.button
type="button"
onClick={flip}
disabled={flipping}
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"
>
<Coins className="h-5 w-5" />
{flipping ? "Fliegt …" : "Münze werfen"}
</motion.button>
<p className="mt-3 text-xs text-ink-soft">
Tipp: Du kannst auch direkt auf die Münze tippen.
</p>
</div>
{/* Einstellungen */}
<div className="space-y-5">
<div className="rounded-3xl border border-line bg-cream/70 p-5">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Seiten beschriften
</h3>
<div className="mt-4 grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
Vorderseite
</span>
<input
value={frontLabel}
onChange={(e) => setFrontLabel(e.target.value)}
disabled={flipping}
maxLength={14}
placeholder="Kopf"
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 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
Rückseite
</span>
<input
value={backLabel}
onChange={(e) => setBackLabel(e.target.value)}
disabled={flipping}
maxLength={14}
placeholder="Zahl"
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 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
Z. B. Ja &amp; Nein, Kino &amp; Couch deine Beschriftung
bleibt gespeichert.
</p>
</div>
{history.length > 0 && (
<div className="rounded-3xl border border-line bg-cream/70 p-5">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Verlauf
</h3>
<div className="mt-3 flex flex-wrap gap-1.5">
<AnimatePresence initial={false}>
{history.map((entry, i) => (
<motion.span
key={`${entry}-${history.length - i}`}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
className={`rounded-full px-3 py-1 text-xs font-semibold ${
i === 0
? "bg-primary text-white"
: "bg-surface text-ink-soft ring-1 ring-line"
}`}
>
{entry}
</motion.span>
))}
</AnimatePresence>
</div>
</div>
)}
</div>
</div>
);
}