diff --git a/components/motion/feedback-widget.tsx b/components/motion/feedback-widget.tsx new file mode 100644 index 0000000..5ba2456 --- /dev/null +++ b/components/motion/feedback-widget.tsx @@ -0,0 +1,377 @@ +"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; + +// Folder-open feel for the morph: a touch of overshoot, no wobble. +const MORPH = { + type: "spring", + stiffness: 320, + damping: 34, + mass: 0.9, +} as const; + +// 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") textareaRef.current?.focus(); + }, [status]); + + // 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 morph = reduce ? { duration: 0.15 } : MORPH; + 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 owns the surface in every state. Swapping two + shared-layout elements here makes their backgrounds crossfade. */} + + + {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} +

+ +
+