Files
campaign-site/pages/[fund]/projects/[slug].tsx
Artur 82955be4cb Campaign Site V2 (#81)
* 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>
2024-10-17 10:29:40 -05:00

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