Merge branch 'master' into pg-account-link

This commit is contained in:
Artur
2025-01-13 19:51:12 -03:00
46 changed files with 1109 additions and 1365 deletions

View File

@@ -57,4 +57,7 @@ PRIVACYGUIDES_DISCOURSE_URL=""
PRIVACYGUIDES_DISCOURSE_CONNECT_SECRET=""
PRIVACYGUIDES_DISCOURSE_API_KEY=""
PRIVACYGUIDES_DISCOURSE_API_USERNAME=""
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID=""
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID=""
ATTESTATION_PRIVATE_KEY=""
NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY=""

View File

@@ -1,74 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "master" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "master" ]
schedule:
- cron: '15 16 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@@ -56,5 +56,6 @@ jobs:
PRIVACYGUIDES_DISCOURSE_API_KEY=${{ secrets.PRIVACYGUIDES_DISCOURSE_API_KEY }} \
PRIVACYGUIDES_DISCOURSE_API_USERNAME=${{ secrets.PRIVACYGUIDES_DISCOURSE_API_USERNAME }} \
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID=${{ secrets.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID }} \
ATTESTATION_PRIVATE_KEY_HEX=${{ secrets.ATTESTATION_PRIVATE_KEY_HEX }} \
docker compose -f docker-compose.prod.yml up -d --build
EOF

View File

@@ -36,6 +36,7 @@ ENV NEXT_PUBLIC_MONERO_APPLICATION_RECIPIENT='monerofund@magicgrants.org'
ENV NEXT_PUBLIC_FIRO_APPLICATION_RECIPIENT='firofund@magicgrants.org'
ENV NEXT_PUBLIC_PRIVACY_GUIDES_APPLICATION_RECIPIENT='privacyguidesfund@magicgrants.org'
ENV NEXT_PUBLIC_GENERAL_APPLICATION_RECIPIENT='info@magicgrants.org'
ENV NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX='25aa6f5740f2a885b2605672586845af2a7afd01f4c0c69f59acd0c619c1a5c2'
RUN npx prisma generate
RUN \

View File

@@ -0,0 +1,62 @@
import { CopyIcon } from 'lucide-react'
import { Button } from './ui/button'
import { DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import { Label } from './ui/label'
import { Textarea } from './ui/textarea'
import { toast } from './ui/use-toast'
type AttestationModalContentProps = {
message?: string
signature?: string
closeModal: () => void
}
function AttestationModalContent({ message, signature, closeModal }: AttestationModalContentProps) {
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
toast({
title: 'Success',
description: 'Copied to clipboard!',
})
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Attestation</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<Label>Message</Label>
<Textarea className="h-56 font-mono" readOnly value={message} />
<Button
size="sm"
variant="light"
className="ml-auto"
onClick={() => copyToClipboard(message!)}
>
<CopyIcon size={20} /> Copy
</Button>
</div>
<div className="flex flex-col space-y-2">
<Label>Signature</Label>
<Textarea className="h-20 font-mono" readOnly value={signature} />
<Button
size="sm"
variant="light"
className="ml-auto"
onClick={() => copyToClipboard(signature!)}
>
<CopyIcon size={20} /> Copy
</Button>
</div>
</div>
</DialogContent>
)
}
export default AttestationModalContent

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

