Campaign page goal improvements

This commit is contained in:
Artur
2025-04-03 12:45:24 -03:00
parent e7f4be25c8
commit d466e0ba16
8 changed files with 195 additions and 128 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

@@ -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

@@ -1,3 +1,5 @@
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'
@@ -14,11 +16,16 @@ import Progress from '../../../components/Progress'
import { prisma } from '../../../server/services'
import { Button } from '../../../components/ui/button'
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
@@ -26,29 +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 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} />
@@ -62,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}`}>
@@ -85,57 +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.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} 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} LTC{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.ltc.count} donations
</span>
</li>
<li>
{`${formatUsd(donationStats.usd.amount + donationStats.manual.fiatAmount)}`}{' '}
Fiat{' '}
<span className="font-normal text-sm text-gray">
in {donationStats.usd.count + donationStats.manual.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">

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