mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-06 12:36:11 +01:00
Merge feedback and support dialogs
This commit is contained in:
@@ -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'
|
import { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
|
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">
|
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<FeedbackButton />
|
<FeedbackModal donationUrl={env.STRIPE_DONATION_LINK}>
|
||||||
|
<FeedbackButton />
|
||||||
|
</FeedbackModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { Toaster } from '@/components/ui/toaster'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
|
import { HeartFilledIcon } from '@radix-ui/react-icons'
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import PlausibleProvider from 'next-plausible'
|
import PlausibleProvider from 'next-plausible'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@@ -141,13 +142,19 @@ export default function RootLayout({
|
|||||||
contributors
|
contributors
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{env.STRIPE_DONATION_LINK && (
|
|
||||||
<div>
|
|
||||||
<DonationButton donationUrl={env.STRIPE_DONATION_LINK} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
23
src/components/feedback-button/feedback-button-actions.tsx
Normal file
23
src/components/feedback-button/feedback-button-actions.tsx
Normal 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 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} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 { formSchema } from '@/components/feedback-button/feedback-button-common'
|
||||||
import { FeedbackButtonEmail } from '@/components/feedback-button/feedback-button-email'
|
import { Button, ButtonProps } from '@/components/ui/button'
|
||||||
import { env } from '@/lib/env'
|
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { getResend } from '@/lib/resend'
|
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() {
|
type FormValues = z.infer<typeof formSchema>
|
||||||
async function sendFeedback(values: unknown) {
|
|
||||||
'use server'
|
type Props = {
|
||||||
const { email, message } = formSchema.parse(values)
|
donationUrl: string
|
||||||
const resend = getResend()
|
defaultTab?: 'feedback' | 'support'
|
||||||
if (!resend || !env.FEEDBACK_EMAIL_FROM || !env.FEEDBACK_EMAIL_TO) {
|
}
|
||||||
console.warn(
|
|
||||||
'Resend is not properly configured. Feedback email won’t be sent.',
|
export function FeedbackModal({
|
||||||
)
|
donationUrl,
|
||||||
return
|
defaultTab = 'feedback',
|
||||||
}
|
children,
|
||||||
await resend.emails.send({
|
}: PropsWithChildren<Props>) {
|
||||||
from: env.FEEDBACK_EMAIL_FROM,
|
const { toast } = useToast()
|
||||||
to: env.FEEDBACK_EMAIL_TO,
|
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
subject: `Spliit: new feedback from ${email || 'anonymous user'}`,
|
const [open, setOpen] = useState(false)
|
||||||
react: <FeedbackButtonEmail email={email} message={message} />,
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const envSchema = z.object({
|
|||||||
FEEDBACK_EMAIL_FROM: z.string().email().optional(),
|
FEEDBACK_EMAIL_FROM: z.string().email().optional(),
|
||||||
FEEDBACK_EMAIL_TO: z.string().email().optional(),
|
FEEDBACK_EMAIL_TO: z.string().email().optional(),
|
||||||
RESEND_API_KEY: z.string().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)
|
export const env = envSchema.parse(process.env)
|
||||||
|
|||||||
Reference in New Issue
Block a user