mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: move donation, membership and perk forms to a dedicated page, and minor ui improvements
This commit is contained in:
@@ -1,417 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign, Info } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { MAX_AMOUNT } from '../config'
|
||||
import Spinner from './Spinner'
|
||||
import { trpc } from '../utils/trpc'
|
||||
import { useToast } from './ui/use-toast'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Button } from './ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
|
||||
import { Input } from './ui/input'
|
||||
import { ProjectItem } from '../utils/types'
|
||||
import { useFundSlug } from '../utils/use-fund-slug'
|
||||
import { Alert, AlertDescription, AlertTitle } from './ui/alert'
|
||||
import CustomLink from './CustomLink'
|
||||
|
||||
type Props = {
|
||||
project: ProjectItem | undefined
|
||||
close: () => void
|
||||
openRegisterModal: () => void
|
||||
}
|
||||
|
||||
const DonationFormModal: React.FC<Props> = ({ project, openRegisterModal, close }) => {
|
||||
const fundSlug = useFundSlug()
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
givePointsBack: z.enum(['yes', 'no']),
|
||||
showDonorNameOnLeaderboard: z.enum(['yes', 'no']),
|
||||
})
|
||||
.refine(
|
||||
(data) => (!isAuthed && data.showDonorNameOnLeaderboard === 'yes' ? !!data.name : true),
|
||||
{
|
||||
message: 'Name is required when you want it to be on the leaderboard.',
|
||||
path: ['name'],
|
||||
}
|
||||
)
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), {
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), {
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
})
|
||||
|
||||
type FormInputs = z.infer<typeof schema>
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const form = useForm<FormInputs>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
amount: '' as unknown as number, // a trick to get trigger to work when amount is empty
|
||||
taxDeductible: 'no',
|
||||
givePointsBack: 'no',
|
||||
showDonorNameOnLeaderboard: 'no',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const amount = form.watch('amount')
|
||||
const taxDeductible = form.watch('taxDeductible')
|
||||
const showDonorNameOnLeaderboard = form.watch('showDonorNameOnLeaderboard')
|
||||
|
||||
const donateWithFiatMutation = trpc.donation.donateWithFiat.useMutation()
|
||||
const donateWithCryptoMutation = trpc.donation.donateWithCrypto.useMutation()
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithCryptoMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithFiatMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
if (!result.url) throw Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.trigger('email', { shouldFocus: true })
|
||||
form.trigger('name', { shouldFocus: true })
|
||||
}, [taxDeductible, showDonorNameOnLeaderboard])
|
||||
|
||||
if (!project) return <></>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="py-4 flex flex-col space-y-8">
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-center sm:text-left font-semibold">{project.title}</h2>
|
||||
<h3 className="text-gray-500">Pledge your support</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-4">
|
||||
{!isAuthed && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name{' '}
|
||||
{taxDeductible === 'no' &&
|
||||
showDonorNameOnLeaderboard === 'no' &&
|
||||
'(optional)'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-row gap-2 items-center flex-wrap ">
|
||||
<Input
|
||||
className="w-40 mr-auto"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
leftIcon={DollarSign}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
{[50, 100, 250, 500].map((value, index) => (
|
||||
<Button
|
||||
key={`amount-button-${index}`}
|
||||
variant="light"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
form.setValue('amount', value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
${value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want this donation to be tax deductible? (US only)</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showDonorNameOnLeaderboard"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your name to be displayed on the leaderboard?</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAuthed && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="givePointsBack"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3 leading-5">
|
||||
<FormLabel>
|
||||
Would you like to receive MAGIC Grants points back for your donation? The points
|
||||
can be redeemed for various donation perks as a thank you for supporting our
|
||||
mission.
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl className="flex-shrink-0">
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
Yes, give me perks! This will reduce the donation amount by 10%, the
|
||||
approximate value of the points when redeemed for goods/services.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
No, use my full contribution toward your mission.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{amount > 500 && taxDeductible === 'yes' && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
When donating over $500 with crypto, you MUST complete{' '}
|
||||
<CustomLink target="_blank" href="https://www.irs.gov/pub/irs-pdf/f8283.pdf">
|
||||
Form 8283
|
||||
</CustomLink>{' '}
|
||||
and send the completed form to{' '}
|
||||
<CustomLink href={`mailto:info@magicgrants.org`}>info@magicgrants.org</CustomLink>{' '}
|
||||
to qualify for a deduction.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{donateWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{donateWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Card
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{!isAuthed && <div className="w-full h-px bg-border" />}
|
||||
|
||||
{!isAuthed && (
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-sm">Want to support more projects and receive optional perks?</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="link"
|
||||
onClick={() => (openRegisterModal(), close())}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DonationFormModal
|
||||
@@ -41,6 +41,12 @@ const Header = () => {
|
||||
}
|
||||
}, [router.query.loginEmail])
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.registerEmail) {
|
||||
setRegisterIsOpen(true)
|
||||
}
|
||||
}, [router.query.registerEmail])
|
||||
|
||||
const fund = fundSlug ? funds[fundSlug] : null
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { z } from 'zod'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { MAX_AMOUNT } from '../config'
|
||||
import Spinner from './Spinner'
|
||||
import { trpc } from '../utils/trpc'
|
||||
import { useToast } from './ui/use-toast'
|
||||
import { Button } from './ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
|
||||
import { Input } from './ui/input'
|
||||
import { ProjectItem } from '../utils/types'
|
||||
import { useFundSlug } from '../utils/use-fund-slug'
|
||||
|
||||
type Props = {
|
||||
project: ProjectItem | undefined
|
||||
close: () => void
|
||||
openRegisterModal: () => void
|
||||
}
|
||||
|
||||
const MembershipFormModal: React.FC<Props> = ({ project, close, openRegisterModal }) => {
|
||||
const fundSlug = useFundSlug()
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
recurring: z.enum(['yes', 'no']),
|
||||
givePointsBack: z.enum(['yes', 'no']),
|
||||
showDonorNameOnLeaderboard: z.enum(['yes', 'no']),
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), {
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), {
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
})
|
||||
|
||||
type FormInputs = z.infer<typeof schema>
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const form = useForm<FormInputs>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
amount: 100, // a trick to get trigger to work when amount is empty
|
||||
taxDeductible: 'no',
|
||||
recurring: 'no',
|
||||
givePointsBack: 'no',
|
||||
showDonorNameOnLeaderboard: 'no',
|
||||
},
|
||||
mode: 'all',
|
||||
})
|
||||
|
||||
const taxDeductible = form.watch('taxDeductible')
|
||||
|
||||
const payMembershipWithFiatMutation = trpc.donation.payMembershipWithFiat.useMutation()
|
||||
const payMembershipWithCryptoMutation = trpc.donation.payMembershipWithCrypto.useMutation()
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithCryptoMutation.mutateAsync({
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithFiatMutation.mutateAsync({
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
recurring: data.recurring === 'yes',
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
if (!result.url) throw new Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) return <></>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="py-4 flex flex-col space-y-8">
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-center sm:text-left font-semibold">{project.title}</h2>
|
||||
<h3 className="text-gray-500">Pledge your support</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-4">
|
||||
{!isAuthed && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<span className="flex flex-row">
|
||||
<DollarSign className="text-primary" />
|
||||
100.00
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your membership to be tax deductible? (US only)</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">No</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Yes</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recurring"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>
|
||||
Do you want your membership payment to be recurring? (Fiat only)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">No</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showDonorNameOnLeaderboard"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your name to be displayed on the leaderboard?</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="givePointsBack"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3 leading-5">
|
||||
<FormLabel>
|
||||
Would you like to receive MAGIC Grants points back for your donation? The points
|
||||
can be redeemed for various donation perks as a thank you for supporting our
|
||||
mission.
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl className="flex-shrink-0">
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
Yes, give me perks! This will reduce the donation amount by 10%, the
|
||||
approximate value of the points when redeemed for goods/services.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
No, use my full contribution toward your mission.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{payMembershipWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{payMembershipWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Card
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{!isAuthed && <div className="w-full h-px bg-border" />}
|
||||
|
||||
{!isAuthed && (
|
||||
<div className="flex flex-col items-center ">
|
||||
<p className="text-sm">Want to support more projects and receive optional perks?</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="link"
|
||||
onClick={() => (openRegisterModal(), close())}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembershipFormModal
|
||||
@@ -69,7 +69,7 @@ export default function PageHeading({ project, children }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0">
|
||||
<div className="pt-4 items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-4 xl:space-y-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useFundSlug } from '../utils/use-fund-slug'
|
||||
import { StrapiPerkPopulated } from '../server/types'
|
||||
import { env } from '../env.mjs'
|
||||
import { cn } from '../utils/cn'
|
||||
import { Dialog, DialogContent } from './ui/dialog'
|
||||
import PerkPurchaseFormModal from './PerkPurchaseFormModal'
|
||||
|
||||
const priceFormat = Intl.NumberFormat('en', { notation: 'standard', compactDisplay: 'long' })
|
||||
|
||||
export type Props = { perk: StrapiPerkPopulated; balance: number }
|
||||
export type Props = { perk: StrapiPerkPopulated }
|
||||
|
||||
const PerkCard: React.FC<Props> = ({ perk, balance }) => {
|
||||
const PerkCard: React.FC<Props> = ({ perk }) => {
|
||||
const fundSlug = useFundSlug()
|
||||
const [purchaseIsOpen, setPurchaseIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href={`/${fundSlug}/perks/${perk.documentId}`}>
|
||||
<figure
|
||||
onClick={() => setPurchaseIsOpen(true)}
|
||||
className={cn(
|
||||
'max-w-sm min-h-[360px] h-full space-y-2 flex flex-col rounded-xl border-b-4 bg-white cursor-pointer',
|
||||
fundSlug === 'monero' && 'border-monero',
|
||||
@@ -57,17 +53,7 @@ const PerkCard: React.FC<Props> = ({ perk, balance }) => {
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Dialog open={purchaseIsOpen} onOpenChange={setPurchaseIsOpen}>
|
||||
<DialogContent className="sm:max-w-[900px]">
|
||||
<PerkPurchaseFormModal
|
||||
perk={perk}
|
||||
balance={balance}
|
||||
close={() => setPurchaseIsOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,13 @@ type Props = {
|
||||
}
|
||||
|
||||
const PerkList: React.FC<Props> = ({ perks }) => {
|
||||
const getBalanceQuery = trpc.perk.getBalance.useQuery()
|
||||
|
||||
return (
|
||||
<section className="bg-light items-left flex flex-col">
|
||||
<ul className="mx-auto grid max-w-5xl grid-cols-1 sm:mx-0 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{perks &&
|
||||
perks.map((perk, i) => (
|
||||
<li key={i} className="">
|
||||
<PerkCard perk={perk} balance={getBalanceQuery.data || 0} />
|
||||
<PerkCard perk={perk} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { cn } from '../utils/cn'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { env } from '../env.mjs'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -125,6 +126,7 @@ type RegisterFormInputs = z.infer<typeof schema>
|
||||
type Props = { close: () => void; openLoginModal: () => void }
|
||||
|
||||
function RegisterFormModal({ close, openLoginModal }: Props) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const fundSlug = useFundSlug()
|
||||
const turnstileRef = useRef<TurnstileInstance | null>()
|
||||
@@ -177,6 +179,17 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
|
||||
return stateOptions
|
||||
}, [addressCountry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fundSlug) return
|
||||
if (router.query.registerEmail) {
|
||||
if (router.query.registerEmail !== '1') {
|
||||
form.setValue('email', router.query.registerEmail as string)
|
||||
setTimeout(() => form.setFocus('password'), 100)
|
||||
}
|
||||
router.replace(`/${fundSlug}`)
|
||||
}
|
||||
}, [router.query.registerEmail])
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('address.state', '')
|
||||
}, [addressCountry])
|
||||
|
||||
@@ -24,4 +24,4 @@ export default withAuth({
|
||||
},
|
||||
})
|
||||
|
||||
export const config = { matcher: ['/:path/account/:path*'] }
|
||||
export const config = { matcher: ['/:path/account/:path*', '/:path/membership/:path*'] }
|
||||
|
||||
@@ -523,13 +523,12 @@ function Settings() {
|
||||
<CommandItem
|
||||
value={state.label}
|
||||
key={state.value}
|
||||
onSelect={() => {
|
||||
console.log('asdasd')
|
||||
onSelect={() => (
|
||||
changeMailingAddressForm.setValue('state', state.value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}),
|
||||
setStateSelectOpen(false)
|
||||
}}
|
||||
)}
|
||||
>
|
||||
{state.label}
|
||||
<Check
|
||||
|
||||
450
pages/[fund]/donate/[slug].tsx
Normal file
450
pages/[fund]/donate/[slug].tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GetStaticProps, GetStaticPropsContext } from 'next'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign, Info } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { MAX_AMOUNT } from '../../../config'
|
||||
import { useFundSlug } from '../../../utils/use-fund-slug'
|
||||
import { trpc } from '../../../utils/trpc'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import { useToast } from '../../../components/ui/use-toast'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from '../../../components/ui/radio-group'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '../../../components/ui/form'
|
||||
import { Input } from '../../../components/ui/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '../../../components/ui/alert'
|
||||
import CustomLink from '../../../components/CustomLink'
|
||||
import { getProjectBySlug, getProjects } from '../../../utils/md'
|
||||
import { funds, fundSlugs } from '../../../utils/funds'
|
||||
import { ProjectItem } from '../../../utils/types'
|
||||
import Link from 'next/link'
|
||||
import Head from 'next/head'
|
||||
|
||||
type QueryParams = { fund: FundSlug; slug: string }
|
||||
type Props = { project: ProjectItem } & QueryParams
|
||||
|
||||
function DonationPage({ fund: fundSlug, slug, project }: Props) {
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
givePointsBack: z.enum(['yes', 'no']),
|
||||
showDonorNameOnLeaderboard: z.enum(['yes', 'no']),
|
||||
})
|
||||
.refine(
|
||||
(data) => (!isAuthed && data.showDonorNameOnLeaderboard === 'yes' ? !!data.name : true),
|
||||
{
|
||||
message: 'Name is required when you want it to be on the leaderboard.',
|
||||
path: ['name'],
|
||||
}
|
||||
)
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), {
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), {
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
})
|
||||
|
||||
type FormInputs = z.infer<typeof schema>
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const form = useForm<FormInputs>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
amount: '' as unknown as number, // a trick to get trigger to work when amount is empty
|
||||
taxDeductible: 'no',
|
||||
givePointsBack: 'no',
|
||||
showDonorNameOnLeaderboard: 'no',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const amount = form.watch('amount')
|
||||
const taxDeductible = form.watch('taxDeductible')
|
||||
const showDonorNameOnLeaderboard = form.watch('showDonorNameOnLeaderboard')
|
||||
|
||||
const donateWithFiatMutation = trpc.donation.donateWithFiat.useMutation()
|
||||
const donateWithCryptoMutation = trpc.donation.donateWithCrypto.useMutation()
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithCryptoMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await donateWithFiatMutation.mutateAsync({
|
||||
email: data.email || null,
|
||||
name: data.name || null,
|
||||
amount: data.amount,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
if (!result.url) throw Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.trigger('email', { shouldFocus: true })
|
||||
form.trigger('name', { shouldFocus: true })
|
||||
}, [taxDeductible, showDonorNameOnLeaderboard])
|
||||
|
||||
if (!project) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Donate to {project.title}</title>
|
||||
</Head>
|
||||
<div className="max-w-[540px] mx-auto p-6 space-y-6 rounded-xl bg-white">
|
||||
<div className="py-4 flex flex-col space-y-6">
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-center sm:text-left font-semibold">Donate to {project.title}</h2>
|
||||
<h3 className="text-gray-500">Pledge your support</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-6">
|
||||
{!isAuthed && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name{' '}
|
||||
{taxDeductible === 'no' &&
|
||||
showDonorNameOnLeaderboard === 'no' &&
|
||||
'(optional)'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-row gap-2 items-center flex-wrap ">
|
||||
<Input
|
||||
className="w-40 mr-auto"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
leftIcon={DollarSign}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
{[50, 100, 250, 500].map((value, index) => (
|
||||
<Button
|
||||
key={`amount-button-${index}`}
|
||||
variant="light"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
form.setValue('amount', value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
${value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want this donation to be tax deductible? (US only)</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showDonorNameOnLeaderboard"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your name to be displayed on the leaderboard?</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal text-gray-700">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAuthed && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="givePointsBack"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3 leading-5">
|
||||
<FormLabel>
|
||||
Would you like to receive MAGIC Grants points back for your donation? The
|
||||
points can be redeemed for various donation perks as a thank you for
|
||||
supporting our mission.
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl className="flex-shrink-0">
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
Yes, give me perks! This will reduce the donation amount by 10%, the
|
||||
approximate value of the points when redeemed for goods/services.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
No, use my full contribution toward your mission.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{amount > 500 && taxDeductible === 'yes' && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
When donating over $500 with crypto, you MUST complete{' '}
|
||||
<CustomLink target="_blank" href="https://www.irs.gov/pub/irs-pdf/f8283.pdf">
|
||||
Form 8283
|
||||
</CustomLink>{' '}
|
||||
and send the completed form to{' '}
|
||||
<CustomLink href={`mailto:info@magicgrants.org`}>info@magicgrants.org</CustomLink>{' '}
|
||||
to qualify for a deduction.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{donateWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{donateWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Donate with Card
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{!isAuthed && <div className="w-full h-px bg-border" />}
|
||||
|
||||
{!isAuthed && (
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-sm">Want to support more projects and receive optional perks?</p>
|
||||
|
||||
<Link href={`/${fundSlug}/?registerEmail=1`}>
|
||||
<Button type="button" size="lg" variant="link">
|
||||
Create an account
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DonationPage
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getProjects()
|
||||
|
||||
return {
|
||||
paths: [
|
||||
...fundSlugs.map((fund) => `/${fund}/donate/${fund}`),
|
||||
...projects.map((project) => `/${project.fund}/donate/${project.slug}`),
|
||||
],
|
||||
fallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticProps({ params }: GetStaticPropsContext<QueryParams>) {
|
||||
if (params?.fund === params?.slug && params?.fund) {
|
||||
return { props: { ...params, project: funds[params.fund] } }
|
||||
}
|
||||
|
||||
const project = getProjectBySlug(params?.slug!, params?.fund!)
|
||||
|
||||
return { props: { ...params, project } }
|
||||
}
|
||||
397
pages/[fund]/membership/[slug].tsx
Normal file
397
pages/[fund]/membership/[slug].tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { faMonero } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { DollarSign } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { FundSlug } from '@prisma/client'
|
||||
import { GetStaticPropsContext } from 'next'
|
||||
import Image from 'next/image'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { MAX_AMOUNT } from '../../../config'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import { trpc } from '../../../utils/trpc'
|
||||
import { useToast } from '../../../components/ui/use-toast'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from '../../../components/ui/radio-group'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '../../../components/ui/form'
|
||||
import { Input } from '../../../components/ui/input'
|
||||
import { ProjectItem } from '../../../utils/types'
|
||||
import { getProjectBySlug, getProjects } from '../../../utils/md'
|
||||
import { funds, fundSlugs } from '../../../utils/funds'
|
||||
import Head from 'next/head'
|
||||
|
||||
type QueryParams = { fund: FundSlug; slug: string }
|
||||
type Props = { project: ProjectItem } & QueryParams
|
||||
|
||||
function MembershipPage({ fund: fundSlug, project }: Props) {
|
||||
const session = useSession()
|
||||
const isAuthed = session.status === 'authenticated'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
amount: z.coerce.number().min(1).max(MAX_AMOUNT),
|
||||
taxDeductible: z.enum(['yes', 'no']),
|
||||
recurring: z.enum(['yes', 'no']),
|
||||
givePointsBack: z.enum(['yes', 'no']),
|
||||
showDonorNameOnLeaderboard: z.enum(['yes', 'no']),
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.name : true), {
|
||||
message: 'Name is required when the donation is tax deductible.',
|
||||
path: ['name'],
|
||||
})
|
||||
.refine((data) => (!isAuthed && data.taxDeductible === 'yes' ? !!data.email : true), {
|
||||
message: 'Email is required when the donation is tax deductible.',
|
||||
path: ['email'],
|
||||
})
|
||||
|
||||
type FormInputs = z.infer<typeof schema>
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const form = useForm<FormInputs>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
amount: 100, // a trick to get trigger to work when amount is empty
|
||||
taxDeductible: 'no',
|
||||
recurring: 'no',
|
||||
givePointsBack: 'no',
|
||||
showDonorNameOnLeaderboard: 'no',
|
||||
},
|
||||
mode: 'all',
|
||||
})
|
||||
|
||||
const taxDeductible = form.watch('taxDeductible')
|
||||
|
||||
const payMembershipWithFiatMutation = trpc.donation.payMembershipWithFiat.useMutation()
|
||||
const payMembershipWithCryptoMutation = trpc.donation.payMembershipWithCrypto.useMutation()
|
||||
|
||||
async function handleBtcPay(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithCryptoMutation.mutateAsync({
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiat(data: FormInputs) {
|
||||
if (!project) return
|
||||
if (!fundSlug) return
|
||||
|
||||
try {
|
||||
const result = await payMembershipWithFiatMutation.mutateAsync({
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
fundSlug,
|
||||
recurring: data.recurring === 'yes',
|
||||
taxDeductible: data.taxDeductible === 'yes',
|
||||
givePointsBack: data.givePointsBack === 'yes',
|
||||
showDonorNameOnLeaderboard: data.showDonorNameOnLeaderboard === 'yes',
|
||||
})
|
||||
|
||||
if (!result.url) throw new Error()
|
||||
|
||||
window.location.assign(result.url)
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Membership to {project.title}</title>
|
||||
</Head>
|
||||
<div className="max-w-[540px] mx-auto p-6 space-y-6 rounded-xl bg-white">
|
||||
<div className="py-4 flex flex-col space-y-6">
|
||||
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
|
||||
<Image
|
||||
alt={project.title}
|
||||
src={project.coverImage}
|
||||
width={200}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
className="w-36 rounded-xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-center sm:text-left font-semibold">
|
||||
Membership to {project.title}
|
||||
</h2>
|
||||
<h3 className="text-gray-500">Pledge your support</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-6">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<FormLabel>Price</FormLabel>
|
||||
<span className="flex flex-row text-gray-700 font-semibold">
|
||||
<DollarSign className="text-primary" />
|
||||
100.00
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isAuthed && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email {taxDeductible === 'no' && '(optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="taxDeductible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your membership to be tax deductible? (US only)</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4 text-gray-700"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">No</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Yes</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recurring"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>
|
||||
Do you want your membership payment to be recurring? (Fiat only)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4 text-gray-700"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">No</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Yes</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showDonorNameOnLeaderboard"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Do you want your name to be displayed on the leaderboard?</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-row space-x-4 text-gray-700"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Yes</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">No</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="givePointsBack"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3 leading-5">
|
||||
<FormLabel>
|
||||
Would you like to receive MAGIC Grants points back for your donation? The points
|
||||
can be redeemed for various donation perks as a thank you for supporting our
|
||||
mission.
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl className="flex-shrink-0">
|
||||
<RadioGroupItem value="yes" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
Yes, give me perks! This will reduce the donation amount by 10%, the
|
||||
approximate value of the points when redeemed for goods/services.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="no" />
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="font-normal text-gray-700">
|
||||
No, use my full contribution toward your mission.
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleBtcPay)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0"
|
||||
>
|
||||
{payMembershipWithCryptoMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faMonero} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Crypto
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFiat)}
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
className="grow basis-0 bg-indigo-500 hover:bg-indigo-700"
|
||||
>
|
||||
{payMembershipWithFiatMutation.isPending ? (
|
||||
<Spinner className="fill-indigo-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCreditCard} className="h-5 w-5" />
|
||||
)}
|
||||
Pay with Card
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembershipPage
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getProjects()
|
||||
|
||||
return {
|
||||
paths: [
|
||||
...fundSlugs.map((fund) => `/${fund}/membership/${fund}`),
|
||||
...projects.map((project) => `/${project.fund}/membership/${project.slug}`),
|
||||
],
|
||||
fallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticProps({ params }: GetStaticPropsContext<QueryParams>) {
|
||||
if (params?.fund === params?.slug && params?.fund) {
|
||||
return { props: { ...params, project: funds[params.fund] } }
|
||||
}
|
||||
|
||||
const project = getProjectBySlug(params?.slug!, params?.fund!)
|
||||
|
||||
return { props: { ...params, project } }
|
||||
}
|
||||
761
pages/[fund]/perks/[id].tsx
Normal file
761
pages/[fund]/perks/[id].tsx
Normal file
@@ -0,0 +1,761 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { BoxIcon, Check, ChevronsUpDown, ShoppingBagIcon, TruckIcon } from 'lucide-react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
StrapiGetPerkPopulatedRes,
|
||||
StrapiGetPerksPopulatedRes,
|
||||
StrapiPerkPopulated,
|
||||
} from '../../../server/types'
|
||||
import { env } from '../../../env.mjs'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '../../../components/ui/form'
|
||||
import { Input } from '../../../components/ui/input'
|
||||
import { toast } from '../../../components/ui/use-toast'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import { Label } from '../../../components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../../components/ui/select'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '../../../components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../../../components/ui/table'
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '../../../components/ui/carousel'
|
||||
import CustomLink from '../../../components/CustomLink'
|
||||
import { Checkbox } from '../../../components/ui/checkbox'
|
||||
import { useFundSlug } from '../../../utils/use-fund-slug'
|
||||
import { trpc } from '../../../utils/trpc'
|
||||
import { cn } from '../../../utils/cn'
|
||||
import { strapiApi } from '../../../server/services'
|
||||
import { GetServerSidePropsContext } from 'next'
|
||||
import { getUserPointBalance } from '../../../server/utils/perks'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '../../api/auth/[...nextauth]'
|
||||
import Head from 'next/head'
|
||||
|
||||
type Props = { perk: StrapiPerkPopulated; balance: number }
|
||||
|
||||
const pointFormat = Intl.NumberFormat('en', { notation: 'standard', compactDisplay: 'long' })
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
shippingAddressLine1: z.string().min(1),
|
||||
shippingAddressLine2: z.string(),
|
||||
shippingCity: z.string().min(1),
|
||||
shippingState: z.string(),
|
||||
shippingCountry: z.string().min(1),
|
||||
shippingZip: z.string().min(1),
|
||||
shippingPhone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
|
||||
shippingTaxNumber: z.string(),
|
||||
printfulSyncVariantId: z.string().optional(),
|
||||
_shippingStateOptionsLength: z.number(),
|
||||
_useAccountMailingAddress: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const cpfRegex =
|
||||
/([0-9]{2}[\.]?[0-9]{3}[\.]?[0-9]{3}[\/]?[0-9]{4}[-]?[0-9]{2})|([0-9]{3}[\.]?[0-9]{3}[\.]?[0-9]{3}[-]?[0-9]{2})/
|
||||
|
||||
if (data.shippingCountry === 'BR') {
|
||||
if (data.shippingTaxNumber.length < 1) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingTaxNumber'],
|
||||
code: 'custom',
|
||||
message: 'CPF is required.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!cpfRegex.test(data.shippingTaxNumber)) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingTaxNumber'],
|
||||
code: 'custom',
|
||||
message: 'Invalid CPF.',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.shippingState && data._shippingStateOptionsLength) {
|
||||
ctx.addIssue({
|
||||
path: ['shippingState'],
|
||||
code: 'custom',
|
||||
message: 'State is required.',
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
type PerkPurchaseInputs = z.infer<typeof schema>
|
||||
|
||||
type CostEstimate = { product: number; shipping: number; tax: number; total: number }
|
||||
|
||||
function Perk({ perk, balance }: Props) {
|
||||
const router = useRouter()
|
||||
const fundSlug = useFundSlug()
|
||||
const getCountriesQuery = trpc.perk.getCountries.useQuery()
|
||||
const getUserAttributesQuery = trpc.account.getUserAttributes.useQuery()
|
||||
const purchasePerkMutation = trpc.perk.purchasePerk.useMutation()
|
||||
const estimatePrintfulOrderCosts = trpc.perk.estimatePrintfulOrderCosts.useMutation()
|
||||
|
||||
const getPrintfulProductVariantsQuery = trpc.perk.getPrintfulProductVariants.useQuery(
|
||||
{ printfulProductId: perk.printfulProductId || '' },
|
||||
{ enabled: !!perk.printfulProductId }
|
||||
)
|
||||
|
||||
const form = useForm<PerkPurchaseInputs>({
|
||||
resolver: zodResolver(perk.needsShippingAddress ? schema : z.object({})),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
_shippingStateOptionsLength: 0,
|
||||
shippingAddressLine1: '',
|
||||
shippingAddressLine2: '',
|
||||
shippingCity: '',
|
||||
shippingState: '',
|
||||
shippingCountry: '',
|
||||
shippingZip: '',
|
||||
shippingPhone: '',
|
||||
shippingTaxNumber: '',
|
||||
},
|
||||
shouldFocusError: false,
|
||||
})
|
||||
|
||||
const [countrySelectOpen, setCountrySelectOpen] = useState(false)
|
||||
const [stateSelectOpen, setStateSelectOpen] = useState(false)
|
||||
const [costEstimate, setCostEstimate] = useState<CostEstimate | null>(null)
|
||||
|
||||
const hasEnoughBalance = balance - (costEstimate?.total || perk.price) > 0
|
||||
|
||||
const shippingCountryOptions = (getCountriesQuery.data || []).map((country) => ({
|
||||
label: country.name,
|
||||
value: country.code,
|
||||
}))
|
||||
|
||||
const shippingCountry = form.watch('shippingCountry')
|
||||
const shippingState = form.watch('shippingState')
|
||||
const printfulSyncVariantId = form.watch('printfulSyncVariantId')
|
||||
const useAccountMailingAddress = form.watch('_useAccountMailingAddress')
|
||||
|
||||
const shippingStateOptions = useMemo(() => {
|
||||
const selectedCountry = (getCountriesQuery.data || []).find(
|
||||
(country) => country.code === shippingCountry
|
||||
)
|
||||
|
||||
const stateOptions =
|
||||
selectedCountry?.states?.map((state) => ({
|
||||
label: state.name,
|
||||
value: state.code,
|
||||
})) || []
|
||||
|
||||
return stateOptions
|
||||
}, [shippingCountry])
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('shippingState', '')
|
||||
form.setValue('shippingTaxNumber', '')
|
||||
}, [shippingCountry])
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('_shippingStateOptionsLength', shippingStateOptions.length)
|
||||
}, [shippingStateOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!getUserAttributesQuery.data) return
|
||||
|
||||
if (useAccountMailingAddress) {
|
||||
form.setValue('shippingAddressLine1', getUserAttributesQuery.data.addressLine1)
|
||||
form.setValue('shippingAddressLine2', getUserAttributesQuery.data.addressLine2)
|
||||
form.setValue('shippingCountry', getUserAttributesQuery.data.addressCountry)
|
||||
form.setValue('shippingCity', getUserAttributesQuery.data.addressCity)
|
||||
form.setValue('shippingZip', getUserAttributesQuery.data.addressZip)
|
||||
setTimeout(() => form.setValue('shippingState', getUserAttributesQuery.data.addressState), 20)
|
||||
} else {
|
||||
form.setValue('shippingAddressLine1', '')
|
||||
form.setValue('shippingAddressLine2', '')
|
||||
form.setValue('shippingCountry', '')
|
||||
form.setValue('shippingState', '')
|
||||
form.setValue('shippingCity', '')
|
||||
form.setValue('shippingZip', '')
|
||||
}
|
||||
}, [useAccountMailingAddress])
|
||||
|
||||
async function onSubmit(data: PerkPurchaseInputs) {
|
||||
if (!fundSlug) return
|
||||
|
||||
// Get order estimate if needed
|
||||
if (perk.needsShippingAddress && !costEstimate && data.printfulSyncVariantId) {
|
||||
try {
|
||||
const _costEstimate = await estimatePrintfulOrderCosts.mutateAsync({
|
||||
...data,
|
||||
|
||||
printfulSyncVariantId: Number(data.printfulSyncVariantId),
|
||||
})
|
||||
|
||||
setCostEstimate(_costEstimate)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Make purchase
|
||||
if (!perk.needsShippingAddress || !!costEstimate) {
|
||||
try {
|
||||
await purchasePerkMutation.mutateAsync({
|
||||
perkId: perk.documentId,
|
||||
fundSlug,
|
||||
perkPrintfulSyncVariantId: Number(data.printfulSyncVariantId) || undefined,
|
||||
...data,
|
||||
})
|
||||
toast({ title: 'Perk successfully purchased!' })
|
||||
router.push(`/${fundSlug}/account/point-history`)
|
||||
close()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Sorry, something went wrong.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Buy {perk.name}</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-center items-start">
|
||||
<div className="p-10 hidden md:block">
|
||||
<Carousel className="w-80 h-80">
|
||||
<CarouselContent>
|
||||
{perk.images.map((image) => (
|
||||
<CarouselItem key={image.formats.medium.url}>
|
||||
<Image
|
||||
alt={perk.name}
|
||||
src={
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? env.NEXT_PUBLIC_STRAPI_URL + image.formats.medium.url
|
||||
: image.formats.medium.url
|
||||
}
|
||||
width={600}
|
||||
height={600}
|
||||
style={{ objectFit: 'contain' }}
|
||||
className="w-80 h-80"
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-full md:max-w-md p-6 flex flex-col space-y-6 bg-white rounded-xl"
|
||||
>
|
||||
<div className="flex flex-col justify-start">
|
||||
<div className="mx-auto p-10 md:hidden justify-center items-center">
|
||||
<Carousel className="w-40 sm:w-56 h-40 sm:h-56">
|
||||
<CarouselContent>
|
||||
{perk.images.map((image) => (
|
||||
<CarouselItem key={image.formats.medium.url}>
|
||||
<Image
|
||||
alt={perk.name}
|
||||
src={
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? env.NEXT_PUBLIC_STRAPI_URL + image.formats.medium.url
|
||||
: image.formats.medium.url
|
||||
}
|
||||
width={200}
|
||||
height={200}
|
||||
style={{ objectFit: 'contain' }}
|
||||
className="w-40 sm:w-56 h-40 sm:h-56"
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="font-semibold">{perk.name}</h1>
|
||||
{!costEstimate && <p className="text-muted-foreground">{perk.description}</p>}
|
||||
{!costEstimate && perk.productDetailsUrl && (
|
||||
<CustomLink className="text-xs" href={perk.productDetailsUrl}>
|
||||
View product details
|
||||
</CustomLink>
|
||||
)}
|
||||
{!!costEstimate && printfulSyncVariantId && (
|
||||
<p className="text-muted-foreground">
|
||||
{
|
||||
getPrintfulProductVariantsQuery.data?.find(
|
||||
(variant) => variant.id === Number(printfulSyncVariantId)
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!costEstimate && (
|
||||
<div className="flex flex-col">
|
||||
<Label>Price</Label>
|
||||
|
||||
<p className="mt-1 text-lg text-green-500">
|
||||
<strong className="font-semibold">{pointFormat.format(perk.price)}</strong>{' '}
|
||||
points
|
||||
</p>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
hasEnoughBalance ? 'text-muted-foreground' : 'text-red-500'
|
||||
)}
|
||||
>
|
||||
You have {pointFormat.format(balance)} points
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 grow">
|
||||
{perk.needsShippingAddress && hasEnoughBalance && !costEstimate && (
|
||||
<>
|
||||
{perk.needsShippingAddress && !!getPrintfulProductVariantsQuery.data && (
|
||||
<div className="flex flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="printfulSyncVariantId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Options *</FormLabel>
|
||||
<Select onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select size and color" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{getPrintfulProductVariantsQuery.data.map((variant) => (
|
||||
<SelectItem
|
||||
key={variant.id}
|
||||
value={variant.id.toString()}
|
||||
>{`${variant.size} | ${variant.color}`}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{perk.needsShippingAddress && !getPrintfulProductVariantsQuery.data && (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
{getUserAttributesQuery.data?.addressLine1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="_useAccountMailingAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 flex flex-col items-start leading-none">
|
||||
<FormLabel>Use saved mailing address</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingAddressLine1"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Address line 1 *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={useAccountMailingAddress} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingAddressLine2"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Address line 2</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={useAccountMailingAddress} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingCountry"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Country *</FormLabel>
|
||||
<Popover
|
||||
modal
|
||||
open={countrySelectOpen}
|
||||
onOpenChange={(open) => setCountrySelectOpen(open)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<FormControl>
|
||||
<Select
|
||||
open={countrySelectOpen}
|
||||
onValueChange={() => setCountrySelectOpen(false)}
|
||||
disabled={useAccountMailingAddress}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
(getCountriesQuery.data || []).find(
|
||||
(country) => country.code === shippingCountry
|
||||
)?.name || ''
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shippingCountryOptions.map((country) => (
|
||||
<CommandItem
|
||||
value={country.label}
|
||||
key={country.value}
|
||||
onSelect={() => (
|
||||
form.setValue('shippingCountry', country.value, {
|
||||
shouldValidate: true,
|
||||
}),
|
||||
setCountrySelectOpen(false)
|
||||
)}
|
||||
>
|
||||
{country.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
country.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!!shippingStateOptions.length && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingState"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>State *</FormLabel>
|
||||
<Popover
|
||||
modal
|
||||
open={stateSelectOpen}
|
||||
onOpenChange={(open) => setStateSelectOpen(open)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<FormControl>
|
||||
<Select disabled={useAccountMailingAddress}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
shippingStateOptions.find(
|
||||
(state) => state.value === shippingState
|
||||
)?.label
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search state..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No state found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shippingStateOptions.map((state) => (
|
||||
<CommandItem
|
||||
value={state.label}
|
||||
key={state.value}
|
||||
onSelect={() => (
|
||||
form.setValue('shippingState', state.value, {
|
||||
shouldValidate: true,
|
||||
}),
|
||||
setStateSelectOpen(false)
|
||||
)}
|
||||
>
|
||||
{state.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
state.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingCity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>City *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={useAccountMailingAddress} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingZip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Postal code *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={useAccountMailingAddress} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingPhone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone number *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{shippingCountry === 'BR' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shippingTaxNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tax number (CPF) *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Price subject to change depending on your region.
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
hasEnoughBalance &&
|
||||
!estimatePrintfulOrderCosts.isPending
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{estimatePrintfulOrderCosts.isPending ? <Spinner /> : <TruckIcon />}
|
||||
Calculate shipping costs
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!costEstimate && (
|
||||
<div className="flex flex-col mb-auto">
|
||||
<Table className="w-fit">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Item</TableCell>
|
||||
<TableCell>{pointFormat.format(costEstimate.product)} points</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Shipping</TableCell>
|
||||
<TableCell>{pointFormat.format(costEstimate.shipping)} points</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Tax</TableCell>
|
||||
<TableCell>{pointFormat.format(costEstimate.tax)} points</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="text-lg">
|
||||
<TableCell className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-green-500">
|
||||
<strong className="font-semibold">
|
||||
{pointFormat.format(costEstimate.total)}
|
||||
</strong>{' '}
|
||||
points
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
hasEnoughBalance ? 'text-muted-foreground' : 'text-red-500'
|
||||
)}
|
||||
>
|
||||
You have {pointFormat.format(balance)} points
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((perk.needsShippingAddress && !!costEstimate) || !perk.needsShippingAddress) && (
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={
|
||||
!(form.formState.isValid && hasEnoughBalance && !purchasePerkMutation.isPending)
|
||||
}
|
||||
className="w-full max-w-96 mx-auto md:mx-0"
|
||||
>
|
||||
{purchasePerkMutation.isPending ? <Spinner /> : <ShoppingBagIcon />}
|
||||
Purchase
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Perk
|
||||
|
||||
export async function getServerSideProps({ params, req, res }: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
|
||||
if (!session) {
|
||||
return { redirect: { destination: `/${params?.fund!}` } }
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
balance,
|
||||
{
|
||||
data: { data: perk },
|
||||
},
|
||||
] = await Promise.all([
|
||||
getUserPointBalance(session.user.sub),
|
||||
strapiApi.get<StrapiGetPerkPopulatedRes>(
|
||||
`/perks/${params?.id!}?populate[images][fields]=formats`
|
||||
),
|
||||
])
|
||||
|
||||
if (!perk) {
|
||||
return { redirect: { destination: `/${params?.fund!}/perks` } }
|
||||
}
|
||||
|
||||
return { props: { perk, balance } }
|
||||
} catch {}
|
||||
|
||||
return { redirect: { destination: `/${params?.fund!}/perks` } }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/router'
|
||||
import { GetServerSidePropsContext, NextPage } from 'next/types'
|
||||
import Head from 'next/head'
|
||||
import ErrorPage from 'next/error'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import xss from 'xss'
|
||||
|
||||
@@ -15,8 +16,6 @@ import Progress from '../../../components/Progress'
|
||||
import { prisma } from '../../../server/services'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import { Dialog, DialogContent } from '../../../components/ui/dialog'
|
||||
import DonationFormModal from '../../../components/DonationFormModal'
|
||||
import MembershipFormModal from '../../../components/MembershipFormModal'
|
||||
import LoginFormModal from '../../../components/LoginFormModal'
|
||||
import RegisterFormModal from '../../../components/RegisterFormModal'
|
||||
import PasswordResetFormModal from '../../../components/PasswordResetFormModal'
|
||||
@@ -42,8 +41,6 @@ type SingleProjectPageProps = {
|
||||
|
||||
const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) => {
|
||||
const router = useRouter()
|
||||
const [donateModalOpen, setDonateModalOpen] = useState(false)
|
||||
const [memberModalOpen, setMemberModalOpen] = useState(false)
|
||||
const [registerIsOpen, setRegisterIsOpen] = useState(false)
|
||||
const [loginIsOpen, setLoginIsOpen] = useState(false)
|
||||
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
|
||||
@@ -90,8 +87,6 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
projectSlug: project.slug,
|
||||
})
|
||||
|
||||
console.log((150 / goal) * 100)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -100,7 +95,7 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<PageHeading project={project}>
|
||||
<div className="w-full mt-8 flex flex-col md:flex-row items-center md:space-x-8 xl:space-x-0 xl:space-y-4 space-y-10 md:space-y-0 xl:block">
|
||||
<div className="w-full flex flex-col items-center gap-4 xl:flex">
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="avatar"
|
||||
@@ -113,19 +108,23 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
{!project.isFunded && (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button onClick={() => setDonateModalOpen(true)}>Donate</Button>
|
||||
|
||||
<Link href={`/${fundSlug}/donate/${project.slug}`}>
|
||||
<Button className="w-full">Donate</Button>
|
||||
</Link>
|
||||
{!userHasMembershipQuery.data && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
session.status === 'authenticated'
|
||||
? setMemberModalOpen(true)
|
||||
: setRegisterIsOpen(true)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
<>
|
||||
{session.status !== 'authenticated' ? (
|
||||
<Button onClick={() => setRegisterIsOpen(true)} variant="outline">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
) : (
|
||||
<Link href={`/${fundSlug}/membership/${project.slug}`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!userHasMembershipQuery.data && (
|
||||
@@ -217,32 +216,12 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
|
||||
</div>
|
||||
|
||||
<article
|
||||
className="prose max-w-none pb-8 pt-8 xl:col-span-2"
|
||||
className="prose max-w-none mt-4 p-6 xl:col-span-2 bg-white rounded-xl"
|
||||
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
|
||||
/>
|
||||
</PageHeading>
|
||||
</div>
|
||||
|
||||
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
||||
<DialogContent>
|
||||
<DonationFormModal
|
||||
project={project}
|
||||
close={() => setDonateModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipFormModal
|
||||
project={project}
|
||||
close={() => setMemberModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{session.status !== 'authenticated' && (
|
||||
<>
|
||||
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
import { getProjects } from '../../utils/md'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
import { Button } from '../../components/ui/button'
|
||||
import { Dialog, DialogContent } from '../../components/ui/dialog'
|
||||
import DonationFormModal from '../../components/DonationFormModal'
|
||||
import MembershipFormModal from '../../components/MembershipFormModal'
|
||||
import ProjectList from '../../components/ProjectList'
|
||||
import LoginFormModal from '../../components/LoginFormModal'
|
||||
import RegisterFormModal from '../../components/RegisterFormModal'
|
||||
@@ -56,26 +55,26 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row my-4 gap-2">
|
||||
<Button
|
||||
className="text-sm md:text-base"
|
||||
onClick={() => setDonateModalOpen(true)}
|
||||
size="lg"
|
||||
>
|
||||
Donate to Firo Fund
|
||||
</Button>
|
||||
<Link href="/firo/donate/firo">
|
||||
<Button className="text-sm md:text-base" size="lg">
|
||||
Donate to Firo Fund
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{!userHasMembershipQuery.data && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
session.status === 'authenticated'
|
||||
? setMemberModalOpen(true)
|
||||
: setRegisterIsOpen(true)
|
||||
}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
<>
|
||||
{session.status !== 'authenticated' ? (
|
||||
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
) : (
|
||||
<Link href="/firo/membership/firo">
|
||||
<Button variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!userHasMembershipQuery.data && (
|
||||
@@ -110,26 +109,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
||||
<DialogContent>
|
||||
<DonationFormModal
|
||||
project={fund}
|
||||
close={() => setDonateModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipFormModal
|
||||
project={fund}
|
||||
close={() => setMemberModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{session.status !== 'authenticated' && (
|
||||
<>
|
||||
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
import { getProjects } from '../../utils/md'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
import { Button } from '../../components/ui/button'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '../../components/ui/dialog'
|
||||
import DonationFormModal from '../../components/DonationFormModal'
|
||||
import MembershipFormModal from '../../components/MembershipFormModal'
|
||||
import { Dialog, DialogContent } from '../../components/ui/dialog'
|
||||
import ProjectList from '../../components/ProjectList'
|
||||
import LoginFormModal from '../../components/LoginFormModal'
|
||||
import RegisterFormModal from '../../components/RegisterFormModal'
|
||||
@@ -57,26 +56,26 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row my-4 gap-2">
|
||||
<Button
|
||||
className="text-sm md:text-base"
|
||||
onClick={() => setDonateModalOpen(true)}
|
||||
size="lg"
|
||||
>
|
||||
Donate to MAGIC Grants
|
||||
</Button>
|
||||
<Link href="/general/donate/general">
|
||||
<Button className="text-sm md:text-base" size="lg">
|
||||
Donate to MAGIC Grants
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{!userHasMembershipQuery.data && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
session.status === 'authenticated'
|
||||
? setMemberModalOpen(true)
|
||||
: setRegisterIsOpen(true)
|
||||
}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
<>
|
||||
{session.status !== 'authenticated' ? (
|
||||
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
) : (
|
||||
<Link href="/general/membership/general">
|
||||
<Button variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!userHasMembershipQuery.data && (
|
||||
@@ -111,26 +110,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
||||
<DialogContent>
|
||||
<DonationFormModal
|
||||
project={fund}
|
||||
close={() => setDonateModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipFormModal
|
||||
project={fund}
|
||||
close={() => setMemberModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{session.status !== 'authenticated' && (
|
||||
<>
|
||||
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
import { getProjects } from '../../utils/md'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
import { Button } from '../../components/ui/button'
|
||||
import { Dialog, DialogContent } from '../../components/ui/dialog'
|
||||
import DonationFormModal from '../../components/DonationFormModal'
|
||||
import MembershipFormModal from '../../components/MembershipFormModal'
|
||||
import ProjectList from '../../components/ProjectList'
|
||||
import LoginFormModal from '../../components/LoginFormModal'
|
||||
import RegisterFormModal from '../../components/RegisterFormModal'
|
||||
@@ -56,26 +55,26 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row my-4 gap-2">
|
||||
<Button
|
||||
className="text-sm md:text-base"
|
||||
onClick={() => setDonateModalOpen(true)}
|
||||
size="lg"
|
||||
>
|
||||
Donate to Monero Fund
|
||||
</Button>
|
||||
<Link href="/monero/donate/monero">
|
||||
<Button className="text-sm md:text-base" size="lg">
|
||||
Donate to Monero Fund
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{!userHasMembershipQuery.data && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
session.status === 'authenticated'
|
||||
? setMemberModalOpen(true)
|
||||
: setRegisterIsOpen(true)
|
||||
}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
<>
|
||||
{session.status !== 'authenticated' ? (
|
||||
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
) : (
|
||||
<Link href="/monero/membership/monero">
|
||||
<Button variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!userHasMembershipQuery.data && (
|
||||
@@ -120,26 +119,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
||||
<DialogContent>
|
||||
<DonationFormModal
|
||||
project={fund}
|
||||
close={() => setDonateModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipFormModal
|
||||
project={fund}
|
||||
close={() => setMemberModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{session.status !== 'authenticated' && (
|
||||
<>
|
||||
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
import { getProjects } from '../../utils/md'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
import { Button } from '../../components/ui/button'
|
||||
import { Dialog, DialogContent } from '../../components/ui/dialog'
|
||||
import DonationFormModal from '../../components/DonationFormModal'
|
||||
import MembershipFormModal from '../../components/MembershipFormModal'
|
||||
import ProjectList from '../../components/ProjectList'
|
||||
import LoginFormModal from '../../components/LoginFormModal'
|
||||
import RegisterFormModal from '../../components/RegisterFormModal'
|
||||
@@ -57,26 +56,26 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row my-4 gap-2">
|
||||
<Button
|
||||
className="text-sm md:text-base"
|
||||
onClick={() => setDonateModalOpen(true)}
|
||||
size="lg"
|
||||
>
|
||||
Donate to Privacy Guides
|
||||
</Button>
|
||||
<Link href="/monero/donate/monero">
|
||||
<Button className="text-sm md:text-base" size="lg">
|
||||
Donate to Privacy Guides
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{!userHasMembershipQuery.data && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
session.status === 'authenticated'
|
||||
? setMemberModalOpen(true)
|
||||
: setRegisterIsOpen(true)
|
||||
}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
<>
|
||||
{session.status !== 'authenticated' ? (
|
||||
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
) : (
|
||||
<Link href="/privacyguides/membership/privacyguides">
|
||||
<Button variant="light" size="lg">
|
||||
Get Annual Membership
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!userHasMembershipQuery.data && (
|
||||
@@ -111,26 +110,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
||||
<DialogContent>
|
||||
<DonationFormModal
|
||||
project={fund}
|
||||
close={() => setDonateModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
||||
<DialogContent>
|
||||
<MembershipFormModal
|
||||
project={fund}
|
||||
close={() => setMemberModalOpen(false)}
|
||||
openRegisterModal={() => setRegisterIsOpen(true)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{session.status !== 'authenticated' && (
|
||||
<>
|
||||
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
||||
|
||||
@@ -98,6 +98,10 @@ export type StrapiPerkPopulated = StrapiPerk & {
|
||||
}[]
|
||||
}
|
||||
|
||||
export type StrapiGetPerkPopulatedRes = {
|
||||
data: StrapiPerkPopulated | null
|
||||
}
|
||||
|
||||
export type StrapiGetPerksPopulatedRes = {
|
||||
data: StrapiPerkPopulated[]
|
||||
|
||||
|
||||
@@ -23,15 +23,13 @@ export async function refreshToken(token: JWT): Promise<JWT> {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(response)
|
||||
console.log(await response.json())
|
||||
throw new Error(`Error: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const newToken = await response.json()
|
||||
|
||||
const jwtPayload: KeycloakJwtPayload = jwtDecode(newToken.access_token)
|
||||
console.log(newToken)
|
||||
|
||||
return {
|
||||
sub: jwtPayload.sub,
|
||||
email: jwtPayload.email,
|
||||
|
||||
@@ -34,8 +34,6 @@ export type PerkPurchaseWorkerData = {
|
||||
|
||||
const globalForWorker = global as unknown as { hasInitializedWorkers: boolean }
|
||||
|
||||
if (!globalForWorker.hasInitializedWorkers) console.log('hey')
|
||||
|
||||
if (!globalForWorker.hasInitializedWorkers)
|
||||
new Worker<PerkPurchaseWorkerData>(
|
||||
'PerkPurchase',
|
||||
|
||||
Reference in New Issue
Block a user