"use client"; import { AlertCircle, MessageSquare, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { Button, StatefulButton } from "@/components/motion/button"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; type Status = "idle" | "open" | "sending" | "sent" | "error"; const SUCCESS_DURATION_MS = 1600; // The trigger grows into the panel with a slight overshoot on open, then uses // a calmer curve on close so dismissing feels faster and less prominent. const MORPH_OPEN_EASE = [0.34, 1.25, 0.64, 1] as const; const MORPH_CLOSE_EASE = [0.22, 1, 0.36, 1] as const; const MORPH_OPEN_DURATION = 0.4; const MORPH_CLOSE_DURATION = 0.28; const MORPH_FADE_DURATION = 0.22; const MORPH_SLIDE = 40; const MORPH_SCALE = 0.97; const MORPH_BLUR = "blur(2px)"; // Celebration sprinkles that burst from the success icon. const SPRINKLES = Array.from({ length: 8 }, (_, i) => { const angle = (i / 8) * Math.PI * 2; return { x: Math.cos(angle) * 26, y: Math.sin(angle) * 26, color: i % 2 === 0 ? "var(--color-success)" : "var(--accent)", }; }); export interface FeedbackData { message: string; } export interface FeedbackWidgetProps { /** Called on submit. May be async; the button shows a sending state until it resolves. */ onSubmit?: (data: FeedbackData) => void | Promise; position?: "bottom-right" | "bottom-left"; title?: string; placeholder?: string; icon?: ReactNode; className?: string; } export function FeedbackWidget({ onSubmit, position = "bottom-right", title = "Help us improve", placeholder = "Share an idea or report a bug", icon, className, }: FeedbackWidgetProps) { const reduce = useReducedMotion(); const rootRef = useRef(null); const textareaRef = useRef(null); const closeTimerRef = useRef | null>(null); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(""); const open = status !== "idle"; const busy = status === "sending"; const clearCloseTimer = useCallback(() => { if (closeTimerRef.current === null) return; clearTimeout(closeTimerRef.current); closeTimerRef.current = null; }, []); const close = useCallback(() => { clearCloseTimer(); setStatus("idle"); setMessage(""); }, [clearCloseTimer]); useEffect( () => () => { if (closeTimerRef.current !== null) { clearTimeout(closeTimerRef.current); } }, [], ); useEffect(() => { if (status !== "open") return; // Match the wallet account switcher's staged interaction: focusing while // the surface is still expanding makes the caret and focus state appear // inside a scaled panel. Arm the field once the open morph has settled. const timer = window.setTimeout( () => textareaRef.current?.focus(), reduce ? 0 : MORPH_OPEN_DURATION * 1000, ); return () => window.clearTimeout(timer); }, [status, reduce]); // Dismiss on escape or outside click while open (but not mid-send). useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !busy) close(); }; const onPointer = (e: PointerEvent) => { if ( !busy && rootRef.current && !rootRef.current.contains(e.target as Node) ) { close(); } }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onPointer); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onPointer); }; }, [open, busy, close]); const scheduleSuccessClose = () => { clearCloseTimer(); closeTimerRef.current = setTimeout(close, SUCCESS_DURATION_MS); }; const submit = async () => { if (busy || message.trim().length === 0) return; setStatus("sending"); try { await onSubmit?.({ message }); setStatus("sent"); scheduleSuccessClose(); } catch { // Preserve the message so a rejected submission can be retried. setStatus("error"); } }; const left = position === "bottom-left"; const contentOffset = left ? -MORPH_SLIDE : MORPH_SLIDE; const surfaceTransition = reduce ? { duration: 0 } : { layout: { duration: open ? MORPH_OPEN_DURATION : MORPH_CLOSE_DURATION, ease: open ? MORPH_OPEN_EASE : MORPH_CLOSE_EASE, }, borderRadius: { duration: open ? MORPH_OPEN_DURATION : MORPH_CLOSE_DURATION, ease: open ? MORPH_OPEN_EASE : MORPH_CLOSE_EASE, }, }; const contentTransition = reduce ? { duration: 0 } : { opacity: { duration: MORPH_FADE_DURATION, ease: MORPH_CLOSE_EASE, }, x: { duration: MORPH_OPEN_DURATION, ease: MORPH_CLOSE_EASE, }, scale: { duration: MORPH_OPEN_DURATION, ease: MORPH_CLOSE_EASE, }, filter: { duration: MORPH_FADE_DURATION, ease: MORPH_CLOSE_EASE, }, rotate: { duration: MORPH_OPEN_DURATION, ease: MORPH_CLOSE_EASE, }, }; const viewInitial = reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }; const viewAnimate = reduce ? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT }, } : { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.24, ease: EASE_OUT }, }; const viewExit = reduce ? { opacity: 0, transition: { duration: 0.14, ease: EASE_OUT }, } : { opacity: 0, y: -8, filter: "blur(4px)", transition: { duration: 0.16, ease: EASE_OUT }, }; return (
{/* One persistent shell grows out of the corner trigger. The shell and its contents use separate timing so the surface leads the reveal. */} {open ? ( {status === "sent" ? (
{reduce ? null : SPRINKLES.map((s, i) => ( ))}

Thanks!

Your feedback helps us build something better.

) : status === "error" ? (

Something went wrong

We couldn't send your feedback. Please try again.

) : (

{title}