Merge pull request #159 from MAGICGrants/general-refactor

General refactor
This commit is contained in:
Artur
2025-04-04 11:20:14 -03:00
committed by GitHub
29 changed files with 1388 additions and 1046 deletions

View File

@@ -1,27 +1,45 @@
import { networkFor, SocialIcon } from 'react-social-icons'
import { ReactNode } from 'react'
import { ReactNode, SVGProps } from 'react'
import { FundSlug } from '@prisma/client'
import Image from 'next/image'
import { ProjectItem } from '../utils/types'
import CustomLink from './CustomLink'
import WebIcon from './WebIcon'
import MagicLogo from './MagicLogo'
import MoneroLogo from './MoneroLogo'
import FiroLogo from './FiroLogo'
import PrivacyGuidesLogo from './PrivacyGuidesLogo'
interface Props {
project: ProjectItem
children: ReactNode
}
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
monero: MoneroLogo,
firo: FiroLogo,
privacyguides: PrivacyGuidesLogo,
general: MagicLogo,
}
export default function PageHeading({ project, children }: Props) {
const PlaceholderImage = placeholderImages[project.fund]
return (
<div className="divide-y divide-gray-200">
<div className="items-start space-y-2 pb-8 pt-6 md:space-y-5 xl:grid xl:grid-cols-3 xl:gap-x-8">
<Image
src={project.coverImage}
alt="avatar"
width={300}
height={300}
className="h-60 w-60 mx-auto my-auto object-contain row-span-3 hidden xl:block"
/>
{project.coverImage ? (
<Image
src={project.coverImage}
alt="avatar"
width={300}
height={300}
className="h-60 w-60 mx-auto my-auto object-contain row-span-3 hidden xl:block"
/>
) : (
<PlaceholderImage className="w-60 h-60 mx-auto my-auto object-contain row-span-3 hidden xl:block" />
)}
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14 xl:col-span-2">
{!!project.website && (

View File

@@ -1,6 +1,10 @@
type ProgressProps = { current: number; goal: number }
import { formatUsd } from '../utils/money-formating'
const Progress = ({ current, goal }: ProgressProps) => {
type ProgressProps = { current: number; goal: number; percentOnly?: boolean }
const numberFormat = Intl.NumberFormat('en', { notation: 'compact', compactDisplay: 'short' })
const Progress = ({ current, goal, percentOnly }: ProgressProps) => {
const percent = Math.floor((current / goal) * 100)
return (
@@ -12,7 +16,14 @@ const Progress = ({ current, goal }: ProgressProps) => {
/>
</div>
<span className="text-sm font-semibold">{percent < 100 ? percent : 100}%</span>
<span className="text-sm">
Raised <strong>{percent < 100 ? percent : 100}%</strong>{' '}
{!percentOnly && (
<>
of <strong className="text-green-500">${numberFormat.format(goal)}</strong> Goal
</>
)}
</span>
</div>
)
}

View File

@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, SVGProps } from 'react'
import { FundSlug } from '@prisma/client'
import Image from 'next/image'
import Link from 'next/link'
import { ProjectItem } from '../utils/types'
import { cn } from '../utils/cn'
import Progress from './Progress'
import MoneroLogo from './MoneroLogo'
import FiroLogo from './FiroLogo'
import PrivacyGuidesLogo from './PrivacyGuidesLogo'
import MagicLogo from './MagicLogo'
const numberFormat = Intl.NumberFormat('en', { notation: 'compact', compactDisplay: 'short' })
@@ -13,21 +18,15 @@ export type ProjectCardProps = {
customImageStyles?: React.CSSProperties
}
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
monero: MoneroLogo,
firo: FiroLogo,
privacyguides: PrivacyGuidesLogo,
general: MagicLogo,
}
const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles }) => {
const [isHorizontal, setIsHorizontal] = useState<boolean | null>(null)
useEffect(() => {
const img = document.createElement('img')
img.src = project.coverImage
// check if image is horizontal - added additional 10% to height to ensure only true
// horizontals get flagged.
img.onload = () => {
const { naturalWidth, naturalHeight } = img
const isHorizontal = naturalWidth >= naturalHeight * 1.1
setIsHorizontal(isHorizontal)
}
}, [project.coverImage])
const PlaceholderImage = placeholderImages[project.fund]
return (
<Link href={`/${project.fund}/projects/${project.slug}`} passHref target="_blank">
@@ -40,16 +39,20 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles })
project.fund === 'general' && 'border-primary'
)}
>
<div className="flex h-36 w-full sm:h-52">
<Image
alt={project.title}
src={project.coverImage}
width={1200}
height={1200}
style={{ objectFit: 'contain', ...customImageStyles }}
priority={true}
className="cursor-pointer rounded-t-xl bg-white"
/>
<div className="flex h-48 w-full sm:h-52">
{project.coverImage ? (
<Image
alt={project.title}
src={project.coverImage}
width={700}
height={700}
style={{ objectFit: 'contain', ...customImageStyles }}
priority={true}
className="cursor-pointer rounded-t-xl bg-white"
/>
) : (
<PlaceholderImage className="w-1/2 h-full max-h-full m-auto cursor-pointer rounded-t-xl bg-white" />
)}
</div>
<figcaption className="p-5 flex flex-col grow space-y-4 justify-between">
@@ -73,6 +76,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, customImageStyles })
project.totalDonationsFiat
}
goal={project.goal}
percentOnly
/>
</figcaption>
</figure>

View File

@@ -6,3 +6,4 @@ export const MONTHLY_MEMBERSHIP_MIN_PRICE_USD = 10
export const ANNUALLY_MEMBERSHIP_MIN_PRICE_USD = 100
export const POINTS_PER_USD = 1
export const POINTS_REDEEM_PRICE_USD = 0.1
export const NET_DONATION_AMOUNT_WITH_POINTS_RATE = 0.9

View File

@@ -7,7 +7,7 @@ services:
magic-btcpayserver:
restart: unless-stopped
container_name: magic-btcpayserver
image: ${BTCPAY_IMAGE:-btcpayserver/btcpayserver:1.13.3-altcoins}
image: ${BTCPAY_IMAGE:-btcpayserver/btcpayserver:2.0.8}
expose:
- '49392'
environment:
@@ -25,7 +25,7 @@ services:
BTCPAY_DOCKERDEPLOYMENT: 'true'
BTCPAY_CHAINS: 'xmr'
BTCPAY_XMR_DAEMON_URI: http://xmr-node.cakewallet.com:18081
BTCPAY_XMR_WALLET_DAEMON_URI: http://monerod_wallet:18082
BTCPAY_XMR_WALLET_DAEMON_URI: http://magic-monerod-wallet:18088
BTCPAY_XMR_WALLET_DAEMON_WALLETDIR: /root/xmr_wallet
labels:
traefik.enable: 'true'
@@ -47,11 +47,9 @@ services:
restart: unless-stopped
container_name: magic-monerod-wallet
image: btcpayserver/monero:0.18.3.3
entrypoint: monero-wallet-rpc --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=xmr-node.cakewallet.com:18081 --wallet-file=/wallet/wallet --password-file /wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -X GET http://btcpayserver:49392/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
entrypoint: monero-wallet-rpc --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18088 --non-interactive --trusted-daemon --daemon-address=xmr-node.cakewallet.com:18081 --wallet-file=/wallet/wallet --password-file /wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -X GET http://magic-btcpayserver:49392/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
expose:
- '18082'
ports:
- 18082:18082
- '18088'
volumes:
- 'xmr_wallet:/wallet'

234
package-lock.json generated
View File

