mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
* Apply OpenSats UI enhancements * Add register modal and some UI fixes * Add login modal * Add reset password button * Use @t3-oss/env-nextjs for env variables * Email verification without keycloak UI * Password reset without keycloak UI * Add "My donations" page with one-time stripe donations list * Display crypto donations in "My donations" page * Donation form fixes and improvements * Display recurring annual fiat donations * Include keycloak realm export file and remove hardcoded client values * fix: correctly handle btcpay webhooks and fix donationList query * feat: add membership modal and implement membership payment using btcpay * feat: add procedure for membership purchases with stripe and use db as single source of truth for donations * feat: use webhooks to update stripe donation/membership status * feat: memberships list page and fixes * feat: db schema changes and webhook fixes * feat: re-add "donate" and add "get annual membership" buttons to project page * feat: open register modal when clicking membership button while logged out * feat: replace "Get Membership" button with "My Memberships" button when user already has a membership for that project * feat: multiple funds support * deps: bump axios * feat: add different color schemes for each fund and some fixes * feat: add home page * feat: add missing titles and responsiveness improvements * chore: add prod workflow file and compose file * chore(deploy workflow): set environment name * chore(Dockerfile): add necessary lines for prisma * chore: make it skip env validation on build * fix: prevent donation amounts from being fetched from db during build * chore(nginx.conf): remove copy-paste junk * chore(docker compose): correctly set APP_URL env * feat: replace Sendgrid with SES * deps: audit fix * chore(deploy.yml): remove unecessary env * fix: correctly manage client and server env * fix(trpc.submitApplication): get recipient emails from server side env * fix(Dockerfile): define NEXT_PUBLIC_ env on build * chore(trpc): make it log any errors * chore(trpc): improve displaying of errors * chore(trpc): improve displaying of errors * fix: buggy link buttons * feat: use single btcpay store * feat: show form 8283 info in donation form and handle tax deductible donations * feat: have only one privacy and terms page for the entire site * feat: support many social links * feat: add funding required endpoint (wip) * feat: get rates using btcpay api and small refactor * deps: audit fix * fix: correctly handle payment methods on InvoiceSettled event * fix: make index on Donation.btcPayInvoiceId * feat(funding-required): improve asset parameter response * feat(funding-required): add project_status param * feat(funding-required): add fund param * feat(funding-required): implement caching * fix(funding-required): minor fixes * feat(funding-required): add remaining_amount_<currency> fields and fixes * chore: include all services in docker-compose.dev.yml and update .env.example * feat: move terms and privacy links to footer * fix: address font not always loading bug * feat: use fund logos as header image * feat: donation confirmation email * fix: use correct stripe client for each fund on webhooks * feat: add account settings page with change password form * feat: add email change form to settings page * fix: address wrong btcpay invoice url redirect * chore: email change request debug * fix(api): better handle user attributes * feat: ui improvements * feat: add btcpay invoice item description * chore(nginx): api rate limit * feat: remove typing component from fund landing pages * feat: implement refresh token rotation using keycloak * refactor: have gross and net amounts for donations * feat: invalidate user sessions on password/email change * fix: make "Create an account" button work on donate/membership modals * refactor: project props * fix(utils.md): correctly load md project attributes * chore(prisma): make composite unique constraint for fundSlug and projectSlug on ProjectAddresses * chore: mark example project as not funded * fix(utils.md): serialization error * chore(funding-required): btcpay invoice payment methods debug * fix(funding-required): get bitcoin address from correct payment method * fix(funding-required): correctly handle project_status ANY filter * fix(btcpay webhook handler): correctly handle payment methods on InvoicePaymentSettled * chore(docker-compose.yml): expose nginx port 80 * fix(funding-required): correctly concat project url * feat: ui improvements for smaller screens * fix(btcpay webhook handler): correctly get payment method amount on InvoiceSettled * fix(btcpay webhook handler): respond with 200 immediately if there is no metadata * chore(funding-required): debugging * chore(funding-required): debugging * chore(funding-required): debugging * fix(Dockerfile): define BUILD_MODE as arg instead of env to make it blank at runtime * fix: correctly pass current and goal values to project card progress * fix(funding-required): set high monitoring time for static address invoice * fix: correctly handle refresh token expiration on the ui * feat: ui improvements * chore: update README * chore: update README * Initial site text * fix: colors * chore: mention funds accordingly in texts * chore: update realm-export.json * chore: rename docker compose files * Update emails * Form updates * Remove unused pages and page improvements * feat: allow editing navbar links for each fund * Cleanup and Firo projects * chore(deploy.yml): change deploy branch to master * fix(auth): use fetch instead of axios when fetching refresh token due to edge runtime compatibility * fix: keep empty project folders * Fix code scanning alert no. 20: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore: sanitize md file paths * Text and link updates --------- Co-authored-by: Artur N <arturnunespe@gmail.com> Co-authored-by: Justin Ehrenhofer <12520755+SamsungGalaxyPlayer@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useRouter } from 'next/router'
|
|
import { GetServerSidePropsContext, NextPage } from 'next/types'
|
|
import Head from 'next/head'
|
|
import ErrorPage from 'next/error'
|
|
import Image from 'next/image'
|
|
import xss from 'xss'
|
|
|
|
import { ProjectDonationStats, ProjectItem } from '../../../utils/types'
|
|
import { getProjectBySlug } from '../../../utils/md'
|
|
import markdownToHtml from '../../../utils/markdownToHtml'
|
|
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 DonationFormModal from '../../../components/DonationFormModal'
|
|
import MembershipFormModal from '../../../components/MembershipFormModal'
|
|
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'
|
|
|
|
type SingleProjectPageProps = {
|
|
project: ProjectItem
|
|
projects: ProjectItem[]
|
|
donationStats: ProjectDonationStats
|
|
}
|
|
|
|
const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) => {
|
|
const router = useRouter()
|
|
const [donateModalOpen, setDonateModalOpen] = useState(false)
|
|
const [memberModalOpen, setMemberModalOpen] = useState(false)
|
|
const [registerIsOpen, setRegisterIsOpen] = useState(false)
|
|
const [loginIsOpen, setLoginIsOpen] = useState(false)
|
|
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
|
|
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) {
|
|
if (bitcoin > 0.1) {
|
|
return `${bitcoin.toFixed(3) || 0.0} BTC`
|
|
} else {
|
|
return `${Math.floor(bitcoin * 100000000).toLocaleString()} sats`
|
|
}
|
|
}
|
|
|
|
function formatUsd(dollars: number): string {
|
|
if (dollars == 0) {
|
|
return '$0'
|
|
} else if (dollars / 1000 > 1) {
|
|
return `$${Math.round(dollars / 1000)}k+`
|
|
} else {
|
|
return `$${dollars.toFixed(0)}`
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (session.status === 'authenticated') {
|
|
userHasMembershipQuery.refetch()
|
|
}
|
|
}, [session.status])
|
|
|
|
if (!router.isFallback && !slug) {
|
|
return <ErrorPage statusCode={404} />
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>Monero Fund | {project.title}</title>
|
|
</Head>
|
|
|
|
<div className="divide-y divide-gray-200">
|
|
<PageHeading project={project}>
|
|
<div className="w-full mt-8 flex flex-col md:flex-row items-center md:space-x-8 xl:space-x-0 space-y-10 md:space-y-0 xl:block">
|
|
<Image
|
|
src={coverImage}
|
|
alt="avatar"
|
|
width={700}
|
|
height={700}
|
|
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">
|
|
{!project.isFunded && (
|
|
<div className="flex flex-col space-y-2">
|
|
<Button onClick={() => setDonateModalOpen(true)}>Donate</Button>
|
|
|
|
{!userHasMembershipQuery.data && (
|
|
<Button
|
|
onClick={() =>
|
|
session.status === 'authenticated'
|
|
? setMemberModalOpen(true)
|
|
: setRegisterIsOpen(true)
|
|
}
|
|
variant="outline"
|
|
>
|
|
Get Annual Membership
|
|
</Button>
|
|
)}
|
|
|
|
{!!userHasMembershipQuery.data && (
|
|
<Button variant="outline">
|
|
<CustomLink href={`${fundSlug}/account/my-memberships`}>
|
|
My Memberships
|
|
</CustomLink>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<h1 className="mb-4 font-bold">Raised</h1>
|
|
|
|
<Progress
|
|
current={
|
|
donationStats.xmr.fiatAmount +
|
|
donationStats.btc.fiatAmount +
|
|
donationStats.usd.fiatAmount
|
|
}
|
|
goal={goal}
|
|
/>
|
|
|
|
<ul className="font-semibold space-y-1">
|
|
<li className="flex items-center space-x-1">
|
|
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
|
|
<span className="font-normal text-sm text-gray">
|
|
in{' '}
|
|
{donationStats.xmr.count + donationStats.btc.count + donationStats.usd.count}{' '}
|
|
donations total
|
|
</span>
|
|
</li>
|
|
<li>
|
|
{donationStats.xmr.amount} XMR{' '}
|
|
<span className="font-normal text-sm text-gray">
|
|
in {donationStats.xmr.count} donations
|
|
</span>
|
|
</li>
|
|
<li>
|
|
{formatBtc(donationStats.btc.amount)}{' '}
|
|
<span className="font-normal text-sm text-gray">
|
|
in {donationStats.btc.count} donations
|
|
</span>
|
|
</li>
|
|
<li>
|
|
{`${formatUsd(donationStats.usd.amount)}`} Fiat{' '}
|
|
<span className="font-normal text-sm text-gray">
|
|
in {donationStats.usd.count} donations
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<article
|
|
className="prose max-w-none pb-8 pt-8 xl:col-span-2"
|
|
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
|
|
/>
|
|
</PageHeading>
|
|
</div>
|
|
|
|
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
|
|
<DialogContent>
|
|
<DonationFormModal
|
|
project={project}
|
|
close={() => setDonateModalOpen(false)}
|
|
openRegisterModal={() => setRegisterIsOpen(true)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
|
|
<DialogContent>
|
|
<MembershipFormModal
|
|
project={project}
|
|
close={() => setMemberModalOpen(false)}
|
|
openRegisterModal={() => setRegisterIsOpen(true)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{session.status !== 'authenticated' && (
|
|
<>
|
|
<Dialog open={loginIsOpen} onOpenChange={setLoginIsOpen}>
|
|
<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>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default Project
|
|
|
|
export async function getServerSideProps({ params, resolvedUrl }: GetServerSidePropsContext) {
|
|
const fundSlug = getFundSlugFromUrlPath(resolvedUrl)
|
|
|
|
if (!params?.slug) return {}
|
|
if (!fundSlug) return {}
|
|
|
|
const project = getProjectBySlug(params.slug as string, fundSlug)
|
|
const content = await markdownToHtml(project.content || '')
|
|
|
|
const donationStats = {
|
|
xmr: {
|
|
count: project.isFunded ? project.numDonationsXMR : 0,
|
|
amount: project.isFunded ? project.totalDonationsXMR : 0,
|
|
fiatAmount: project.isFunded ? project.totalDonationsXMRInFiat : 0,
|
|
},
|
|
btc: {
|
|
count: project.isFunded ? project.numDonationsBTC : 0,
|
|
amount: project.isFunded ? project.totalDonationsBTC : 0,
|
|
fiatAmount: project.isFunded ? project.totalDonationsBTCInFiat : 0,
|
|
},
|
|
usd: {
|
|
count: project.isFunded ? project.numDonationsFiat : 0,
|
|
amount: project.isFunded ? project.totalDonationsFiat : 0,
|
|
fiatAmount: project.isFunded ? project.totalDonationsFiat : 0,
|
|
},
|
|
}
|
|
|
|
if (!project.isFunded) {
|
|
const donations = await prisma.donation.findMany({
|
|
where: { projectSlug: params.slug as string, fundSlug },
|
|
})
|
|
|
|
donations.forEach((donation) => {
|
|
if (donation.cryptoCode === 'XMR') {
|
|
donationStats.xmr.count += 1
|
|
donationStats.xmr.amount += donation.netCryptoAmount || 0
|
|
donationStats.xmr.fiatAmount += donation.netFiatAmount
|
|
}
|
|
|
|
if (donation.cryptoCode === 'BTC') {
|
|
donationStats.btc.count += 1
|
|
donationStats.btc.amount += donation.netCryptoAmount || 0
|
|
donationStats.btc.fiatAmount += donation.netFiatAmount
|
|
}
|
|
|
|
if (donation.cryptoCode === null) {
|
|
donationStats.usd.count += 1
|
|
donationStats.usd.amount += donation.netFiatAmount
|
|
donationStats.usd.fiatAmount += donation.netFiatAmount
|
|
}
|
|
})
|
|
}
|
|
|
|
return {
|
|
props: {
|
|
project: {
|
|
...project,
|
|
content,
|
|
},
|
|
donationStats,
|
|
},
|
|
}
|
|
}
|