'use client' /** * FuseButton — destructive / consequential action confirmation. * * UX: * 1. Press-and-hold the button. A progress bar fills over `holdMs` (default 700 ms). * 2. Hold until the bar reaches 100 % → button enters the "primed" state. * 3. Release the pointer (press-end). On release, `onCommit` fires. * 4. If the pointer leaves the button or is released early, the action is cancelled * and the fuse resets — no commit happens. * 5. After commit, the button shows `doneLabel` for `undoMs` (default 5000 ms) and * emits a Sonner toast with an optional "Rückgängig" action bound to `onUndo`. * If `onUndo` is omitted the toast is a plain success message. * * Why: prevents accidental approvals / deletions. A single stray click cannot trigger * the action — the user must deliberately hold and release. */ import * as React from 'react' import { Button } from '@/components/ui/button' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Check, Loader2, Undo2 } from 'lucide-react' type Phase = 'idle' | 'pressing' | 'primed' | 'committing' | 'done' export interface FuseButtonProps extends Omit, 'onClick' | 'onPointerDown' | 'onPointerUp' | 'onPointerLeave'> { /** Initial label, shown when idle / pressing. */ label: string /** Label shown after commit has fired. */ doneLabel: string /** Fires on press-end after the hold threshold was reached. */ onCommit: () => Promise | void /** Optional undo handler. When provided, the success toast shows a "Rückgängig" action. */ onUndo?: () => Promise | void /** How long the user must hold before the fuse is primed (ms). */ holdMs?: number /** How long the done-state + undo toast is shown (ms). */ undoMs?: number } export function FuseButton({ label, doneLabel, onCommit, onUndo, holdMs = 700, undoMs = 5000, disabled, variant = 'default', className, children, ...rest }: FuseButtonProps) { const [phase, setPhase] = React.useState('idle') const [progress, setProgress] = React.useState(0) const holdRef = React.useRef | null>(null) const startRef = React.useRef(0) const primedRef = React.useRef(false) const clearHold = React.useCallback(() => { if (holdRef.current) { clearInterval(holdRef.current) holdRef.current = null } }, []) React.useEffect(() => { return () => clearHold() }, [clearHold]) // Reset done-state after undoMs so the button becomes usable again. React.useEffect(() => { if (phase !== 'done') return const t = setTimeout(() => { setPhase('idle') setProgress(0) primedRef.current = false }, undoMs) return () => clearTimeout(t) }, [phase, undoMs]) const handlePointerDown = (e: React.PointerEvent) => { if (disabled || phase === 'committing' || phase === 'done') return e.preventDefault() primedRef.current = false setPhase('pressing') setProgress(0) startRef.current = Date.now() clearHold() holdRef.current = setInterval(() => { const elapsed = Date.now() - startRef.current const pct = Math.min(100, (elapsed / holdMs) * 100) setProgress(pct) if (pct >= 100 && !primedRef.current) { primedRef.current = true setPhase('primed') } }, 16) } const handlePointerUp = async () => { if (phase === 'idle' || phase === 'done' || phase === 'committing') return clearHold() if (!primedRef.current) { // Released too early — cancel. setPhase('idle') setProgress(0) return } primedRef.current = false setProgress(100) setPhase('committing') try { await onCommit() setPhase('done') toast.success(doneLabel, { duration: undoMs, action: onUndo ? { label: ( <> Rückgängig ), onClick: async () => { try { await onUndo() setPhase('idle') setProgress(0) toast.success('Aktion rückgängig gemacht') } catch { toast.error('Rückgängig fehlgeschlagen') } }, } : undefined, }) } catch { setPhase('idle') setProgress(0) toast.error('Aktion fehlgeschlagen') } } const handlePointerLeave = () => { // Leaving the button while holding cancels the fuse. if (phase === 'pressing' || phase === 'primed') { clearHold() primedRef.current = false setPhase('idle') setProgress(0) } } const displayLabel = phase === 'done' ? doneLabel : label const busy = phase === 'committing' const holding = phase === 'pressing' || phase === 'primed' return ( ) }