feat: multiple funds support

This commit is contained in:
Artur N
2024-08-15 19:09:24 -03:00
parent e7c3a9c606
commit 21e71f679c
60 changed files with 1040 additions and 985 deletions

View File

@@ -1,21 +1,40 @@
APP_URL="http://localhost:3000"
DATABASE_URL="postgresql://docker:docker@localhost:5432/monerofund?schema=public"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
NEXTAUTH_SECRET=""
SMTP_HOST=""
SMTP_HOST="sandbox.smtp.mailtrap.io"
SMTP_PORT="2525"
SMTP_USER=""
SMTP_PASS=""
STRIPE_SECRET_KEY=""
STRIPE_MONERO_SECRET_KEY=""
STRIPE_MONERO_WEBHOOK_SECRET=""
STRIPE_FIRO_SECRET_KEY=""
STRIPE_FIRO_WEBHOOK_SECRET=""
STRIPE_PRIVACY_GUIDES_SECRET_KEY=""
STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET=""
STRIPE_GENERAL_SECRET_KEY=""
STRIPE_GENERAL_WEBHOOK_SECRET=""
KEYCLOAK_CLIENT_ID="app"
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_REALM_NAME="monerofund"
BTCPAY_URL=""
BTCPAY_STORE_ID=""
KEYCLOAK_REALM_NAME="magic"
BTCPAY_URL="http://localhost"
BTCPAY_API_KEY=""
BTCPAY_WEBHOOK_SECRET=""
BTCPAY_MONERO_STORE_ID=""
BTCPAY_MONERO_WEBHOOK_SECRET=""
BTCPAY_FIRO_STORE_ID=""
BTCPAY_FIRO_WEBHOOK_SECRET=""
BTCPAY_PRIVACY_GUIDES_STORE_ID=""
BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET=""
BTCPAY_GENERAL_STORE_ID=""
BTCPAY_GENERAL_WEBHOOK_SECRET=""
SENDGRID_RECIPIENT=""
SENDGRID_VERIFIED_SENDER=""
SENDGRID_API_KEY=""
NEXT_PUBLIC_BTCPAY_API_KEY=""

View File

