mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
Merge branch 'master' into pg-account-link
This commit is contained in:
@@ -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=""
|
||||
|
||||
74
.github/workflows/codeql.yml
vendored
74
.github/workflows/codeql.yml
vendored
@@ -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}}"
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
62
components/AttestationModalContent.tsx
Normal file
62
components/AttestationModalContent.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
components/ui/textarea.tsx
Normal file
20
components/ui/textarea.tsx
Normal 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 }
|
||||
4
env.mjs
4
env.mjs
@@ -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
|
||||
|
||||
@@ -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
96
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
@@ -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: {} }
|
||||
}
|
||||
@@ -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!] } }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
pages/[fund]/verify-attestation.tsx
Normal file
98
pages/[fund]/verify-attestation.tsx
Normal 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
|
||||
@@ -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}`)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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
13
pages/api/public-key.ts
Normal 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
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
124
pages/verify-attestation.tsx
Normal file
124
pages/verify-attestation.tsx
Normal 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
|
||||
@@ -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>`,
|
||||
})
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
94
server/utils/attestation.ts
Normal file
94
server/utils/attestation.ts
Normal 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
10
server/utils/clipboard.ts
Normal 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!',
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user