aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app/groups/layout.tsx10
-rw-r--r--src/app/layout.tsx19
-rw-r--r--src/components/donation-button.tsx126
-rw-r--r--src/components/feedback-button/feedback-button-actions.tsx23
-rw-r--r--src/components/feedback-button/feedback-button-client.tsx206
-rw-r--r--src/components/feedback-button/feedback-button.tsx292
-rw-r--r--src/lib/env.ts2
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)