feat: membership flow and UI improvements

This commit is contained in:
Artur
2025-01-07 15:42:25 -03:00
parent d810bdb169
commit e4887b04ae
22 changed files with 278 additions and 1176 deletions

View File

@@ -6,11 +6,7 @@ import Link from './CustomLink'
import MobileNav from './MobileNav'
import { fundHeaderNavLinks } from '../data/headerNavLinks'
import MagicLogo from './MagicLogo'
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'
import { Button } from './ui/button'
import RegisterFormModal from './RegisterFormModal'
import LoginFormModal from './LoginFormModal'
import PasswordResetFormModal from './PasswordResetFormModal'
import { Avatar, AvatarFallback } from './ui/avatar'
import {
DropdownMenu,
@@ -28,25 +24,10 @@ import FiroLogo from './FiroLogo'
import PrivacyGuidesLogo from './PrivacyGuidesLogo'
const Header = () => {
const [registerIsOpen, setRegisterIsOpen] = useState(false)
const [loginIsOpen, setLoginIsOpen] = useState(false)
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
const router = useRouter()
const session = useSession()
const fundSlug = useFundSlug()
useEffect(() => {
if (router.query.loginEmail) {
setLoginIsOpen(true)
}
}, [router.query.loginEmail])
useEffect(() => {
if (router.query.registerEmail) {
setRegisterIsOpen(true)
}
}, [router.query.registerEmail])
const fund = fundSlug ? funds[fundSlug] : null
return (
@@ -87,42 +68,23 @@ const Header = () => {
{!!fund && session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="w-18 block sm:hidden" size="sm">
Login
</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button variant="outline" className="w-24 hidden sm:block">
Login
</Button>
</DialogTrigger>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Link href={`/${fund.slug}/login`}>
<Button variant="outline" className="w-18 block sm:hidden" size="sm">
Login
</Button>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogTrigger asChild>
<Button className="w-18 block sm:hidden" size="sm">
Register
</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button className="w-24 hidden sm:block">Register</Button>
</DialogTrigger>
<DialogContent>
<RegisterFormModal
close={() => setRegisterIsOpen(false)}
openLoginModal={() => setLoginIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Button variant="outline" className="w-24 hidden sm:block">
Login
</Button>
</Link>
<Link href={`/${fund.slug}/register`}>
<Button className="w-18 block sm:hidden" size="sm">
Register
</Button>
<Button className="w-24 hidden sm:block">Register</Button>
</Link>
</>
)}
@@ -164,12 +126,6 @@ const Header = () => {
{!!fundSlug && <MobileNav />}
</div>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</header>
)
}

View File

@@ -20,7 +20,9 @@ const LayoutWrapper = ({ children }: Props) => {
useEffect(() => {
if (session?.error === 'RefreshAccessTokenError') {
if (fundSlug) {
signOut({ callbackUrl: `/${fundSlug}/?loginEmail=${session?.user.email}` })
signOut({
callbackUrl: `/${fundSlug}/login?email=${encodeURIComponent(session?.user.email)}`,
})
} else {
signOut({ callbackUrl: '/' })
}
@@ -38,7 +40,7 @@ const LayoutWrapper = ({ children }: Props) => {
<SectionContainer>
<div className="flex h-screen flex-col justify-between">
<Header />
<main className="grow">{children}</main>
<main className="flex flex-col items-start grow">{children}</main>
<Footer />
</div>
</SectionContainer>

View File

@@ -17,7 +17,7 @@ const PerkCard: React.FC<Props> = ({ perk }) => {
<Link href={`/${fundSlug}/perks/${perk.documentId}`}>
<figure
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',
'max-w-sm min-h-[360px] h-full space-y-2 flex flex-col rounded-lg border-b-4 bg-white cursor-pointer',
fundSlug === 'monero' && 'border-monero',
fundSlug === 'firo' && 'border-firo',
fundSlug === 'privacyguides' && 'border-privacyguides',

View File

@@ -1,696 +0,0 @@
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 { StrapiPerkPopulated } from '../server/types'
import { env } from '../env.mjs'
import { Button } from './ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form'
import { Input } from './ui/input'
import { toast } from './ui/use-toast'
import { useFundSlug } from '../utils/use-fund-slug'
import { trpc } from '../utils/trpc'
import Spinner from './Spinner'
import { cn } from '../utils/cn'
import { Label } from './ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from './ui/carousel'
import CustomLink from './CustomLink'
import { Checkbox } from './ui/checkbox'
type Props = { perk: StrapiPerkPopulated; balance: number; close: () => void }
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 PerkPurchaseFormModal({ perk, balance, close }: 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 (
<div className="min-w-0 flex flex-col md:flex-row gap-8 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 flex flex-col space-y-8"
>
<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 PerkPurchaseFormModal

View File

@@ -33,7 +33,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles })
<Link href={`/${project.fund}/projects/${project.slug}`} passHref target="_blank">
<figure
className={cn(
'max-w-sm min-h-[460px] h-full space-y-2 flex flex-col rounded-xl border-b-4 bg-white',
'max-w-sm min-h-[460px] h-full space-y-2 flex flex-col rounded-lg border-b-4 bg-white',
project.fund === 'monero' && 'border-monero',
project.fund === 'firo' && 'border-firo',
project.fund === 'privacyguides' && 'border-privacyguides',

View File

@@ -27,7 +27,7 @@ const toastVariants = cva(
{
variants: {
variant: {
default: 'border bg-background text-foreground',
default: 'border bg-white text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
@@ -40,8 +40,7 @@ const toastVariants = cva(
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root

View File

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

View File

@@ -171,10 +171,11 @@ function Settings() {
async function onChangeProfileSubmit(data: ChangeProfileFormInputs) {
try {
await changeProfileMutation.mutateAsync(data)
toast({ title: 'Your profile has successfully been changed!' })
toast({ title: 'Success', description: 'Your profile has successfully been changed!' })
} catch (error) {
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -186,7 +187,7 @@ function Settings() {
try {
await requestEmailChangeMutation.mutateAsync({ fundSlug, newEmail: data.newEmail })
changeEmailForm.reset()
toast({ title: 'A verification link has been sent to your email.' })
toast({ title: 'Success', description: 'A verification link has been sent to your email.' })
} catch (error) {
const errorMessage = (error as any).message
@@ -199,7 +200,8 @@ function Settings() {
}
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -214,9 +216,12 @@ function Settings() {
changePasswordForm.reset()
toast({ title: 'Your password has successfully been changed! Please log in again.' })
toast({
title: 'Success',
description: 'Your password has successfully been changed! Please log in again.',
})
await signOut({ redirect: false })
router.push(`/${fundSlug}/?loginEmail=${session.data?.user.email}`)
router.push(`/${fundSlug}/login?email=${encodeURIComponent(session.data?.user.email!)}`)
} catch (error) {
const errorMessage = (error as any).message
@@ -229,7 +234,8 @@ function Settings() {
}
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -238,10 +244,14 @@ function Settings() {
async function onChangeMailingAddressSubmit(data: ChangeMailingAddressFormInputs) {
try {
await changeMailingAddressMutation.mutateAsync(data)
toast({ title: 'Your mailing address has successfully been changed!' })
toast({
title: 'Success',
description: 'Your mailing address has successfully been changed!',
})
} catch (error) {
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}

View File

@@ -109,10 +109,7 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
window.location.assign(result.url)
} catch (e) {
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
}
@@ -137,10 +134,7 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
window.location.assign(result.url)
} catch (e) {
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
}
@@ -156,7 +150,7 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
<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="max-w-[540px] mx-auto p-6 space-y-6 rounded-lg 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
@@ -165,7 +159,7 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
width={200}
height={96}
objectFit="cover"
className="w-36 rounded-xl"
className="w-36 rounded-lg"
/>
<div className="flex flex-col justify-center">
<h2 className="text-center sm:text-left font-semibold">Donate to {project.title}</h2>
@@ -256,7 +250,9 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
name="taxDeductible"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Do you want this donation to potentially qualify for a tax deduction? (US only)</FormLabel>
<FormLabel>
Do you want this donation to potentially qualify for a tax deduction? (US only)
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
@@ -413,7 +409,7 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
<div className="flex flex-col items-center">
<p className="text-sm">Want to support more projects and receive optional perks?</p>
<Link href={`/${encodeURIComponent(fundSlug)}/?registerEmail=1`}>
<Link href={`/${encodeURIComponent(fundSlug)}/register`}>
<Button type="button" size="lg" variant="link">
Create an account
</Button>

View File

@@ -2,16 +2,25 @@ import { useRef } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'
import { useForm } from 'react-hook-form'
import { GetServerSidePropsContext } from 'next'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Input } from './ui/input'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import { Button } from './ui/button'
import { useToast } from './ui/use-toast'
import { trpc } from '../utils/trpc'
import Spinner from './Spinner'
import { env } from '../env.mjs'
import { Input } from '../../components/ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../../components/ui/form'
import { Button } from '../../components/ui/button'
import { useToast } from '../../components/ui/use-toast'
import { trpc } from '../../utils/trpc'
import Spinner from '../../components/Spinner'
import { env } from '../../env.mjs'
import { authOptions } from '../api/auth/[...nextauth]'
const schema = z.object({
turnstileToken: z.string().min(1),
@@ -20,9 +29,7 @@ const schema = z.object({
type PasswordResetFormInputs = z.infer<typeof schema>
type Props = { close: () => void }
function PasswordResetFormModal({ close }: Props) {
function ForgotPassword() {
const { toast } = useToast()
const turnstileRef = useRef<TurnstileInstance | null>()
@@ -34,22 +41,18 @@ function PasswordResetFormModal({ close }: Props) {
try {
await requestPasswordResetMutation.mutateAsync(data)
toast({ title: 'A password reset link has been sent to your email.' })
close()
toast({ title: 'Success', description: 'A password reset link has been sent to your email.' })
form.reset({ email: '' })
} catch (error) {
toast({ title: 'Sorry, something went wrong.', variant: 'destructive' })
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
turnstileRef.current?.reset()
}
return (
<>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
<DialogDescription>Recover your account.</DialogDescription>
</DialogHeader>
<div className="w-full max-w-xl m-auto p-6 flex flex-col space-y-4 bg-white rounded-lg">
<h1 className="font-bold">Request password reset</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col space-y-4">
@@ -80,8 +83,16 @@ function PasswordResetFormModal({ close }: Props) {
</Button>
</form>
</Form>
</>
</div>
)
}
export default PasswordResetFormModal
export default ForgotPassword
export async function getServerSideProps({ params, req, res }: GetServerSidePropsContext) {
const session = await getServerSession(req, res, authOptions)
if (session) {
return { redirect: { destination: `/${params?.fund!}` } }
}
}

View File

@@ -1,19 +1,28 @@
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/router'
import { zodResolver } from '@hookform/resolvers/zod'
import { signIn, useSession } from 'next-auth/react'
import { signIn } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'
import { getServerSession } from 'next-auth'
import { GetServerSidePropsContext } from 'next'
import { z } from 'zod'
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Input } from './ui/input'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import { Button } from './ui/button'
import { useToast } from './ui/use-toast'
import { useFundSlug } from '../utils/use-fund-slug'
import Spinner from './Spinner'
import { env } from '../env.mjs'
import { Input } from '../../components/ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../../components/ui/form'
import { Button } from '../../components/ui/button'
import { useToast } from '../../components/ui/use-toast'
import { useFundSlug } from '../../utils/use-fund-slug'
import Spinner from '../../components/Spinner'
import { env } from '../../env.mjs'
import { authOptions } from '../api/auth/[...nextauth]'
const schema = z.object({
email: z.string().email(),
@@ -23,13 +32,7 @@ const schema = z.object({
type LoginFormInputs = z.infer<typeof schema>
type Props = {
close: () => void
openPasswordResetModal: () => void
openRegisterModal: () => void
}
function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Props) {
function Login() {
const { toast } = useToast()
const router = useRouter()
const fundSlug = useFundSlug()
@@ -43,12 +46,11 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
useEffect(() => {
if (!fundSlug) return
if (router.query.loginEmail) {
form.setValue('email', router.query.loginEmail as string)
if (router.query.email) {
form.setValue('email', router.query.email as string)
setTimeout(() => form.setFocus('password'), 100)
router.replace(`/${fundSlug}`)
}
}, [router.query.loginEmail])
}, [router.query.email])
async function onSubmit(data: LoginFormInputs) {
const result = await signIn('credentials', {
@@ -69,25 +71,24 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
)
}
return toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
toast({
title: 'Successfully logged in!',
title: 'Success',
description: 'Successfully logged in!',
})
close()
if (router.query.nextAction === 'membership') {
router.push(`/${fundSlug}/membership`)
} else {
router.push(`/${fundSlug}`)
}
}
return (
<>
<DialogHeader>
<DialogTitle>Login</DialogTitle>
<DialogDescription>Log into your account.</DialogDescription>
</DialogHeader>
<div className="w-full max-w-xl m-auto p-6 flex flex-col space-y-4 bg-white rounded-lg">
<h1 className="font-bold">Login</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col space-y-4">
@@ -122,7 +123,7 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
<Button
type="button"
onClick={() => (openPasswordResetModal(), close())}
onClick={() => router.push(`/${fundSlug}/forgot-password`)}
variant="link"
className="self-end"
>
@@ -143,7 +144,7 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
className="grow basis-0"
variant="outline"
type="button"
onClick={() => (openRegisterModal(), close())}
onClick={() => router.push(`/${fundSlug}/register`)}
>
Register
</Button>
@@ -158,8 +159,16 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
</div>
</form>
</Form>
</>
</div>
)
}
export default LoginFormModal
export default Login
export async function getServerSideProps({ params, req, res }: GetServerSidePropsContext) {
const session = await getServerSession(req, res, authOptions)
if (session) {
return { redirect: { destination: `/${params?.fund!}` } }
}
}

View File

@@ -6,16 +6,17 @@ 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 { GetServerSidePropsContext, GetStaticPropsContext } from 'next'
import Image from 'next/image'
import Head from 'next/head'
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 { 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,
@@ -23,18 +24,21 @@ import {
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'
} from '../../components/ui/form'
import { Input } from '../../components/ui/input'
import { ProjectItem } from '../../utils/types'
import { funds, fundSlugs } from '../../utils/funds'
import { getServerSession } from 'next-auth'
import { authOptions } from '../api/auth/[...nextauth]'
import { useEffect } from 'react'
import { useRouter } from 'next/router'
type QueryParams = { fund: FundSlug; slug: string }
type Props = { project: ProjectItem } & QueryParams
function MembershipPage({ fund: fundSlug, project }: Props) {
const session = useSession()
const router = useRouter()
const isAuthed = session.status === 'authenticated'
const schema = z
@@ -95,10 +99,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
window.location.assign(result.url)
} catch (e) {
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
}
@@ -121,21 +122,24 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
window.location.assign(result.url)
} catch (e) {
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
}
if (!project) return <></>
useEffect(() => {
if (session.status === 'unauthenticated') {
router.push(`/${fundSlug}/login?nextAction=membership`)
}
}, [session])
if (!project || session.status === 'loading') 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="max-w-[540px] mx-auto p-6 space-y-6 rounded-lg 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
@@ -144,7 +148,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
width={200}
height={96}
objectFit="cover"
className="w-36 rounded-xl"
className="w-36 rounded-lg"
/>
<div className="flex flex-col justify-center">
<h2 className="text-center sm:text-left font-semibold">
@@ -273,18 +277,18 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
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>
<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 />
@@ -375,23 +379,12 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
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}`),
],
paths: fundSlugs.map((fund) => `/${fund}/membership`),
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 } }
return { props: { ...params, project: funds[params?.fund!] } }
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import Head from 'next/head'
import Image from 'next/image'
import { BoxIcon, Check, ChevronsUpDown, ShoppingBagIcon, TruckIcon } from 'lucide-react'
import { Check, ChevronsUpDown, ShoppingBagIcon, TruckIcon } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter } from 'next/router'
import { useForm } from 'react-hook-form'
@@ -67,7 +68,6 @@ 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 }
@@ -233,7 +233,8 @@ function Perk({ perk, balance }: Props) {
setCostEstimate(_costEstimate)
} catch {
toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -250,12 +251,12 @@ function Perk({ perk, balance }: Props) {
perkPrintfulSyncVariantId: Number(data.printfulSyncVariantId) || undefined,
...data,
})
toast({ title: 'Perk successfully purchased!' })
toast({ title: 'Success', description: 'Perk successfully purchased!' })
router.push(`/${fundSlug}/account/point-history`)
close()
} catch (error) {
toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -297,7 +298,7 @@ function Perk({ perk, balance }: Props) {
<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"
className="w-full md:max-w-md p-6 flex flex-col space-y-6 bg-white rounded-lg"
>
<div className="flex flex-col justify-start">
<div className="mx-auto p-10 md:hidden justify-center items-center">

View File

@@ -16,21 +16,13 @@ import Progress from '../../../components/Progress'
import { prisma } from '../../../server/services'
import { Button } from '../../../components/ui/button'
import { Dialog, DialogContent } from '../../../components/ui/dialog'
import LoginFormModal from '../../../components/LoginFormModal'
import RegisterFormModal from '../../../components/RegisterFormModal'
import PasswordResetFormModal from '../../../components/PasswordResetFormModal'
import CustomLink from '../../../components/CustomLink'
import { trpc } from '../../../utils/trpc'
import { getFundSlugFromUrlPath } from '../../../utils/funds'
import { useFundSlug } from '../../../utils/use-fund-slug'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../../components/ui/table'
import { Table, TableBody, TableCell, TableRow } from '../../../components/ui/table'
import { cn } from '../../../utils/cn'
type SingleProjectPageProps = {
@@ -104,7 +96,7 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
className="w-full max-w-[700px] mx-auto object-contain xl:hidden"
/>
<div className="w-full max-w-96 space-y-8 p-6 bg-white rounded-xl">
<div className="w-full max-w-96 space-y-8 p-6 bg-white rounded-lg">
{!project.isFunded && (
<div className="w-full">
<div className="flex flex-col space-y-2">
@@ -181,7 +173,7 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
</div>
</div>
<div className="w-full max-w-96 min-h-72 space-y-4 p-6 bg-white rounded-xl">
<div className="w-full max-w-96 min-h-72 space-y-4 p-6 bg-white rounded-lg">
<h1 className="font-bold">Leaderboard</h1>
{leaderboardQuery.data?.length ? (
@@ -216,40 +208,11 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
</div>
<article
className="prose max-w-none mt-4 p-6 xl:col-span-2 bg-white rounded-xl"
className="prose max-w-none mt-4 p-6 xl:col-span-2 bg-white rounded-lg"
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
/>
</PageHeading>
</div>
{session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogContent>
<RegisterFormModal
openLoginModal={() => setLoginIsOpen(true)}
close={() => setRegisterIsOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</>
)
}

View File

@@ -4,16 +4,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'
import { z } from 'zod'
import { GetServerSidePropsContext } from 'next'
import { getServerSession } from 'next-auth'
import { useRouter } from 'next/router'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Input } from './ui/input'
import { Input } from '../../components/ui/input'
import {
Form,
FormControl,
@@ -22,14 +17,14 @@ import {
FormItem,
FormLabel,
FormMessage,
} from './ui/form'
import { Button } from './ui/button'
import { useToast } from './ui/use-toast'
import { trpc } from '../utils/trpc'
import { useFundSlug } from '../utils/use-fund-slug'
import Spinner from './Spinner'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { Select, SelectTrigger, SelectValue } from './ui/select'
} from '../../components/ui/form'
import { Button } from '../../components/ui/button'
import { useToast } from '../../components/ui/use-toast'
import { trpc } from '../../utils/trpc'
import { useFundSlug } from '../../utils/use-fund-slug'
import Spinner from '../../components/Spinner'
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover'
import { Select, SelectTrigger, SelectValue } from '../../components/ui/select'
import {
Command,
CommandEmpty,
@@ -37,11 +32,11 @@ import {
CommandInput,
CommandItem,
CommandList,
} from './ui/command'
import { cn } from '../utils/cn'
import { Checkbox } from './ui/checkbox'
import { env } from '../env.mjs'
import { useRouter } from 'next/router'
} from '../../components/ui/command'
import { cn } from '../../utils/cn'
import { Checkbox } from '../../components/ui/checkbox'
import { env } from '../../env.mjs'
import { authOptions } from '../api/auth/[...nextauth]'
const schema = z
.object({
@@ -123,9 +118,7 @@ const schema = z
type RegisterFormInputs = z.infer<typeof schema>
type Props = { close: () => void; openLoginModal: () => void }
function RegisterFormModal({ close, openLoginModal }: Props) {
function RegisterFormModal() {
const router = useRouter()
const { toast } = useToast()
const fundSlug = useFundSlug()
@@ -179,17 +172,6 @@ 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])
@@ -215,13 +197,18 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
if (!fundSlug) return
try {
await registerMutation.mutateAsync({ ...data, fundSlug })
toast({
title: 'Please check your email to verify your account.',
await registerMutation.mutateAsync({
...data,
fundSlug,
nextAction: router.query.nextAction === 'membership' ? 'membership' : undefined,
})
close()
toast({
title: 'Success',
description: 'Please check your email to verify your account.',
})
router.push(`/${fundSlug}`)
} catch (error) {
const errorMessage = (error as any).message
@@ -229,21 +216,15 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
return form.setError('email', { message: 'Email is already taken.' }, { shouldFocus: true })
}
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
turnstileRef.current?.reset()
}
return (
<>
<DialogHeader>
<DialogTitle>Register</DialogTitle>
<DialogDescription>Start supporting projects today!</DialogDescription>
</DialogHeader>
<div className="w-full max-w-xl m-auto p-6 flex flex-col space-y-4 bg-white rounded-lg">
<h1 className="font-bold">Register</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col space-y-4">
@@ -557,7 +538,7 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
type="button"
variant="link"
className="grow basis-0"
onClick={() => (openLoginModal(), close())}
onClick={() => router.push(`/${fundSlug}/login`)}
>
I already have an account
</Button>
@@ -572,9 +553,16 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
</div>
</form>
</Form>
</>
</div>
)
}
1
export default RegisterFormModal
export async function getServerSideProps({ params, req, res }: GetServerSidePropsContext) {
const session = await getServerSession(req, res, authOptions)
if (session) {
return { redirect: { destination: `/${params?.fund!}` } }
}
}

View File

@@ -54,14 +54,22 @@ function ResetPassword() {
password: data.password,
})
toast({ title: 'Password successfully reset. You may now log in.' })
router.push(`/${fundSlug}/?loginEmail=${encodeURIComponent(data.email)}`)
toast({ title: 'Success', description: 'Password successfully reset. You may now log in.' })
if (router.query.nextAction === 'membership') {
router.push(
`/${fundSlug}/login?email=${encodeURIComponent(data.email)}&nextAction=membership`
)
} else {
router.push(`/${fundSlug}/login?email=${encodeURIComponent(data.email)}`)
}
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'INVALID_TOKEN') {
toast({
title: 'Invalid password reset link.',
title: 'Error',
description: 'Invalid password reset link.',
variant: 'destructive',
})
@@ -70,10 +78,7 @@ function ResetPassword() {
return
}
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
toast({ title: 'Error', description: 'Sorry, something went wrong.', variant: 'destructive' })
}
}

View File

@@ -20,11 +20,18 @@ function VerifyEmail() {
try {
const result = await verifyEmailMutation.mutateAsync({ token: token as string })
toast({ title: 'Email verified! You may now log in.' })
toast({ title: 'Success', description: 'Email verified! You may now log in.' })
await signOut({ redirect: false })
router.push(`/${fundSlug}/?loginEmail=${result.email}`)
if (router.query.nextAction === 'membership') {
router.push(
`/${fundSlug}/login?email=${encodeURIComponent(result.email)}&nextAction=membership`
)
} else {
router.push(`/${fundSlug}/login?email=${encodeURIComponent(result.email)}`)
}
} catch (error) {
toast({ title: 'Invalid verification link.', variant: 'destructive' })
toast({ title: 'Error', description: 'Invalid verification link.', variant: 'destructive' })
router.push(`/${fundSlug}`)
}
})()

View File

@@ -7,22 +7,13 @@ 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 ProjectList from '../../components/ProjectList'
import LoginFormModal from '../../components/LoginFormModal'
import RegisterFormModal from '../../components/RegisterFormModal'
import PasswordResetFormModal from '../../components/PasswordResetFormModal'
import { trpc } from '../../utils/trpc'
import { funds } from '../../utils/funds'
const fund = funds['firo']
const Home: NextPage<{ projects: any }> = ({ projects }) => {
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)
const session = useSession()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
@@ -63,12 +54,14 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
{!userHasMembershipQuery.data && (
<>
{session.status !== 'authenticated' ? (
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
Get Annual Membership
</Button>
{session.status === 'authenticated' ? (
<Link href={`/${fund.slug}/membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
</Link>
) : (
<Link href={`/${fund.slug}/membership/${fund.slug}`}>
<Link href={`/${fund.slug}/register?nextAction=membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
@@ -108,35 +101,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
</div>
</div>
</div>
{session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogContent>
<RegisterFormModal
openLoginModal={() => setLoginIsOpen(true)}
close={() => setRegisterIsOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</>
)
}

View File

@@ -7,22 +7,13 @@ 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 ProjectList from '../../components/ProjectList'
import LoginFormModal from '../../components/LoginFormModal'
import RegisterFormModal from '../../components/RegisterFormModal'
import PasswordResetFormModal from '../../components/PasswordResetFormModal'
import { trpc } from '../../utils/trpc'
import { funds } from '../../utils/funds'
const fund = funds['general']
const Home: NextPage<{ projects: any }> = ({ projects }) => {
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)
const session = useSession()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
@@ -64,12 +55,14 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
{!userHasMembershipQuery.data && (
<>
{session.status !== 'authenticated' ? (
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
Get Annual Membership
</Button>
{session.status === 'authenticated' ? (
<Link href={`/${fund.slug}/membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
</Link>
) : (
<Link href={`/${fund.slug}/membership/${fund.slug}`}>
<Link href={`/${fund.slug}/register?nextAction=membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
@@ -109,35 +102,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
</div>
</div>
</div>
{session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogContent>
<RegisterFormModal
openLoginModal={() => setLoginIsOpen(true)}
close={() => setRegisterIsOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</>
)
}

View File

@@ -7,22 +7,13 @@ 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 ProjectList from '../../components/ProjectList'
import LoginFormModal from '../../components/LoginFormModal'
import RegisterFormModal from '../../components/RegisterFormModal'
import PasswordResetFormModal from '../../components/PasswordResetFormModal'
import { trpc } from '../../utils/trpc'
import { funds } from '../../utils/funds'
const fund = funds['monero']
const Home: NextPage<{ projects: any }> = ({ projects }) => {
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)
const session = useSession()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
@@ -63,12 +54,14 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
{!userHasMembershipQuery.data && (
<>
{session.status !== 'authenticated' ? (
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
Get Annual Membership
</Button>
{session.status === 'authenticated' ? (
<Link href={`/${fund.slug}/membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
</Link>
) : (
<Link href={`/${fund.slug}/membership/${fund.slug}`}>
<Link href={`/${fund.slug}/register?nextAction=membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
@@ -118,35 +111,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
</div>
</div>
</div>
{session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogContent>
<RegisterFormModal
openLoginModal={() => setLoginIsOpen(true)}
close={() => setRegisterIsOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</>
)
}

View File

@@ -7,22 +7,13 @@ 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 ProjectList from '../../components/ProjectList'
import LoginFormModal from '../../components/LoginFormModal'
import RegisterFormModal from '../../components/RegisterFormModal'
import PasswordResetFormModal from '../../components/PasswordResetFormModal'
import { trpc } from '../../utils/trpc'
import { funds } from '../../utils/funds'
const fund = funds['privacyguides']
const Home: NextPage<{ projects: any }> = ({ projects }) => {
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)
const session = useSession()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
@@ -64,12 +55,14 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
{!userHasMembershipQuery.data && (
<>
{session.status !== 'authenticated' ? (
<Button onClick={() => setRegisterIsOpen(true)} variant="light" size="lg">
Get Annual Membership
</Button>
{session.status === 'authenticated' ? (
<Link href={`/${fund.slug}/membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
</Link>
) : (
<Link href={`/${fund.slug}/membership/${fund.slug}`}>
<Link href={`/${fund.slug}/register?nextAction=membership`}>
<Button variant="light" size="lg">
Get Annual Membership
</Button>
@@ -89,14 +82,15 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
<div className="flex flex-row flex-wrap">
<p className="text-md leading-7 text-gray-500">
Donate to
<CustomLink href={`https://www.privacyguides.org/en/`}>
{' '}
Privacy Guides
</CustomLink> and support our mission to defend digital rights and
spread the word about mass surveillance programs and other daily privacy invasions.
You can help Privacy Guides researchers, activists, and maintainers create informative content,
host private digital services, and protect privacy rights at a time when the world needs it most.
Donate to
<CustomLink href={`https://www.privacyguides.org/en/`}>
{' '}
Privacy Guides
</CustomLink>{' '}
and support our mission to defend digital rights and spread the word about mass
surveillance programs and other daily privacy invasions. You can help Privacy Guides
researchers, activists, and maintainers create informative content, host private
digital services, and protect privacy rights at a time when the world needs it most.
</p>
</div>
@@ -122,35 +116,6 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
</div>
</div>
</div>
{session.status !== 'authenticated' && (
<>
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openRegisterModal={() => setRegisterIsOpen(true)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogContent>
<RegisterFormModal
openLoginModal={() => setLoginIsOpen(true)}
close={() => setRegisterIsOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</>
)
}

View File

@@ -31,6 +31,7 @@ export const authRouter = router({
password: z.string().min(8),
confirmPassword: z.string().min(8),
fundSlug: z.enum(fundSlugs),
nextAction: z.enum(['membership']).optional(),
_addMailingAddress: z.boolean(),
address: z
.object({
@@ -143,7 +144,7 @@ export const authRouter = router({
from: env.SES_VERIFIED_SENDER,
to: input.email,
subject: 'Verify your email',
html: `<a href="${env.APP_URL}/${input.fundSlug}/verify-email/${emailVerifyToken}" target="_blank">Verify email</a>`,
html: `<a href="${env.APP_URL}/${input.fundSlug}/verify-email/${emailVerifyToken}${input.nextAction === 'membership' ? `?nextAction=membership` : ''}" target="_blank">Verify email</a>`,
})
}),