Files
entscheidomat/components/tools/entweder-oder.tsx
2026-08-05 19:33:11 +02:00

195 lines
7.4 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 { Scale, Sparkles } from "lucide-react";
import { gsap } from "@/lib/gsap";
import { popConfetti } from "@/lib/confetti";
import { useSound } from "@/components/providers/sound-provider";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const MAX_LEN = 28;
export default function EntwederOder() {
const [optionA, setOptionA] = useState("");
const [optionB, setOptionB] = useState("");
const [result, setResult] = useState<"A" | "B" | null>(null);
const [deciding, setDeciding] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const cardRef = useRef<HTMLDivElement>(null);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
const labelA = optionA.trim() || "Option A";
const labelB = optionB.trim() || "Option B";
const decide = () => {
if (deciding) return;
const pick: "A" | "B" = Math.random() < 0.5 ? "A" : "B";
setDeciding(true);
setResult(null);
play("whoosh");
const reveal = () => {
setDeciding(false);
setResult(pick);
setHistory((h) => [pick === "A" ? labelA : labelB, ...h].slice(0, 10));
play("win");
popConfetti();
};
if (reduced) {
gsap.delayedCall(0.25, reveal);
return;
}
const tl = gsap.timeline({ onComplete: reveal });
tl.to(cardRef.current, {
rotate: -5,
duration: 0.1,
ease: "power1.inOut",
})
.to(cardRef.current, {
rotate: 5,
duration: 0.1,
ease: "power1.inOut",
})
.repeat(3)
.to(cardRef.current, { rotate: 0, duration: 0.2, 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 className="flex w-full max-w-md items-center gap-3">
<input
value={optionA}
onChange={(e) => setOptionA(e.target.value)}
maxLength={MAX_LEN}
placeholder="Option A"
aria-label="Erste Option"
className="w-full rounded-full border border-line bg-surface px-5 py-3 text-center text-sm font-medium shadow-soft outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
<span className="shrink-0 text-xs font-semibold uppercase tracking-[0.16em] text-ink-soft">
oder
</span>
<input
value={optionB}
onChange={(e) => setOptionB(e.target.value)}
maxLength={MAX_LEN}
placeholder="Option B"
aria-label="Zweite Option"
className="w-full rounded-full border border-line bg-surface px-5 py-3 text-center text-sm font-medium shadow-soft outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
</div>
<div ref={cardRef} className="mt-8">
<button
type="button"
onClick={decide}
aria-label="Zwischen beiden Optionen entscheiden"
className="flex h-52 w-52 cursor-pointer items-center justify-center rounded-[2rem] border border-line bg-surface shadow-lift transition-transform duration-200 hover:scale-[1.03] active:scale-95 sm:h-60 sm:w-60"
>
<div
className="relative flex h-full w-full items-center justify-center px-6"
aria-live="polite"
>
<AnimatePresence mode="wait">
{result ? (
<motion.div
key={result + history.length}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 380, damping: 22 }}
className="flex flex-col items-center gap-2 text-[#0d7a57]"
>
<Sparkles className="h-12 w-12" />
<span className="font-display text-2xl font-semibold leading-tight tracking-tight break-words text-center">
{result === "A" ? labelA : labelB}
</span>
</motion.div>
) : (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-2 text-ink-soft"
>
<Scale className="h-12 w-12" />
<span className="text-sm font-medium">
Tippen zum Entscheiden
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</button>
</div>
<motion.button
type="button"
onClick={decide}
disabled={deciding}
whileTap={{ scale: 0.95 }}
className="mt-6 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"
>
<Scale className="h-5 w-5" />
{deciding ? "Wägt ab …" : "Entweder oder?"}
</motion.button>
<p className="mt-3 text-center text-xs text-ink-soft">
{optionA.trim() || optionB.trim()
? `${labelA}“ oder „${labelB}`
: "Trage zwei Optionen ein oder entscheide direkt."}
</p>
</div>
{/* Info & Verlauf */}
<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">
So funktioniert&apos;s
</h3>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
Beide Optionen eintragen, auf die Karte oder den Button tippen
der Generator wählt zufällig eine davon aus. Beide haben dieselbe
Chance. Achte auf deine erste Reaktion: Erleichterung oder der
Wunsch nach einem zweiten Versuch verrät oft mehr als das Ergebnis
selbst.
</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-[#0d7a57] text-white"
: "bg-surface text-ink-soft ring-1 ring-line"
}`}
>
{entry}
</motion.span>
))}
</AnimatePresence>
</div>
</div>
)}
</div>
</div>
);
}