diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-18 15:46:50 -0500 |
|---|---|---|
| committer | Sebastien Castiel <sebastien@castiel.me> | 2024-01-18 15:48:45 -0500 |
| commit | f9040f8beda10b3969572cf8764e49f9b24b222a (patch) | |
| tree | a48e28bb1d92db3ca29985f2ea450df4e1acc76e /src | |
| parent | 395c86666cbca10fe5e2f26476437e1e840b3a65 (diff) | |
Merge feedback and support dialogs
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/groups/layout.tsx | 10 | ||||
| -rw-r--r-- | src/app/layout.tsx | 19 | ||||
| -rw-r--r-- | src/components/donation-button.tsx | 126 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button-actions.tsx | 23 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button-client.tsx | 206 | ||||
| -rw-r--r-- | src/components/feedback-button/feedback-button.tsx | 292 | ||||
| -rw-r--r-- | src/lib/env.ts | 2 |
7 files changed, 316 insertions, 362 deletions
diff --git a/src/app/groups/layout.tsx b/src/app/groups/layout.tsx index dadd3a1..75128c8 100644 --- a/src/app/groups/layout.tsx +++ b/src/app/groups/layout.tsx @@ -1,4 +1,8 @@ -import { FeedbackButton } from '@/components/feedback-button/feedback-button' +import { + FeedbackButton, + FeedbackModal, +} from '@/components/feedback-button/feedback-button' +import { env } from '@/lib/env' import { PropsWithChildren } from 'react' export default function GroupsLayout({ children }: PropsWithChildren<{}>) { @@ -7,7 +11,9 @@ export default function GroupsLayout({ children }: PropsWithChildren<{}>) { <main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6"> {children} </main> - <FeedbackButton /> + <FeedbackModal donationUrl={env.STRIPE_DONATION_LINK}> + <FeedbackButton /> + </FeedbackModal> </> ) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 56fed4f..bff432c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,11 @@ -import { DonationButton } from '@/components/donation-button' +import { FeedbackModal } from '@/components/feedback-button/feedback-button' 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 { HeartFilledIcon } from '@radix-ui/react-icons' import type { Metadata, Viewport } from 'next' import PlausibleProvider from 'next-plausible' import Image from 'next/image' @@ -141,13 +142,19 @@ export default function RootLayout({ contributors </a> </span> + <span> + <FeedbackModal + donationUrl={env.STRIPE_DONATION_LINK} + defaultTab="support" + > + <Button variant="link" className="text-pink-600 -mx-4"> + <HeartFilledIcon className="w-4 h-4 mr-2" /> + Support us + </Button> + </FeedbackModal> + </span> </div> </div> - {env.STRIPE_DONATION_LINK && ( - <div> - <DonationButton donationUrl={env.STRIPE_DONATION_LINK} /> - </div> - )} </footer> <Toaster /> </ThemeProvider> diff --git a/src/components/donation-button.tsx b/src/components/donation-button.tsx deleted file mode 100644 index b2c360c..0000000 --- a/src/components/donation-button.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' -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 { useMediaQuery } from '@/lib/hooks' -import { Heart } from 'lucide-react' -import { useState } from 'react' - -type Props = { - donationUrl: string -} - -export function DonationButton({ donationUrl }: Props) { - const isDesktop = useMediaQuery('(min-width: 768px)') - return isDesktop ? ( - <DonationDialog donationUrl={donationUrl} /> - ) : ( - <DonationDrawer donationUrl={donationUrl} /> - ) -} - -function DonationDrawer({ donationUrl }: Props) { - const [open, setOpen] = useState(false) - - return ( - <Drawer open={open} onOpenChange={setOpen}> - <DrawerTrigger asChild> - <Button className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600"> - <Heart className="w-4 h-4 mr-2" /> Support us - </Button> - </DrawerTrigger> - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Support us</DrawerTitle> - <DrawerDescription> - Help keep <strong>Spliit</strong> free and without ads! - </DrawerDescription> - </DrawerHeader> - <div className="px-4 pb-4"> - <DonationForm donationUrl={donationUrl} /> - </div> - </DrawerContent> - </Drawer> - ) -} - -function DonationDialog({ donationUrl }: Props) { - const [open, setOpen] = useState(false) - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600"> - <Heart className="w-4 h-4 mr-2" /> Support us - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Support us</DialogTitle> - <DialogDescription> - Help keep <strong>Spliit</strong> free and without ads! - </DialogDescription> - </DialogHeader> - <DonationForm donationUrl={donationUrl} /> - </DialogContent> - </Dialog> - ) -} - -function DonationForm({ donationUrl }: Props) { - return ( - <> - <div className="prose prose-sm dark:prose-invert"> - <p> - Spliit is offered for free, but costs money and energy. If you like - the app, you can choose to support it by buying me (Sebastien) a - coffee with a one-time small donation. - </p> - <p>By supporting Spliit:</p> - <ul> - <li> - You contribute to the <strong>hosting costs</strong> for the app - (currently ~$150/year). - </li> - <li> - You help us keeping the application{' '} - <strong>free and without ads</strong>. - </li> - <li> - You give me energy to build <strong>new features</strong> and - improve the application. - </li> - </ul> - <p> - You will be redirected to <strong>Stripe</strong>, our payment - provider, where you can choose an amount to donate and complete the - payment. - </p> - </div> - <div className="mt-4 text-center"> - <Button - asChild - className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600" - > - <a href={donationUrl} target="_blank"> - <Heart className="w-4 h-4 mr-2" /> Support us - </a> - </Button> - </div> - </> - ) -} diff --git a/src/components/feedback-button/feedback-button-actions.tsx b/src/components/feedback-button/feedback-button-actions.tsx new file mode 100644 index 0000000..00c8748 --- /dev/null +++ b/src/components/feedback-button/feedback-button-actions.tsx @@ -0,0 +1,23 @@ +'use server' +import { formSchema } from '@/components/feedback-button/feedback-button-common' +import { FeedbackButtonEmail } from '@/components/feedback-button/feedback-button-email' +import { getResend } from '@/lib/resend' +import { env } from 'process' + +export 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} />, + }) +} diff --git a/src/components/feedback-button/feedback-button-client.tsx b/src/components/feedback-button/feedback-button-client.tsx deleted file mode 100644 index 96a1758..0000000 --- a/src/components/feedback-button/feedback-button-client.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'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" - className="text-base" - {...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" - className="text-base" - {...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.tsx b/src/components/feedback-button/feedback-button.tsx index d43f8a7..b1b6ae2 100644 --- a/src/components/feedback-button/feedback-button.tsx +++ b/src/components/feedback-button/feedback-button.tsx @@ -1,27 +1,277 @@ -import { FeedbackButtonClient } from '@/components/feedback-button/feedback-button-client' +'use client' +import { sendFeedback } from '@/components/feedback-button/feedback-button-actions' 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' +import { Button, ButtonProps } from '@/components/ui/button' +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +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 { Heart, HeartIcon, Loader2, MessageCircle } from 'lucide-react' +import { PropsWithChildren, ReactNode, SetStateAction, useState } from 'react' +import { useForm } from 'react-hook-form' +import * as z from 'zod' -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} />, +type FormValues = z.infer<typeof formSchema> + +type Props = { + donationUrl: string + defaultTab?: 'feedback' | 'support' +} + +export function FeedbackModal({ + donationUrl, + defaultTab = 'feedback', + children, +}: PropsWithChildren<Props>) { + const { toast } = useToast() + const isDesktop = useMediaQuery('(min-width: 640px)') + const [open, setOpen] = useState(false) + + 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 <FeedbackButtonClient sendFeedback={sendFeedback} /> + const Wrapper = isDesktop ? FeedbackDialog : FeedbackDrawer + + return ( + <Wrapper open={open} setOpen={setOpen} button={children}> + <FeedbackContent + onSubmit={async (values) => { + await onSubmit(values) + setOpen(false) + }} + donationUrl={donationUrl} + defaultTab={defaultTab} + /> + </Wrapper> + ) +} + +function FeedbackDrawer({ + children, + open, + setOpen, + button, +}: PropsWithChildren<{ + open: boolean + setOpen: (open: SetStateAction<boolean>) => void + button: ReactNode +}>) { + return ( + <Drawer open={open} onOpenChange={setOpen}> + <DrawerTrigger asChild>{button}</DrawerTrigger> + <DrawerContent> + <div className="p-4">{children}</div> + </DrawerContent> + </Drawer> + ) +} + +function FeedbackDialog({ + children, + open, + setOpen, + button, +}: PropsWithChildren<{ + open: boolean + setOpen: (open: SetStateAction<boolean>) => void + button: ReactNode +}>) { + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{button}</DialogTrigger> + <DialogContent> + <div className="pt-4">{children}</div> + </DialogContent> + </Dialog> + ) +} + +function FeedbackContent({ + onSubmit, + donationUrl, + defaultTab, +}: { + onSubmit: (values: FormValues) => Promise<void> + donationUrl: string + defaultTab: 'feedback' | 'support' +}) { + return ( + <Tabs defaultValue={defaultTab}> + <div className="mt-2 mb-6"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="feedback">Give feedback</TabsTrigger> + <TabsTrigger value="support">Support us</TabsTrigger> + </TabsList> + </div> + <TabsContent value="feedback"> + <FeedbackForm onSubmit={onSubmit} /> + </TabsContent> + <TabsContent value="support"> + <DonationForm donationUrl={donationUrl} /> + </TabsContent> + </Tabs> + ) +} + +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> + <h2 className="text-lg font-semibold leading-none tracking-tight pb-1.5"> + Give us your feedback + </h2> + <p className="text-sm text-muted-foreground"> + We are always working to improve the user experience, and your + feedback helps us a lot. + </p> + </div> + <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" + className="text-base" + {...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" + className="text-base" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <div className="text-center mt-1"> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> Submitting… + </> + ) : ( + <> + <MessageCircle className="w-4 h-4 mr-2" /> Send + </> + )} + </Button> + </div> + </form> + </Form> + ) +} + +function DonationForm({ donationUrl }: { donationUrl: string }) { + return ( + <div className="flex flex-col gap-4"> + <div> + <h2 className="text-lg font-semibold leading-none tracking-tight pb-1.5"> + Support us + </h2> + <p className="text-sm text-muted-foreground"> + Help keep <strong>Spliit</strong> free and without ads! + </p> + </div> + <div className="prose prose-sm dark:prose-invert"> + <p> + Spliit is offered for free, but costs money and energy. If you like + the app, you can choose to support it by buying me (Sebastien) a + coffee with a one-time small donation. + </p> + <p>By supporting Spliit:</p> + <ul> + <li> + You contribute to the <strong>hosting costs</strong> for the app + (currently ~$150/year). + </li> + <li> + You help us keeping the application{' '} + <strong>free and without ads</strong>. + </li> + <li> + You give me energy to build <strong>new features</strong> and + improve the application. + </li> + </ul> + <p> + You will be redirected to <strong>Stripe</strong>, our payment + provider, where you can choose an amount to donate and complete the + payment. + </p> + </div> + <div className="text-center"> + <Button + asChild + className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600" + > + <a href={donationUrl} target="_blank"> + <Heart className="w-4 h-4 mr-2" /> Support us + </a> + </Button> + </div> + </div> + ) +} + +export function FeedbackButton({ ...props }: ButtonProps) { + return ( + <Button + className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600 fixed right-0 bottom-4 rounded-r-none gap-2" + {...props} + > + <MessageCircle className="w-4 h-4" /> + <HeartIcon className="w-4 h-4" /> + </Button> + ) } diff --git a/src/lib/env.ts b/src/lib/env.ts index 4bf76ca..bc0c0d7 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -8,7 +8,7 @@ const envSchema = z.object({ FEEDBACK_EMAIL_FROM: z.string().email().optional(), FEEDBACK_EMAIL_TO: z.string().email().optional(), RESEND_API_KEY: z.string().optional(), - STRIPE_DONATION_LINK: z.string().optional(), + STRIPE_DONATION_LINK: z.string().optional().default('https://example.com'), }) export const env = envSchema.parse(process.env) |
