DocsFeedback

Feedback

User feedback section

Like our service?

Installation

Dependencies

npm i framer-motion lucide-react

Create @/utils/cn.ts file

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs));
}

Create feedback component

'use client'

import { Angry, Check, Frown, Laugh, Loader2, Smile } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { twMerge } from 'tailwind-merge'
import { cn } from '@/utils/cn'

const feedback = [
  { happiness: 4, emoji: <Laugh size={16} className="stroke-inherit" /> },
  { happiness: 3, emoji: <Smile size={16} className="stroke-inherit" /> },
  { happiness: 2, emoji: <Frown size={16} className="stroke-inherit" /> },
  { happiness: 1, emoji: <Angry size={16} className="stroke-inherit" /> }
]

export const Feedback = () => {
  const textRef = useRef<HTMLTextAreaElement>(null)
  const [happiness, setHappiness] = useState<null | number>(null)

  const [isSubmitted, setSubmissionState] = useState(false)
  const { submitFeedback, isLoading, isSent } = useSubmitFeedback()

  useEffect(() => {
    if (!happiness) {
      //cleaning up textarea
      if (textRef.current) textRef.current!.value = ''
    }
  }, [happiness])

  useEffect(() => {
    let timeout = null
    let submissionStateTimeout = null

    if (isSent) {
      setSubmissionState(true)

      //cleaning up textarea and customer happiness state
      timeout = setTimeout(() => {
        setHappiness(null)
        if (textRef.current) textRef.current!.value = ''
      }, 2000)

      //cleaning up successful submission text 100ms later
      submissionStateTimeout = setTimeout(() => {
        setSubmissionState(false)
      }, 2200)
    }

    return () => {
      if (timeout) {
        clearTimeout(timeout)
      }
      if (submissionStateTimeout) {
        clearTimeout(submissionStateTimeout)
      }
    }
  }, [isSent])

  return (
    <motion.div
      layout
      initial={{ borderRadius: '2rem' }}
      animate={happiness ? { borderRadius: '0.5rem' } : { borderRadius: '2rem' }}
      className={twMerge(
        'w-fit overflow-hidden border py-2 shadow-sm dark:border-neutral-800 dark:bg-neutral-950'
      )}>
      <span className="flex items-center justify-center gap-3 pl-4 pr-2">
        <div className="text-sm text-black dark:text-neutral-400">Like our service?</div>
        <div className="flex items-center text-neutral-400">
          {feedback.map((e) => (
            <button
              onClick={() => setHappiness((prev) => (e.happiness === prev ? null : e.happiness))}
              className={twMerge(
                happiness === e.happiness
                  ? 'bg-blue-100 stroke-blue-500 dark:bg-sky-900 dark:stroke-sky-500'
                  : 'stroke-neutral-500 dark:stroke-neutral-400',
                'flex h-8 w-8 items-center justify-center rounded-full transition-all hover:bg-blue-100 hover:stroke-blue-500 hover:dark:bg-sky-900 hover:dark:stroke-sky-500'
              )}
              key={e.happiness}>
              {e.emoji}
            </button>
          ))}
        </div>
      </span>
      <motion.div
        aria-hidden={happiness ? false : true}
        initial={{ height: 0, translateY: 15 }}
        className="px-2"
        transition={{ ease: 'easeInOut', duration: 0.3 }}
        animate={happiness ? { height: '195px', width: '330px' } : {}}>
        <AnimatePresence>
          {!isSubmitted ? (
            <motion.span exit={{ opacity: 0 }} initial={{ opacity: 1 }}>
              <textarea
                ref={textRef}
                placeholder="Your app is awesoooome"
                className="min-h-32 w-full resize-none rounded-md border bg-transparent p-2 text-sm placeholder-neutral-400 focus:border-neutral-400 focus:outline-0 dark:border-neutral-800 focus:dark:border-white"
              />
              <div className="flex h-fit w-full justify-end">
                <button
                  onClick={() => submitFeedback(happiness!, textRef.current!.value || '')}
                  className={cn(
                    'mt-1 flex h-9 items-center justify-center rounded-md border bg-neutral-950 px-2 text-sm text-white dark:bg-white dark:text-neutral-950',
                    {
                      'bg-neutral-500 dark:bg-white dark:text-neutral-500': isLoading
                    }
                  )}>
                  {isLoading ? (
                    <>
                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                      Loading
                    </>
                  ) : (
                    'Submit'
                  )}
                </button>
              </div>
            </motion.span>
          ) : (
            <motion.div
              variants={container}
              initial="hidden"
              animate="show"
              className="flex h-full w-full flex-col items-center justify-start gap-2 pt-9 text-sm font-normal">
              <motion.div
                variants={item}
                className="flex h-8 min-h-8 w-8 min-w-8 items-center justify-center rounded-full bg-blue-500 dark:bg-sky-500">
                <Check strokeWidth={2.5} size={16} className="stroke-white" />
              </motion.div>
              <motion.div variants={item}>Your feedback has been received!</motion.div>
              <motion.div variants={item}>Thank you for your help.</motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </motion.div>
  )
}

const container = {
  hidden: { opacity: 0, y: 20 },
  show: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.2,
      staggerChildren: 0.04
    }
  }
}

const item = {
  hidden: { y: 10 },
  show: { y: 0 }
}

const useSubmitFeedback = () => {
  const [feedback, setFeedback] = useState<{ happiness: number; feedback: string } | null>(null)
  const [isLoading, setLoadingState] = useState(false)
  //error never happens in case of this mockup btw
  const [error, setError] = useState<{ error: any } | null>(null)
  const [isSent, setRequestState] = useState(false)

  //fake api call
  const submitFeedback = (feedback: { happiness: number; feedback: string }) =>
    new Promise((res) => setTimeout(() => res(feedback), 1000))

  useEffect(() => {
    if (feedback) {
      setLoadingState(true)
      setRequestState(false)

      submitFeedback(feedback)
        .then(() => {
          setRequestState(true)
          setError(null)
        })
        .catch(() => {
          setRequestState(false)
          setError({ error: 'some error' })
        })
        .finally(() => setLoadingState(false))
    }
  }, [feedback])

  return {
    submitFeedback: (happiness: number, feedback: string) => setFeedback({ happiness, feedback }),
    isLoading,
    error,
    isSent
  }
}