feat: move donation, membership and perk forms to a dedicated page, and minor ui improvements

This commit is contained in:
Artur
2024-12-17 15:46:44 -03:00
parent f40df4d1b7
commit 9b5e8ac48a
20 changed files with 1739 additions and 1029 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,4 +24,4 @@ export default withAuth({
},
})
export const config = { matcher: ['/:path/account/:path*'] }
export const config = { matcher: ['/:path/account/:path*', '/:path/membership/:path*'] }

View File

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

View 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 } }
}

View 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
View 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` } }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,6 +98,10 @@ export type StrapiPerkPopulated = StrapiPerk & {
}[]
}
export type StrapiGetPerkPopulatedRes = {
data: StrapiPerkPopulated | null
}
export type StrapiGetPerksPopulatedRes = {
data: StrapiPerkPopulated[]

View File

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

View File

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