mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-28 02:16:12 +01:00
Feedback button
This commit is contained in:
814
package-lock.json
generated
814
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,11 @@
|
|||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@react-email/heading": "^0.0.11",
|
||||||
|
"@react-email/html": "^0.0.7",
|
||||||
|
"@react-email/preview": "^0.0.8",
|
||||||
|
"@react-email/text": "^0.0.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"lucide-react": "^0.290.0",
|
"lucide-react": "^0.290.0",
|
||||||
@@ -36,6 +41,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
|
"resend": "^2.1.0",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { FeedbackButton } from '@/components/feedback-button/feedback-button'
|
||||||
import { PropsWithChildren } from 'react'
|
import { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
|
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
|
<>
|
||||||
{children}
|
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
|
||||||
</main>
|
{children}
|
||||||
|
</main>
|
||||||
|
<FeedbackButton />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ProgressBar } from '@/components/progress-bar'
|
|||||||
import { ThemeProvider } from '@/components/theme-provider'
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import PlausibleProvider from 'next-plausible'
|
import PlausibleProvider from 'next-plausible'
|
||||||
@@ -132,6 +133,7 @@ export default function RootLayout({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
198
src/components/feedback-button/feedback-button-client.tsx
Normal file
198
src/components/feedback-button/feedback-button-client.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
24
src/components/feedback-button/feedback-button-email.tsx
Normal file
24
src/components/feedback-button/feedback-button-email.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/components/feedback-button/feedback-button.tsx
Normal file
27
src/components/feedback-button/feedback-button.tsx
Normal 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 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} />
|
||||||
|
}
|
||||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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
127
src/components/ui/toast.tsx
Normal 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,
|
||||||
|
}
|
||||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
src/components/ui/use-toast.ts
Normal file
192
src/components/ui/use-toast.ts
Normal 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 }
|
||||||
@@ -5,6 +5,9 @@ const envSchema = z.object({
|
|||||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||||
POSTGRES_PRISMA_URL: z.string().url(),
|
POSTGRES_PRISMA_URL: z.string().url(),
|
||||||
PLAUSIBLE_DOMAIN: z.string().optional(),
|
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)
|
export const env = envSchema.parse(process.env)
|
||||||
|
|||||||
5
src/lib/resend.ts
Normal file
5
src/lib/resend.ts
Normal 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
|
||||||
Reference in New Issue
Block a user