mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: memberships list page and fixes
This commit is contained in:
@@ -38,15 +38,9 @@ const Header = () => {
|
||||
return (
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Monero Fund"
|
||||
className="flex items-center mr-3 gap-4"
|
||||
>
|
||||
<Link href="/" aria-label="Monero Fund" className="flex items-center mr-3 gap-4">
|
||||
<Logo className="w-12 h-12" />
|
||||
<span className="text-foreground text-lg font-bold">
|
||||
MAGIC Monero Fund
|
||||
</span>
|
||||
<span className="text-foreground text-lg font-bold">MAGIC Monero Fund</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-base leading-5">
|
||||
@@ -113,6 +107,14 @@ const Header = () => {
|
||||
My Donations
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
href="/account/my-memberships"
|
||||
className="text-foreground hover:text-foreground"
|
||||
>
|
||||
My Memberships
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/' })}>
|
||||
Logout
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '../../components/ui/table'
|
||||
import { trpc } from '../../utils/trpc'
|
||||
import Head from 'next/head'
|
||||
import { Button } from '../../components/ui/button'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
|
||||
dayjs.extend(localizedFormat)
|
||||
@@ -31,53 +30,31 @@ function MyDonations() {
|
||||
</Head>
|
||||
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h1 className="text-3xl font-bold mb-4">My Donations</h1>
|
||||
{!!donationListQuery.data?.billingPortalUrl && (
|
||||
<CustomLink href={donationListQuery.data.billingPortalUrl}>
|
||||
Manage Subscriptions
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-4">My Donations</h1>
|
||||
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Fund</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Invoice ID</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Subscription End</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{donationListQuery.data?.donations.map((donation) => (
|
||||
{donationListQuery.data?.map((donation) => (
|
||||
<TableRow key={donation.createdAt.toISOString()}>
|
||||
<TableCell className="font-medium">
|
||||
{donation.projectName}
|
||||
</TableCell>
|
||||
<TableCell>{donation.fundName}</TableCell>
|
||||
<TableCell>{donationTypePretty[donation.type]}</TableCell>
|
||||
<TableCell>
|
||||
{donation.method.charAt(0).toUpperCase() +
|
||||
donation.method.slice(1)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{donation.stripePaymentStatus ||
|
||||
donation.stripeSubscriptionStatus ||
|
||||
donation.btcPayStatus}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{donation.projectName}</TableCell>
|
||||
<TableCell>{donation.fund}</TableCell>
|
||||
<TableCell>{donation.stripeInvoiceId ? 'Fiat' : 'Monero'}</TableCell>
|
||||
<TableCell>{donation.stripeInvoiceId}</TableCell>
|
||||
<TableCell>${donation.fiatAmount}</TableCell>
|
||||
<TableCell>{donation.status}</TableCell>
|
||||
<TableCell>{dayjs(donation.createdAt).format('lll')}</TableCell>
|
||||
<TableCell>
|
||||
{donation.subscriptionCancelAt
|
||||
? dayjs(donation.subscriptionCancelAt).format('lll')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">${donation.amount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
71
pages/account/my-memberships.tsx
Normal file
71
pages/account/my-memberships.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import dayjs from 'dayjs'
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../../components/ui/table'
|
||||
import { trpc } from '../../utils/trpc'
|
||||
import Head from 'next/head'
|
||||
import CustomLink from '../../components/CustomLink'
|
||||
|
||||
dayjs.extend(localizedFormat)
|
||||
|
||||
function MyMemberships() {
|
||||
const membershipListQuery = trpc.donation.membershipList.useQuery()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Monero Fund - My Memberships</title>
|
||||
</Head>
|
||||
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h1 className="text-3xl font-bold mb-4">My Memberships</h1>
|
||||
{membershipListQuery.data?.billingPortalUrl && (
|
||||
<CustomLink
|
||||
href={membershipListQuery.data?.billingPortalUrl}
|
||||
aria-label="Manage Fiat Subscriptions"
|
||||
>
|
||||
Manage Fiat Subscriptions
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Fund</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Invoice ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Period End</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{membershipListQuery.data?.memberships.map((membership) => (
|
||||
<TableRow key={membership.createdAt.toISOString()}>
|
||||
<TableCell className="font-medium">{membership.projectName}</TableCell>
|
||||
<TableCell>{membership.fund}</TableCell>
|
||||
<TableCell>{membership.stripeInvoiceId ? 'Fiat' : 'Monero'}</TableCell>
|
||||
<TableCell>{membership.stripeInvoiceId || membership.btcPayInvoiceId}</TableCell>
|
||||
<TableCell>{membership.status}</TableCell>
|
||||
<TableCell>{dayjs(membership.createdAt).format('lll')}</TableCell>
|
||||
<TableCell>{dayjs(membership.membershipExpiresAt).format('lll')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyMemberships
|
||||
@@ -3,6 +3,7 @@ import getRawBody from 'raw-body'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { env } from '../../../env.mjs'
|
||||
import { prisma, stripe } from '../../../server/services'
|
||||
import { DonationMetadata } from '../../../server/types'
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -60,6 +61,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
})
|
||||
}
|
||||
|
||||
// Create subscription when subscription is created
|
||||
if (event.type === 'customer.subscription.created') {
|
||||
const subscription = event.data.object
|
||||
const metadata = subscription.metadata as DonationMetadata
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
stripeInvoiceId: subscription.id,
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
currency: 'USD',
|
||||
fiatAmount: 100,
|
||||
status: 'Complete',
|
||||
membershipExpiresAt: new Date(subscription.current_period_end * 1000),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update subscription expiration date when subscription is updated
|
||||
if (event.type === 'customer.subscription.updated') {
|
||||
const subscription = event.data.object
|
||||
|
||||
await prisma.donation.updateMany({
|
||||
where: { stripeInvoiceId: subscription.id },
|
||||
data: { membershipExpiresAt: new Date(subscription.current_period_end * 1000) },
|
||||
})
|
||||
}
|
||||
|
||||
// Return a 200 response to acknowledge receipt of the event
|
||||
res.status(200).end()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ CREATE TABLE "Donation" (
|
||||
"projectSlug" TEXT NOT NULL,
|
||||
"projectName" TEXT NOT NULL,
|
||||
"fund" TEXT NOT NULL,
|
||||
"crypto" TEXT NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"fiatAmount" DOUBLE PRECISION NOT NULL,
|
||||
"membershipExpiresAt" TIMESTAMP(3),
|
||||
"status" "DonationStatus" NOT NULL,
|
||||
@@ -21,7 +21,10 @@ CREATE TABLE "Donation" (
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_btcPayInvoiceId_stripeInvoiceId_idx" ON "Donation"("btcPayInvoiceId", "stripeInvoiceId");
|
||||
CREATE INDEX "Donation_btcPayInvoiceId_idx" ON "Donation"("btcPayInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_stripeInvoiceId_idx" ON "Donation"("stripeInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
|
||||
@@ -24,7 +24,7 @@ model Donation {
|
||||
projectSlug String
|
||||
projectName String
|
||||
fund String
|
||||
crypto String
|
||||
currency String
|
||||
fiatAmount Float
|
||||
membershipExpiresAt DateTime?
|
||||
status DonationStatus
|
||||
|
||||
@@ -129,7 +129,7 @@ export const donationRouter = router({
|
||||
data: {
|
||||
userId: metadata.userId as string,
|
||||
btcPayInvoiceId: response.data.id,
|
||||
crypto: 'XMR',
|
||||
currency: 'USD',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
@@ -270,6 +270,8 @@ export const donationRouter = router({
|
||||
membershipExpiresAt: dayjs().add(1, 'year').toISOString(),
|
||||
}
|
||||
|
||||
console.log(1)
|
||||
|
||||
const response = await btcpayApi.post(`/stores/${env.BTCPAY_STORE_ID}/invoices`, {
|
||||
amount: MEMBERSHIP_PRICE,
|
||||
currency: CURRENCY,
|
||||
@@ -277,11 +279,13 @@ export const donationRouter = router({
|
||||
checkout: { redirectURL: `${env.APP_URL}/thankyou` },
|
||||
})
|
||||
|
||||
console.log(2)
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
userId,
|
||||
btcPayInvoiceId: response.data.id,
|
||||
crypto: 'XMR',
|
||||
currency: 'USD',
|
||||
projectName: metadata.projectName,
|
||||
projectSlug: metadata.projectSlug,
|
||||
fund: 'Monero Fund',
|
||||
@@ -295,9 +299,7 @@ export const donationRouter = router({
|
||||
}),
|
||||
|
||||
donationList: protectedProcedure.query(async ({ ctx }) => {
|
||||
await authenticateKeycloakClient()
|
||||
const userId = ctx.session.user.sub
|
||||
const user = await keycloak.users.findOne({ id: userId })
|
||||
|
||||
// Get all user's donations that are not expired OR are expired AND are less than 1 month old
|
||||
const donations = await prisma.donation.findMany({
|
||||
@@ -315,4 +317,30 @@ export const donationRouter = router({
|
||||
|
||||
return donations
|
||||
}),
|
||||
|
||||
membershipList: protectedProcedure.query(async ({ ctx }) => {
|
||||
await authenticateKeycloakClient()
|
||||
const userId = ctx.session.user.sub
|
||||
const user = await keycloak.users.findOne({ id: userId })
|
||||
const stripeCustomerId = user?.attributes?.stripeCustomerId?.[0]
|
||||
let billingPortalUrl: string | null = null
|
||||
|
||||
if (stripeCustomerId) {
|
||||
const billingPortalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${env.APP_URL}/account/my-memberships`,
|
||||
})
|
||||
|
||||
billingPortalUrl = billingPortalSession.url
|
||||
}
|
||||
|
||||
const memberships = await prisma.donation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
membershipExpiresAt: { not: null },
|
||||
},
|
||||
})
|
||||
|
||||
return { memberships, billingPortalUrl }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user