Merge feedback and support dialogs

This commit is contained in:
Sebastien Castiel
2024-01-18 15:46:50 -05:00
parent 395c86666c
commit f9040f8bed
7 changed files with 316 additions and 362 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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