mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: multiple funds support
This commit is contained in:
33
.env.example
33
.env.example
@@ -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=""
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
56
env.mjs
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
33
pages/[fund]/about.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
27
pages/[fund]/apply_research.tsx
Normal file
27
pages/[fund]/apply_research.tsx
Normal 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
34
pages/[fund]/faq.tsx
Normal 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
27
pages/[fund]/privacy.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
27
pages/[fund]/terms.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
11
pages/api/btcpay/firo-webhook.ts
Normal file
11
pages/api/btcpay/firo-webhook.ts
Normal 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)
|
||||
11
pages/api/btcpay/general-webhook.ts
Normal file
11
pages/api/btcpay/general-webhook.ts
Normal 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)
|
||||
11
pages/api/btcpay/monero-webhook.ts
Normal file
11
pages/api/btcpay/monero-webhook.ts
Normal 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)
|
||||
11
pages/api/btcpay/privacy-guides-webhook.ts
Normal file
11
pages/api/btcpay/privacy-guides-webhook.ts
Normal 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)
|
||||
@@ -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 })
|
||||
}
|
||||
10
pages/api/stripe/firo-webhook.ts
Normal file
10
pages/api/stripe/firo-webhook.ts
Normal 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)
|
||||
10
pages/api/stripe/general-webhook.ts
Normal file
10
pages/api/stripe/general-webhook.ts
Normal 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)
|
||||
10
pages/api/stripe/monero-webhook.ts
Normal file
10
pages/api/stripe/monero-webhook.ts
Normal 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)
|
||||
10
pages/api/stripe/privacy-guides-webhook.ts
Normal file
10
pages/api/stripe/privacy-guides-webhook.ts
Normal 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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
196
pages/index.tsx
196
pages/index.tsx
@@ -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 →
|
||||
</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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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're incredibly grateful to contributors like you working to
|
||||
support Monero, Bitcoin and other free and open source projects.
|
||||
We'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'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'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's Lead Contributor or
|
||||
Maintainer
|
||||
If someone else, please list the project'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'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'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
197
pages/monero/index.tsx
Normal 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 →
|
||||
</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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
@@ -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: {
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
153
server/utils/webhooks.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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
46
utils/funds.ts
Normal 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
|
||||
}
|
||||
23
utils/md.ts
23
utils/md.ts
@@ -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
7
utils/use-fund-slug.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { getFundSlugFromUrlPath } from './funds'
|
||||
|
||||
export function useFundSlug() {
|
||||
const router = useRouter()
|
||||
return getFundSlugFromUrlPath(router.asPath)
|
||||
}
|
||||
Reference in New Issue
Block a user