@@ -139,13 +139,14 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.24.7",
"@babel/helper-validator-identifier": "^7.25.9",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
@@ -445,9 +446,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -455,9 +456,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -490,43 +491,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.25.0",
"@babel/types": "^7.25.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/highlight": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.7",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.6"
"@babel/types": "^7.27.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -2018,9 +2003,9 @@
"license": "MIT"
},
"node_modules/@babel/runtime": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz",
"integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -2030,15 +2015,15 @@
}
},
"node_modules/@babel/template": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
"integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/parser": "^7.25.0",
"@babel/types": "^7.25.0"
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
@@ -2064,15 +2049,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2869,9 +2853,9 @@
]
},
"node_modules/@next/env": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.23.tgz",
"integrity": "sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.26.tgz",
"integrity": "sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2885,9 +2869,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz",
"integrity": "sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.26.tgz",
"integrity": "sha512-zDJY8gsKEseGAxG+C2hTMT0w9Nk9N1Sk1qV7vXYz9MEiyRoF5ogQX2+vplyUMIfygnjn9/A04I6yrUTRTuRiyQ==",
"cpu": [
"arm64"
],
@@ -2901,9 +2885,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz",
"integrity": "sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.26.tgz",
"integrity": "sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==",
"cpu": [
"x64"
],
@@ -2917,9 +2901,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz",
"integrity": "sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.26.tgz",
"integrity": "sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==",
"cpu": [
"arm64"
],
@@ -2933,9 +2917,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz",
"integrity": "sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.26.tgz",
"integrity": "sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==",
"cpu": [
"arm64"
],
@@ -2949,9 +2933,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz",
"integrity": "sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.26.tgz",
"integrity": "sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==",
"cpu": [
"x64"
],
@@ -2965,9 +2949,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz",
"integrity": "sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.26.tgz",
"integrity": "sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==",
"cpu": [
"x64"
],
@@ -2981,9 +2965,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz",
"integrity": "sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.26.tgz",
"integrity": "sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==",
"cpu": [
"arm64"
],
@@ -2997,9 +2981,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz",
"integrity": "sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz",
"integrity": "sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==",
"cpu": [
"ia32"
],
@@ -3013,9 +2997,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz",
"integrity": "sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.26.tgz",
"integrity": "sha512-2rdB3T1/Gp7bv1eQTTm9d1Y1sv9UuJ2LAwOE0Pe2prHKe32UNscj7YS13fRB37d0GAiGNR+Y7ZcW8YjDI8Ns0w==",
"cpu": [
"x64"
],
@@ -5342,19 +5326,6 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -5661,9 +5632,9 @@
}
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -5976,21 +5947,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -6499,16 +6455,6 @@
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
@@ -7454,16 +7400,6 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/eslint": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
@@ -10815,12 +10751,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.23.tgz",
"integrity": "sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==",
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.26.tgz",
"integrity": "sha512-b81XSLihMwCfwiUVRRja3LphLo4uBBMZEzBBWMaISbKTwOmq3wPknIETy/8000tr7Gq4WmbuFYPS7jOYIf+ZJw==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.23",
"@next/env": "14.2.26",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -10835,15 +10771,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.23",
"@next/swc-darwin-x64": "14.2.23",
"@next/swc-linux-arm64-gnu": "14.2.23",
"@next/swc-linux-arm64-musl": "14.2.23",
"@next/swc-linux-x64-gnu": "14.2.23",
"@next/swc-linux-x64-musl": "14.2.23",
"@next/swc-win32-arm64-msvc": "14.2.23",
"@next/swc-win32-ia32-msvc": "14.2.23",
"@next/swc-win32-x64-msvc": "14.2.23"
"@next/swc-darwin-arm64": "14.2.26",
"@next/swc-darwin-x64": "14.2.26",
"@next/swc-linux-arm64-gnu": "14.2.26",
"@next/swc-linux-arm64-musl": "14.2.26",
"@next/swc-linux-x64-gnu": "14.2.26",
"@next/swc-linux-x64-musl": "14.2.26",
"@next/swc-win32-arm64-msvc": "14.2.26",
"@next/swc-win32-ia32-msvc": "14.2.26",
"@next/swc-win32-x64-msvc": "14.2.26"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -13271,16 +13207,6 @@
"readable-stream": "3"
}
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { GetStaticProps, GetStaticPropsContext } from 'next'
import { SVGProps, useEffect, useRef, useState } from 'react'
import { GetStaticPropsContext } from 'next'
import Link from 'next/link'
import Head from 'next/head'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { faMonero } from '@fortawesome/free-brands-svg-icons'
@@ -12,7 +14,6 @@ import { z } from 'zod'
import Image from 'next/image'
import { MAX_AMOUNT } from '../../../config'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { trpc } from '../../../utils/trpc'
import Spinner from '../../../components/Spinner'
import { useToast } from '../../../components/ui/use-toast'
@@ -32,15 +33,25 @@ import CustomLink from '../../../components/CustomLink'
import { getProjectBySlug, getProjects } from '../../../utils/md'
import { funds, fundSlugs } from '../../../utils/funds'
import { ProjectItem } from '../../../utils/types'
import Link from 'next/link'
import Head from 'next/head'
import MoneroLogo from '../../../components/MoneroLogo'
import FiroLogo from '../../../components/FiroLogo'
import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo'
import MagicLogo from '../../../components/MagicLogo'
type QueryParams = { fund: FundSlug; slug: string }
type Props = { project: ProjectItem } & QueryParams
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
monero: MoneroLogo,
firo: FiroLogo,
privacyguides: PrivacyGuidesLogo,
general: MagicLogo,
}
function DonationPage({ fund: fundSlug, slug, project }: Props) {
const session = useSession()
const isAuthed = session.status === 'authenticated'
const PlaceholderImage = placeholderImages[project.fund]
const schema = z
.object({
@@ -153,14 +164,21 @@ function DonationPage({ fund: fundSlug, slug, project }: Props) {
<div className="max-w-[540px] mx-auto p-6 space-y-6 rounded-lg bg-white">
<div className="py-4 flex flex-col space-y-6">
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
<Image
alt={project.title}
src={project.coverImage}
width={200}
height={96}
objectFit="cover"
className="w-36 rounded-lg"
/>
{project.coverImage ? (
<Image
alt={project.title}
src={project.coverImage}
width={200}
height={96}
objectFit="cover"
className="w-36 rounded-lg"
/>
) : (
<div className="w-52">
<PlaceholderImage className="w-20 h-20 m-auto" />
</div>
)}
<div className="flex flex-col justify-center">
<h2 className="text-center sm:text-left font-semibold">Donate to {project.title}</h2>
<h3 className="text-gray-500">Pledge your support</h3>

View File

@@ -189,7 +189,7 @@ function MembershipPage({ fund: fundSlug, project }: Props) {
<div className="flex flex-col items-center sm:space-x-4 sm:flex-row">
<Image
alt={project.title}
src={project.coverImage}
src={project.coverImage!}
width={200}
height={96}
objectFit="cover"

View File

@@ -65,7 +65,7 @@ import { trpc } from '../../../utils/trpc'
import { cn } from '../../../utils/cn'
import { strapiApi } from '../../../server/services'
import { GetServerSidePropsContext } from 'next'
import { getUserPointBalance } from '../../../server/utils/perks'
import { getPointsBalance } from '../../../server/utils/perks'
import { getServerSession } from 'next-auth'
import { authOptions } from '../../api/auth/[...nextauth]'
@@ -75,26 +75,28 @@ const pointFormat = Intl.NumberFormat('en', { notation: 'standard', compactDispl
const schema = z
.object({
shippingAddressLine1: z.string().min(1),
shippingAddressLine2: z.string(),
shippingCity: z.string().min(1),
shippingState: z.string(),
shippingCountry: z.string().min(1),
shippingZip: z.string().min(1),
shippingPhone: z
.string()
.min(1)
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
shippingTaxNumber: z.string(),
printfulSyncVariantId: z.string().optional(),
_shippingStateOptionsLength: z.number(),
_useAccountMailingAddress: z.boolean(),
shipping: z.object({
addressLine1: z.string().min(1),
addressLine2: z.string(),
city: z.string().min(1),
stateCode: z.string(),
countryCode: z.string().min(1),
zip: z.string().min(1),
phone: z
.string()
.min(1)
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
taxNumber: z.string(),
}),
printfulSyncVariantId: z.string().optional(),
})
.superRefine((data, ctx) => {
const cpfRegex = /(^\d{3}\.\d{3}\.\d{3}\-\d{2}$)|(^\d{2}\.\d{3}\.\d{3}\/\d{4}\-\d{2}$)/
if (data.shippingCountry === 'BR') {
if (data.shippingTaxNumber.length < 1) {
if (data.shipping.countryCode === 'BR') {
if (data.shipping.taxNumber.length < 1) {
ctx.addIssue({
path: ['shippingTaxNumber'],
code: 'custom',
@@ -103,7 +105,7 @@ const schema = z
return
}
if (!cpfRegex.test(data.shippingTaxNumber)) {
if (!cpfRegex.test(data.shipping.taxNumber)) {
ctx.addIssue({
path: ['shippingTaxNumber'],
code: 'custom',
@@ -114,7 +116,7 @@ const schema = z
}
})
.superRefine((data, ctx) => {
if (!data.shippingState && data._shippingStateOptionsLength) {
if (!data.shipping.stateCode && data._shippingStateOptionsLength) {
ctx.addIssue({
path: ['shippingState'],
code: 'custom',
@@ -147,14 +149,16 @@ function Perk({ perk, balance }: Props) {
defaultValues: {
_shippingStateOptionsLength: 0,
_useAccountMailingAddress: false,
shippingAddressLine1: '',
shippingAddressLine2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingZip: '',
shippingPhone: '',
shippingTaxNumber: '',
shipping: {
addressLine1: '',
addressLine2: '',
city: '',
stateCode: '',
countryCode: '',
zip: '',
phone: '',
taxNumber: '',
},
},
shouldFocusError: false,
})
@@ -170,8 +174,8 @@ function Perk({ perk, balance }: Props) {
value: country.code,
}))
const shippingCountry = form.watch('shippingCountry')
const shippingState = form.watch('shippingState')
const shippingCountry = form.watch('shipping.countryCode')
const shippingState = form.watch('shipping.stateCode')
const printfulSyncVariantId = form.watch('printfulSyncVariantId')
const useAccountMailingAddress = form.watch('_useAccountMailingAddress')
@@ -190,8 +194,8 @@ function Perk({ perk, balance }: Props) {
}, [shippingCountry])
useEffect(() => {
form.setValue('shippingState', '')
form.setValue('shippingTaxNumber', '')
form.setValue('shipping.stateCode', '')
form.setValue('shipping.taxNumber', '')
}, [shippingCountry])
useEffect(() => {
@@ -202,19 +206,22 @@ function Perk({ perk, balance }: Props) {
if (!getUserAttributesQuery.data) return
if (useAccountMailingAddress) {
form.setValue('shippingAddressLine1', getUserAttributesQuery.data.addressLine1)
form.setValue('shippingAddressLine2', getUserAttributesQuery.data.addressLine2)
form.setValue('shippingCountry', getUserAttributesQuery.data.addressCountry)
form.setValue('shippingCity', getUserAttributesQuery.data.addressCity)
form.setValue('shippingZip', getUserAttributesQuery.data.addressZip)
setTimeout(() => form.setValue('shippingState', getUserAttributesQuery.data.addressState), 20)
form.setValue('shipping.addressLine1', getUserAttributesQuery.data.addressLine1)
form.setValue('shipping.addressLine2', getUserAttributesQuery.data.addressLine2)
form.setValue('shipping.countryCode', getUserAttributesQuery.data.addressCountry)
form.setValue('shipping.city', getUserAttributesQuery.data.addressCity)
form.setValue('shipping.zip', getUserAttributesQuery.data.addressZip)
setTimeout(
() => form.setValue('shipping.stateCode', getUserAttributesQuery.data.addressState),
100
)
} else {
form.setValue('shippingAddressLine1', '')
form.setValue('shippingAddressLine2', '')
form.setValue('shippingCountry', '')
form.setValue('shippingState', '')
form.setValue('shippingCity', '')
form.setValue('shippingZip', '')
form.setValue('shipping.addressLine1', '')
form.setValue('shipping.addressLine2', '')
form.setValue('shipping.countryCode', '')
form.setValue('shipping.stateCode', '')
form.setValue('shipping.city', '')
form.setValue('shipping.zip', '')
}
}, [useAccountMailingAddress])
@@ -226,7 +233,6 @@ function Perk({ perk, balance }: Props) {
try {
const _costEstimate = await estimatePrintfulOrderCosts.mutateAsync({
...data,
printfulSyncVariantId: Number(data.printfulSyncVariantId),
})
@@ -424,7 +430,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingAddressLine1"
name="shipping.addressLine1"
render={({ field }) => (
<FormItem>
<FormLabel>Address line 1 *</FormLabel>
@@ -438,7 +444,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingAddressLine2"
name="shipping.addressLine2"
render={({ field }) => (
<FormItem>
<FormLabel>Address line 2</FormLabel>
@@ -452,7 +458,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingCountry"
name="shipping.countryCode"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Country *</FormLabel>
@@ -493,7 +499,7 @@ function Perk({ perk, balance }: Props) {
value={country.label}
key={country.value}
onSelect={() => (
form.setValue('shippingCountry', country.value, {
form.setValue('shipping.countryCode', country.value, {
shouldValidate: true,
}),
setCountrySelectOpen(false)
@@ -523,7 +529,7 @@ function Perk({ perk, balance }: Props) {
{!!shippingStateOptions.length && (
<FormField
control={form.control}
name="shippingState"
name="shipping.stateCode"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>State *</FormLabel>
@@ -560,7 +566,7 @@ function Perk({ perk, balance }: Props) {
value={state.label}
key={state.value}
onSelect={() => (
form.setValue('shippingState', state.value, {
form.setValue('shipping.stateCode', state.value, {
shouldValidate: true,
}),
setStateSelectOpen(false)
@@ -590,7 +596,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingCity"
name="shipping.city"
render={({ field }) => (
<FormItem>
<FormLabel>City *</FormLabel>
@@ -604,7 +610,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingZip"
name="shipping.zip"
render={({ field }) => (
<FormItem>
<FormLabel>Postal code *</FormLabel>
@@ -618,7 +624,7 @@ function Perk({ perk, balance }: Props) {
<FormField
control={form.control}
name="shippingPhone"
name="shipping.phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone number *</FormLabel>
@@ -633,7 +639,7 @@ function Perk({ perk, balance }: Props) {
{shippingCountry === 'BR' && (
<FormField
control={form.control}
name="shippingTaxNumber"
name="shipping.taxNumber"
render={({ field }) => (
<FormItem className="space-y-0">
<FormLabel>Tax number (Brazilian CPF/CNPJ) *</FormLabel>
@@ -746,7 +752,7 @@ export async function getServerSideProps({ params, req, res }: GetServerSideProp
data: { data: perk },
},
] = await Promise.all([
getUserPointBalance(session.user.sub),
getPointsBalance(session.user.sub),
strapiApi.get<StrapiGetPerkPopulatedRes>(
`/perks/${params?.id!}?populate[images][fields]=formats`
),

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { SVGProps } from 'react'
import { FundSlug } from '@prisma/client'
import { useRouter } from 'next/router'
import { GetServerSidePropsContext, NextPage } from 'next/types'
import Head from 'next/head'
@@ -15,12 +15,17 @@ import PageHeading from '../../../components/PageHeading'
import Progress from '../../../components/Progress'
import { prisma } from '../../../server/services'
import { Button } from '../../../components/ui/button'
import CustomLink from '../../../components/CustomLink'
import { trpc } from '../../../utils/trpc'
import { getFundSlugFromUrlPath } from '../../../utils/funds'
import { funds, getFundSlugFromUrlPath } from '../../../utils/funds'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { Table, TableBody, TableCell, TableRow } from '../../../components/ui/table'
import { cn } from '../../../utils/cn'
import { DonationCryptoPayments } from '../../../server/types'
import { formatBtc, formatUsd } from '../../../utils/money-formating'
import MagicLogo from '../../../components/MagicLogo'
import MoneroLogo from '../../../components/MoneroLogo'
import FiroLogo from '../../../components/FiroLogo'
import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo'
type SingleProjectPageProps = {
project: ProjectItem
@@ -28,33 +33,20 @@ type SingleProjectPageProps = {
donationStats: ProjectDonationStats
}
const placeholderImages: Record<FundSlug, (props: SVGProps<SVGSVGElement>) => JSX.Element> = {
monero: MoneroLogo,
firo: FiroLogo,
privacyguides: PrivacyGuidesLogo,
general: MagicLogo,
}
const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) => {
const router = useRouter()
const [registerIsOpen, setRegisterIsOpen] = useState(false)
const [loginIsOpen, setLoginIsOpen] = useState(false)
const [passwordResetIsOpen, setPasswordResetIsOpen] = useState(false)
const session = useSession()
const fundSlug = useFundSlug()
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
function formatBtc(bitcoin: number) {
if (bitcoin > 0.1) {
return `${bitcoin.toFixed(3) || 0.0} BTC`
} else {
return `${Math.floor(bitcoin * 100000000).toLocaleString()} sats`
}
}
function formatUsd(dollars: number): string {
if (dollars == 0) {
return '$0'
} else if (dollars / 1000 > 1) {
return `$${Math.round(dollars / 1000)}k+`
} else {
return `$${dollars.toFixed(0)}`
}
}
const PlaceholderImage = placeholderImages[project.fund]
if (!router.isFallback && !slug) {
return <ErrorPage statusCode={404} />
@@ -68,21 +60,27 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
return (
<>
<Head>
<title>Monero Fund | {project.title}</title>
<title>
{project.title} - {funds[project.fund].title}
</title>
</Head>
<div className="divide-y divide-gray-200">
<PageHeading project={project}>
<div className="w-full flex flex-col items-center gap-4 xl:flex">
<Image
src={coverImage}
alt="avatar"
width={700}
height={700}
className="w-full max-w-[700px] mx-auto object-contain xl:hidden"
/>
{coverImage ? (
<Image
src={coverImage}
alt="avatar"
width={700}
height={700}
className="w-full max-w-[700px] mx-auto object-contain xl:hidden"
/>
) : (
<PlaceholderImage className="w-full max-w-[700px] mx-auto object-contain xl:hidden" />
)}
<div className="w-full max-w-96 space-y-8 p-6 bg-white rounded-lg">
<div className="w-full max-w-96 space-y-6 p-6 bg-white rounded-lg">
{!project.isFunded && (
<div className="w-full">
<Link href={`/${fundSlug}/donate/${project.slug}`}>
@@ -91,47 +89,52 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
</div>
)}
<div className="w-full">
<h1 className="mb-4 font-bold">Raised</h1>
<Progress
current={
donationStats.xmr.fiatAmount +
donationStats.btc.fiatAmount +
donationStats.usd.fiatAmount
}
goal={goal}
/>
<Progress
current={
donationStats.xmr.fiatAmount +
donationStats.btc.fiatAmount +
donationStats.usd.fiatAmount
}
goal={goal}
/>
<ul className="font-semibold space-y-1">
<li className="flex items-center space-x-1">
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
<span className="font-normal text-sm text-gray">
in{' '}
{donationStats.xmr.count + donationStats.btc.count + donationStats.usd.count}{' '}
donations total
</span>
</li>
<li>
{donationStats.xmr.amount} XMR{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.xmr.count} donations
</span>
</li>
<li>
{formatBtc(donationStats.btc.amount)}{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.btc.count} donations
</span>
</li>
<li>
{`${formatUsd(donationStats.usd.amount)}`} Fiat{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.usd.count} donations
</span>
</li>
</ul>
</div>
<ul className="font-semibold">
<li className="flex items-center space-x-1">
<span className="text-green-500 text-xl">{`${formatUsd(donationStats.xmr.fiatAmount + donationStats.btc.fiatAmount + donationStats.ltc.fiatAmount + donationStats.manual.fiatAmount + donationStats.usd.fiatAmount)}`}</span>{' '}
<span className="font-normal text-sm text-gray">
in{' '}
{donationStats.xmr.count +
donationStats.btc.count +
donationStats.manual.count +
donationStats.usd.count}{' '}
donations total
</span>
</li>
<li>
{donationStats.xmr.amount.toFixed(2)} XMR{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.xmr.count} donations
</span>
</li>
<li>
{formatBtc(donationStats.btc.amount)}{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.btc.count} donations
</span>
</li>
<li>
{donationStats.ltc.amount.toFixed(2)} LTC{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.ltc.count} donations
</span>
</li>
<li>
{`${formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}`}{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.usd.count + donationStats.manual.count} donations
</span>
</li>
</ul>
</div>
<div className="w-full max-w-96 min-h-72 space-y-4 p-6 bg-white rounded-lg">
@@ -200,6 +203,16 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
amount: project.isFunded ? project.totalDonationsBTC : 0,
fiatAmount: project.isFunded ? project.totalDonationsBTCInFiat : 0,
},
ltc: {
count: project.isFunded ? project.numDonationsLTC : 0,
amount: project.isFunded ? project.totalDonationsLTC : 0,
fiatAmount: project.isFunded ? project.totalDonationsLTCInFiat : 0,
},
manual: {
count: project.isFunded ? project.numDonationsManual : 0,
amount: project.isFunded ? project.totalDonationsManual : 0,
fiatAmount: project.isFunded ? project.totalDonationsManual : 0,
},
usd: {
count: project.isFunded ? project.numDonationsFiat : 0,
amount: project.isFunded ? project.totalDonationsFiat : 0,
@@ -212,20 +225,23 @@ export async function getServerSideProps({ params, resolvedUrl }: GetServerSideP
where: { projectSlug: params.slug as string, fundSlug },
})
const cryptoCodeToStats = {
BTC: donationStats.btc,
XMR: donationStats.xmr,
LTC: donationStats.ltc,
MANUAL: donationStats.manual,
} as const
donations.forEach((donation) => {
if (donation.cryptoCode === 'XMR') {
donationStats.xmr.count += 1
donationStats.xmr.amount += donation.netCryptoAmount || 0
donationStats.xmr.fiatAmount += donation.netFiatAmount
}
;(donation.cryptoPayments as DonationCryptoPayments | null)?.forEach((payment) => {
const stats = cryptoCodeToStats[payment.cryptoCode]
if (donation.cryptoCode === 'BTC') {
donationStats.btc.count += 1
donationStats.btc.amount += donation.netCryptoAmount || 0
donationStats.btc.fiatAmount += donation.netFiatAmount
}
stats.count += 1
stats.amount += payment.netAmount
stats.fiatAmount += payment.netAmount * payment.rate
})
if (donation.cryptoCode === null) {
if (!donation.cryptoPayments) {
donationStats.usd.count += 1
donationStats.usd.amount += donation.netFiatAmount
donationStats.usd.fiatAmount += donation.netFiatAmount

View File

@@ -7,22 +7,20 @@ import {
BtcPayGetRatesRes,
BtcPayGetPaymentMethodsRes,
DonationMetadata,
StrapiCreatePointBody,
DonationCryptoPayments,
} from '../../../server/types'
import {
btcpayApi as _btcpayApi,
btcpayApi,
prisma,
privacyGuidesDiscourseApi,
strapiApi,
} from '../../../server/services'
import { btcpayApi as _btcpayApi, btcpayApi, prisma } from '../../../server/services'
import { env } from '../../../env.mjs'
import { getUserPointBalance } from '../../../server/utils/perks'
import { givePointsToUser } from '../../../server/utils/perks'
import { sendDonationConfirmationEmail } from '../../../server/utils/mailing'
import { POINTS_PER_USD } from '../../../config'
import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../../config'
import { getDonationAttestation, getMembershipAttestation } from '../../../server/utils/attestation'
import { funds } from '../../../utils/funds'
import { addUserToPgMembersGroup } from '../../../utils/pg-forum-connection'
import { log } from '../../../utils/logging'
import {
getBtcPayInvoice,
getBtcPayInvoicePaymentMethods,
} from '../../../server/utils/btcpayserver'
export const config = {
api: {
@@ -30,7 +28,8 @@ export const config = {
},
}
type BtcpayBody = Record<string, any> & {
type WebhookBody = Record<string, any> & {
manuallyMarked: boolean
deliveryId: string
webhookId: string
originalDeliveryId: string
@@ -43,6 +42,237 @@ type BtcpayBody = Record<string, any> & {
paymentMethod: string
}
async function handleFundingRequiredApiDonation(body: WebhookBody) {
if (!body.metadata || JSON.stringify(body.metadata) === '{}') return
const existingDonation = await prisma.donation.findFirst({
where: { btcPayInvoiceId: body.invoiceId },
})
if (existingDonation) {
log(
'warn',
`[BTCPay webhook] Attempted to process already processed invoice ${body.invoiceId}.`
)
return
}
// Handle payment methods like "BTC-LightningNetwork" if added in the future
const cryptoCode = body.paymentMethod.includes('-')
? body.paymentMethod.split('-')[0]
: body.paymentMethod
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
`/rates?currencyPair=${cryptoCode}_USD`
)
const cryptoRate = Number(rates[0].rate)
const cryptoAmount = Number(body.payment.value)
const fiatAmount = Number((cryptoAmount * cryptoRate).toFixed(2))
await prisma.donation.create({
data: {
userId: null,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
grossFiatAmount: fiatAmount,
netFiatAmount: fiatAmount,
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
donorName: body.metadata.donorName,
},
})
log('info', `[BTCPay webhook] Successfully processed invoice ${body.invoiceId}!`)
}
// This handles both donations and memberships.
async function handleDonationOrMembership(body: WebhookBody) {
if (!body.metadata || JSON.stringify(body.metadata) === '{}') return
const existingDonation = await prisma.donation.findFirst({
where: { btcPayInvoiceId: body.invoiceId },
})
if (existingDonation) {
log(
'warn',
`[BTCPay webhook] Attempted to process already processed invoice ${body.invoiceId}.`
)
return
}
const termToMembershipExpiresAt = {
monthly: dayjs().add(1, 'month').toDate(),
annually: dayjs().add(1, 'year').toDate(),
} as const
let membershipExpiresAt = null
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
membershipExpiresAt = termToMembershipExpiresAt[body.metadata.membershipTerm]
}
const cryptoPayments: DonationCryptoPayments = []
const paymentMethods = await getBtcPayInvoicePaymentMethods(body.invoiceId)
const shouldGivePointsBack = body.metadata.givePointsBack === 'true'
// Get how much was paid for each crypto
paymentMethods.forEach((paymentMethod) => {
if (!body.metadata) return
const cryptoRate = Number(paymentMethod.rate)
const grossCryptoAmount = Number(paymentMethod.paymentMethodPaid)
// Deduct 10% of amount if donator wants points
const netCryptoAmount = shouldGivePointsBack
? grossCryptoAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE
: grossCryptoAmount
// Move on if amound paid with current method is 0
if (!grossCryptoAmount) return
cryptoPayments.push({
cryptoCode: paymentMethod.currency,
grossAmount: grossCryptoAmount,
netAmount: netCryptoAmount,
rate: cryptoRate,
})
})
// Handle marked paid invoice
if (body.manuallyMarked) {
const invoice = await getBtcPayInvoice(body.invoiceId)
const amountPaidFiat = cryptoPayments.reduce(
(total, paymentMethod) => total + paymentMethod.grossAmount * paymentMethod.rate,
0
)
const invoiceAmountFiat = Number(invoice.amount)
const amountDueFiat = invoiceAmountFiat - amountPaidFiat
if (amountDueFiat > 0) {
cryptoPayments.push({
cryptoCode: 'MANUAL',
grossAmount: amountDueFiat,
netAmount: shouldGivePointsBack
? amountDueFiat * NET_DONATION_AMOUNT_WITH_POINTS_RATE
: amountDueFiat,
rate: 1,
})
}
}
const grossFiatAmount = cryptoPayments.reduce(
(total, paymentMethod) => total + paymentMethod.grossAmount * paymentMethod.rate,
0
)
const netFiatAmount = cryptoPayments.reduce(
(total, paymentMethod) => total + paymentMethod.netAmount * paymentMethod.rate,
0
)
const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
const donation = await prisma.donation.create({
data: {
userId: body.metadata.userId,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
cryptoPayments,
grossFiatAmount: Number(grossFiatAmount.toFixed(2)),
netFiatAmount: Number(netFiatAmount.toFixed(2)),
pointsAdded: pointsToGive,
membershipExpiresAt,
membershipTerm: body.metadata.membershipTerm || null,
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
donorName: body.metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && body.metadata.userId) {
try {
await givePointsToUser({ pointsToGive, donation })
} catch (error) {
log(
'error',
`[Stripe webhook] Failed to give points for invoice ${body.invoiceId}. Rolling back.`
)
await prisma.donation.delete({ where: { id: donation.id } })
throw error
}
}
// Add PG forum user to membership group
if (
body.metadata.isMembership &&
body.metadata.fundSlug === 'privacyguides' &&
body.metadata.userId
) {
try {
await addUserToPgMembersGroup(body.metadata.userId)
} catch (error) {
log(
'warn',
`[BTCPay webhook] Could not add user ${body.metadata.userId} to PG forum members group. Invoice: ${body.invoiceId}. NOT rolling back. Continuing... Cause:`
)
console.error(error)
}
}
if (body.metadata.donorEmail && body.metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
const attestation = await getMembershipAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
totalAmountToDate: grossFiatAmount,
donation,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (body.metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
donation,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
try {
await sendDonationConfirmationEmail({
to: body.metadata.donorEmail,
donorName: body.metadata.donorName,
donation,
attestationMessage,
attestationSignature,
})
} catch (error) {
log(
'warn',
`[BTCPay webhook] Failed to send donation confirmation email for invoice ${body.invoiceId}. NOT rolling back. Cause:`
)
console.error(error)
}
}
log('info', `[BTCPay webhook] Successfully processed invoice ${body.invoiceId}!`)
}
async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
@@ -56,7 +286,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
}
const rawBody = await getRawBody(req)
const body: BtcpayBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
const body: WebhookBody = JSON.parse(Buffer.from(rawBody).toString('utf8'))
const expectedSigHash = crypto
.createHmac('sha256', env.BTCPAY_WEBHOOK_SECRET)
@@ -67,7 +297,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
if (expectedSigHash !== incomingSigHash) {
console.error('Invalid signature')
res.status(400).json({ success: false })
res.status(401).json({ success: false })
return
}
@@ -81,35 +311,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ success: true })
}
// Handle payment methods like "BTC-LightningNetwork" if added in the future
const cryptoCode = body.paymentMethod.includes('-')
? body.paymentMethod.split('-')[0]
: body.paymentMethod
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
`/rates?currencyPair=${cryptoCode}_USD`
)
const cryptoRate = Number(rates[0].rate)
const cryptoAmount = Number(body.payment.value)
const fiatAmount = Number((cryptoAmount * cryptoRate).toFixed(2))
await prisma.donation.create({
data: {
userId: null,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
cryptoCode,
grossCryptoAmount: cryptoAmount,
grossFiatAmount: fiatAmount,
netCryptoAmount: cryptoAmount,
netFiatAmount: fiatAmount,
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
donorName: body.metadata.donorName,
},
})
await handleFundingRequiredApiDonation(body)
}
if (body.type === 'InvoiceSettled') {
@@ -118,143 +320,7 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ success: true })
}
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${body.invoiceId}/payment-methods`
)
let membershipExpiresAt = null
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm === 'monthly') {
membershipExpiresAt = dayjs().add(1, 'month').toDate()
}
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm === 'annually') {
membershipExpiresAt = dayjs().add(1, 'year').toDate()
}
// Create one donation and one point history for each invoice payment method
await Promise.all(
paymentMethods.map(async (paymentMethod) => {
if (!body.metadata) return
const shouldGivePointsBack = body.metadata.givePointsBack === 'true'
const cryptoRate = Number(paymentMethod.rate)
const grossCryptoAmount = Number(paymentMethod.paymentMethodPaid)
const grossFiatAmount = grossCryptoAmount * cryptoRate
// Deduct 10% of amount if donator wants points
const netCryptoAmount = shouldGivePointsBack ? grossCryptoAmount * 0.9 : grossCryptoAmount
const netFiatAmount = netCryptoAmount * cryptoRate
// Move on if amound paid with current method is 0
if (!grossCryptoAmount) return
const pointsAdded = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
// Add PG forum user to membership group
if (
body.metadata.isMembership &&
body.metadata.fundSlug === 'privacyguides' &&
body.metadata.userId
) {
await addUserToPgMembersGroup(body.metadata.userId)
}
const donation = 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: paymentMethod.currency,
grossCryptoAmount: Number(grossCryptoAmount.toFixed(2)),
grossFiatAmount: Number(grossFiatAmount.toFixed(2)),
netCryptoAmount: Number(netCryptoAmount.toFixed(2)),
netFiatAmount: Number(netFiatAmount.toFixed(2)),
pointsAdded,
membershipExpiresAt,
membershipTerm: body.metadata.membershipTerm || null,
showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true',
donorName: body.metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && body.metadata.userId) {
// Get balance for project/fund by finding user's last point history
const currentBalance = await getUserPointBalance(body.metadata.userId)
try {
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
data: {
balanceChange: pointsAdded.toString(),
balance: (currentBalance + pointsAdded).toString(),
userId: body.metadata.userId,
donationId: donation.id,
donationProjectName: donation.projectName,
donationProjectSlug: donation.projectSlug,
donationFundSlug: donation.fundSlug,
},
})
} catch (error) {
console.log((error as any).data.error)
throw error
}
}
if (body.metadata.donorEmail && body.metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (body.metadata.isMembership === 'true' && body.metadata.membershipTerm) {
const attestation = await getMembershipAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
term: body.metadata.membershipTerm,
method: paymentMethod.currency,
fundName: funds[body.metadata.fundSlug].title,
fundSlug: body.metadata.fundSlug,
periodStart: new Date(),
periodEnd: membershipExpiresAt!,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (body.metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: body.metadata.donorName,
donorEmail: body.metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
method: paymentMethod.currency,
fundName: funds[body.metadata.fundSlug].title,
fundSlug: body.metadata.fundSlug,
projectName: body.metadata.projectName,
date: new Date(),
donationId: donation.id,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
sendDonationConfirmationEmail({
to: body.metadata.donorEmail,
donorName: body.metadata.donorName,
fundSlug: body.metadata.fundSlug,
projectName: body.metadata.projectName,
isMembership: body.metadata.isMembership === 'true',
isSubscription: false,
pointsReceived: pointsAdded,
btcpayAsset: paymentMethod.currency,
btcpayCryptoAmount: grossCryptoAmount,
attestationMessage,
attestationSignature,
})
}
})
)
await handleDonationOrMembership(body)
}
res.status(200).json({ success: true })

View File

@@ -0,0 +1,17 @@
/*
Warnings:
- A unique constraint covering the columns `[btcPayInvoiceId]` on the table `Donation` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[stripePaymentIntentId]` on the table `Donation` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Donation" ADD COLUMN "cryptoPayments" JSONB,
ALTER COLUMN "grossCryptoAmount" SET DATA TYPE TEXT,
ALTER COLUMN "netCryptoAmount" SET DATA TYPE TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Donation_btcPayInvoiceId_key" ON "Donation"("btcPayInvoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "Donation_stripePaymentIntentId_key" ON "Donation"("stripePaymentIntentId");

View File

@@ -37,16 +37,14 @@ model Donation {
userId String?
donorName String?
btcPayInvoiceId String?
stripePaymentIntentId String? // For donations and non-recurring memberships
btcPayInvoiceId String? @unique
stripePaymentIntentId String? @unique // For donations and non-recurring memberships
stripeInvoiceId String? @unique // For recurring memberships
stripeSubscriptionId String? // For recurring memberships
projectSlug String
projectName String
fundSlug FundSlug
cryptoCode String?
grossCryptoAmount Float?
netCryptoAmount Float?
cryptoPayments Json?
grossFiatAmount Float
netFiatAmount Float
pointsAdded Int @default(0)
@@ -54,6 +52,10 @@ model Donation {
membershipTerm MembershipTerm?
showDonorNameOnLeaderboard Boolean? @default(false)
cryptoCode String?
grossCryptoAmount String?
netCryptoAmount String?
@@index([btcPayInvoiceId])
@@index([stripePaymentIntentId])
@@index([stripeSubscriptionId])

View File

@@ -4,6 +4,7 @@ import { redisConnection as connection } from '../config/redis'
import './workers/perk'
import './workers/membership-check'
import './workers/donation-migration'
export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase', {
connection,
@@ -11,8 +12,16 @@ export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase
export const membershipCheckQueue = new Queue('MembershipCheck', { connection })
export const donationMigration = new Queue('DonationMigration', { connection })
membershipCheckQueue.upsertJobScheduler(
'MembershipCheckScheduler',
{ pattern: '0 * * * *' },
{ name: 'MembershipCheck' }
)
donationMigration.upsertJobScheduler(
'DonationMigrationScheduler',
{ pattern: '* * * * *' },
{ name: 'DonationMigration' }
)

View File

@@ -503,13 +503,7 @@ export const donationRouter = router({
const { message, signature } = await getDonationAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
amount: donation.grossFiatAmount,
method: donation.cryptoCode ? donation.cryptoCode : 'Fiat',
fundSlug: donation.fundSlug,
fundName: funds[donation.fundSlug].title,
projectName: donation.projectName,
date: donation.createdAt,
donationId: donation.id,
donation,
})
return { message, signature }
@@ -556,15 +550,9 @@ export const donationRouter = router({
const { message, signature } = await getMembershipAttestation({
donorName: user.attributes?.name,
donorEmail: ctx.session.user.email,
// For membership donations, a null membership term means that membership is an annual one,
// since it was started before monthly memberships were introduced.
term: donations[0].membershipTerm || 'annually',
amount: membershipValue,
method: donations[0].cryptoCode ? donations[0].cryptoCode : 'Fiat',
fundSlug: donations[0].fundSlug,
fundName: funds[donations[0].fundSlug].title,
donation: donations[0],
periodStart: membershipStart,
periodEnd: membershipEnd,
totalAmountToDate: membershipValue,
})
return { message, signature }

View File

@@ -11,7 +11,7 @@ import {
StrapiGetPointsPopulatedRes,
} from '../types'
import { TRPCError } from '@trpc/server'
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
import { estimatePrintfulOrderCost, getPointsBalance } from '../utils/perks'
import { POINTS_REDEEM_PRICE_USD } from '../../config'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { perkPurchaseQueue } from '../queues'
@@ -20,7 +20,7 @@ import { redisConnection } from '../../config/redis'
export const perkRouter = router({
getBalance: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.sub
const balance = getUserPointBalance(userId)
const balance = getPointsBalance(userId)
return balance
}),
@@ -71,14 +71,16 @@ export const perkRouter = router({
.input(
z.object({
printfulSyncVariantId: z.number(),
shippingAddressLine1: z.string().min(1),
shippingAddressLine2: z.string(),
shippingCity: z.string().min(1),
shippingState: z.string(),
shippingCountry: z.string().min(1),
shippingZip: z.string().min(1),
shippingPhone: z.string().min(1),
shippingTaxNumber: z.string(),
shipping: z.object({
addressLine1: z.string().min(1),
addressLine2: z.string().optional(),
city: z.string().min(1),
stateCode: z.string().min(1),
countryCode: z.string().min(1),
zip: z.string().min(1),
phone: z.string().min(1),
taxNumber: z.string().optional(),
}),
})
)
.mutation(async ({ input, ctx }) => {
@@ -94,16 +96,9 @@ export const perkRouter = router({
})
const costEstimate = await estimatePrintfulOrderCost({
address1: input.shippingAddressLine1!,
address2: input.shippingAddressLine2 || '',
city: input.shippingCity!,
stateCode: input.shippingState!,
countryCode: input.shippingCountry!,
zip: input.shippingZip!,
name: user.attributes?.name?.[0],
phone: input.shippingPhone!,
shipping: input.shipping,
email: user.email,
tax_number: input.shippingTaxNumber,
name: user.attributes?.name?.[0],
printfulSyncVariantId: input.printfulSyncVariantId,
})
@@ -121,14 +116,18 @@ export const perkRouter = router({
perkId: z.string(),
perkPrintfulSyncVariantId: z.number().optional(),
fundSlug: z.enum(fundSlugs),
shippingAddressLine1: z.string().optional(),
shippingAddressLine2: z.string().optional(),
shippingCity: z.string().optional(),
shippingState: z.string().optional(),
shippingCountry: z.string().optional(),
shippingZip: z.string().optional(),
shippingPhone: z.string().optional(),
shippingTaxNumber: z.string().optional(),
shipping: z
.object({
addressLine1: z.string().min(1),
addressLine2: z.string().optional(),
city: z.string().min(1),
stateCode: z.string().min(1),
countryCode: z.string().min(1),
zip: z.string().min(1),
phone: z.string().min(1),
taxNumber: z.string().optional(),
})
.optional(),
})
)
.mutation(async ({ input, ctx }) => {
@@ -150,20 +149,8 @@ export const perkRouter = router({
if (!perk) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Perk not found.' })
// Check if shipping data is present if required
if (perk.needsShippingAddress) {
const shippingDataIsMissing =
[
input.shippingAddressLine1,
input.shippingCity,
input.shippingState,
input.shippingCountry,
input.shippingZip,
input.shippingPhone,
].filter((data) => !data).length > 0
if (shippingDataIsMissing) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Shipping data is missing.' })
}
if (perk.needsShippingAddress && !input.shipping) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Shipping data is missing.' })
}
// Check if perk is available in the fund
@@ -176,16 +163,9 @@ export const perkRouter = router({
if (perk.printfulProductId && input.perkPrintfulSyncVariantId) {
const printfulCostEstimate = await estimatePrintfulOrderCost({
address1: input.shippingAddressLine1!,
address2: input.shippingAddressLine2 || '',
city: input.shippingCity!,
stateCode: input.shippingState!,
countryCode: input.shippingCountry!,
zip: input.shippingZip!,
name: user.attributes?.name?.[0],
phone: input.shippingPhone!,
shipping: input.shipping!,
email: user.email,
tax_number: input.shippingTaxNumber,
name: user.attributes?.name?.[0],
printfulSyncVariantId: input.perkPrintfulSyncVariantId,
})
@@ -194,7 +174,7 @@ export const perkRouter = router({
deductionAmount = perk.price
}
const currentBalance = await getUserPointBalance(userId)
const currentBalance = await getPointsBalance(userId)
const balanceAfterPurchase = currentBalance - deductionAmount
if (balanceAfterPurchase < 0) {
@@ -204,14 +184,7 @@ export const perkRouter = router({
const purchaseJob = await perkPurchaseQueue.add('purchase', {
perk,
perkPrintfulSyncVariantId: input.perkPrintfulSyncVariantId,
shippingAddressLine1: input.shippingAddressLine1,
shippingAddressLine2: input.shippingAddressLine2,
shippingCountry: input.shippingCountry,
shippingState: input.shippingState,
shippingCity: input.shippingCity,
shippingZip: input.shippingZip,
shippingPhone: input.shippingPhone,
shippingTaxNumber: input.shippingTaxNumber,
shipping: input.shipping,
userId: user.id,
userEmail: user.email,
userFullname: user?.attributes?.name?.[0],

View File

@@ -29,6 +29,13 @@ export type DonationMetadata = {
showDonorNameOnLeaderboard: 'true' | 'false'
}
export type DonationCryptoPayments = {
cryptoCode: 'BTC' | 'XMR' | 'LTC' | 'MANUAL'
grossAmount: number
netAmount: number
rate: number
}[]
export type BtcPayGetRatesRes = [
{
currencyPair: string
@@ -37,6 +44,11 @@ export type BtcPayGetRatesRes = [
},
]
export type BtcPayGetInvoiceRes = {
id: string
amount: string
}
export type BtcPayGetPaymentMethodsRes = {
rate: string
amount: string
@@ -123,7 +135,7 @@ export type StrapiGetPerkRes = {
// Strapi Order
type StrapiOrder = {
export type StrapiOrder = {
id: number
documentId: string
createdAt: string
@@ -339,6 +351,7 @@ export type PrintfulCreateOrderReq = {
}
export type PrintfulCreateOrderRes = {
externalId: string
costs: {
currency: 'USD'
subtotal: string

View File

@@ -1,44 +1,33 @@
import * as ed from '@noble/ed25519'
import { FundSlug, MembershipTerm } from '@prisma/client'
import { Donation, FundSlug, MembershipTerm } from '@prisma/client'
import dayjs from 'dayjs'
import { env } from '../../env.mjs'
import { funds } from '../../utils/funds'
type GetDonationAttestationParams = {
donorName: string
donorEmail: string
donationId: string
amount: number
method: string
fundSlug: FundSlug
fundName: string
projectName: string
date: Date
donation: Donation
}
export async function getDonationAttestation({
donorName,
donorEmail,
donationId,
amount,
method,
fundSlug,
fundName,
projectName,
date,
donation,
}: GetDonationAttestationParams) {
const message = `MAGIC Grants Donation Attestation
Name: ${donorName}
Email: ${donorEmail}
Donation ID: ${donationId}
Amount: $${amount.toFixed(2)}
Method: ${method}
Fund: ${fundName}
Project: ${projectName}
Date: ${dayjs(date).format('YYYY-M-D')}
Donation ID: ${donation.id}
Amount: $${donation.grossFiatAmount.toFixed(2)}
Method: ${donation.cryptoPayments ? 'Crypto' : 'Fiat'}
Fund: ${funds[donation.fundSlug].title}
Project: ${donation.projectName}
Date: ${dayjs(donation.createdAt).format('YYYY-M-D')}
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
Verify this attestation at donate.magicgrants.org/${donation.fundSlug}/verify-attestation`
const signature = await ed.signAsync(
Buffer.from(message, 'utf-8').toString('hex'),
@@ -53,38 +42,30 @@ Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation
type GetMembershipAttestation = {
donorName: string
donorEmail: string
term: MembershipTerm
amount: number
method: string
fundName: string
fundSlug: FundSlug
periodStart: Date
periodEnd: Date
donation: Donation
totalAmountToDate?: number
periodStart?: Date
}
export async function getMembershipAttestation({
donorName,
donorEmail,
term,
amount,
method,
fundName,
fundSlug,
totalAmountToDate,
donation,
periodStart,
periodEnd,
}: GetMembershipAttestation) {
const message = `MAGIC Grants Membership Attestation
Name: ${donorName}
Email: ${donorEmail}
Term: ${term.charAt(0).toUpperCase() + term.slice(1)}
Total amount to date: $${amount.toFixed(2)}
Method: ${method}
Fund: ${fundName}
Period start: ${dayjs(periodStart).format('YYYY-M-D')}
Period end: ${dayjs(periodEnd).format('YYYY-M-D')}
Term: ${donation.membershipTerm!.charAt(0).toUpperCase() + donation.membershipTerm!.slice(1)}
Total amount to date: $${(totalAmountToDate || donation.grossFiatAmount).toFixed(2)}
Method: ${donation.cryptoPayments ? 'Crypto' : 'Fiat'}
Fund: ${funds[donation.fundSlug].title}
Period start: ${dayjs(periodStart || donation.createdAt).format('YYYY-M-D')}
Period end: ${dayjs(donation.membershipExpiresAt).format('YYYY-M-D')}
Verify this attestation at donate.magicgrants.org/${fundSlug}/verify-attestation`
Verify this attestation at donate.magicgrants.org/${donation.fundSlug}/verify-attestation`
const signature = await ed.signAsync(
Buffer.from(message, 'utf-8').toString('hex'),

View File

@@ -0,0 +1,26 @@
import { log } from '../../utils/logging'
import { btcpayApi } from '../services'
import { BtcPayGetInvoiceRes, BtcPayGetPaymentMethodsRes } from '../types'
export async function getBtcPayInvoice(id: string) {
try {
const { data: invoice } = await btcpayApi.get<BtcPayGetInvoiceRes>(`/invoices/${id}`)
return invoice
} catch (error) {
log('error', `Failed to get BTCPayServer invoice ${id}.`)
throw error
}
}
export async function getBtcPayInvoicePaymentMethods(invoiceId: string) {
try {
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${invoiceId}/payment-methods`
)
return paymentMethods
} catch (error) {
log('error', `Failed to get BTCPayServer payment methods for invoice ${invoiceId}.`)
throw error
}
}

View File

@@ -1,4 +1,4 @@
import { FundSlug } from '@prisma/client'
import { Donation, FundSlug } from '@prisma/client'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import dayjs from 'dayjs'
@@ -7,6 +7,7 @@ import { transporter } from '../services'
import { funds } from '../../utils/funds'
import { POINTS_REDEEM_PRICE_USD } from '../../config'
import markdownToHtml from '../../utils/markdownToHtml'
import { DonationCryptoPayments } from '../types'
dayjs.extend(localizedFormat)
@@ -15,14 +16,7 @@ const pointsFormat = Intl.NumberFormat('en', { notation: 'standard', compactDisp
type SendDonationConfirmationEmailParams = {
to: string
donorName: string
fundSlug: FundSlug
projectName?: string
isMembership: boolean
isSubscription: boolean
stripeUsdAmount?: number
btcpayCryptoAmount?: number
btcpayAsset?: 'BTC' | 'XMR'
pointsReceived: number
donation: Donation
attestationMessage: string
attestationSignature: string
}
@@ -30,27 +24,26 @@ type SendDonationConfirmationEmailParams = {
export async function sendDonationConfirmationEmail({
to,
donorName,
fundSlug,
projectName,
isMembership,
isSubscription,
stripeUsdAmount,
btcpayCryptoAmount,
btcpayAsset,
pointsReceived,
donation,
attestationMessage,
attestationSignature,
}: SendDonationConfirmationEmailParams) {
const dateStr = dayjs().format('YYYY-M-D')
const fundName = funds[fundSlug].title
const fundName = funds[donation.fundSlug].title
const isMembership = !donation.membershipExpiresAt
const isSubscription = donation.stripeSubscriptionId
const isPaidWithCrypto = (donation.cryptoPayments as DonationCryptoPayments | null)?.length
const cryptoDonationDescription = (donation.cryptoPayments as DonationCryptoPayments | null)
?.map((payment) => `${payment.grossAmount} ${payment.cryptoCode}`)
.join(', ')
const markdown = `# Donation receipt
Thank you for your donation to MAGIC Grants! Your donation supports our charitable mission.
${!isMembership ? `You donated to: ${fundName}` : ''}
${isMembership ? `You donated to: ${fundName}` : ''}
${projectName ? `You supported this campaign: ${projectName}` : ''}
${donation.projectName ? `You supported this campaign: ${donation.projectName}` : ''}
${
isMembership
@@ -69,17 +62,17 @@ export async function sendDonationConfirmationEmail({
${donorName}
MAGIC Grants acknowledges and expresses appreciation for the following contribution:
- [${stripeUsdAmount ? '☑️' : '⬜'}] Cash or bank transfer donation amount: ${stripeUsdAmount ? stripeUsdAmount.toFixed(2) : 'N/A'}
- [${btcpayCryptoAmount ? '☑️' : '⬜'}] In-kind (non-fiat) donation description: ${btcpayCryptoAmount && btcpayAsset ? `${btcpayCryptoAmount} ${btcpayAsset}` : '-'}
- ${!isPaidWithCrypto ? '☑️' : '⬜'} Cash or bank transfer donation amount: ${!isPaidWithCrypto ? `$${donation.grossFiatAmount}` : '$0.00'}
- ${isPaidWithCrypto ? '☑️' : '⬜'} In-kind (non-fiat) donation description: ${cryptoDonationDescription ? cryptoDonationDescription : '-'}
Description and/or restrictions: ${fundSlug === 'general' ? 'None' : `Donation to the ${fundName}`}
Description and/or restrictions: ${donation.fundSlug === 'general' ? 'None' : `Donation to the ${fundName}`}
The following describes the context of your donation:
- [${!pointsReceived ? '☑️' : '⬜'}] No goods or services were received in exchange for your generous donation.
- [${pointsReceived ? '☑️' : '⬜'}] In connection with your generous donation, you received ${pointsFormat.format(pointsReceived)} points, valued at approximately $${(pointsReceived * POINTS_REDEEM_PRICE_USD).toFixed(2)}.
- ${!donation.pointsAdded ? '☑️' : '⬜'} No goods or services were received in exchange for your generous donation.
- ${donation.pointsAdded ? '☑️' : '⬜'} In connection with your generous donation, you received ${pointsFormat.format(donation.pointsAdded)} points, valued at approximately $${(donation.pointsAdded * POINTS_REDEEM_PRICE_USD).toFixed(2)}.
${btcpayCryptoAmount ? 'If you wish to receive a tax deduction for a cryptocurrency donation over $500, you MUST complete [Form 8283](https://www.irs.gov/pub/irs-pdf/f8283.pdf) and send the completed form to [info@magicgrants.org](mailto:info@magicgrants.org) to qualify for a deduction.' : ''}
${isPaidWithCrypto ? 'If you wish to receive a tax deduction for a cryptocurrency donation over $500, you MUST complete [Form 8283](https://www.irs.gov/pub/irs-pdf/f8283.pdf) and send the completed form to [info@magicgrants.org](mailto:info@magicgrants.org) to qualify for a deduction.' : ''}
### Signed attestation
@@ -98,7 +91,7 @@ export async function sendDonationConfirmationEmail({
${env.NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY_HEX}
\`\`\`
This attestation can be verified at [donate.magicgrants.org/${fundSlug}/verify-attestation](https://donate.magicgrants.org/${fundSlug}/verify-attestation).
This attestation can be verified at [donate.magicgrants.org/${donation.fundSlug}/verify-attestation](https://donate.magicgrants.org/${donation.fundSlug}/verify-attestation).
MAGIC Grants
1942 Broadway St., STE 314C
@@ -146,11 +139,11 @@ type SendPerkPurchaseConfirmationEmailParams = {
to: string
perkName: string
address?: {
address1: string
address2?: string
addressLine1: string
addressLine2?: string
city: string
state?: string
country: string
stateCode: string
countryCode: string
zip: string
}
pointsRedeemed: number
@@ -171,11 +164,11 @@ export async function sendPerkPurchaseConfirmationEmail({
address
? `Mailing address
Address line 1: ${address.address1}
Address line 2: ${address.address2 ? address.address2 : '-'}
Address line 1: ${address.addressLine1}
Address line 2: ${address.addressLine2 || '-'}
City: ${address.city}
State: ${address.state}
Country: ${address.country}
State: ${address.stateCode}
Country: ${address.countryCode}
Zip: ${address.zip}`
: ''
}

View File

@@ -1,12 +1,29 @@
import { AxiosResponse } from 'axios'
import { printfulApi, prisma, strapiApi } from '../services'
import {
PrintfulCreateOrderReq,
PrintfulCreateOrderRes,
PrintfulEstimateOrderReq,
PrintfulEstimateOrderRes,
StrapiCreateOrderBody,
StrapiCreateOrderRes,
StrapiCreatePointBody,
StrapiGetPointsPopulatedRes,
} from '../types'
import { Donation } from '@prisma/client'
export async function getUserPointBalance(userId: string): Promise<number> {
type Shipping = {
addressLine1: string
addressLine2?: string
city: string
stateCode: string
countryCode: string
zip: string
phone: string
taxNumber?: string
}
export async function getPointsBalance(userId: string): Promise<number> {
const {
data: { data: pointHistory },
} = await strapiApi.get<StrapiGetPointsPopulatedRes>(
@@ -19,32 +36,111 @@ export async function getUserPointBalance(userId: string): Promise<number> {
return currentBalance
}
type GivePointsToUserParams = { pointsToGive: number; donation: Donation }
export async function givePointsToUser({ pointsToGive, donation }: GivePointsToUserParams) {
if (!donation.userId) {
console.error(
'Could not give points using donation with null userId. Donation ID:',
donation.userId
)
return
}
const pointsBalance = await getPointsBalance(donation.userId)
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
data: {
balanceChange: pointsToGive.toString(),
balance: (pointsBalance + pointsToGive).toString(),
userId: donation.userId,
donationId: donation.id,
donationProjectName: donation.projectName,
donationProjectSlug: donation.projectSlug,
donationFundSlug: donation.fundSlug,
},
})
}
type DeductPointsFromUserParams = {
deductionAmount: number
userId: string
perkId: string
orderId: string
}
export async function deductPointsFromUser({
deductionAmount,
userId,
perkId,
orderId,
}: DeductPointsFromUserParams) {
const pointsBalance = await getPointsBalance(userId)
const newPointsBalance = pointsBalance - deductionAmount
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
data: {
balanceChange: (-deductionAmount).toString(),
balance: newPointsBalance.toString(),
userId: userId,
perk: perkId,
order: orderId,
},
})
}
export type CreateStrapiOrderParams = {
perkId: string
userId: string
userEmail: string
shipping?: Shipping
}
export async function createStrapiOrder({
perkId,
userId,
userEmail,
shipping,
}: CreateStrapiOrderParams) {
const {
data: { data: order },
} = await strapiApi.post<any, AxiosResponse<StrapiCreateOrderRes>, StrapiCreateOrderBody>(
'/orders',
{
data: {
perk: perkId,
userId: userId,
userEmail: userEmail,
shippingAddressLine1: shipping?.addressLine1,
shippingAddressLine2: shipping?.addressLine2,
shippingCity: shipping?.city,
shippingState: shipping?.stateCode,
shippingCountry: shipping?.countryCode,
shippingZip: shipping?.zip,
shippingPhone: shipping?.phone,
},
}
)
return order
}
export async function deleteStrapiOrder(orderId: string) {
await strapiApi.delete(`/orders/${orderId}`)
}
type EstimatePrintfulOrderCostParams = {
printfulSyncVariantId: number
address1: string
address2: string
city: string
stateCode: string
countryCode: string
zip: string
name: string
phone: string
email: string
tax_number?: string
name: string
printfulSyncVariantId: number
shipping: Shipping
}
export async function estimatePrintfulOrderCost({
printfulSyncVariantId,
address1,
address2,
city,
stateCode,
countryCode,
zip,
name,
phone,
email,
tax_number,
name,
shipping,
}: EstimatePrintfulOrderCostParams) {
const {
data: { result: costEstimate },
@@ -52,16 +148,16 @@ export async function estimatePrintfulOrderCost({
`/orders/estimate-costs`,
{
recipient: {
address1,
address2,
city,
state_code: stateCode,
country_code: countryCode,
zip,
name,
phone,
address1: shipping.addressLine1,
address2: shipping.addressLine2 || '',
city: shipping.city,
state_code: shipping.stateCode,
country_code: shipping.countryCode,
zip: shipping.zip,
phone: shipping.phone,
tax_number: shipping.taxNumber,
email,
tax_number,
name,
},
items: [{ quantity: 1, sync_variant_id: printfulSyncVariantId }],
}
@@ -69,3 +165,43 @@ export async function estimatePrintfulOrderCost({
return costEstimate
}
type CreatePrintfulOrderParams = {
email: string
name: string
printfulSyncVariantId: number
shipping: Shipping
}
export async function createPrintfulOrder({
printfulSyncVariantId,
email,
name,
shipping,
}: CreatePrintfulOrderParams) {
const { data } = await printfulApi.post<
{},
AxiosResponse<PrintfulCreateOrderRes>,
PrintfulCreateOrderReq
>(process.env.NODE_ENV === 'production' ? '/orders?confirm=true' : '/orders', {
recipient: {
name,
email,
address1: shipping.addressLine1,
address2: shipping?.addressLine2 || '',
city: shipping.city,
state_code: shipping.stateCode,
country_code: shipping.countryCode!,
zip: shipping.zip,
phone: shipping.phone,
tax_number: shipping.taxNumber,
},
items: [{ quantity: 1, sync_variant_id: printfulSyncVariantId }],
})
return data
}
export async function cancelPrintfulOrder(id: string) {
await printfulApi.delete(`/orders/${id}`)
}

View File

@@ -9,16 +9,253 @@ import {
prisma,
stripe as _stripe,
strapiApi,
privacyGuidesDiscourseApi,
} from '../../server/services'
import { DonationMetadata, StrapiCreatePointBody } from '../../server/types'
import { DonationMetadata } from '../../server/types'
import { sendDonationConfirmationEmail } from './mailing'
import { getUserPointBalance } from './perks'
import { POINTS_PER_USD } from '../../config'
import { env } from '../../env.mjs'
import { getPointsBalance, givePointsToUser } from './perks'
import { NET_DONATION_AMOUNT_WITH_POINTS_RATE, POINTS_PER_USD } from '../../config'
import { getDonationAttestation, getMembershipAttestation } from './attestation'
import { funds } from '../../utils/funds'
import { addUserToPgMembersGroup } from '../../utils/pg-forum-connection'
import { log } from '../../utils/logging'
async function handleDonationOrNonRecurringMembership(paymentIntent: Stripe.PaymentIntent) {
const metadata = paymentIntent.metadata as DonationMetadata
// Payment intents for subscriptions will not have metadata
if (!metadata) return
if (JSON.stringify(metadata) === '{}') return
if (metadata.isSubscription === 'true') return
const existingDonation = await prisma.donation.findFirst({
where: { stripePaymentIntentId: paymentIntent.id },
})
if (existingDonation) {
log(
'warn',
`[Stripe webhook] Attempted to process already processed payment intent ${paymentIntent.id}.`
)
return
}
// Skip this event if intent is still not fully paid
if (paymentIntent.amount_received !== paymentIntent.amount) return
const shouldGivePointsBack = metadata.givePointsBack === 'true'
const grossFiatAmount = paymentIntent.amount_received / 100
const netFiatAmount = shouldGivePointsBack
? Number((grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE).toFixed(2))
: grossFiatAmount
const pointsToGive = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
let membershipExpiresAt = null
const termToMembershipExpiresAt = {
monthly: dayjs().add(1, 'month').toDate(),
annually: dayjs().add(1, 'year').toDate(),
} as const
if (paymentIntent.metadata.isMembership === 'true' && metadata.membershipTerm) {
membershipExpiresAt = termToMembershipExpiresAt[metadata.membershipTerm]
}
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await addUserToPgMembersGroup(metadata.userId)
}
const donation = await prisma.donation.create({
data: {
userId: metadata.userId,
stripePaymentIntentId: paymentIntent.id,
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fundSlug: metadata.fundSlug,
grossFiatAmount,
netFiatAmount,
pointsAdded: pointsToGive,
membershipExpiresAt,
membershipTerm: metadata.membershipTerm || null,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && metadata.userId) {
try {
await givePointsToUser({ pointsToGive, donation })
} catch (error) {
log(
'error',
`[Stripe webhook] Failed to give points for payment intent ${paymentIntent.id}. Rolling back.`
)
await prisma.donation.delete({ where: { id: donation.id } })
throw error
}
}
// Get attestation and send confirmation email
if (metadata.donorEmail && metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (metadata.isMembership === 'true' && metadata.membershipTerm) {
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
donation,
totalAmountToDate: grossFiatAmount,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
donation,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
try {
await sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
donation,
attestationMessage,
attestationSignature,
})
} catch (error) {
log(
'warn',
`[Stripe webhook] Failed to send donation confirmation email for payment intent ${paymentIntent.id}. NOT rolling back. Cause:`
)
console.error(error)
}
}
log('info', `[Stripe webhook] Successfully processed payment intent ${paymentIntent.id}!`)
}
async function handleRecurringMembership(invoice: Stripe.Invoice) {
if (!invoice.subscription) return
const metadata = invoice.subscription_details?.metadata as DonationMetadata
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
if (!invoiceLine) {
log(
'warn',
`[/api/stripe/${metadata.fundSlug}-webhook] Line not fund for invoice ${invoice.id}. Skipping.`
)
return
}
const existingDonation = await prisma.donation.findFirst({
where: { stripeInvoiceId: invoice.id },
})
if (existingDonation) {
log('warn', `[Stripe webhook] Attempted to process already processed invoice ${invoice.id}.`)
return
}
const shouldGivePointsBack = metadata.givePointsBack === 'true'
const grossFiatAmount = invoice.total / 100
const netFiatAmount = shouldGivePointsBack
? Number((grossFiatAmount * NET_DONATION_AMOUNT_WITH_POINTS_RATE).toFixed(2))
: grossFiatAmount
const pointsToGive = shouldGivePointsBack ? parseInt(String(grossFiatAmount * 100)) : 0
const membershipExpiresAt = new Date(invoiceLine.period.end * 1000)
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await addUserToPgMembersGroup(metadata.userId)
}
const donation = 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,
grossFiatAmount,
netFiatAmount,
pointsAdded: pointsToGive,
membershipExpiresAt,
membershipTerm: metadata.membershipTerm || null,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && metadata.userId) {
// Get balance for project/fund by finding user's last point history
const currentBalance = await getPointsBalance(metadata.userId)
try {
await givePointsToUser({ donation, pointsToGive })
} catch (error) {
log(
'error',
`[BTCPay webhook] Failed to give points for invoice ${invoice.id}. Rolling back.`
)
await prisma.donation.delete({ where: { id: donation.id } })
throw error
}
}
if (metadata.donorEmail && metadata.donorName && metadata.membershipTerm) {
const donations = await prisma.donation.findMany({
where: {
stripeSubscriptionId: invoice.subscription.toString(),
membershipExpiresAt: { not: null },
},
orderBy: { membershipExpiresAt: 'desc' },
})
const membershipStart = donations.slice(-1)[0].createdAt
const membershipValue = donations.reduce(
(total, donation) => total + donation.grossFiatAmount,
0
)
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
donation,
totalAmountToDate: membershipValue,
periodStart: membershipStart,
})
try {
await sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
donation,
attestationMessage: attestation.message,
attestationSignature: attestation.signature,
})
} catch (error) {
log(
'warn',
`[Stripe webhook] Failed to send donation confirmation email for invoice ${invoice.id}. NOT rolling back. Cause:`
)
console.error(error)
}
}
log('info', `[Stripe webhook] Successfully processed invoice ${invoice.id}!`)
}
export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
return async (req: NextApiRequest, res: NextApiResponse) => {
@@ -39,244 +276,12 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
// 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
// Payment intents for subscriptions will not have metadata
if (!metadata) return res.status(200).end()
if (JSON.stringify(metadata) === '{}') return res.status(200).end()
if (metadata.isSubscription === 'true') return res.status(200).end()
// Skip this event if intent is still not fully paid
if (paymentIntent.amount_received !== paymentIntent.amount) return res.status(200).end()
const shouldGivePointsBack = metadata.givePointsBack === 'true'
const grossFiatAmount = paymentIntent.amount_received / 100
const netFiatAmount = shouldGivePointsBack
? Number((grossFiatAmount * 0.9).toFixed(2))
: grossFiatAmount
const pointsAdded = shouldGivePointsBack ? Math.floor(grossFiatAmount / POINTS_PER_USD) : 0
let membershipExpiresAt = null
if (
paymentIntent.metadata.isMembership === 'true' &&
paymentIntent.metadata.membershipTerm === 'monthly'
) {
membershipExpiresAt = dayjs().add(1, 'month').toDate()
}
if (
paymentIntent.metadata.isMembership === 'true' &&
paymentIntent.metadata.membershipTerm === 'annually'
) {
membershipExpiresAt = dayjs().add(1, 'year').toDate()
}
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await addUserToPgMembersGroup(metadata.userId)
}
const donation = await prisma.donation.create({
data: {
userId: metadata.userId,
stripePaymentIntentId: paymentIntent.id,
projectName: metadata.projectName,
projectSlug: metadata.projectSlug,
fundSlug: metadata.fundSlug,
grossFiatAmount,
netFiatAmount,
pointsAdded,
membershipExpiresAt,
membershipTerm: metadata.membershipTerm || null,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && metadata.userId) {
// Get balance for project/fund by finding user's last point history
const currentBalance = await getUserPointBalance(metadata.userId)
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
data: {
balanceChange: pointsAdded.toString(),
balance: (currentBalance + pointsAdded).toString(),
userId: metadata.userId,
donationId: donation.id,
donationProjectName: donation.projectName,
donationProjectSlug: donation.projectSlug,
donationFundSlug: donation.fundSlug,
},
})
}
// Get attestation and send confirmation email
if (metadata.donorEmail && metadata.donorName) {
let attestationMessage = ''
let attestationSignature = ''
if (metadata.isMembership === 'true' && metadata.membershipTerm) {
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: Number(grossFiatAmount.toFixed(2)),
term: metadata.membershipTerm,
method: 'Fiat',
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
periodStart: new Date(),
periodEnd: membershipExpiresAt!,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
if (metadata.isMembership === 'false') {
const attestation = await getDonationAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: grossFiatAmount,
method: 'Fiat',
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
projectName: metadata.projectName,
date: new Date(),
donationId: donation.id,
})
attestationMessage = attestation.message
attestationSignature = attestation.signature
}
sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
fundSlug: metadata.fundSlug,
projectName: metadata.projectName,
isMembership: metadata.isMembership === 'true',
isSubscription: false,
stripeUsdAmount: paymentIntent.amount_received / 100,
pointsReceived: pointsAdded,
attestationMessage,
attestationSignature,
})
}
handleDonationOrNonRecurringMembership(event.data.object)
}
// Store subscription data when subscription invoice is paid
if (event.type === 'invoice.paid') {
const invoice = event.data.object
if (!invoice.subscription) return res.status(200).end()
const metadata = event.data.object.subscription_details?.metadata as DonationMetadata
const invoiceLine = invoice.lines.data.find((line) => line.invoice === invoice.id)
if (!invoiceLine) {
console.error(
`[/api/stripe/${metadata.fundSlug}-webhook] Line not fund for invoice ${invoice.id}`
)
return res.status(200).end()
}
const shouldGivePointsBack = metadata.givePointsBack === 'true'
const grossFiatAmount = invoice.total / 100
const netFiatAmount = shouldGivePointsBack
? Number((grossFiatAmount * 0.9).toFixed(2))
: grossFiatAmount
const pointsAdded = shouldGivePointsBack ? parseInt(String(grossFiatAmount * 100)) : 0
const membershipExpiresAt = new Date(invoiceLine.period.end * 1000)
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await addUserToPgMembersGroup(metadata.userId)
}
const donation = 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,
grossFiatAmount,
netFiatAmount,
pointsAdded,
membershipExpiresAt,
membershipTerm: metadata.membershipTerm || null,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
},
})
// Add points
if (shouldGivePointsBack && metadata.userId) {
// Get balance for project/fund by finding user's last point history
const currentBalance = await getUserPointBalance(metadata.userId)
await strapiApi.post('/points', {
data: {
balanceChange: pointsAdded,
pointsBalance: currentBalance + pointsAdded,
userId: metadata.userId,
donationId: donation.id,
donationProjectName: donation.projectName,
donationProjectSlug: donation.projectSlug,
donationFundSlug: donation.fundSlug,
},
})
}
if (metadata.donorEmail && metadata.donorName && metadata.membershipTerm) {
const donations = await prisma.donation.findMany({
where: {
stripeSubscriptionId: invoice.subscription.toString(),
membershipExpiresAt: { not: null },
},
orderBy: { membershipExpiresAt: 'desc' },
})
const membershipStart = donations.slice(-1)[0].createdAt
const membershipValue = donations.reduce(
(total, donation) => total + donation.grossFiatAmount,
0
)
const attestation = await getMembershipAttestation({
donorName: metadata.donorName,
donorEmail: metadata.donorEmail,
amount: membershipValue,
method: 'Fiat',
term: metadata.membershipTerm,
fundName: funds[metadata.fundSlug].title,
fundSlug: metadata.fundSlug,
periodStart: membershipStart,
periodEnd: membershipExpiresAt,
})
sendDonationConfirmationEmail({
to: metadata.donorEmail,
donorName: metadata.donorName,
fundSlug: metadata.fundSlug,
projectName: metadata.projectName,
isMembership: metadata.isMembership === 'true',
isSubscription: metadata.isSubscription === 'true',
stripeUsdAmount: invoice.total / 100,
pointsReceived: pointsAdded,
attestationMessage: attestation.message,
attestationSignature: attestation.signature,
})
}
}
// Handle subscription end
if (event.type === 'customer.subscription.deleted') {
console.log(event.data.object)
handleRecurringMembership(event.data.object)
}
// Return a 200 response to acknowledge receipt of the event

View File

@@ -0,0 +1,54 @@
import { Worker } from 'bullmq'
import { redisConnection as connection } from '../../config/redis'
import { prisma } from '../services'
import { DonationCryptoPayments } from '../types'
import { log } from '../../utils/logging'
import { Prisma } from '@prisma/client'
const globalForWorker = global as unknown as { hasInitializedWorkers: boolean }
if (!globalForWorker.hasInitializedWorkers)
new Worker(
'DonationMigration',
async (job) => {
// Finds unmigrated donations and updates them
log('info', '[Donation migration] Migrating old donations...')
const donations = await prisma.donation.findMany({
where: {
btcPayInvoiceId: { not: null },
cryptoPayments: { equals: Prisma.DbNull },
},
})
if (!donations.length) return
await Promise.all(
donations.map(async (donation) => {
const cryptoPayments: DonationCryptoPayments = [
{
cryptoCode: donation.cryptoCode as DonationCryptoPayments[0]['cryptoCode'],
grossAmount: Number(donation.grossCryptoAmount!),
netAmount: Number(donation.netCryptoAmount),
rate: donation.grossFiatAmount / Number(donation.grossCryptoAmount),
},
]
await prisma.donation.update({
where: { id: donation.id },
data: {
cryptoPayments,
cryptoCode: null,
grossCryptoAmount: null,
netCryptoAmount: null,
},
})
})
)
log('info', `[Donation migration] Successfully updated ${donations.length} records!!!!!!!!!!`)
},
{ connection }
)
if (process.env.NODE_ENV !== 'production') globalForWorker.hasInitializedWorkers = true

View File

@@ -3,7 +3,15 @@ import { AxiosResponse } from 'axios'
import { TRPCError } from '@trpc/server'
import { redisConnection as connection } from '../../config/redis'
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
import {
cancelPrintfulOrder,
createPrintfulOrder,
createStrapiOrder,
deductPointsFromUser,
deleteStrapiOrder,
estimatePrintfulOrderCost,
getPointsBalance,
} from '../utils/perks'
import { POINTS_REDEEM_PRICE_USD } from '../../config'
import {
PrintfulCreateOrderReq,
@@ -11,22 +19,26 @@ import {
StrapiCreateOrderBody,
StrapiCreateOrderRes,
StrapiCreatePointBody,
StrapiOrder,
StrapiPerk,
} from '../types'
import { printfulApi, strapiApi } from '../services'
import { sendPerkPurchaseConfirmationEmail } from '../utils/mailing'
import { log } from '../../utils/logging'
export type PerkPurchaseWorkerData = {
perk: StrapiPerk
perkPrintfulSyncVariantId?: number
shippingAddressLine1?: string
shippingAddressLine2?: string
shippingZip?: string
shippingCity?: string
shippingState?: string
shippingCountry?: string
shippingPhone?: string
shippingTaxNumber?: string
shipping?: {
addressLine1: string
addressLine2?: string
city: string
stateCode: string
countryCode: string
zip: string
phone: string
taxNumber?: string
}
userId: string
userEmail: string
userFullname: string
@@ -41,18 +53,15 @@ if (!globalForWorker.hasInitializedWorkers)
// Check if user has enough balance
let deductionAmount = 0
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
if (
job.data.perk.printfulProductId &&
job.data.perkPrintfulSyncVariantId &&
job.data.shipping
) {
const printfulCostEstimate = await estimatePrintfulOrderCost({
address1: job.data.shippingAddressLine1!,
address2: job.data.shippingAddressLine2 || '',
city: job.data.shippingCity!,
stateCode: job.data.shippingState!,
countryCode: job.data.shippingCountry!,
zip: job.data.shippingZip!,
phone: job.data.shippingPhone!,
shipping: job.data.shipping,
name: job.data.userFullname,
email: job.data.userEmail,
tax_number: job.data.shippingTaxNumber,
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
})
@@ -61,86 +70,84 @@ if (!globalForWorker.hasInitializedWorkers)
deductionAmount = job.data.perk.price
}
const currentBalance = await getUserPointBalance(job.data.userId)
const currentBalance = await getPointsBalance(job.data.userId)
const balanceAfterPurchase = currentBalance - deductionAmount
if (balanceAfterPurchase < 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Insufficient balance.' })
}
let printfulOrder: PrintfulCreateOrderRes | null = null
// Create printful order (if applicable)
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
const result = await printfulApi.post<
{},
AxiosResponse<PrintfulCreateOrderRes>,
PrintfulCreateOrderReq
>(process.env.NODE_ENV === 'production' ? '/orders?confirm=true' : '/orders', {
recipient: {
address1: job.data.shippingAddressLine1!,
address2: job.data.shippingAddressLine2 || '',
city: job.data.shippingCity!,
state_code: job.data.shippingState!,
country_code: job.data.shippingCountry!,
zip: job.data.shippingZip!,
phone: job.data.shippingPhone!,
if (
job.data.perk.printfulProductId &&
job.data.perkPrintfulSyncVariantId &&
job.data.shipping
) {
try {
printfulOrder = await createPrintfulOrder({
shipping: job.data.shipping,
name: job.data.userFullname,
email: job.data.userEmail,
tax_number: job.data.shippingTaxNumber,
},
items: [{ quantity: 1, sync_variant_id: job.data.perkPrintfulSyncVariantId }],
})
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
})
} catch (error) {
log('error', `[Perk purchase worker] Failed to create Printful order.`)
throw error
}
}
let strapiOrder: StrapiOrder | null = null
// Create strapi order
const {
data: { data: order },
} = await strapiApi.post<StrapiCreateOrderRes, any, StrapiCreateOrderBody>('/orders', {
data: {
perk: job.data.perk.documentId,
try {
strapiOrder = await createStrapiOrder({
perkId: job.data.perk.documentId,
userId: job.data.userId,
userEmail: job.data.userEmail,
shippingAddressLine1: job.data.shippingAddressLine1,
shippingAddressLine2: job.data.shippingAddressLine2,
shippingCity: job.data.shippingCity,
shippingState: job.data.shippingState,
shippingCountry: job.data.shippingCountry,
shippingZip: job.data.shippingZip,
shippingPhone: job.data.shippingPhone,
},
})
try {
// Deduct points
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
data: {
balanceChange: (-deductionAmount).toString(),
balance: balanceAfterPurchase.toString(),
userId: job.data.userId,
perk: job.data.perk.documentId,
order: order.documentId,
},
shipping: job.data.shipping,
})
} catch (error) {
// If it fails, delete order
await strapiApi.delete(`/orders/${order.documentId}`)
log('error', `[Perk purchase worker] Failed to create Strapi order. Rolling back.`)
await cancelPrintfulOrder(printfulOrder?.externalId!)
throw error
}
sendPerkPurchaseConfirmationEmail({
to: job.data.userEmail,
perkName: job.data.perk.name,
pointsRedeemed: deductionAmount,
address: job.data.shippingAddressLine1
? {
address1: job.data.shippingAddressLine1,
address2: job.data.shippingAddressLine2,
state: job.data.shippingState,
city: job.data.shippingCity!,
country: job.data.shippingCountry!,
zip: job.data.shippingZip!,
}
: undefined,
})
try {
// Deduct points
await deductPointsFromUser({
deductionAmount,
orderId: strapiOrder.documentId,
perkId: job.data.perk.documentId,
userId: job.data.userId,
})
} catch (error) {
log('error', `[Perk purchase worker] Failed to deduct points. Rolling back.`)
if (printfulOrder) await cancelPrintfulOrder(printfulOrder.externalId)
await deleteStrapiOrder(strapiOrder.documentId)
throw error
}
try {
sendPerkPurchaseConfirmationEmail({
to: job.data.userEmail,
perkName: job.data.perk.name,
pointsRedeemed: deductionAmount,
address: job.data.shipping,
})
} catch (error) {
log(
'error',
`[Perk purchase worker] Failed to send puchase confirmation email. NOT rolling back.`
)
throw error
}
log(
'info',
`[Perk purchase worker] Successfully processed perk purchase! Order ID: ${strapiOrder.documentId}`
)
},
{ connection, concurrency: 1 }
)

View File

@@ -18,12 +18,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
goal: 100000,
numDonationsBTC: 0,
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
totalDonationsFiat: 0,
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
firo: {
fund: 'firo',
@@ -40,12 +45,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
goal: 100000,
numDonationsBTC: 0,
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
totalDonationsFiat: 0,
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
privacyguides: {
fund: 'privacyguides',
@@ -67,12 +77,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
goal: 100000,
numDonationsBTC: 0,
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
totalDonationsFiat: 0,
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
general: {
fund: 'general',
@@ -93,12 +108,17 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
goal: 100000,
numDonationsBTC: 0,
numDonationsXMR: 0,
numDonationsLTC: 0,
numDonationsFiat: 0,
numDonationsManual: 0,
totalDonationsBTC: 0,
totalDonationsXMR: 0,
totalDonationsLTC: 0,
totalDonationsFiat: 0,
totalDonationsBTCInFiat: 0,
totalDonationsXMRInFiat: 0,
totalDonationsLTCInFiat: 0,
totalDonationsManual: 0,
},
}

4
utils/logging.ts Normal file
View File

@@ -0,0 +1,4 @@
export function log(level: 'info' | 'warn' | 'error', message: string) {
const timestamp = new Date().toISOString()
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`)
}

View File

@@ -7,7 +7,7 @@ import { Donation, FundSlug } from '@prisma/client'
import { fundSlugs } from './funds'
import { ProjectItem } from './types'
import { prisma } from '../server/services'
import { env } from '../env.mjs'
import { DonationCryptoPayments } from '../server/types'
const directories: Record<FundSlug, string> = {
monero: join(process.cwd(), 'docs/monero/projects'),
@@ -62,12 +62,17 @@ export function getProjectBySlug(slug: string, fundSlug: FundSlug) {
staticXMRaddress: data.staticXMRaddress || null,
numDonationsBTC: data.numDonationsBTC || 0,
numDonationsXMR: data.numDonationsXMR || 0,
numDonationsLTC: data.numDonationsLTC || 0,
numDonationsFiat: data.numDonationsFiat || 0,
numDonationsManual: data.numDonationsManual || 0,
totalDonationsBTC: data.totalDonationsBTC || 0,
totalDonationsXMR: data.totalDonationsXMR || 0,
totalDonationsLTC: data.totalDonationsLTC || 0,
totalDonationsFiat: data.totalDonationsFiat || 0,
totalDonationsManual: data.totalDonationsManual || 0,
totalDonationsBTCInFiat: data.totalDonationsBTCInFiat || 0,
totalDonationsXMRInFiat: data.totalDonationsXMRInFiat || 0,
totalDonationsLTCInFiat: data.totalDonationsLTCInFiat || 0,
}
return project
@@ -118,19 +123,32 @@ export async function getProjects(fundSlug?: FundSlug) {
}
donations.forEach((donation) => {
if (donation.cryptoCode === 'XMR') {
project.numDonationsXMR += 1
project.totalDonationsXMR += donation.netCryptoAmount || 0
project.totalDonationsXMRInFiat += donation.netFiatAmount
}
;(donation.cryptoPayments as DonationCryptoPayments | null)?.forEach((payment) => {
if (payment.cryptoCode === 'XMR') {
project.numDonationsXMR += 1
project.totalDonationsXMR += payment.netAmount
project.totalDonationsXMRInFiat += payment.netAmount * payment.rate
}
if (donation.cryptoCode === 'BTC') {
project.numDonationsBTC += 1
project.totalDonationsBTC += donation.netCryptoAmount || 0
project.totalDonationsBTCInFiat += donation.netFiatAmount
}
if (payment.cryptoCode === 'BTC') {
project.numDonationsBTC += 1
project.totalDonationsBTC += payment.netAmount
project.totalDonationsBTCInFiat += payment.netAmount * payment.rate
}
if (donation.cryptoCode === null) {
if (payment.cryptoCode === 'LTC') {
project.numDonationsLTC += 1
project.totalDonationsLTC += payment.netAmount
project.totalDonationsLTCInFiat += payment.netAmount * payment.rate
}
if (payment.cryptoCode === 'MANUAL') {
project.numDonationsManual += 1
project.totalDonationsManual += payment.netAmount * payment.rate
}
})
if (!donation.cryptoPayments) {
project.numDonationsFiat += 1
project.totalDonationsFiat += donation.netFiatAmount
}

17
utils/money-formating.ts Normal file
View File

@@ -0,0 +1,17 @@
export function formatUsd(dollars: number): string {
if (dollars == 0) {
return '$0'
} else if (dollars / 1000 > 1) {
return `$${Math.round(dollars / 1000)}k+`
} else {
return `$${dollars.toFixed(0)}`
}
}
export function formatBtc(bitcoin: number) {
if (bitcoin > 0.1) {
return `${bitcoin.toFixed(3) || 0.0} BTC`
} else {
return `${Math.floor(bitcoin * 100000000).toLocaleString()} sats`
}
}

View File

@@ -7,7 +7,7 @@ export type ProjectItem = {
content?: string
title: string
summary: string
coverImage: string
coverImage?: string
website: string
socialLinks: string[]
date: string
@@ -16,12 +16,17 @@ export type ProjectItem = {
isFunded?: boolean
numDonationsBTC: number
numDonationsXMR: number
numDonationsLTC: number
numDonationsFiat: number
numDonationsManual: number
totalDonationsBTC: number
totalDonationsXMR: number
totalDonationsLTC: number
totalDonationsFiat: number
totalDonationsManual: number
totalDonationsBTCInFiat: number
totalDonationsXMRInFiat: number
totalDonationsLTCInFiat: number
}
export type PayReq = {
@@ -43,6 +48,16 @@ export type ProjectDonationStats = {
amount: number
fiatAmount: number
}
ltc: {
count: number
amount: number
fiatAmount: number
}
manual: {
count: number
amount: number
fiatAmount: number
}
usd: {
count: number
amount: number