Feedback button

This commit is contained in:
Sebastien Castiel
2024-01-09 15:32:19 -05:00
parent 5ce96aef30
commit 323b0ea128
14 changed files with 1459 additions and 17 deletions

View File

@@ -1,9 +1,13 @@
import { FeedbackButton } from '@/components/feedback-button/feedback-button'
import { PropsWithChildren } from 'react'
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
return (
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
{children}
</main>
<>
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
{children}
</main>
<FeedbackButton />
</>
)
}

View File

@@ -2,6 +2,7 @@ import { ProgressBar } from '@/components/progress-bar'
import { ThemeProvider } from '@/components/theme-provider'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/toaster'
import { env } from '@/lib/env'
import type { Metadata, Viewport } from 'next'
import PlausibleProvider from 'next-plausible'
@@ -132,6 +133,7 @@ export default function RootLayout({
</span>
</div>
</footer>
<Toaster />
</ThemeProvider>
</body>
</html>

View File

@@ -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>
)
}

View File

@@ -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),
})

View File

@@ -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>
)
}

View File

@@ -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 wont 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} />
}

View File

@@ -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 }

127
src/components/ui/toast.tsx Normal file
View File

@@ -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,
}

View File

@@ -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>
)
}

View File

@@ -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 }

View File

@@ -5,6 +5,9 @@ const envSchema = z.object({
POSTGRES_URL_NON_POOLING: z.string().url(),
POSTGRES_PRISMA_URL: z.string().url(),
PLAUSIBLE_DOMAIN: z.string().optional(),
FEEDBACK_EMAIL_FROM: z.string().email().optional(),
FEEDBACK_EMAIL_TO: z.string().email().optional(),
RESEND_API_KEY: z.string().optional(),
})
export const env = envSchema.parse(process.env)

5
src/lib/resend.ts Normal file
View File

@@ -0,0 +1,5 @@
import { env } from '@/lib/env'
import { Resend } from 'resend'
export const getResend = () =>
env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null