diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-09 15:32:19 -0500 |
|---|---|---|
| committer | Sebastien Castiel <sebastien@castiel.me> | 2024-01-09 15:32:19 -0500 |
| commit | 323b0ea1282abc0945cef1ebf8185bdf9a156f5d (patch) | |
| tree | 5526370fed1acbcb3cc2d43b9b1d0a6264783b47 /src/components | |
| parent | 5ce96aef30b87a1aeecfac67e99621b06ab64b93 (diff) | |
Feedback button
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/feedback-button/feedback-button-client.tsx | 198 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button-common.tsx | 9 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button-email.tsx | 24 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button.tsx | 27 | ||||
| -rw-r--r-- | src/components/ui/textarea.tsx | 24 | ||||
| -rw-r--r-- | src/components/ui/toast.tsx | 127 | ||||
| -rw-r--r-- | src/components/ui/toaster.tsx | 35 | ||||
| -rw-r--r-- | src/components/ui/use-toast.ts | 192 |
8 files changed, 636 insertions, 0 deletions
diff --git a/src/components/feedback-button/feedback-button-client.tsx b/src/components/feedback-button/feedback-button-client.tsx new file mode 100644 index 0000000..16debec --- /dev/null +++ b/src/components/feedback-button/feedback-button-client.tsx @@ -0,0 +1,198 @@ +'use client' +import { formSchema } from '@/components/feedback-button/feedback-button-common' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { useToast } from '@/components/ui/use-toast' +import { useMediaQuery } from '@/lib/hooks' +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2, MessageCircle } from 'lucide-react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import * as z from 'zod' + +type FormValues = z.infer<typeof formSchema> + +type Props = { + sendFeedback: (values: FormValues) => Promise<void> +} + +export function FeedbackButtonClient({ sendFeedback }: Props) { + const { toast } = useToast() + const isDesktop = useMediaQuery('(min-width: 768px)') + + async function onSubmit(values: FormValues) { + await sendFeedback(values) + toast({ + title: 'Thank you for your feedback!', + description: + 'We will have a look at it as soon as possible, and will get back to you if needed.', + }) + } + + return ( + <div className="fixed right-4 bottom-4"> + {isDesktop ? ( + <FeedbackDialog onSubmit={onSubmit} /> + ) : ( + <FeedbackDrawer onSubmit={onSubmit} /> + )} + </div> + ) +} + +function FeedbackDrawer({ + onSubmit, +}: { + onSubmit: (values: FormValues) => Promise<void> +}) { + const [open, setOpen] = useState(false) + + return ( + <div className="fixed right-4 bottom-4"> + <Drawer open={open} onOpenChange={setOpen}> + <DrawerTrigger asChild> + <Button> + <MessageCircle className="w-4 h-4 mr-2" /> Feedback + </Button> + </DrawerTrigger> + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Give us your feedback!</DrawerTitle> + <DrawerDescription> + We are always working to improve the user experience, and your + feedback helps us a lot. + </DrawerDescription> + </DrawerHeader> + <div className="px-4 pb-4"> + <FeedbackForm + onSubmit={async (values) => { + await onSubmit(values) + setOpen(false) + }} + /> + </div> + </DrawerContent> + </Drawer> + </div> + ) +} + +function FeedbackDialog({ + onSubmit, +}: { + onSubmit: (values: FormValues) => Promise<void> +}) { + const [open, setOpen] = useState(false) + + return ( + <div className="fixed right-4 bottom-4"> + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <MessageCircle className="w-4 h-4 mr-2" /> Feedback + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Give us your feedback!</DialogTitle> + <DialogDescription> + We are always working to improve the user experience, and your + feedback helps us a lot. + </DialogDescription> + </DialogHeader> + <FeedbackForm + onSubmit={async (values) => { + await onSubmit(values) + setOpen(false) + }} + /> + </DialogContent> + </Dialog> + </div> + ) +} + +function FeedbackForm({ + onSubmit, +}: { + onSubmit: (values: FormValues) => Promise<void> +}) { + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { email: '', message: '' }, + }) + + const isSubmitting = form.formState.isSubmitting + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4"> + <div className="grid gap-4"> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Your email address</FormLabel> + <FormControl> + <Input placeholder="your@email.com" {...field} /> + </FormControl> + <FormDescription> + Optional. Provide it if you want us to get back to you. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="message" + render={({ field }) => ( + <FormItem> + <FormLabel>Your feedback</FormLabel> + <FormControl> + <Textarea placeholder="Enter your feedback" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> Submitting… + </> + ) : ( + <>Submit</> + )} + </Button> + </form> + </Form> + ) +} diff --git a/src/components/feedback-button/feedback-button-common.tsx b/src/components/feedback-button/feedback-button-common.tsx new file mode 100644 index 0000000..04567ef --- /dev/null +++ b/src/components/feedback-button/feedback-button-common.tsx @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const formSchema = z.object({ + email: z.union([ + z.string().email('Please enter a valid email address.'), + z.string().max(0), + ]), + message: z.string().min(10, 'Please enter at least 10 characters.').max(5000), +}) diff --git a/src/components/feedback-button/feedback-button-email.tsx b/src/components/feedback-button/feedback-button-email.tsx new file mode 100644 index 0000000..ecb8c7d --- /dev/null +++ b/src/components/feedback-button/feedback-button-email.tsx @@ -0,0 +1,24 @@ +import { Heading } from '@react-email/heading' +import { Html } from '@react-email/html' +import { Preview } from '@react-email/preview' +import { Text } from '@react-email/text' + +type Props = { + email?: string + message: string +} + +export function FeedbackButtonEmail({ email, message }: Props) { + return ( + <Html> + <Preview>New feedback from {email || 'anonymous user'}</Preview> + <Heading>New feedback on Spliit</Heading> + <Text> + Email address: <strong>{email || 'not provided'}</strong> + </Text> + <pre style={{ padding: 16, borderLeft: '2px solid lightgray' }}> + {message} + </pre> + </Html> + ) +} diff --git a/src/components/feedback-button/feedback-button.tsx b/src/components/feedback-button/feedback-button.tsx new file mode 100644 index 0000000..d43f8a7 --- /dev/null +++ b/src/components/feedback-button/feedback-button.tsx @@ -0,0 +1,27 @@ +import { FeedbackButtonClient } from '@/components/feedback-button/feedback-button-client' +import { formSchema } from '@/components/feedback-button/feedback-button-common' +import { FeedbackButtonEmail } from '@/components/feedback-button/feedback-button-email' +import { env } from '@/lib/env' +import { getResend } from '@/lib/resend' + +export function FeedbackButton() { + async function sendFeedback(values: unknown) { + 'use server' + const { email, message } = formSchema.parse(values) + const resend = getResend() + if (!resend || !env.FEEDBACK_EMAIL_FROM || !env.FEEDBACK_EMAIL_TO) { + console.warn( + 'Resend is not properly configured. Feedback email won’t be sent.', + ) + return + } + await resend.emails.send({ + from: env.FEEDBACK_EMAIL_FROM, + to: env.FEEDBACK_EMAIL_TO, + subject: `Spliit: new feedback from ${email || 'anonymous user'}`, + react: <FeedbackButtonEmail email={email} message={message} />, + }) + } + + return <FeedbackButtonClient sendFeedback={sendFeedback} /> +} diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000..9f9a6dc --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} + +const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Textarea.displayName = "Textarea" + +export { Textarea } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..e223385 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..1671307 --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial<ToasterToast> + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit<ToasterToast, "id"> + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState<State>(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } |
