aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorSebastien Castiel <sebastien@castiel.me>2024-01-09 15:32:19 -0500
committerSebastien Castiel <sebastien@castiel.me>2024-01-09 15:32:19 -0500
commit323b0ea1282abc0945cef1ebf8185bdf9a156f5d (patch)
tree5526370fed1acbcb3cc2d43b9b1d0a6264783b47 /src
parent5ce96aef30b87a1aeecfac67e99621b06ab64b93 (diff)
Feedback button
Diffstat (limited to 'src')
-rw-r--r--src/app/groups/layout.tsx10
-rw-r--r--src/app/layout.tsx2
-rw-r--r--src/components/feedback-button/feedback-button-client.tsx198
-rw-r--r--src/components/feedback-button/feedback-button-common.tsx9
-rw-r--r--src/components/feedback-button/feedback-button-email.tsx24
-rw-r--r--src/components/feedback-button/feedback-button.tsx27
-rw-r--r--src/components/ui/textarea.tsx24
-rw-r--r--src/components/ui/toast.tsx127
-rw-r--r--src/components/ui/toaster.tsx35
-rw-r--r--src/components/ui/use-toast.ts192
-rw-r--r--src/lib/env.ts3
-rw-r--r--src/lib/resend.ts5
12 files changed, 653 insertions, 3 deletions
diff --git a/src/app/groups/layout.tsx b/src/app/groups/layout.tsx
index 8c221e1..dadd3a1 100644
--- a/src/app/groups/layout.tsx
+++ b/src/app/groups/layout.tsx
@@ -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 />
+ </>
)
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c6965b9..7ffaab0 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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>
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 }
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 89648eb..ddaa553 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -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)
diff --git a/src/lib/resend.ts b/src/lib/resend.ts
new file mode 100644
index 0000000..48c5fb7
--- /dev/null
+++ b/src/lib/resend.ts
@@ -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