{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "centered-feedback-drawer", "type": "registry:ui", "title": "Centered Feedback Drawer", "description": "Centered feedback panel — three SVG faces, contextual comment, spring selection, auto-dismiss thank-you state.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/centered-feedback-drawer.tsx", "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/**\n * Centered Feedback Drawer — three emoji, one question.\n *\n * Not a form. Not a survey. Three faces —\n * 🙁 😐 😊 — universally understood in an instant.\n * Click one. It springs up, the others dim. A contextual\n * textarea slides in: \"What went wrong?\" or \"What did you\n * enjoy?\" — the interface reads your mood. Submit. Done.\n *\n * The faces are the interface.\n */\n\n/* ── Types ── */\n\nexport interface FeedbackData {\n rating: number;\n label: string;\n message: string;\n}\n\nexport interface CenteredFeedbackDrawerProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n onSubmit?: (feedback: FeedbackData) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst RATINGS = [\n { label: \"Not great\", placeholder: \"What went wrong?\" },\n { label: \"Okay\", placeholder: \"What could be better?\" },\n { label: \"Great\", placeholder: \"What did you enjoy?\" },\n];\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.15;\n gain.gain.value = 0.03;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Emoji Faces ── */\n\nconst EMOJIS = [\"🙁\", \"😐\", \"😊\"];\n\n/* ── Component ── */\n\nexport function CenteredFeedbackDrawer({\n open,\n onOpenChange,\n onSubmit,\n sound = true,\n}: CenteredFeedbackDrawerProps) {\n const [rating, setRating] = useState(null);\n const [hovered, setHovered] = useState(null);\n const [message, setMessage] = useState(\"\");\n const [submitted, setSubmitted] = useState(false);\n const lastSound = useRef(0);\n const textareaRef = useRef(null);\n\n function reset() {\n setRating(null);\n setHovered(null);\n setMessage(\"\");\n setSubmitted(false);\n }\n\n function handleRate(i: number) {\n setRating(i);\n if (sound) playTick(lastSound);\n setTimeout(() => textareaRef.current?.focus(), 220);\n }\n\n function handleSubmit() {\n if (rating === null) return;\n if (sound) playTick(lastSound);\n onSubmit?.({\n rating: rating + 1,\n label: RATINGS[rating].label,\n message,\n });\n setSubmitted(true);\n setTimeout(() => {\n onOpenChange(false);\n setTimeout(reset, 300);\n }, 1200);\n }\n\n function handleClose() {\n if (submitted) return;\n onOpenChange(false);\n setTimeout(reset, 300);\n }\n\n /* ── Style helpers ── */\n\n function faceOpacity(i: number): number {\n const sel = rating === i;\n const hov = hovered === i;\n const has = rating !== null;\n\n if (sel) return 1;\n if (hov) return has ? 0.5 : 0.85;\n return has ? 0.2 : 0.6;\n }\n\n function faceBg(i: number): string {\n if (rating === i) return \"rgba(var(--d-ink),0.08)\";\n if (hovered === i) return \"rgba(var(--d-ink),0.04)\";\n return \"transparent\";\n }\n\n function faceBorder(i: number): string {\n if (rating === i) return \"1px solid rgba(var(--d-ink),0.12)\";\n return \"1px solid transparent\";\n }\n\n return (\n <>\n \n \n {open && (\n <>\n {/* Backdrop */}\n \n\n {/* Centering wrapper */}\n \n {/* Panel */}\n \n \n {!submitted ? (\n \n {/* Question */}\n \n How was your experience?\n \n\n {/* Faces */}\n
\n {EMOJIS.map((emoji, i) => (\n handleRate(i)}\n onMouseEnter={() => setHovered(i)}\n onMouseLeave={() => setHovered(null)}\n whileTap={{ scale: 0.92 }}\n animate={{ scale: rating === i ? 1.1 : 1 }}\n transition={{\n type: \"spring\",\n damping: 18,\n stiffness: 300,\n }}\n style={{\n width: 52,\n height: 52,\n borderRadius: 14,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n cursor: \"pointer\",\n fontSize: 26,\n lineHeight: 1,\n opacity: faceOpacity(i),\n background: faceBg(i),\n border: faceBorder(i),\n transition:\n \"opacity 0.15s, background 0.15s, border 0.15s\",\n }}\n >\n {emoji}\n \n ))}\n
\n\n {/* Label — appears below faces after selection */}\n
\n \n {rating !== null && (\n \n {RATINGS[rating].label}\n \n )}\n \n
\n\n {/* Comment area — slides in after rating */}\n \n {rating !== null && (\n \n {/* Hairline */}\n \n\n {/* Textarea — contextual placeholder */}\n setMessage(e.target.value)}\n placeholder={\n rating !== null\n ? RATINGS[rating].placeholder\n : \"\"\n }\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n }}\n style={{\n width: \"100%\",\n minHeight: 60,\n resize: \"none\",\n background: \"transparent\",\n border: \"none\",\n outline: \"none\",\n fontSize: 13,\n fontWeight: 400,\n lineHeight: 1.6,\n color: \"rgba(var(--d-ink),0.65)\",\n fontFamily: \"inherit\",\n }}\n />\n\n {/* Send */}\n \n {\n e.currentTarget.style.color =\n \"rgba(var(--d-ink),0.85)\";\n }}\n onMouseLeave={(e) => {\n e.currentTarget.style.color = message\n ? \"rgba(var(--d-ink),0.6)\"\n : \"rgba(var(--d-ink),0.3)\";\n }}\n >\n Send\n \n \n \n )}\n \n \n ) : (\n /* Thank you */\n \n Thank you.\n \n )}\n
\n \n \n \n )}\n
\n \n );\n}\n\nexport default CenteredFeedbackDrawer;\n", "type": "registry:ui", "target": "components/ruixen/centered-feedback-drawer.tsx" } ] }