@@ -4,6 +4,10 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { faMonero } from '@fortawesome/free-brands-svg-icons'
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { DollarSign } from 'lucide-react'
import { z } from 'zod'
import Image from 'next/image'
import { MAX_AMOUNT } from '../config'
import Spinner from './Spinner'
import { trpc } from '../utils/trpc'
@@ -11,20 +15,17 @@ import { useToast } from './ui/use-toast'
import { useSession } from 'next-auth/react'
import { Button } from './ui/button'
import { RadioGroup, RadioGroupItem } from './ui/radio-group'
import { Label } from './ui/label'
import { z } from 'zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import { Input } from './ui/input'
import { DollarSign } from 'lucide-react'
import { ProjectItem } from '../utils/types'
import Image from 'next/image'
import CustomLink from './CustomLink'
import { useFundSlug } from '../utils/use-fund-slug'
type Props = {
project: ProjectItem | undefined
}
const DonationFormModal: React.FC<Props> = ({ project }) => {
const fundSlug = useFundSlug()
const session = useSession()
const isAuthed = session.status === 'authenticated'
@@ -67,6 +68,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
async function handleBtcPay(data: FormInputs) {
if (!project) return
if (!fundSlug) return
try {
const result = await donateWithCryptoMutation.mutateAsync({
@@ -75,6 +77,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
amount: data.amount,
projectSlug: project.slug,
projectName: project.title,
fundSlug,
})
window.location.assign(result.url)
@@ -88,6 +91,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
async function handleFiat(data: FormInputs) {
if (!project) return
if (!fundSlug) return
try {
const result = await donateWithFiatMutation.mutateAsync({
@@ -96,6 +100,7 @@ const DonationFormModal: React.FC<Props> = ({ project }) => {
amount: data.amount,
projectSlug: project.slug,
projectName: project.title,
fundSlug,
})
if (!result.url) throw Error()

View File

@@ -21,6 +21,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu'
import { useFundSlug } from '../utils/use-fund-slug'
const Header = () => {
const [registerIsOpen, setRegisterIsOpen] = useState(false)
@@ -28,6 +29,7 @@ const Header = () => {
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
const router = useRouter()
const session = useSession()
const fundSlug = useFundSlug()
useEffect(() => {
if (router.query.loginEmail) {
@@ -47,7 +49,7 @@ const Header = () => {
{headerNavLinks.map((link) => (
<Link
key={link.title}
href={link.href}
href={`/${fundSlug}/${link.href}`}
className={
link.isButton
? 'rounded border border-orange-500 bg-transparent px-4 py-2 font-semibold text-orange-500 hover:border-transparent hover:bg-orange-500 hover:text-white'
@@ -105,7 +107,7 @@ const Header = () => {
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link
href="/account/my-donations"
href={`/${fundSlug}/account/my-donations`}
className="text-foreground hover:text-foreground"
>
My Donations
@@ -113,7 +115,7 @@ const Header = () => {
</DropdownMenuItem>
<DropdownMenuItem>
<Link
href="/account/my-memberships"
href={`/${fundSlug}/account/my-memberships`}
className="text-foreground hover:text-foreground"
>
My Memberships

View File

@@ -1,29 +1,30 @@
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { faMonero } from '@fortawesome/free-brands-svg-icons'
import { faCreditCard } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { DollarSign } from 'lucide-react'
import { useSession } from 'next-auth/react'
import { z } from 'zod'
import Image from 'next/image'
import { MAX_AMOUNT } from '../config'
import Spinner from './Spinner'
import { trpc } from '../utils/trpc'
import { useToast } from './ui/use-toast'
import { useSession } from 'next-auth/react'
import { Button } from './ui/button'
import { RadioGroup, RadioGroupItem } from './ui/radio-group'
import { Label } from './ui/label'
import { z } from 'zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import { Input } from './ui/input'
import { DollarSign } from 'lucide-react'
import { ProjectItem } from '../utils/types'
import Image from 'next/image'
import { useFundSlug } from '../utils/use-fund-slug'
type Props = {
project: ProjectItem | undefined
}
const MembershipFormModal: React.FC<Props> = ({ project }) => {
const fundSlug = useFundSlug()
const session = useSession()
const isAuthed = session.status === 'authenticated'
@@ -67,11 +68,13 @@ const MembershipFormModal: React.FC<Props> = ({ project }) => {
async function handleBtcPay(data: FormInputs) {
if (!project) return
if (!fundSlug) return
try {
const result = await payMembershipWithCryptoMutation.mutateAsync({
projectSlug: project.slug,
projectName: project.title,
fundSlug,
})
window.location.assign(result.url)
@@ -85,11 +88,13 @@ const MembershipFormModal: React.FC<Props> = ({ project }) => {
async function handleFiat(data: FormInputs) {
if (!project) return
if (!fundSlug) return
try {
const result = await payMembershipWithFiatMutation.mutateAsync({
projectSlug: project.slug,
projectName: project.title,
fundSlug,
recurring: data.recurring === 'yes',
})

View File

@@ -3,17 +3,16 @@ import Link from 'next/link'
import { useState, useEffect } from 'react'
import { ProjectItem } from '../utils/types'
import { useFundSlug } from '../utils/use-fund-slug'
export type ProjectCardProps = {
project: ProjectItem
customImageStyles?: React.CSSProperties
}
const ProjectCard: React.FC<ProjectCardProps> = ({
project,
customImageStyles,
}) => {
const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles }) => {
const [isHorizontal, setIsHorizontal] = useState<boolean | null>(null)
const fundSlug = useFundSlug()
useEffect(() => {
const img = document.createElement('img')
@@ -45,7 +44,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({
return (
<figure className={cardStyle}>
<Link href={`/projects/${project.slug}`} passHref>
<Link href={`/${fundSlug}/projects/${project.slug}`} passHref>
<div className="flex h-36 w-full sm:h-52">
<Image
alt={project.title}

View File

@@ -4,6 +4,7 @@ import ProjectCard from './ProjectCard'
import Link from 'next/link'
import { ProjectItem } from '../utils/types'
import { useEffect, useState } from 'react'
import { FundSlug } from '../utils/funds'
type ProjectListProps = {
header?: string
@@ -19,9 +20,7 @@ const ProjectList: React.FC<ProjectListProps> = ({
const [sortedProjects, setSortedProjects] = useState<ProjectItem[]>()
useEffect(() => {
setSortedProjects(
projects.filter((p) => p.slug !== exclude).sort(() => 0.5 - Math.random())
)
setSortedProjects(projects.filter((p) => p.slug !== exclude).sort(() => 0.5 - Math.random()))
}, [projects])
return (

View File

@@ -1,6 +1,6 @@
---
title: 'vtnerd Monero and Monero-LWS dev work for Q1/Q2 2024'
summary: "This development work will improve security, performance, and usability with an end goal of helping to broaden the user base. "
summary: 'This development work will improve security, performance, and usability with an end goal of helping to broaden the user base. '
date: '2024-01-26'
nym: 'vtnerd (Lee Clagett)'
website: 'https://github.com/vtnerd'
@@ -23,29 +23,31 @@ fiatnumdonations: 0
fiattotaldonationsinfiat: 0
fiattotaldonations: 0
---
### Funded goal: 28,800 USD
### Start: February 2024
vtnerd (Lee Clagett) is the author of [Monero-LWS](https://github.com/vtnerd/monero-lws), and has been a [contributor to the Monero codebase since 2016](https://github.com/monero-project/monero/pulls?page=7&q=is%3Apr+author%3Avtnerd+created%3A%3E2016-10-01). He is a veteran of four CCS proposals; [[1]](https://ccs.getmonero.org/proposals/vtnerd-tor-tx-broadcasting.html), [[2]](https://ccs.getmonero.org/proposals/vtnerd-2020-q4.html), [[3]](https://ccs.getmonero.org/proposals/vtnerd-2021-q1.html), [[4]](https://ccs.getmonero.org/proposals/vtnerd-2023-q3.html)
This proposal funds 480 hours of work, ~3 months. The milestones will be hour based; 160 (1 month), 320 (2 months), 480 (3 months). At the completion of hours, he will provide the Monero Fund committee references to the work that was completed during that timeframe.
This proposal funds 480 hours of work, ~3 months. The milestones will be hour based; 160 (1 month), 320 (2 months), 480 (3 months). At the completion of hours, he will provide the Monero Fund committee references to the work that was completed during that timeframe.
Some features that are being targeted in [`monero-project/monero`](https://www.github.com/monero-project/monero) :
Some features that are being targeted in [`monero-project/monero`](https://www.github.com/monero-project/monero) :
* Get new serialization routine merged (work on piecemeal PRs for reviewers sake) (already in-progress)
* Complete work necessary to merge DANE/TLSA in wallet2/epee.
* Adding trust-on-first-use support to wallet2
- Get new serialization routine merged (work on piecemeal PRs for reviewers sake) (already in-progress)
- Complete work necessary to merge DANE/TLSA in wallet2/epee.
- Adding trust-on-first-use support to wallet2
Work targeted towards [`vtnerd/monero-lws`](https://github.com/vtnerd/monero-lws) :
Work targeted towards [`vtnerd/monero-lws`](https://github.com/vtnerd/monero-lws) :
* Optional full chain verification for malicious daemon attack (already-in progress)
* Webhooks/ZMQ-PUB support for tx sending (watch for unexpected sends)
* ZMQ-pub support for incoming transactions and blocks (notifies of any new transaction or block)
* Implement "horizontal" scaling of account scanning (transfer account info via zmq to another process for scanning)
* Make account creation more "enterprise grade" (currently scanning engine re-starts on every new account creation, and uses non-cacheable memory) * Unit tests for REST-API
* Create frontend LWS C/C++ library
* Provide official LWS docker-image
* Provide official snap/flatpak/appimge (tbd one or all of those)
* Provide pre-built binaries
* (Unlikely) - reproducible builds so community members can verify+sign the binary hashes
* It is unlikely that all features will be implemented, at which point the unfinished features will roll into the next quarter.
- Optional full chain verification for malicious daemon attack (already-in progress)
- Webhooks/ZMQ-PUB support for tx sending (watch for unexpected sends)
- ZMQ-pub support for incoming transactions and blocks (notifies of any new transaction or block)
- Implement "horizontal" scaling of account scanning (transfer account info via zmq to another process for scanning)
- Make account creation more "enterprise grade" (currently scanning engine re-starts on every new account creation, and uses non-cacheable memory) \* Unit tests for REST-API
- Create frontend LWS C/C++ library
- Provide official LWS docker-image
- Provide official snap/flatpak/appimge (tbd one or all of those)
- Provide pre-built binaries
- (Unlikely) - reproducible builds so community members can verify+sign the binary hashes
- It is unlikely that all features will be implemented, at which point the unfinished features will roll into the next quarter.

56
env.mjs
View File

@@ -10,19 +10,36 @@ export const env = createEnv({
server: {
APP_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.string().min(1),
SMTP_USER: z.string().min(1),
SMTP_PASS: z.string().min(1),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SIGNING_SECRET: z.string().min(1),
STRIPE_MONERO_SECRET_KEY: z.string().min(1),
STRIPE_MONERO_WEBHOOK_SECRET: z.string().min(1),
STRIPE_FIRO_SECRET_KEY: z.string().min(1),
STRIPE_FIRO_WEBHOOK_SECRET: z.string().min(1),
STRIPE_PRIVACY_GUIDES_SECRET_KEY: z.string().min(1),
STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET: z.string().min(1),
STRIPE_GENERAL_SECRET_KEY: z.string().min(1),
STRIPE_GENERAL_WEBHOOK_SECRET: z.string().min(1),
KEYCLOAK_CLIENT_ID: z.string().min(1),
KEYCLOAK_CLIENT_SECRET: z.string().min(1),
KEYCLOAK_REALM_NAME: z.string().min(1),
BTCPAY_URL: z.string().url(),
BTCPAY_STORE_ID: z.string().min(1),
BTCPAY_API_KEY: z.string().min(1),
BTCPAY_WEBHOOK_SECRET: z.string().min(1),
BTCPAY_MONERO_STORE_ID: z.string().min(1),
BTCPAY_MONERO_WEBHOOK_SECRET: z.string().min(1),
BTCPAY_FIRO_STORE_ID: z.string().min(1),
BTCPAY_FIRO_WEBHOOK_SECRET: z.string().min(1),
BTCPAY_PRIVACY_GUIDES_STORE_ID: z.string().min(1),
BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET: z.string().min(1),
BTCPAY_GENERAL_STORE_ID: z.string().min(1),
BTCPAY_GENERAL_WEBHOOK_SECRET: z.string().min(1),
SENDGRID_RECIPIENT: z.string().min(1),
SENDGRID_VERIFIED_SENDER: z.string().min(1),
SENDGRID_API_KEY: z.string().min(1),
@@ -32,9 +49,7 @@ export const env = createEnv({
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {
NEXT_PUBLIC_BTCPAY_API_KEY: z.string().min(1),
},
client: {},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
@@ -44,23 +59,38 @@ export const env = createEnv({
runtimeEnv: {
APP_URL: process.env.APP_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SIGNING_SECRET: process.env.STRIPE_WEBHOOK_SIGNING_SECRET,
STRIPE_MONERO_SECRET_KEY: process.env.STRIPE_MONERO_SECRET_KEY,
STRIPE_MONERO_WEBHOOK_SECRET: process.env.STRIPE_MONERO_WEBHOOK_SECRET,
STRIPE_FIRO_SECRET_KEY: process.env.STRIPE_FIRO_SECRET_KEY,
STRIPE_FIRO_WEBHOOK_SECRET: process.env.STRIPE_FIRO_WEBHOOK_SECRET,
STRIPE_PRIVACY_GUIDES_SECRET_KEY: process.env.STRIPE_PRIVACY_GUIDES_SECRET_KEY,
STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET: process.env.STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET,
STRIPE_GENERAL_SECRET_KEY: process.env.STRIPE_GENERAL_SECRET_KEY,
STRIPE_GENERAL_WEBHOOK_SECRET: process.env.STRIPE_GENERAL_WEBHOOK_SECRET,
KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_REALM_NAME: process.env.KEYCLOAK_REALM_NAME,
BTCPAY_URL: process.env.BTCPAY_URL,
BTCPAY_STORE_ID: process.env.BTCPAY_STORE_ID,
BTCPAY_API_KEY: process.env.BTCPAY_API_KEY,
BTCPAY_WEBHOOK_SECRET: process.env.BTCPAY_WEBHOOK_SECRET,
BTCPAY_MONERO_STORE_ID: process.env.BTCPAY_MONERO_STORE_ID,
BTCPAY_MONERO_WEBHOOK_SECRET: process.env.BTCPAY_MONERO_WEBHOOK_SECRET,
BTCPAY_FIRO_STORE_ID: process.env.BTCPAY_FIRO_STORE_ID,
BTCPAY_FIRO_WEBHOOK_SECRET: process.env.BTCPAY_FIRO_WEBHOOK_SECRET,
BTCPAY_PRIVACY_GUIDES_STORE_ID: process.env.BTCPAY_PRIVACY_GUIDES_STORE_ID,
BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET: process.env.BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET,
BTCPAY_GENERAL_STORE_ID: process.env.BTCPAY_GENERAL_STORE_ID,
BTCPAY_GENERAL_WEBHOOK_SECRET: process.env.BTCPAY_GENERAL_WEBHOOK_SECRET,
SENDGRID_RECIPIENT: process.env.SENDGRID_RECIPIENT,
SENDGRID_VERIFIED_SENDER: process.env.SENDGRID_VERIFIED_SENDER,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
NEXT_PUBLIC_BTCPAY_API_KEY: process.env.NEXT_PUBLIC_BTCPAY_API_KEY,
},
})

View File

@@ -1,11 +1,9 @@
import { withAuth } from 'next-auth/middleware'
// export { default } from 'next-auth/middleware'
export default withAuth({
pages: {
signIn: '/',
},
})
export const config = { matcher: ['/account/:path*'] }
export const config = { matcher: ['/:path*/account/:path*'] }

33
pages/[fund]/about.tsx Normal file
View File

@@ -0,0 +1,33 @@
import xss from 'xss'
import markdownToHtml from '../../utils/markdownToHtml'
import { getSingleFile } from '../../utils/md'
import { FundSlug, funds, fundSlugs } from '../../utils/funds'
export default function About({ content }: { content: string }) {
return (
<article
className="prose max-w-3xl mx-auto pb-8 pt-8 dark:prose-dark xl:col-span-2"
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
/>
)
}
export async function getStaticProps({ params }: { params: { fund: FundSlug } }) {
const md = getSingleFile(`docs/${params.fund}/about_us.md`)
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}
export function getStaticPaths() {
return {
paths: fundSlugs.map((fund) => `/${fund}/about`),
fallback: true,
}
}

View File

@@ -1,5 +1,6 @@
import dayjs from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import Head from 'next/head'
import {
Table,
@@ -8,10 +9,9 @@ import {
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table'
import { trpc } from '../../utils/trpc'
import Head from 'next/head'
import CustomLink from '../../components/CustomLink'
} from '../../../components/ui/table'
import { trpc } from '../../../utils/trpc'
import { useFundSlug } from '../../../utils/use-fund-slug'
dayjs.extend(localizedFormat)
@@ -21,7 +21,12 @@ const donationTypePretty = {
}
function MyDonations() {
const donationListQuery = trpc.donation.donationList.useQuery()
const fundSlug = useFundSlug()
// Conditionally render hooks should be ok in this case
if (!fundSlug) return <></>
const donationListQuery = trpc.donation.donationList.useQuery({ fundSlug })
return (
<>
@@ -46,7 +51,7 @@ function MyDonations() {
{donationListQuery.data?.map((donation) => (
<TableRow key={donation.createdAt.toISOString()}>
<TableCell>{donation.projectName}</TableCell>
<TableCell>{donation.fund}</TableCell>
<TableCell>{donation.fundSlug}</TableCell>
<TableCell>{donation.btcPayInvoiceId ? 'Crypto' : 'Fiat'}</TableCell>
<TableCell>${donation.fiatAmount}</TableCell>
<TableCell>{dayjs(donation.createdAt).format('lll')}</TableCell>

View File

@@ -1,5 +1,6 @@
import dayjs from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import Head from 'next/head'
import {
Table,
@@ -8,15 +9,20 @@ import {
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table'
import { trpc } from '../../utils/trpc'
import Head from 'next/head'
import CustomLink from '../../components/CustomLink'
} from '../../../components/ui/table'
import { trpc } from '../../../utils/trpc'
import CustomLink from '../../../components/CustomLink'
import { useFundSlug } from '../../../utils/use-fund-slug'
dayjs.extend(localizedFormat)
function MyMemberships() {
const membershipListQuery = trpc.donation.membershipList.useQuery()
const fundSlug = useFundSlug()
// Conditionally render hooks should be ok in this case
if (!fundSlug) return <></>
const membershipListQuery = trpc.donation.membershipList.useQuery({ fundSlug })
return (
<>
@@ -52,7 +58,7 @@ function MyMemberships() {
{membershipListQuery.data?.memberships.map((membership) => (
<TableRow key={membership.createdAt.toISOString()}>
<TableCell>{membership.projectName}</TableCell>
<TableCell>{membership.fund}</TableCell>
<TableCell>{membership.fundSlug}</TableCell>
<TableCell>{membership.btcPayInvoiceId ? 'Crypto' : 'Fiat'}</TableCell>
<TableCell>{membership.stripeSubscriptionId ? 'Yes' : 'No'}</TableCell>
<TableCell>{dayjs(membership.createdAt).format('lll')}</TableCell>

View File

@@ -0,0 +1,27 @@
import markdownToHtml from '../../utils/markdownToHtml'
import { getSingleFile } from '../../utils/md'
import BigDumbMarkdown from '../../components/BigDumbMarkdown'
import { FundSlug, fundSlugs } from '../../utils/funds'
export default function About({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps({ params }: { params: { fund: FundSlug } }) {
const md = getSingleFile(`docs/${params.fund}/apply_research.md`)
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}
export function getStaticPaths() {
return {
paths: fundSlugs.map((fund) => `/${fund}/apply_research`),
fallback: true,
}
}

34
pages/[fund]/faq.tsx Normal file
View File

@@ -0,0 +1,34 @@
import xss from 'xss'
import markdownToHtml from '../../utils/markdownToHtml'
import { getSingleFile } from '../../utils/md'
import BigDumbMarkdown from '../../components/BigDumbMarkdown'
import { FundSlug, fundSlugs } from '../../utils/funds'
export default function Faq({ content }: { content: string }) {
return (
<article
className="prose max-w-3xl mx-auto pb-8 pt-8 dark:prose-dark xl:col-span-2"
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
/>
)
}
export async function getStaticProps({ params }: { params: { fund: FundSlug } }) {
const md = getSingleFile(`docs/${params.fund}/faq.md`)
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}
export function getStaticPaths() {
return {
paths: fundSlugs.map((fund) => `/${fund}/faq`),
fallback: true,
}
}

27
pages/[fund]/privacy.tsx Normal file
View File

@@ -0,0 +1,27 @@
import markdownToHtml from '../../utils/markdownToHtml'
import { getSingleFile } from '../../utils/md'
import BigDumbMarkdown from '../../components/BigDumbMarkdown'
import { FundSlug, fundSlugs } from '../../utils/funds'
export default function Terms({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps({ params }: { params: { fund: FundSlug } }) {
const md = getSingleFile(`docs/${params.fund}/privacy.md`)
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}
export function getStaticPaths() {
return {
paths: fundSlugs.map((fund) => `/${fund}/privacy`),
fallback: true,
}
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { ReloadIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -13,13 +14,12 @@ import {
FormItem,
FormLabel,
FormMessage,
} from '../../components/ui/form'
import { Input } from '../../components/ui/input'
import { Button } from '../../components/ui/button'
import { toast } from '../../components/ui/use-toast'
import { useEffect } from 'react'
import { trpc } from '../../utils/trpc'
import { cn } from '../../utils/cn'
} from '../../../components/ui/form'
import { Input } from '../../../components/ui/input'
import { Button } from '../../../components/ui/button'
import { toast } from '../../../components/ui/use-toast'
import { trpc } from '../../../utils/trpc'
import { useFundSlug } from '../../../utils/use-fund-slug'
const schema = z
.object({
@@ -36,6 +36,7 @@ type ResetPasswordFormInputs = z.infer<typeof schema>
function ResetPassword() {
const router = useRouter()
const fundSlug = useFundSlug()
const form = useForm<ResetPasswordFormInputs>({
resolver: zodResolver(schema),
@@ -55,7 +56,7 @@ function ResetPassword() {
})
toast({ title: 'Password successfully reset. You may now log in.' })
router.push(`/?loginEmail=${data.email}`)
router.push(`/${fundSlug}/?loginEmail=${data.email}`)
} catch (error) {
const errorMessage = (error as any).message
@@ -92,18 +93,13 @@ function ResetPassword() {
return (
<div className="w-full max-w-md m-auto">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col space-y-4"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1.5 text-center sm:text-left">
<span className="text-lg font-semibold leading-none tracking-tight">
Password Reset
</span>
<span className="text-sm text-muted-foreground">
Reset your password
</span>
<span className="text-sm text-muted-foreground">Reset your password</span>
</div>
<FormField
@@ -149,9 +145,7 @@ function ResetPassword() {
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}{' '}
{form.formState.isSubmitting && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}{' '}
Reset Password
</Button>
</form>

27
pages/[fund]/terms.tsx Normal file
View File

@@ -0,0 +1,27 @@
import markdownToHtml from '../../utils/markdownToHtml'
import { getSingleFile } from '../../utils/md'
import BigDumbMarkdown from '../../components/BigDumbMarkdown'
import { FundSlug, fundSlugs } from '../../utils/funds'
export default function Terms({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps({ params }: { params: { fund: FundSlug } }) {
const md = getSingleFile(`docs/${params.fund}/terms.md`)
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}
export function getStaticPaths() {
return {
paths: fundSlugs.map((fund) => `/${fund}/terms`),
fallback: true,
}
}

View File

@@ -1,13 +1,15 @@
import { useEffect } from 'react'
import { useRouter } from 'next/router'
import { trpc } from '../../utils/trpc'
import { useToast } from '../../components/ui/use-toast'
import { trpc } from '../../../utils/trpc'
import { useToast } from '../../../components/ui/use-toast'
import { useFundSlug } from '../../../utils/use-fund-slug'
function VerifyEmail() {
const router = useRouter()
const { token } = router.query
const { toast } = useToast()
const fundSlug = useFundSlug()
const verifyEmailMutation = trpc.auth.verifyEmail.useMutation()
@@ -20,11 +22,11 @@ function VerifyEmail() {
token: token as string,
})
router.push(`/?loginEmail=${result.email}`)
router.push(`/${fundSlug}/?loginEmail=${result.email}`)
toast({ title: 'Email verified! You may now log in.' })
} catch (error) {
toast({ title: 'Invalid verification link.', variant: 'destructive' })
router.push('/')
router.push(`/${fundSlug}`)
}
})()
}, [token])

View File

@@ -1,25 +0,0 @@
import xss from 'xss'
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
export default function About({ content }: { content: string }) {
return (
<article
className="prose max-w-3xl mx-auto pb-8 pt-8 dark:prose-dark xl:col-span-2"
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
/>
)
}
export async function getStaticProps() {
const md = getSingleFile('docs/about_us.md')
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}

View File

@@ -0,0 +1,11 @@
import { env } from '../../../env.mjs'
import { btcpayApi as _btcpayApi } from '../../../server/services'
import { getBtcpayWebhookHandler } from '../../../server/utils/webhooks'
export const config = {
api: {
bodyParser: false,
},
}
export default getBtcpayWebhookHandler(env.BTCPAY_FIRO_WEBHOOK_SECRET)

View File

@@ -0,0 +1,11 @@
import { env } from '../../../env.mjs'
import { btcpayApi as _btcpayApi } from '../../../server/services'
import { getBtcpayWebhookHandler } from '../../../server/utils/webhooks'
export const config = {
api: {
bodyParser: false,
},
}
export default getBtcpayWebhookHandler(env.BTCPAY_GENERAL_WEBHOOK_SECRET)

View File

@@ -0,0 +1,11 @@
import { env } from '../../../env.mjs'
import { btcpayApi as _btcpayApi } from '../../../server/services'
import { getBtcpayWebhookHandler } from '../../../server/utils/webhooks'
export const config = {
api: {
bodyParser: false,
},
}
export default getBtcpayWebhookHandler(env.BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET)

View File

@@ -0,0 +1,11 @@
import { env } from '../../../env.mjs'
import { btcpayApi as _btcpayApi } from '../../../server/services'
import { getBtcpayWebhookHandler } from '../../../server/utils/webhooks'
export const config = {
api: {
bodyParser: false,
},
}
export default getBtcpayWebhookHandler(env.BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET)

View File

@@ -1,87 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import crypto from 'crypto'
import getRawBody from 'raw-body'
import { env } from '../../../env.mjs'
import { btcpayApi, prisma } from '../../../server/services'
import { DonationMetadata } from '../../../server/types'
import dayjs from 'dayjs'
type Body = {
deliveryId: string
webhookId: string
originalDeliveryId: string
isRedelivery: boolean
type: string
timestamp: number
storeId: string
invoiceId: string
metadata: DonationMetadata
}
type PaymentMethodsResponse = {
rate: string
amount: string
cryptoCode: string
}[]
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${req.method} Not Allowed`)
return
}
if (typeof req.headers['btcpay-sig'] !== 'string') {
res.status(400).json({ success: false })
return
}
const rawBody = await getRawBody(req)
const body: Body = JSON.parse(Buffer.from(rawBody).toString('utf8'))
const expectedSigHash = crypto
.createHmac('sha256', env.BTCPAY_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const incomingSigHash = (req.headers['btcpay-sig'] as string).split('=')[1]
if (expectedSigHash !== incomingSigHash) {
console.error('Invalid signature')
res.status(400).json({ success: false })
return
}
if (body.type === 'InvoiceSettled') {
const { data: paymentMethods } = await btcpayApi.get<PaymentMethodsResponse>(
`stores/${env.BTCPAY_STORE_ID}/invoices/${body.invoiceId}/payment-methods`
)
const cryptoAmount = Number(paymentMethods[0].amount)
const fiatAmount = Number(paymentMethods[0].amount) * Number(paymentMethods[0].rate)
await prisma.donation.create({
data: {
userId: body.metadata.userId,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fund: 'Monero Fund',
cryptoCode: paymentMethods[0].cryptoCode,
cryptoAmount,
fiatAmount,
membershipExpiresAt:
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
},
})
}
res.status(200).json({ success: true })
}

View File

@@ -0,0 +1,10 @@
import { getStripeWebhookHandler } from '../../../server/utils/webhooks'
import { env } from '../../../env.mjs'
export const config = {
api: {
bodyParser: false,
},
}
export default getStripeWebhookHandler(env.STRIPE_FIRO_WEBHOOK_SECRET)

View File

@@ -0,0 +1,10 @@
import { getStripeWebhookHandler } from '../../../server/utils/webhooks'
import { env } from '../../../env.mjs'
export const config = {
api: {
bodyParser: false,
},
}
export default getStripeWebhookHandler(env.STRIPE_GENERAL_WEBHOOK_SECRET)

View File

@@ -0,0 +1,10 @@
import { getStripeWebhookHandler } from '../../../server/utils/webhooks'
import { env } from '../../../env.mjs'
export const config = {
api: {
bodyParser: false,
},
}
export default getStripeWebhookHandler(env.STRIPE_MONERO_WEBHOOK_SECRET)

View File

@@ -0,0 +1,10 @@
import { getStripeWebhookHandler } from '../../../server/utils/webhooks'
import { env } from '../../../env.mjs'
export const config = {
api: {
bodyParser: false,
},
}
export default getStripeWebhookHandler(env.STRIPE_PRIVACY_GUIDES_WEBHOOK_SECRET)

View File

@@ -1,85 +0,0 @@
import Stripe from 'stripe'
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'
import dayjs from 'dayjs'
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let event: Stripe.Event
// Get the signature sent by Stripe
const signature = req.headers['stripe-signature']
try {
event = stripe.webhooks.constructEvent(
await getRawBody(req),
signature!,
env.STRIPE_WEBHOOK_SIGNING_SECRET
)
} catch (err) {
console.log(`⚠️ Webhook signature verification failed.`, (err as any).message)
res.status(400).end()
return
}
// Store donation data when payment intent is valid
// Subscriptions are handled on the invoice.paid event instead
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object
const metadata = paymentIntent.metadata as DonationMetadata
// Skip this event if intent is still not fully paid
if (paymentIntent.amount_received !== paymentIntent.amount) return
// Payment intents for subscriptions will not have metadata
if (metadata.isSubscription === 'false')
await prisma.donation.create({
data: {
userId: metadata.userId,
stripePaymentIntentId: paymentIntent.id,
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fund: 'Monero Fund',
fiatAmount: paymentIntent.amount_received / 100,
membershipExpiresAt:
metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
},
})
}
// Store subscription data when subscription invoice is paid
if (event.type === 'invoice.paid') {
const invoice = event.data.object
if (invoice.subscription) {
const metadata = event.data.object.subscription_details?.metadata as DonationMetadata
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
if (!invoiceLine) return
await prisma.donation.create({
data: {
userId: metadata.userId as string,
stripeInvoiceId: invoice.id,
stripeSubscriptionId: invoice.subscription.toString(),
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fund: 'Monero Fund',
fiatAmount: invoice.total / 100,
membershipExpiresAt: new Date(invoiceLine.period.end * 1000),
},
})
}
}
// Return a 200 response to acknowledge receipt of the event
res.status(200).end()
}

View File

@@ -1,19 +0,0 @@
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
import BigDumbMarkdown from '../components/BigDumbMarkdown'
export default function About({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps() {
const md = getSingleFile('docs/apply_research.md')
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}

View File

@@ -1,29 +0,0 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import Image from 'next/image'
const Checkout: NextPage = () => {
async function handleClick() {
console.log('yo')
}
return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1>yooo</h1>
<button onClick={handleClick}>Heyo</button>
<p>testing 123</p>
</main>
<footer>footer</footer>
</div>
)
}
export default Checkout

View File

@@ -1,25 +0,0 @@
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
import BigDumbMarkdown from '../components/BigDumbMarkdown'
import xss from 'xss'
export default function Faq({ content }: { content: string }) {
return (
<article
className="prose max-w-3xl mx-auto pb-8 pt-8 dark:prose-dark xl:col-span-2"
dangerouslySetInnerHTML={{ __html: xss(content || '') }}
/>
)
}
export async function getStaticProps() {
const md = getSingleFile('docs/faq.md')
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}

View File

@@ -1,196 +1,16 @@
import { useEffect, useState } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { useSession } from 'next-auth/react'
import { redirect } from 'next/navigation'
import { getAllPosts } from '../utils/md'
import { ProjectItem } from '../utils/types'
import Typing from '../components/Typing'
import CustomLink from '../components/CustomLink'
import { Button } from '../components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '../components/ui/dialog'
import DonationFormModal from '../components/DonationFormModal'
import MembershipFormModal from '../components/MembershipFormModal'
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'
// These shouldn't be swept up in the regular list so we hardcode them
const generalFund: ProjectItem = {
slug: 'general_fund',
nym: 'MagicMonero',
website: 'https://monerofund.org',
personalWebsite: 'https://monerofund.org',
title: 'MAGIC Monero General Fund',
summary: 'Support contributors to Monero',
coverImage: '/img/crystalball.jpg',
git: 'magicgrants',
twitter: 'magicgrants',
goal: 100000,
function Root() {
return <></>
}
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(
{ projectSlug: generalFund.slug },
{ enabled: false }
)
useEffect(() => {
if (session.status === 'authenticated') {
console.log('refetching')
userHasMembershipQuery.refetch()
}
}, [session.status])
return (
<>
<Head>
<title>Monero Fund</title>
<meta name="description" content="TKTK" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="pt-4 md:pb-8">
<h1 className="py-4 text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Support <Typing />
</h1>
<p className="text-xl leading-7 text-gray-500 dark:text-gray-400">
Help us to provide sustainable funding for free and open-source contributors working on
freedom tech and projects that help Monero flourish.
</p>
<div className="flex flex-wrap space-x-4 py-4">
<Button
onClick={() => setDonateModalOpen(true)}
size="lg"
className="px-14 text-black font-semibold text-lg"
>
Donate to Monero Comittee General Fund
</Button>
{!userHasMembershipQuery.data && (
<Button
onClick={() =>
session.status === 'authenticated'
? setMemberModalOpen(true)
: setRegisterIsOpen(true)
}
variant="outline"
size="lg"
className="px-14 font-semibold text-lg"
>
Get Annual Membership
</Button>
)}
{!!userHasMembershipQuery.data && (
<CustomLink href="/account/my-memberships">
<Button
variant="outline"
size="lg"
className="px-14 font-semibold text-lg text-white"
>
My Memberships
</Button>{' '}
</CustomLink>
)}
</div>
<div className="flex flex-row flex-wrap">
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
Want to receive funding for your work?
<CustomLink href="/apply" className="text-orange-500">
{' '}
Apply for a Monero development or research grant!
</CustomLink>
</p>
</div>
<p className="text-md leading-7 text-gray-500 dark:text-gray-400">
We are a 501(c)(3) public charity. All donations are tax deductible.
</p>
</div>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="xl:pt-18 space-y-2 pt-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Explore Projects
</h1>
<p className="pt-2 text-lg leading-7 text-gray-500 dark:text-gray-400">
Browse through a showcase of projects supported by us.
</p>
<ProjectList projects={projects} />
<div className="flex justify-end pt-4 text-base font-medium leading-6">
<CustomLink href="/projects" aria-label="View All Projects">
View Projects &rarr;
</CustomLink>
</div>
</div>
</div>
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
<DialogContent>
<DonationFormModal project={generalFund} />
</DialogContent>
</Dialog>
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
<DialogContent>
<MembershipFormModal project={generalFund} />
</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 Home
export async function getStaticProps({ params }: { params: any }) {
const projects = getAllPosts()
export default Root
export function getServerSideProps() {
return {
props: {
projects,
redirect: {
destination: '/monero',
permanent: true,
},
}
}

View File

@@ -1,11 +1,11 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { fetchPostJSON } from '../utils/api-helpers'
import Link from 'next/link'
import { Button } from '../components/ui/button'
import { Button } from '../../components/ui/button'
export default function Apply() {
async function handleClick() {}
const [loading, setLoading] = useState(false)
const router = useRouter()
const {
@@ -18,100 +18,88 @@ export default function Apply() {
const onSubmit = async (data: any) => {
setLoading(true)
console.log(data)
const res = await fetchPostJSON('/api/sendgrid', data)
if (res.message === 'success') {
router.push('/submitted')
}
console.log(res)
// TODO: fix dis
// const res = await fetchPostJSON('/api/sendgrid', data)
// if (res.message === 'success') {
// router.push('/submitted')
// }
// console.log(res)
setLoading(false)
}
return (
<div className="mx-auto flex-1 flex flex-col items-center justify-center gap-4 py-8 prose dark:prose-dark">
<form
onSubmit={handleSubmit(onSubmit)}
className="max-w-5xl flex flex-col gap-4 p-4"
>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-5xl flex flex-col gap-4 p-4">
<div>
<h1>
Application for Monero Fund Project Listing or General Fund Grant
</h1>
<h1>Application for Monero Fund Project Listing or General Fund Grant</h1>
<p>Thanks for your interest in the Monero Fund!</p>
<p>
We&#39;re incredibly grateful to contributors like you working to
support Monero, Bitcoin and other free and open source projects.
We&#39;re incredibly grateful to contributors like you working to support Monero,
Bitcoin and other free and open source projects.
</p>
<p>
The MAGIC Monero Fund is offering a grant program and fundraising
platform to support Monero research and development, especially
relating to privacy, security, user experience, and efficiency.
Proposals can be related to the Monero protocol directly, or they
can be related to other areas of the Monero ecosystem. For research
projects, please refer to special instructions
The MAGIC Monero Fund is offering a grant program and fundraising platform to support
Monero research and development, especially relating to privacy, security, user
experience, and efficiency. Proposals can be related to the Monero protocol directly, or
they can be related to other areas of the Monero ecosystem. For research projects,
please refer to special instructions
<Link href="/apply_research"> here</Link>.
</p>
<h2>Proposal Evaluation Criteria</h2>
<div>
Submitted proposals will be evaluated by the committee based on the
following criteria:
Submitted proposals will be evaluated by the committee based on the following criteria:
<ul>
<li>
<b>Impact:</b> The proposal should have a clear impact on the
Monero Project.
<b>Impact:</b> The proposal should have a clear impact on the Monero Project.
</li>
<li>
<b>Originality:</b> The proposal should be original and not a
rehash of existing work.
<b>Originality:</b> The proposal should be original and not a rehash of existing
work.
</li>
<li>
<b>Feasibility:</b> The proposal should be feasible to complete
within the proposed time frame.
<b>Feasibility:</b> The proposal should be feasible to complete within the proposed
time frame.
</li>
<li>
<b>Quality:</b> The proposal should be well-written and
well-organized.
<b>Quality:</b> The proposal should be well-written and well-organized.
</li>
<li>
<b>Relevance:</b> The proposal should be relevant to the Monero
Project.
<b>Relevance:</b> The proposal should be relevant to the Monero Project.
</li>
</ul>
</div>
<h2 id="Eligibility">Eligibility</h2>
<p>
All qualified researchers are eligible to apply, regardless of
educational attainment or occupation. However, as a nonprofit
organization registered under U.S. tax laws, MAGIC Grants is
required to comply with certain laws when disbursing funds to grant
recipients. Grant recipients must complete a Due Diligence
checklist, which are the last two pages of{' '}
All qualified researchers are eligible to apply, regardless of educational attainment or
occupation. However, as a nonprofit organization registered under U.S. tax laws, MAGIC
Grants is required to comply with certain laws when disbursing funds to grant
recipients. Grant recipients must complete a Due Diligence checklist, which are the last
two pages of{' '}
<a href="https://magicgrants.org/funds/MAGIC%20Fund%20Grant%20Disbursement%20Process%20and%20Requirements.pdf">
this document
</a>
. This includes the collection of your ID and tax information. We
will conduct sanctions checks.
. This includes the collection of your ID and tax information. We will conduct sanctions
checks.
</p>
<h2>How to Submit a Proposal</h2>
<p>
To submit a proposal, please complete the form below or create an
issue on{' '}
To submit a proposal, please complete the form below or create an issue on{' '}
<a href="https://github.com/MAGICGrants/Monero-Fund/issues/new?assignees=&labels=&template=grant-application.md&title=[Grant+Title]">
Github
</a>
. Applicants are free to use their legal name or a pseudonym at this
step, although note the{' '}
. Applicants are free to use their legal name or a pseudonym at this step, although note
the{' '}
<a href="#Eligibility">
<b>Eligibility</b>
</a>{' '}
section. You can choose to apply for a direct grant from the MAGIC
Monero Fund&#39;s General Fund and/or request that your project be
listed on MoneroFund.org to raise funds from Monero community
members.
section. You can choose to apply for a direct grant from the MAGIC Monero Fund&#39;s
General Fund and/or request that your project be listed on MoneroFund.org to raise funds
from Monero community members.
</p>
<p>
Please note this form is not considered confidential and is
effectively equivalent to a public GitHub issue. In order to reach
out privately, please send an email to MoneroFund@magicgrants.org.
Please note this form is not considered confidential and is effectively equivalent to a
public GitHub issue. In order to reach out privately, please send an email to
MoneroFund@magicgrants.org.
</p>
</div>
@@ -166,9 +154,7 @@ export default function Apply() {
</div>
<div className="w-full flex flex-col">
<label htmlFor="personal_github">
Personal GitHub (if applicable)
</label>
<label htmlFor="personal_github">Personal GitHub (if applicable)</label>
<input
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
id="personal_github"
@@ -178,14 +164,12 @@ export default function Apply() {
</div>
<div className="w-full flex flex-col">
<label htmlFor="other_contact">
Other Contact Details (if applicable)
</label>
<label htmlFor="other_contact">Other Contact Details (if applicable)</label>
<small>
Please list any other relevant contact details you are comfortable
sharing in case we need to reach out with questions. These could
include github username, twitter username, LinkedIn, Reddit handle,
other social media handles, emails, phone numbers, usernames, etc.
Please list any other relevant contact details you are comfortable sharing in case we
need to reach out with questions. These could include github username, twitter username,
LinkedIn, Reddit handle, other social media handles, emails, phone numbers, usernames,
etc.
</small>
<textarea
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
@@ -197,8 +181,8 @@ export default function Apply() {
<div className="w-full flex flex-col">
<label htmlFor="short_description">Short Project Description *</label>
<small>
This will be listed on the explore projects page of the Monero Fund
website. 2-3 sentences.
This will be listed on the explore projects page of the Monero Fund website. 2-3
sentences.
</small>
<textarea
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
@@ -210,8 +194,8 @@ export default function Apply() {
<div className="w-full flex flex-col">
<label htmlFor="long_description">Long Project Description</label>
<small>
This will be listed on your personal project page of the Monero Fund
website. It can be longer and go into detail about your project.
This will be listed on your personal project page of the Monero Fund website. It can be
longer and go into detail about your project.
</small>
<textarea
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
@@ -232,8 +216,7 @@ export default function Apply() {
<div className="w-full flex flex-col">
<label htmlFor="other_lead">
If someone else, please list the project&#39;s Lead Contributor or
Maintainer
If someone else, please list the project&#39;s Lead Contributor or Maintainer
</label>
<input
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
@@ -254,9 +237,7 @@ export default function Apply() {
</div>
<div className="w-full flex flex-col">
<label htmlFor="timelines">
Project Timelines and Potential Milestones *
</label>
<label htmlFor="timelines">Project Timelines and Potential Milestones *</label>
<textarea
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
id="timelines"
@@ -266,9 +247,8 @@ export default function Apply() {
<div className="w-full flex flex-col">
<label htmlFor="proposed_budget">
If you&#39;re applying for a grant from the general fund, please
submit a proposed budget for the requested amount and how it will be
used.
If you&#39;re applying for a grant from the general fund, please submit a proposed
budget for the requested amount and how it will be used.
</label>
<input
className="appearance-none block w-full text-gray-700 border rounded py-2 px-3 mb-3 leading-tight focus:outline-none focus:ring-0"
@@ -290,22 +270,19 @@ export default function Apply() {
</div>
<small>
The MAGIC Monero Fund may require each recipient to sign a Grant
Agreement before any funds are disbursed. This agreement will set
milestones and funds will only be released upon completion of
milestones. In order to comply with US regulations, recipients will
The MAGIC Monero Fund may require each recipient to sign a Grant Agreement before any
funds are disbursed. This agreement will set milestones and funds will only be released
upon completion of milestones. In order to comply with US regulations, recipients will
need to identify themselves to MAGIC, in accordance with US law.
</small>
<Button disabled={loading}>Apply</Button>
<p>
After submitting your application, please allow our team up to three
weeks to review your application. Email us at{' '}
<a href="mailto:monerofund@magicgrants.org">
monerofund@magicgrants.org
</a>{' '}
if you have any questions.
After submitting your application, please allow our team up to three weeks to review your
application. Email us at{' '}
<a href="mailto:monerofund@magicgrants.org">monerofund@magicgrants.org</a> if you have any
questions.
</p>
</form>
</div>

197
pages/monero/index.tsx Normal file
View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { useSession } from 'next-auth/react'
import { getProjects } from '../../utils/md'
import { ProjectItem } from '../../utils/types'
import Typing from '../../components/Typing'
import CustomLink from '../../components/CustomLink'
import { Button } from '../../components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '../../components/ui/dialog'
import DonationFormModal from '../../components/DonationFormModal'
import MembershipFormModal from '../../components/MembershipFormModal'
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 { useFundSlug } from '../../utils/use-fund-slug'
// These shouldn't be swept up in the regular list so we hardcode them
const fund: ProjectItem = {
slug: 'monero',
nym: 'MagicMonero',
website: 'https://monerofund.org',
personalWebsite: 'https://monerofund.org',
title: 'MAGIC Monero General Fund',
summary: 'Support contributors to Monero',
coverImage: '/img/crystalball.jpg',
git: 'magicgrants',
twitter: 'magicgrants',
goal: 100000,
}
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 fundSlug = useFundSlug()
const userHasMembershipQuery = trpc.donation.userHasMembership.useQuery(
{ projectSlug: fund.slug },
{ enabled: false }
)
useEffect(() => {
if (session.status === 'authenticated') {
userHasMembershipQuery.refetch()
}
}, [session.status])
return (
<>
<Head>
<title>Monero Fund</title>
<meta name="description" content="TKTK" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="pt-4 md:pb-8">
<h1 className="py-4 text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Support <Typing />
</h1>
<p className="text-xl leading-7 text-gray-500 dark:text-gray-400">
Help us to provide sustainable funding for free and open-source contributors working on
freedom tech and projects that help Monero flourish.
</p>
<div className="flex flex-wrap space-x-4 py-4">
<Button
onClick={() => setDonateModalOpen(true)}
size="lg"
className="px-14 text-black font-semibold text-lg"
>
Donate to Monero Comittee General Fund
</Button>
{!userHasMembershipQuery.data && (
<Button
onClick={() =>
session.status === 'authenticated'
? setMemberModalOpen(true)
: setRegisterIsOpen(true)
}
variant="outline"
size="lg"
className="px-14 font-semibold text-lg"
>
Get Annual Membership
</Button>
)}
{!!userHasMembershipQuery.data && (
<CustomLink href={`/${fundSlug}/account/my-memberships`}>
<Button
variant="outline"
size="lg"
className="px-14 font-semibold text-lg text-white"
>
My Memberships
</Button>{' '}
</CustomLink>
)}
</div>
<div className="flex flex-row flex-wrap">
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
Want to receive funding for your work?
<CustomLink href={`/${fundSlug}/apply`} className="text-orange-500">
{' '}
Apply for a Monero development or research grant!
</CustomLink>
</p>
</div>
<p className="text-md leading-7 text-gray-500 dark:text-gray-400">
We are a 501(c)(3) public charity. All donations are tax deductible.
</p>
</div>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="xl:pt-18 space-y-2 pt-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Explore Projects
</h1>
<p className="pt-2 text-lg leading-7 text-gray-500 dark:text-gray-400">
Browse through a showcase of projects supported by us.
</p>
<ProjectList projects={projects} />
<div className="flex justify-end pt-4 text-base font-medium leading-6">
<CustomLink href={`/${fundSlug}/projects`} aria-label="View All Projects">
View Projects &rarr;
</CustomLink>
</div>
</div>
</div>
<Dialog open={donateModalOpen} onOpenChange={setDonateModalOpen}>
<DialogContent>
<DonationFormModal project={fund} />
</DialogContent>
</Dialog>
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
<DialogContent>
<MembershipFormModal project={fund} />
</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 Home
export async function getStaticProps({ params }: { params: any }) {
const projects = getProjects('monero')
return {
props: {
projects,
},
}
}

View File

@@ -1,27 +1,28 @@
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import { NextPage } from 'next/types'
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 { 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'
type SingleProjectPageProps = {
project: ProjectItem
@@ -229,8 +230,13 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
export default Project
export async function getServerSideProps({ params }: { params: any }) {
const project = getProjectBySlug(params.slug)
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 = {
@@ -252,7 +258,9 @@ export async function getServerSideProps({ params }: { params: any }) {
}
if (!project.isFunded) {
const donations = await prisma.donation.findMany({ where: { projectSlug: params.slug } })
const donations = await prisma.donation.findMany({
where: { projectSlug: params.slug as string, fundSlug },
})
donations.forEach((donation) => {
if (donation.cryptoCode === 'XMR') {

View File

@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { useEffect, useState } from 'react'
import ProjectCard from '../../components/ProjectCard'
import { ProjectItem } from '../../utils/types'
import { getAllPosts } from '../../utils/md'
import ProjectCard from '../../../components/ProjectCard'
import { ProjectItem } from '../../../utils/types'
import { getProjects } from '../../../utils/md'
import { useFundSlug } from '../../../utils/use-fund-slug'
const AllProjects: NextPage<{ projects: ProjectItem[] }> = ({ projects }) => {
const [modalOpen, setModalOpen] = useState(false)
const [selectedProject, setSelectedProject] = useState<ProjectItem>()
const [sortedProjects, setSortedProjects] = useState<ProjectItem[]>()
const fundSlug = useFundSlug()
useEffect(() => {
setSortedProjects(projects.sort(() => 0.5 - Math.random()))
@@ -24,7 +24,8 @@ const AllProjects: NextPage<{ projects: ProjectItem[] }> = ({ projects }) => {
setSelectedProject(project)
setModalOpen(true)
}
// const projects = ["one", "two", "three", "one", "two", "three", "one", "two", "three"];
if (!fundSlug) return <></>
return (
<>
@@ -44,11 +45,6 @@ const AllProjects: NextPage<{ projects: ProjectItem[] }> = ({ projects }) => {
))}
</ul>
</section>
{/* <PaymentModal
isOpen={modalOpen}
onRequestClose={closeModal}
project={selectedProject}
/> */}
</>
)
}
@@ -56,7 +52,7 @@ const AllProjects: NextPage<{ projects: ProjectItem[] }> = ({ projects }) => {
export default AllProjects
export async function getStaticProps({ params }: { params: any }) {
const projects = getAllPosts()
const projects = getProjects('monero')
return {
props: {

View File

@@ -1,19 +0,0 @@
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
import BigDumbMarkdown from '../components/BigDumbMarkdown'
export default function Terms({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps() {
const md = getSingleFile('docs/privacy.md')
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}

View File

@@ -1,19 +0,0 @@
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
import BigDumbMarkdown from '../components/BigDumbMarkdown'
export default function Terms({ content }: { content: string }) {
return <BigDumbMarkdown content={content} />
}
export async function getStaticProps() {
const md = getSingleFile('docs/terms.md')
const content = await markdownToHtml(md || '')
return {
props: {
content,
},
}
}

View File

@@ -1,3 +1,6 @@
-- CreateEnum
CREATE TYPE "FundSlug" AS ENUM ('monero', 'firo', 'privacy_guides', 'general');
-- CreateTable
CREATE TABLE "Donation" (
"id" TEXT NOT NULL,
@@ -10,7 +13,7 @@ CREATE TABLE "Donation" (
"stripeSubscriptionId" TEXT,
"projectSlug" TEXT NOT NULL,
"projectName" TEXT NOT NULL,
"fund" TEXT NOT NULL,
"fundSlug" "FundSlug" NOT NULL,
"cryptoCode" TEXT,
"fiatAmount" DOUBLE PRECISION NOT NULL,
"cryptoAmount" DOUBLE PRECISION,

View File

@@ -25,7 +25,7 @@ model Donation {
stripeSubscriptionId String? // For recurring memberships
projectSlug String
projectName String
fund String
fundSlug FundSlug
cryptoCode String?
fiatAmount Float
cryptoAmount Float?
@@ -35,3 +35,10 @@ model Donation {
@@index([stripeSubscriptionId])
@@index([userId])
}
enum FundSlug {
monero
firo
privacy_guides
general
}

View File

@@ -1,15 +1,15 @@
import { Stripe } from 'stripe'
import { TRPCError } from '@trpc/server'
import { Donation } from '@prisma/client'
import { z } from 'zod'
import dayjs from 'dayjs'
import { protectedProcedure, publicProcedure, router } from '../trpc'
import { CURRENCY, MAX_AMOUNT, MEMBERSHIP_PRICE, MIN_AMOUNT } from '../../config'
import { env } from '../../env.mjs'
import { btcpayApi, keycloak, prisma, stripe } from '../services'
import { btcpayApi as _btcpayApi, keycloak, prisma, stripe as _stripe } from '../services'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { DonationMetadata } from '../types'
import { Donation } from '@prisma/client'
import { btcpayFundSlugToStoreId, fundSlugs } from '../../utils/funds'
export const donationRouter = router({
donateWithFiat: publicProcedure
@@ -19,6 +19,7 @@ export const donationRouter = router({
email: z.string().email().nullable(),
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
})
)
@@ -33,9 +34,11 @@ export const donationRouter = router({
const user = await keycloak.users.findOne({ id: userId })!
email = user?.email!
name = user?.attributes?.name?.[0]
stripeCustomerId = user?.attributes?.stripeCustomerId?.[0] || null
stripeCustomerId = user?.attributes?.fundSlugToCustomerIdAttr[input.fundSlug]?.[0] || null
}
const stripe = _stripe[input.fundSlug]
if (!stripeCustomerId && userId && email && name) {
const customer = await stripe.customers.create({
email,
@@ -56,6 +59,7 @@ export const donationRouter = router({
donorName: name,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
isMembership: 'false',
isSubscription: 'false',
}
@@ -96,6 +100,7 @@ export const donationRouter = router({
email: z.string().trim().email().nullable(),
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
amount: z.number().min(MIN_AMOUNT).max(MAX_AMOUNT),
})
)
@@ -117,11 +122,14 @@ export const donationRouter = router({
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
isMembership: 'false',
isSubscription: 'false',
}
const response = await btcpayApi.post(`/stores/${env.BTCPAY_STORE_ID}/invoices`, {
const btcpayApi = _btcpayApi[input.fundSlug]
const response = await btcpayApi.post(`/invoices`, {
amount: input.amount,
currency: CURRENCY,
metadata,
@@ -136,10 +144,12 @@ export const donationRouter = router({
z.object({
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
recurring: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
const stripe = _stripe[input.fundSlug]
const userId = ctx.session.user.sub
const userHasMembership = await prisma.donation.findFirst({
@@ -161,7 +171,7 @@ export const donationRouter = router({
const user = await keycloak.users.findOne({ id: userId })
const email = user?.email!
const name = user?.attributes?.name?.[0]!
let stripeCustomerId = user?.attributes?.stripeCustomerId?.[0] || null
let stripeCustomerId = user?.attributes?.fundSlugToCustomerIdAttr[input.fundSlug]?.[0] || null
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email, name })
@@ -180,6 +190,7 @@ export const donationRouter = router({
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
isMembership: 'true',
isSubscription: input.recurring ? 'true' : 'false',
}
@@ -242,6 +253,7 @@ export const donationRouter = router({
z.object({
projectName: z.string().min(1),
projectSlug: z.string().min(1),
fundSlug: z.enum(fundSlugs),
})
)
.mutation(async ({ input, ctx }) => {
@@ -273,11 +285,14 @@ export const donationRouter = router({
donorEmail: email,
projectSlug: input.projectSlug,
projectName: input.projectName,
fundSlug: input.fundSlug,
isMembership: 'true',
isSubscription: 'false',
}
const response = await btcpayApi.post(`/stores/${env.BTCPAY_STORE_ID}/invoices`, {
const btcpayApi = _btcpayApi[input.fundSlug]
const response = await btcpayApi.post(`/invoices`, {
amount: MEMBERSHIP_PRICE,
currency: CURRENCY,
metadata,
@@ -287,64 +302,69 @@ export const donationRouter = router({
return { url: response.data.checkoutLink }
}),
donationList: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.sub
donationList: protectedProcedure
.input(z.object({ fundSlug: z.enum(fundSlugs) }))
.query(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
// 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({
where: {
userId,
stripeSubscriptionId: null,
},
orderBy: { createdAt: 'desc' },
})
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`,
const donations = await prisma.donation.findMany({
where: {
userId,
stripeSubscriptionId: null,
fundSlug: input.fundSlug,
},
orderBy: { createdAt: 'desc' },
})
billingPortalUrl = billingPortalSession.url
}
return donations
}),
const memberships = await prisma.donation.findMany({
where: {
userId,
membershipExpiresAt: { not: null },
},
orderBy: { createdAt: 'desc' },
})
membershipList: protectedProcedure
.input(z.object({ fundSlug: z.enum(fundSlugs) }))
.query(async ({ input, ctx }) => {
const stripe = _stripe[input.fundSlug]
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
const stripeCustomerId = user?.attributes?.fundSlugToCustomerIdAttr[input.fundSlug]?.[0]
let billingPortalUrl: string | null = null
const subscriptionIds = new Set<string>()
const membershipsUniqueSubsId: Donation[] = []
if (stripeCustomerId) {
const billingPortalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${env.APP_URL}/${input.fundSlug}/account/my-memberships`,
})
billingPortalUrl = billingPortalSession.url
}
const memberships = await prisma.donation.findMany({
where: {
userId,
membershipExpiresAt: { not: null },
},
orderBy: { createdAt: 'desc' },
})
const subscriptionIds = new Set<string>()
const membershipsUniqueSubsId: Donation[] = []
memberships.forEach((membership) => {
if (!membership.stripeSubscriptionId) {
membershipsUniqueSubsId.push(membership)
return
}
if (subscriptionIds.has(membership.stripeSubscriptionId)) {
return
}
memberships.forEach((membership) => {
if (!membership.stripeSubscriptionId) {
membershipsUniqueSubsId.push(membership)
return
}
subscriptionIds.add(membership.stripeSubscriptionId)
})
if (subscriptionIds.has(membership.stripeSubscriptionId)) {
return
}
membershipsUniqueSubsId.push(membership)
subscriptionIds.add(membership.stripeSubscriptionId)
})
return { memberships: membershipsUniqueSubsId, billingPortalUrl }
}),
return { memberships: membershipsUniqueSubsId, billingPortalUrl }
}),
userHasMembership: protectedProcedure
.input(z.object({ projectSlug: z.string() }))

View File

@@ -1,11 +1,12 @@
import { PrismaClient } from '@prisma/client'
import Stripe from 'stripe'
import sendgrid from '@sendgrid/mail'
import KeycloakAdminClient from '@keycloak/keycloak-admin-client'
import nodemailer from 'nodemailer'
import axios from 'axios'
import axios, { AxiosInstance } from 'axios'
import { env } from '../env.mjs'
import Stripe from 'stripe'
import { FundSlug } from '../utils/funds'
sendgrid.setApiKey(env.SENDGRID_API_KEY)
@@ -14,10 +15,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient }
const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log:
process.env.NODE_ENV === 'production'
? ['error']
: ['query', 'info', 'warn', 'error'],
log: process.env.NODE_ENV === 'production' ? ['error'] : ['query', 'info', 'warn', 'error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
@@ -36,14 +34,30 @@ const transporter = nodemailer.createTransport({
},
})
const btcpayApi = axios.create({
baseURL: `${env.BTCPAY_URL}/api/v1`,
headers: { Authorization: `token ${env.BTCPAY_API_KEY}` },
})
const btcpayApi: Record<FundSlug, AxiosInstance> = {
monero: axios.create({
baseURL: `${env.BTCPAY_URL}/api/v1/stores/${env.BTCPAY_MONERO_STORE_ID}`,
headers: { Authorization: `token ${env.BTCPAY_API_KEY}` },
}),
firo: axios.create({
baseURL: `${env.BTCPAY_URL}/api/v1/stores/${env.BTCPAY_FIRO_STORE_ID}`,
headers: { Authorization: `token ${env.BTCPAY_API_KEY}` },
}),
privacy_guides: axios.create({
baseURL: `${env.BTCPAY_URL}/api/v1/stores/${env.BTCPAY_PRIVACY_GUIDES_STORE_ID}`,
headers: { Authorization: `token ${env.BTCPAY_API_KEY}` },
}),
general: axios.create({
baseURL: `${env.BTCPAY_URL}/api/v1/stores/${env.BTCPAY_GENERAL_STORE_ID}`,
headers: { Authorization: `token ${env.BTCPAY_API_KEY}` },
}),
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2024-04-10',
})
const stripe: Record<FundSlug, Stripe> = {
monero: new Stripe(env.STRIPE_MONERO_SECRET_KEY, { apiVersion: '2024-04-10' }),
firo: new Stripe(env.STRIPE_MONERO_SECRET_KEY, { apiVersion: '2024-04-10' }),
privacy_guides: new Stripe(env.STRIPE_MONERO_SECRET_KEY, { apiVersion: '2024-04-10' }),
general: new Stripe(env.STRIPE_MONERO_SECRET_KEY, { apiVersion: '2024-04-10' }),
}
export { sendgrid, prisma, keycloak, transporter, btcpayApi, stripe }

View File

@@ -1,9 +1,12 @@
import { FundSlug } from '../utils/funds'
export type DonationMetadata = {
userId: string | null
donorEmail: string | null
donorName: string | null
projectSlug: string
projectName: string
fundSlug: FundSlug
isMembership: 'true' | 'false'
isSubscription: 'true' | 'false'
}

View File

@@ -1,30 +0,0 @@
import { env } from '../../env.mjs'
import { btcpayApi } from '../services'
type InvoiceInput = {
price: number
currency: string
orderId?: string
itemDesc?: string
buyerEmail?: string
}
type Invoice = {
id: string
url: string
checkoutLink: string // Add this property to retrieve the payment page URL
}
export async function createInvoice(invoice: InvoiceInput): Promise<Invoice> {
try {
const response = await btcpayApi.post(
`/stores/${env.BTCPAY_STORE_ID}/invoices`,
invoice
)
return response.data
} catch (error) {
console.error('Error creating invoice')
throw error
}
}

153
server/utils/webhooks.ts Normal file
View File

@@ -0,0 +1,153 @@
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import getRawBody from 'raw-body'
import dayjs from 'dayjs'
import crypto from 'crypto'
import { btcpayApi as _btcpayApi, prisma, stripe } from '../../server/services'
import { DonationMetadata } from '../../server/types'
import { btcpayStoreIdToFundSlug } from '../../utils/funds'
export function getStripeWebhookHandler(secret: string) {
return async (req: NextApiRequest, res: NextApiResponse) => {
let event: Stripe.Event
// Get the signature sent by Stripe
const signature = req.headers['stripe-signature']
try {
event = stripe.monero.webhooks.constructEvent(await getRawBody(req), signature!, secret)
} catch (err) {
console.log(`⚠️ Webhook signature verification failed.`, (err as any).message)
res.status(400).end()
return
}
// Store donation data when payment intent is valid
// Subscriptions are handled on the invoice.paid event instead
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object
const metadata = paymentIntent.metadata as DonationMetadata
// Skip this event if intent is still not fully paid
if (paymentIntent.amount_received !== paymentIntent.amount) return
// Payment intents for subscriptions will not have metadata
if (metadata.isSubscription === 'false')
await prisma.donation.create({
data: {
userId: metadata.userId,
stripePaymentIntentId: paymentIntent.id,
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fundSlug: metadata.fundSlug,
fiatAmount: paymentIntent.amount_received / 100,
membershipExpiresAt:
metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
},
})
}
// Store subscription data when subscription invoice is paid
if (event.type === 'invoice.paid') {
const invoice = event.data.object
if (invoice.subscription) {
const metadata = event.data.object.subscription_details?.metadata as DonationMetadata
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
if (!invoiceLine) return
await prisma.donation.create({
data: {
userId: metadata.userId as string,
stripeInvoiceId: invoice.id,
stripeSubscriptionId: invoice.subscription.toString(),
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fundSlug: metadata.fundSlug,
fiatAmount: invoice.total / 100,
membershipExpiresAt: new Date(invoiceLine.period.end * 1000),
},
})
}
}
// Return a 200 response to acknowledge receipt of the event
res.status(200).end()
}
}
type BtcpayBody = {
deliveryId: string
webhookId: string
originalDeliveryId: string
isRedelivery: boolean
type: string
timestamp: number
storeId: string
invoiceId: string
metadata: DonationMetadata
}
type BtcpayPaymentMethodsResponse = {
rate: string
amount: string
cryptoCode: string
}[]
export function getBtcpayWebhookHandler(secret: string) {
return async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${req.method} Not Allowed`)
return
}
if (typeof req.headers['btcpay-sig'] !== 'string') {
res.status(400).json({ success: false })
return
}
const rawBody = await getRawBody(req)
const body: BtcpayBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
const fundSlug = btcpayStoreIdToFundSlug[body.storeId]
const expectedSigHash = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const incomingSigHash = (req.headers['btcpay-sig'] as string).split('=')[1]
if (expectedSigHash !== incomingSigHash) {
console.error('Invalid signature')
res.status(400).json({ success: false })
return
}
if (body.type === 'InvoiceSettled') {
const btcpayApi = _btcpayApi[fundSlug]
const { data: paymentMethods } = await btcpayApi.get<BtcpayPaymentMethodsResponse>(
`/invoices/${body.invoiceId}/payment-methods`
)
const cryptoAmount = Number(paymentMethods[0].amount)
const fiatAmount = Number(paymentMethods[0].amount) * Number(paymentMethods[0].rate)
await prisma.donation.create({
data: {
userId: body.metadata.userId,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
cryptoCode: paymentMethods[0].cryptoCode,
cryptoAmount,
fiatAmount: Number(fiatAmount.toFixed(2)),
membershipExpiresAt:
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
},
})
}
res.status(200).json({ success: true })
}
}

View File

@@ -1,163 +0,0 @@
import { env } from '../env.mjs'
export async function fetchGetJSON(url: string) {
try {
const data = await fetch(url).then((res) => res.json())
return data
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}
export async function fetchPostJSON(url: string, data?: {}) {
try {
// Default options are marked with *
const response = await fetch(url, {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: {
'Content-Type': 'application/json',
Authorization: `token ${env.NEXT_PUBLIC_BTCPAY_API_KEY}`,
},
redirect: 'follow', // manual, *follow, error
referrerPolicy: 'no-referrer', // no-referrer, *client
body: JSON.stringify(data || {}), // body data type must match "Content-Type" header
})
return await response.json() // parses JSON response into native JavaScript objects
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}
export async function fetchPostJSONAuthed(url: string, auth: string, data?: {}) {
try {
// Default options are marked with *
const response = await fetch(url, {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: {
'Content-Type': 'application/json',
Authorization: auth,
},
redirect: 'follow', // manual, *follow, error
referrerPolicy: 'no-referrer', // no-referrer, *client
body: JSON.stringify(data || {}), // body data type must match "Content-Type" header
})
return await response.json() // parses JSON response into native JavaScript objects
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}
export async function fetchGetJSONAuthedBTCPay(slug: string) {
try {
const url = `${env.BTCPAY_URL}stores/${env.BTCPAY_STORE_ID}/invoices`
const auth = `token ${env.BTCPAY_API_KEY}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: auth,
},
})
const data = await response.json()
let numdonationsxmr = 0
let numdonationsbtc = 0
let totaldonationsxmr = 0
let totaldonationsbtc = 0
let totaldonationsinfiatxmr = 0
let totaldonationsinfiatbtc = 0
for (let i = 0; i < data.length; i++) {
if (data[i].metadata.orderId != slug && data[i].metadata.orderId != `${slug}_STATIC`) {
continue
}
const id = data[i].id
const urliter = `${env.BTCPAY_URL}stores/${env.BTCPAY_STORE_ID}/invoices/${id}/payment-methods`
const authiter = `token ${env.BTCPAY_API_KEY}`
const responseiter = await fetch(urliter, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: authiter,
},
})
const dataiter = await responseiter.json()
if (dataiter[1].cryptoCode == 'XMR' && dataiter[1].paymentMethodPaid > 0) {
numdonationsxmr += dataiter[1].payments.length
totaldonationsxmr += Number(dataiter[1].paymentMethodPaid)
totaldonationsinfiatxmr += Number(dataiter[1].paymentMethodPaid) * Number(dataiter[1].rate)
}
if (dataiter[0].cryptoCode == 'BTC' && dataiter[0].paymentMethodPaid > 0) {
numdonationsbtc += dataiter[0].payments.length
totaldonationsbtc += Number(dataiter[0].paymentMethodPaid)
totaldonationsinfiatbtc += Number(dataiter[0].paymentMethodPaid) * Number(dataiter[0].rate)
}
}
return await {
xmr: {
numdonations: numdonationsxmr,
totaldonationsinfiat: totaldonationsinfiatxmr,
totaldonations: totaldonationsxmr,
},
btc: {
numdonations: numdonationsbtc,
totaldonationsinfiat: totaldonationsinfiatbtc,
totaldonations: totaldonationsbtc,
},
}
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}
export async function fetchGetJSONAuthedStripe(slug: string) {
try {
const url = 'https://api.stripe.com/v1/charges'
const auth = `Bearer ${env.STRIPE_SECRET_KEY}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: auth,
},
})
const data = await response.json()
const dataext = data.data
let total = 0
let donations = 0
for (let i = 0; i < dataext.length; i++) {
if (dataext[i].metadata.project_slug == null || dataext[i].metadata.project_slug != slug) {
continue
}
total += Number(dataext[i].amount) / 100
donations += 1
}
return await {
numdonations: donations,
totaldonationsinfiat: total,
totaldonations: total,
}
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}

46
utils/funds.ts Normal file
View File

@@ -0,0 +1,46 @@
import { env } from '../env.mjs'
type FundSlugs = ['monero', 'firo', 'privacy_guides', 'general']
export type FundSlug = FundSlugs[number]
export const funds: Record<FundSlug, Record<string, any>> = {
monero: {},
firo: {},
privacy_guides: {},
general: {},
}
export const fundSlugs = Object.keys(funds) as FundSlugs
export const btcpayFundSlugToStoreId: Record<FundSlug, string> = {
monero: env.BTCPAY_MONERO_STORE_ID,
firo: env.BTCPAY_FIRO_STORE_ID,
privacy_guides: env.BTCPAY_PRIVACY_GUIDES_STORE_ID,
general: env.BTCPAY_GENERAL_STORE_ID,
}
export const btcpayFundSlugToWebhookSecret: Record<FundSlug, string> = {
monero: env.BTCPAY_MONERO_WEBHOOK_SECRET,
firo: env.BTCPAY_FIRO_WEBHOOK_SECRET,
privacy_guides: env.BTCPAY_PRIVACY_GUIDES_WEBHOOK_SECRET,
general: env.BTCPAY_GENERAL_WEBHOOK_SECRET,
}
export const btcpayStoreIdToFundSlug: Record<string, FundSlug> = {}
Object.entries(btcpayFundSlugToStoreId).forEach(
([fundSlug, storeId]) => (btcpayStoreIdToFundSlug[storeId] = fundSlug as FundSlug)
)
export const fundSlugToCustomerIdAttr: Record<FundSlug, string> = {
monero: 'stripeMoneroCustomerId',
firo: 'stripeFiroCustomerId',
privacy_guides: 'stripePgCustomerId',
general: 'stripeGeneralCustomerId',
}
export function getFundSlugFromUrlPath(urlPath: string) {
const fundSlug = urlPath.split('/')[1]
return fundSlugs.includes(fundSlug as any) ? (fundSlug as FundSlug) : null
}

View File

@@ -3,7 +3,14 @@ import { join } from 'path'
import matter from 'gray-matter'
import sanitize from 'sanitize-filename'
const postsDirectory = join(process.cwd(), 'docs/projects')
import { FundSlug } from './funds'
const directories: Record<FundSlug, string> = {
monero: join(process.cwd(), 'docs/monero/projects'),
firo: join(process.cwd(), 'docs/firo/projects'),
privacy_guides: join(process.cwd(), 'docs/privacy-guides/projects'),
general: join(process.cwd(), 'docs/general/projects'),
}
const FIELDS = [
'title',
@@ -32,8 +39,8 @@ const FIELDS = [
'fiattotaldonations',
]
export function getPostSlugs() {
return fs.readdirSync(postsDirectory)
export function getProjectSlugs(fund: FundSlug) {
return fs.readdirSync(directories[fund])
}
export function getSingleFile(path: string) {
@@ -41,10 +48,10 @@ export function getSingleFile(path: string) {
return fs.readFileSync(fullPath, 'utf8')
}
export function getProjectBySlug(slug: string) {
export function getProjectBySlug(slug: string, fund: FundSlug) {
const fields = FIELDS
const realSlug = slug.replace(/\.md$/, '')
const fullPath = join(postsDirectory, `${sanitize(realSlug)}.md`)
const fullPath = join(directories[fund], `${sanitize(realSlug)}.md`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
@@ -67,9 +74,9 @@ export function getProjectBySlug(slug: string) {
return items
}
export function getAllPosts() {
const slugs = getPostSlugs()
const posts = slugs.map((slug) => getProjectBySlug(slug))
export function getProjects(fund: FundSlug) {
const slugs = getProjectSlugs(fund)
const posts = slugs.map((slug) => getProjectBySlug(slug, fund))
return posts
}

7
utils/use-fund-slug.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useRouter } from 'next/router'
import { getFundSlugFromUrlPath } from './funds'
export function useFundSlug() {
const router = useRouter()
return getFundSlugFromUrlPath(router.asPath)
}