@@ -33,13 +33,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'max-h-[calc(100vh-20px)] fixed left-[50%] top-[50%] z-50 grid w-full overflow-auto sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'max-h-[calc(100vh-20px)] fixed left-[50%] top-[50%] z-50 grid w-full overflow-auto sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-background data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-white data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -0,0 +1,20 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -58,6 +58,7 @@ export const env = createEnv({
PRIVACYGUIDES_DISCOURSE_API_KEY: z.string(),
PRIVACYGUIDES_DISCOURSE_API_USERNAME: z.string(),
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID: z.string(),
ATTESTATION_PRIVATE_KEY_HEX: z.string().min(1),
},
/*
* Environment variables available on the client (and server).
@@ -72,6 +73,7 @@ export const env = createEnv({
NEXT_PUBLIC_PRIVACY_GUIDES_APPLICATION_RECIPIENT: z.string().email(),
NEXT_PUBLIC_GENERAL_APPLICATION_RECIPIENT: z.string().email(),
NEXT_PUBLIC_TURNSTILE_SITEKEY: z.string().min(1),
NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX: z.string().min(1),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -144,6 +146,8 @@ export const env = createEnv({
PRIVACYGUIDES_DISCOURSE_API_USERNAME: process.env.PRIVACYGUIDES_DISCOURSE_API_USERNAME,
PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID:
process.env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID,
ATTESTATION_PRIVATE_KEY_HEX: process.env.ATTESTATION_PRIVATE_KEY_HEX,
NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX: process.env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

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*'] }

96
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@hookform/resolvers": "^3.6.0",
"@keycloak/keycloak-admin-client": "^24.0.5",
"@marsidev/react-turnstile": "^1.1.0",
"@noble/ed25519": "^2.2.2",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",
@@ -2867,9 +2868,9 @@
]
},
"node_modules/@next/env": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.20.tgz",
"integrity": "sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.23.tgz",
"integrity": "sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2883,9 +2884,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.20.tgz",
"integrity": "sha512-WDfq7bmROa5cIlk6ZNonNdVhKmbCv38XteVFYsxea1vDJt3SnYGgxLGMTXQNfs5OkFvAhmfKKrwe7Y0Hs+rWOg==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz",
"integrity": "sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==",
"cpu": [
"arm64"
],
@@ -2899,9 +2900,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.20.tgz",
"integrity": "sha512-XIQlC+NAmJPfa2hruLvr1H1QJJeqOTDV+v7tl/jIdoFvqhoihvSNykLU/G6NMgoeo+e/H7p/VeWSOvMUHKtTIg==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz",
"integrity": "sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==",
"cpu": [
"x64"
],
@@ -2915,9 +2916,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.20.tgz",
"integrity": "sha512-pnzBrHTPXIMm5QX3QC8XeMkpVuoAYOmyfsO4VlPn+0NrHraNuWjdhe+3xLq01xR++iCvX+uoeZmJDKcOxI201Q==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz",
"integrity": "sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==",
"cpu": [
"arm64"
],
@@ -2931,9 +2932,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.20.tgz",
"integrity": "sha512-WhJJAFpi6yqmUx1momewSdcm/iRXFQS0HU2qlUGlGE/+98eu7JWLD5AAaP/tkK1mudS/rH2f9E3WCEF2iYDydQ==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz",
"integrity": "sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==",
"cpu": [
"arm64"
],
@@ -2947,9 +2948,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.20.tgz",
"integrity": "sha512-ao5HCbw9+iG1Kxm8XsGa3X174Ahn17mSYBQlY6VGsdsYDAbz/ZP13wSLfvlYoIDn1Ger6uYA+yt/3Y9KTIupRg==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz",
"integrity": "sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==",
"cpu": [
"x64"
],
@@ -2963,9 +2964,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.20.tgz",
"integrity": "sha512-CXm/kpnltKTT7945np6Td3w7shj/92TMRPyI/VvveFe8+YE+/YOJ5hyAWK5rpx711XO1jBCgXl211TWaxOtkaA==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz",
"integrity": "sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==",
"cpu": [
"x64"
],
@@ -2979,9 +2980,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.20.tgz",
"integrity": "sha512-upJn2HGQgKNDbXVfIgmqT2BN8f3z/mX8ddoyi1I565FHbfowVK5pnMEwauvLvaJf4iijvuKq3kw/b6E9oIVRWA==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz",
"integrity": "sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==",
"cpu": [
"arm64"
],
@@ -2995,9 +2996,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.20.tgz",
"integrity": "sha512-igQW/JWciTGJwj3G1ipalD2V20Xfx3ywQy17IV0ciOUBbFhNfyU1DILWsTi32c8KmqgIDviUEulW/yPb2FF90w==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz",
"integrity": "sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==",
"cpu": [
"ia32"
],
@@ -3011,9 +3012,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.20.tgz",
"integrity": "sha512-AFmqeLW6LtxeFTuoB+MXFeM5fm5052i3MU6xD0WzJDOwku6SkZaxb1bxjBaRC8uNqTRTSPl0yMFtjNowIVI67w==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz",
"integrity": "sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==",
"cpu": [
"x64"
],
@@ -3026,6 +3027,15 @@
"node": ">= 10"
}
},
"node_modules/@noble/ed25519": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.2.2.tgz",
"integrity": "sha512-8dbNnAGY8497RMEes5MWzjZkKRLWToKwenlrqzHTWVKyOaEzLqU2iPaVJGRvkyEcJL6CmuqSt0paPFB2aR6WwA==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -10673,12 +10683,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "14.2.20",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.20.tgz",
"integrity": "sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug==",
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.23.tgz",
"integrity": "sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.20",
"@next/env": "14.2.23",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -10693,15 +10703,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.20",
"@next/swc-darwin-x64": "14.2.20",
"@next/swc-linux-arm64-gnu": "14.2.20",
"@next/swc-linux-arm64-musl": "14.2.20",
"@next/swc-linux-x64-gnu": "14.2.20",
"@next/swc-linux-x64-musl": "14.2.20",
"@next/swc-win32-arm64-msvc": "14.2.20",
"@next/swc-win32-ia32-msvc": "14.2.20",
"@next/swc-win32-x64-msvc": "14.2.20"
"@next/swc-darwin-arm64": "14.2.23",
"@next/swc-darwin-x64": "14.2.23",
"@next/swc-linux-arm64-gnu": "14.2.23",
"@next/swc-linux-arm64-musl": "14.2.23",
"@next/swc-linux-x64-gnu": "14.2.23",
"@next/swc-linux-x64-musl": "14.2.23",
"@next/swc-win32-arm64-msvc": "14.2.23",
"@next/swc-win32-ia32-msvc": "14.2.23",
"@next/swc-win32-x64-msvc": "14.2.23"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",

View File

@@ -17,6 +17,7 @@
"@hookform/resolvers": "^3.6.0",
"@keycloak/keycloak-admin-client": "^24.0.5",
"@marsidev/react-turnstile": "^1.1.0",
"@noble/ed25519": "^2.2.2",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",

View File

@@ -1,7 +1,15 @@
import { useState } from 'react'
import dayjs from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import Head from 'next/head'
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
DialogHeader,
} from '../../../components/ui/dialog'
import {
Table,
TableBody,
@@ -13,16 +21,28 @@ import {
import { trpc } from '../../../utils/trpc'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { funds } from '../../../utils/funds'
import { Button } from '../../../components/ui/button'
import Spinner from '../../../components/Spinner'
import AttestationModalContent from '../../../components/AttestationModalContent'
dayjs.extend(localizedFormat)
function MyDonations() {
const fundSlug = useFundSlug()
const [attestationModalIsOpen, setAttestationModalIsOpen] = useState(false)
const [attestation, setAttestation] = useState<{ message: string; signature: string } | null>()
// Conditionally render hooks should be ok in this case
if (!fundSlug) return <></>
const donationListQuery = trpc.donation.donationList.useQuery({ fundSlug })
const getDonationAttestationMutation = trpc.donation.getDonationAttestation.useMutation()
async function getAttestation(donationId: string) {
const _attestation = await getDonationAttestationMutation.mutateAsync({ donationId })
setAttestation(_attestation)
setAttestationModalIsOpen(true)
}
return (
<>
@@ -41,6 +61,7 @@ function MyDonations() {
<TableHead>Method</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Date</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -50,12 +71,30 @@ function MyDonations() {
<TableCell>{donation.btcPayInvoiceId ? 'Crypto' : 'Fiat'}</TableCell>
<TableCell>${donation.grossFiatAmount}</TableCell>
<TableCell>{dayjs(donation.createdAt).format('lll')}</TableCell>
<TableCell>
<Button
size="sm"
disabled={getDonationAttestationMutation.isPending}
onClick={() => getAttestation(donation.id)}
>
{getDonationAttestationMutation.isPending && <Spinner />}
Get Attestation
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<Dialog open={attestationModalIsOpen} onOpenChange={setAttestationModalIsOpen}>
<AttestationModalContent
message={attestation?.message}
signature={attestation?.signature}
closeModal={() => setAttestationModalIsOpen(false)}
/>
</Dialog>
</>
)
}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import dayjs from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import Head from 'next/head'
@@ -14,16 +15,32 @@ import { trpc } from '../../../utils/trpc'
import CustomLink from '../../../components/CustomLink'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { funds } from '../../../utils/funds'
import { Button } from '../../../components/ui/button'
import Spinner from '../../../components/Spinner'
import { Dialog } from '../../../components/ui/dialog'
import AttestationModalContent from '../../../components/AttestationModalContent'
dayjs.extend(localizedFormat)
function MyMemberships() {
const fundSlug = useFundSlug()
const [attestationModalIsOpen, setAttestationModalIsOpen] = useState(false)
const [attestation, setAttestation] = useState<{ message: string; signature: string } | null>()
// Conditionally render hooks should be ok in this case
if (!fundSlug) return <></>
const membershipListQuery = trpc.donation.membershipList.useQuery({ fundSlug })
const getMembershipAttestationMutation = trpc.donation.getMembershipAttestation.useMutation()
async function getAttestation(donationId?: string, subscriptionId?: string) {
const _attestation = await getMembershipAttestationMutation.mutateAsync({
donationId,
subscriptionId,
})
setAttestation(_attestation)
setAttestationModalIsOpen(true)
}
return (
<>
@@ -51,8 +68,9 @@ function MyMemberships() {
<TableHead>Project</TableHead>
<TableHead>Method</TableHead>
<TableHead>Recurring</TableHead>
<TableHead>Date</TableHead>
<TableHead>Period Start</TableHead>
<TableHead>Period End</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -63,12 +81,35 @@ function MyMemberships() {
<TableCell>{membership.stripeSubscriptionId ? 'Yes' : 'No'}</TableCell>
<TableCell>{dayjs(membership.createdAt).format('lll')}</TableCell>
<TableCell>{dayjs(membership.membershipExpiresAt).format('lll')}</TableCell>
<TableCell>
<Button
size="sm"
disabled={getMembershipAttestationMutation.isPending}
onClick={() =>
getAttestation(
membership.stripeSubscriptionId ? undefined : membership.id,
membership.stripeSubscriptionId || undefined
)
}
>
{getMembershipAttestationMutation.isPending && <Spinner />}
Get Attestation
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<Dialog open={attestationModalIsOpen} onOpenChange={setAttestationModalIsOpen}>
<AttestationModalContent
message={attestation?.message}
signature={attestation?.signature}
closeModal={() => setAttestationModalIsOpen(false)}
/>
</Dialog>
</>
)
}

View File

@@ -174,10 +174,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',
})
}
@@ -189,7 +190,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
@@ -202,7 +203,8 @@ function Settings() {
}
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -217,9 +219,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
@@ -232,7 +237,8 @@ function Settings() {
}
return toast({
title: 'Sorry, something went wrong.',
title: 'Error',
description: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
@@ -241,10 +247,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,18 @@ 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!}` } }
}
return { props: {} }
}

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,18 @@ 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!}` } }
}
return { props: {} }
}

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 schema = z.object({
taxDeductible: z.enum(['yes', 'no']),
@@ -67,8 +71,6 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
try {
const result = await payMembershipWithCryptoMutation.mutateAsync({
projectSlug: project.slug,
projectName: project.title,
fundSlug,
taxDeductible: data.taxDeductible === 'yes',
givePointsBack: data.givePointsBack === 'yes',
@@ -77,10 +79,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' })
}
}
@@ -90,8 +89,6 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
try {
const result = await payMembershipWithFiatMutation.mutateAsync({
projectSlug: project.slug,
projectName: project.title,
fundSlug,
recurring: data.recurring === 'yes',
taxDeductible: data.taxDeductible === 'yes',
@@ -103,21 +100,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
@@ -126,7 +126,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">
@@ -223,18 +223,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 />
@@ -325,23 +325,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

@@ -15,22 +15,11 @@ import PageHeading from '../../../components/PageHeading'
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 = {
@@ -47,11 +36,6 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
const session = useSession()
const fundSlug = useFundSlug()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
{ projectSlug: project.slug },
{ enabled: false }
)
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
function formatBtc(bitcoin: number) {
@@ -72,12 +56,6 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
}
}
useEffect(() => {
if (session.status === 'authenticated') {
userHasMembershipQuery.refetch()
}
}, [session.status])
if (!router.isFallback && !slug) {
return <ErrorPage statusCode={404} />
}
@@ -104,37 +82,12 @@ 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">
<Link href={`/${fundSlug}/donate/${project.slug}`}>
<Button className="w-full">Donate</Button>
</Link>
{!userHasMembershipQuery.data && (
<>
{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 && (
<Button variant="outline">
<CustomLink href={`${fundSlug}/account/my-memberships`}>
My Memberships
</CustomLink>
</Button>
)}
</div>
<Link href={`/${fundSlug}/donate/${project.slug}`}>
<Button className="w-full">Donate</Button>
</Link>
</div>
)}
@@ -181,7 +134,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 +169,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,18 @@ 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!}` } }
}
return { props: {} }
}

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

@@ -0,0 +1,98 @@
import { useEffect, useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { CheckIcon, XIcon } from 'lucide-react'
import { z } from 'zod'
import * as ed from '@noble/ed25519'
import { Form, FormControl, FormField, FormItem, FormLabel } from '../../components/ui/form'
import { Textarea } from '../../components/ui/textarea'
import { env } from '../../env.mjs'
const schema = z.object({ message: z.string(), signature: z.string() })
type AttestationInputs = z.infer<typeof schema>
function VerifyDonation() {
const [signatureIsValid, setSignatureIsValid] = useState(false)
const form = useForm<AttestationInputs>({
resolver: zodResolver(schema),
defaultValues: { message: '', signature: '' },
mode: 'all',
})
const message = form.watch('message')
const signature = form.watch('signature')
useEffect(() => {
;(async () => {
if (!(message && signature)) return setSignatureIsValid(false)
try {
const isValid = await ed.verifyAsync(
signature,
Buffer.from(message, 'utf-8').toString('hex'),
env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX.toLowerCase()
)
return setSignatureIsValid(isValid)
} catch (error) {
console.log(error)
setSignatureIsValid(false)
}
})()
}, [message, signature])
return (
<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">Verify Attestation</h1>
<Form {...form}>
<form className="flex flex-col space-y-4">
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea className="h-56 font-mono" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel>Signature</FormLabel>
<FormControl>
<Textarea className="h-20 font-mono" {...field} />
</FormControl>
</FormItem>
)}
/>
{!!(message && signature) ? (
signatureIsValid ? (
<span className="flex flex-row items-center text-sm self-end text-teal-500 font-semibold">
<CheckIcon className="mr-2" /> Valid signature
</span>
) : (
<span className="flex flex-row items-center text-sm self-end text-red-500 font-semibold">
<XIcon className="mr-2" /> Invalid signature
</span>
)
) : (
''
)}
</form>
</Form>
</div>
)
}
export default VerifyDonation

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

@@ -14,6 +14,8 @@ import { env } from '../../../env.mjs'
import { getUserPointBalance } from '../../../server/utils/perks'
import { sendDonationConfirmationEmail } from '../../../server/utils/mailing'
import { POINTS_PER_USD } from '../../../config'
import { getDonationAttestation, getMembershipAttestation } from '../../../server/utils/attestation'
import { funds } from '../../../utils/funds'
export const config = {
api: {
@@ -113,6 +115,9 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
`/invoices/${body.invoiceId}/payment-methods`
)
const membershipExpiresAt =
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null
// Create one donation and one point history for each invoice payment method
await Promise.all(
paymentMethods.map(async (paymentMethod) => {
@@ -143,8 +148,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
netCryptoAmount: Number(netCryptoAmount.toFixed(2)),
netFiatAmount: Number(netFiatAmount.toFixed(2)),
pointsAdded,
membershipExpiresAt:
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
membershipExpiresAt,
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
donorName: body.metadata.donorName,
},
@@ -174,6 +178,42 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
}
if (body.metadata.donorEmail && body.metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (body.metadata.isMembership === 'true') {
const attestation = await getMembershipAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
method: paymentMethod.cryptoCode as 'BTC' | 'XMR',
fundName: funds[body.metadata.fundSlug].title,
fundSlug: body.metadata.fundSlug,
periodStart: new Date(),
periodEnd: membershipExpiresAt!,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (body.metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
method: paymentMethod.cryptoCode as 'BTC' | 'XMR',
fundName: funds[body.metadata.fundSlug].title,
fundSlug: body.metadata.fundSlug,
projectName: body.metadata.projectName,
date: new Date(),
donationId: donation.id,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
sendDonationConfirmationEmail({
to: body.metadata.donorEmail,
donorName: body.metadata.donorName,
@@ -184,6 +224,8 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
pointsReceived: pointsAdded,
btcpayAsset: paymentMethod.cryptoCode as 'BTC' | 'XMR',
btcpayCryptoAmount: grossCryptoAmount,
attestationMessage,
attestationSignature,
})
}
})

13
pages/api/public-key.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { env } from '../../env.mjs'
async function handle(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET'])
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
return res.json({ type: 'ed25519', publicKey: env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX })
}
export default handle

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

@@ -5,8 +5,9 @@ import { Button } from '../../components/ui/button'
export default function ThankYou() {
const fundSlug = useFundSlug()
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<div className="m-auto flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2 className="font-bold">Thank you for your donation!</h2>
<p>
If you have any questions or need a donation receipt, please reach out to{' '}

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

@@ -6,7 +6,7 @@ import { Button } from '../../components/ui/button'
export default function ThankYou() {
const fundSlug = useFundSlug()
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<div className="m-auto flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2 className="font-bold">Thank you for your donation!</h2>
<p>
If you have any questions or need a donation receipt, please reach out to{' '}

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

@@ -6,7 +6,7 @@ import { Button } from '../../components/ui/button'
export default function ThankYou() {
const fundSlug = useFundSlug()
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<div className="m-auto flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2 className="font-bold">Thank you for your donation!</h2>
<p>
If you have any questions or need a donation receipt, please reach out to{' '}

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

@@ -6,7 +6,7 @@ import { Button } from '../../components/ui/button'
export default function ThankYou() {
const fundSlug = useFundSlug()
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<div className="m-auto flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2 className="font-bold">Thank you for your donation!</h2>
<p>
If you have any questions or need a donation receipt, please reach out to{' '}

View File

@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { CheckIcon, CopyIcon, XIcon } from 'lucide-react'
import { z } from 'zod'
import * as ed from '@noble/ed25519'
import { Form, FormControl, FormField, FormItem, FormLabel } from '../components/ui/form'
import { Textarea } from '../components/ui/textarea'
import { env } from '../env.mjs'
import { Label } from '../components/ui/label'
import { Input } from '../components/ui/input'
import { Button } from '../components/ui/button'
import { copyToClipboard } from '../server/utils/clipboard'
const schema = z.object({ message: z.string(), signature: z.string() })
type AttestationInputs = z.infer<typeof schema>
function VerifyDonation() {
const [signatureIsValid, setSignatureIsValid] = useState(false)
const form = useForm<AttestationInputs>({
resolver: zodResolver(schema),
defaultValues: { message: '', signature: '' },
mode: 'all',
})
const message = form.watch('message')
const signature = form.watch('signature')
useEffect(() => {
;(async () => {
if (!(message && signature)) return setSignatureIsValid(false)
try {
const isValid = await ed.verifyAsync(
signature,
Buffer.from(message, 'utf-8').toString('hex'),
env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX.toLowerCase()
)
return setSignatureIsValid(isValid)
} catch (error) {
console.log(error)
setSignatureIsValid(false)
}
})()
}, [message, signature])
return (
<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">Verify Attestation</h1>
<Form {...form}>
<form className="w-full flex flex-col space-y-4">
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea className="h-56 font-mono" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel>Signature</FormLabel>
<FormControl>
<Textarea className="h-20 font-mono" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormItem>
<FormLabel>Public key (ED25519)</FormLabel>
<FormControl>
<div className="space-x-2 flex flex-row items-center">
<div className="flex flex-col grow">
<Input
className="w-full font-mono"
readOnly
value={env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX}
/>
</div>
<Button
variant="light"
onClick={() => copyToClipboard(env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX)}
>
<CopyIcon size={20} /> Copy
</Button>
</div>
</FormControl>
</FormItem>
{!!(message && signature) ? (
signatureIsValid ? (
<span className="flex flex-row items-center text-sm self-end text-teal-500 font-semibold">
<CheckIcon className="mr-2" /> Valid signature
</span>
) : (
<span className="flex flex-row items-center text-sm self-end text-red-500 font-semibold">
<XIcon className="mr-2" /> Invalid signature
</span>
)
) : (
''
)}
</form>
</Form>
</div>
)
}
export default VerifyDonation

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>`,
})
}),

View File

@@ -2,6 +2,8 @@ import { Stripe } from 'stripe'
import { TRPCError } from '@trpc/server'
import { Donation } from '@prisma/client'
import { z } from 'zod'
import crypto from 'crypto'
import * as ed from '@noble/ed25519'
import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'
import { protectedProcedure, publicProcedure, router } from '../trpc'
@@ -12,6 +14,8 @@ import { authenticateKeycloakClient } from '../utils/keycloak'
import { BtcPayCreateInvoiceRes, DonationMetadata } from '../types'
import { funds, fundSlugs } from '../../utils/funds'
import { fundSlugToCustomerIdAttr } from '../utils/funds'
import dayjs from 'dayjs'
import { getDonationAttestation, getMembershipAttestation } from '../utils/attestation'
export const donationRouter = router({
donateWithFiat: publicProcedure
@@ -182,8 +186,6 @@ export const donationRouter = router({
payMembershipWithFiat: protectedProcedure
.input(
z.object({
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
recurring: z.boolean(),
taxDeductible: z.boolean(),
@@ -198,7 +200,7 @@ export const donationRouter = router({
const userHasMembership = await prisma.donation.findFirst({
where: {
userId,
projectSlug: input.projectSlug,
projectSlug: input.fundSlug,
membershipExpiresAt: { gt: new Date() },
},
})
@@ -239,8 +241,8 @@ export const donationRouter = router({
userId,
donorName: name,
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
projectSlug: input.fundSlug,
projectName: funds[input.fundSlug].title,
fundSlug: input.fundSlug,
isMembership: 'true',
isSubscription: input.recurring ? 'true' : 'false',
@@ -260,7 +262,7 @@ export const donationRouter = router({
price_data: {
currency: CURRENCY,
product_data: {
name: `MAGIC Grants Annual Membership: ${input.projectName}`,
name: `MAGIC Grants Annual Membership: ${funds[input.fundSlug].title}`,
},
unit_amount: MEMBERSHIP_PRICE * 100,
},
@@ -282,7 +284,7 @@ export const donationRouter = router({
price_data: {
currency: CURRENCY,
product_data: {
name: `MAGIC Grants Annual Membership: ${input.projectName}`,
name: `MAGIC Grants Annual Membership: ${funds[input.fundSlug].title}`,
},
recurring: { interval: 'year' },
unit_amount: MEMBERSHIP_PRICE * 100,
@@ -306,8 +308,6 @@ export const donationRouter = router({
payMembershipWithCrypto: protectedProcedure
.input(
z.object({
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
taxDeductible: z.boolean(),
givePointsBack: z.boolean(),
@@ -320,7 +320,7 @@ export const donationRouter = router({
const userHasMembership = await prisma.donation.findFirst({
where: {
userId,
projectSlug: input.projectSlug,
projectSlug: input.fundSlug,
membershipExpiresAt: { gt: new Date() },
},
})
@@ -341,8 +341,8 @@ export const donationRouter = router({
userId,
donorName: name,
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
projectSlug: input.fundSlug,
projectName: funds[input.fundSlug].title,
itemDesc: `MAGIC ${funds[input.fundSlug].title}`,
fundSlug: input.fundSlug,
isMembership: 'true',
@@ -434,9 +434,99 @@ export const donationRouter = router({
const userId = ctx.session.user.sub
const membership = await prisma.donation.findFirst({
where: { projectSlug: input.projectSlug, membershipExpiresAt: { gt: new Date() } },
where: { userId, projectSlug: input.projectSlug, membershipExpiresAt: { gt: new Date() } },
})
return !!membership
}),
getDonationAttestation: protectedProcedure
.input(z.object({ donationId: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const donation = await prisma.donation.findFirst({
where: { id: input.donationId, membershipExpiresAt: null, userId },
})
if (!donation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Donation not found.' })
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const { message, signature } = await getDonationAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
amount: donation.grossFiatAmount,
method: donation.cryptoCode ? donation.cryptoCode : 'Fiat',
fundSlug: donation.fundSlug,
fundName: funds[donation.fundSlug].title,
projectName: donation.projectName,
date: donation.createdAt,
donationId: donation.id,
})
return { message, signature }
}),
getMembershipAttestation: protectedProcedure
.input(z.object({ donationId: z.string().optional(), subscriptionId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const donations = await prisma.donation.findMany({
where: input.subscriptionId
? {
stripeSubscriptionId: input.subscriptionId,
membershipExpiresAt: { not: null },
userId,
}
: { id: input.donationId, membershipExpiresAt: { not: null }, userId },
orderBy: { membershipExpiresAt: 'desc' },
})
if (!donations.length) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Membership not found.' })
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const membershipStart = donations.slice(-1)[0].createdAt
const membershipEnd = donations[0].membershipExpiresAt!
const membershipValue = donations.reduce(
(total, donation) => total + donation.grossFiatAmount,
0
)
const { message, signature } = await getMembershipAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
amount: membershipValue,
method: donations[0].cryptoCode ? donations[0].cryptoCode : 'Fiat',
fundSlug: donations[0].fundSlug,
fundName: funds[donations[0].fundSlug].title,
periodStart: membershipStart,
periodEnd: membershipEnd,
})
return { message, signature }
}),
})

View File

@@ -0,0 +1,94 @@
import * as ed from '@noble/ed25519'
import { FundSlug } from '@prisma/client'
import dayjs from 'dayjs'
import { env } from '../../env.mjs'
type GetDonationAttestationParams = {
donorName: string
donorEmail: string
donationId: string
amount: number
method: string
fundSlug: FundSlug
fundName: string
projectName: string
date: Date
}
export async function getDonationAttestation({
donorName,
donorEmail,
donationId,
amount,
method,
fundSlug,
fundName,
projectName,
date,
}: GetDonationAttestationParams) {
const message = `MAGIC Grants Donation Attestation
Name: ${donorName}
Email: ${donorEmail}
Donation ID: ${donationId}
Amount: $${amount.toFixed(2)}
Method: ${method}
Fund: ${fundName}
Project: ${projectName}
Date: ${dayjs(date).format('YYYY-M-D')}
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
const signature = await ed.signAsync(
Buffer.from(message, 'utf-8').toString('hex'),
env.ATTESTATION_PRIVATE_KEY_HEX
)
const signatureHex = Buffer.from(signature).toString('hex')
return { message, signature: signatureHex }
}
type GetMembershipAttestation = {
donorName: string
donorEmail: string
amount: number
method: string
fundName: string
fundSlug: FundSlug
periodStart: Date
periodEnd: Date
}
export async function getMembershipAttestation({
donorName,
donorEmail,
amount,
method,
fundName,
fundSlug,
periodStart,
periodEnd,
}: GetMembershipAttestation) {
const message = `MAGIC Grants Membership Attestation
Name: ${donorName}
Email: ${donorEmail}
Total amount to date: $${amount.toFixed(2)}
Method: ${method}
Fund: ${fundName}
Period start: ${dayjs(periodStart).format('YYYY-M-D')}
Period end: ${dayjs(periodEnd).format('YYYY-M-D')}
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
const signature = await ed.signAsync(
Buffer.from(message, 'utf-8').toString('hex'),
env.ATTESTATION_PRIVATE_KEY_HEX
)
const signatureHex = Buffer.from(signature).toString('hex')
return { message, signature: signatureHex }
}

10
server/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,10 @@
import { toast } from '../../components/ui/use-toast'
export function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
toast({
title: 'Success',
description: 'Copied to clipboard!',
})
}

View File

@@ -23,6 +23,8 @@ type SendDonationConfirmationEmailParams = {
btcpayCryptoAmount?: number
btcpayAsset?: 'BTC' | 'XMR'
pointsReceived: number
attestationMessage: string
attestationSignature: string
}
export async function sendDonationConfirmationEmail({
@@ -36,11 +38,15 @@ export async function sendDonationConfirmationEmail({
btcpayCryptoAmount,
btcpayAsset,
pointsReceived,
attestationMessage,
attestationSignature,
}: SendDonationConfirmationEmailParams) {
const dateStr = dayjs().format('YYYY-M-D')
const fundName = funds[fundSlug].title
const markdown = `Thank you for your donation to MAGIC Grants! Your donation supports our charitable mission.
const markdown = `# Donation receipt
Thank you for your donation to MAGIC Grants! Your donation supports our charitable mission.
${!isMembership ? `You donated to: ${fundName}` : ''}
@@ -75,6 +81,25 @@ export async function sendDonationConfirmationEmail({
${btcpayCryptoAmount ? 'If you wish to receive a tax deduction for a cryptocurrency donation over $500, you MUST complete [Form 8283](https://www.irs.gov/pub/irs-pdf/f8283.pdf) and send the completed form to [info@magicgrants.org](mailto:info@magicgrants.org) to qualify for a deduction.' : ''}
### Signed attestation
Message
\`\`\`
${attestationMessage}
\`\`\`
Signature
\`\`\`
${attestationSignature}
\`\`\`
Public key (ED25519)
\`\`\`
${env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX}
\`\`\`
This attestation can be verified at [donate.magicgrants.org/${fundSlug}/verify-attestation](https://donate.magicgrants.org/${fundSlug}/verify-attestation).
MAGIC Grants
1942 Broadway St., STE 314C
Boulder, CO 80302
@@ -100,6 +125,11 @@ export async function sendDonationConfirmationEmail({
a {
color: #3a76f0;
}
pre {
word-break: break-all;
white-space: pre-wrap;
}
</style>
${htmlFromMarkdown}`

View File

@@ -9,15 +9,15 @@ import {
prisma,
stripe as _stripe,
strapiApi,
keycloak,
privacyGuidesDiscourseApi,
} from '../../server/services'
import { DonationMetadata, StrapiCreatePointBody } from '../../server/types'
import { sendDonationConfirmationEmail } from './mailing'
import { getUserPointBalance } from './perks'
import { POINTS_PER_USD } from '../../config'
import { authenticateKeycloakClient } from './keycloak'
import { env } from '../../env.mjs'
import { getDonationAttestation, getMembershipAttestation } from './attestation'
import { funds } from '../../utils/funds'
export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
return async (req: NextApiRequest, res: NextApiResponse) => {
@@ -55,6 +55,8 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
? Number((grossFiatAmount * 0.9).toFixed(2))
: grossFiatAmount
const pointsAdded = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
const membershipExpiresAt =
metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
@@ -83,8 +85,7 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
grossFiatAmount,
netFiatAmount,
pointsAdded,
membershipExpiresAt:
metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
membershipExpiresAt,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
@@ -109,6 +110,42 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
}
if (metadata.donorEmail && metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (metadata.isMembership === 'true') {
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
method: 'Fiat',
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
periodStart: new Date(),
periodEnd: membershipExpiresAt!,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: grossFiatAmount,
method: 'Fiat',
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
projectName: metadata.projectName,
date: new Date(),
donationId: donation.id,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
@@ -118,6 +155,8 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
isSubscription: false,
stripeUsdAmount: paymentIntent.amount_received / 100,
pointsReceived: pointsAdded,
attestationMessage,
attestationSignature,
})
}
}
@@ -144,6 +183,7 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
? Number((grossFiatAmount * 0.9).toFixed(2))
: grossFiatAmount
const pointsAdded = shouldGivePointsBack ? parseInt(String(grossFiatAmount * 100)) : 0
const membershipExpiresAt = new Date(invoiceLine.period.end * 1000)
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
@@ -173,7 +213,7 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
grossFiatAmount,
netFiatAmount,
pointsAdded,
membershipExpiresAt: new Date(invoiceLine.period.end * 1000),
membershipExpiresAt,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
@@ -198,6 +238,32 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
}
if (metadata.donorEmail && metadata.donorName) {
const donations = await prisma.donation.findMany({
where: {
stripeSubscriptionId: invoice.subscription.toString(),
membershipExpiresAt: { not: null },
},
orderBy: { membershipExpiresAt: 'desc' },
})
const membershipStart = donations.slice(-1)[0].createdAt
const membershipValue = donations.reduce(
(total, donation) => total + donation.grossFiatAmount,
0
)
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: membershipValue,
method: 'Fiat',
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
periodStart: membershipStart,
periodEnd: membershipExpiresAt,
})
sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
@@ -207,6 +273,8 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
isSubscription: metadata.isSubscription === 'true',
stripeUsdAmount: invoice.total / 100,
pointsReceived: pointsAdded,
attestationMessage: attestation.message,
attestationSignature: attestation.signature,
})
}
}