feat: implemented zap split UI
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s

This commit is contained in:
daniyal 2024-09-30 21:20:30 +05:00
parent 3ed7eada83
commit bad1404e1a
2 changed files with 413 additions and 187 deletions

View File

@ -12,14 +12,16 @@ import { toast } from 'react-toastify'
import { MetadataController, ZapController } from '../controllers' import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount } from '../hooks' import { useAppSelector, useDidMount } from '../hooks'
import '../styles/popup.css' import '../styles/popup.css'
import { PaymentRequest } from '../types' import { PaymentRequest, UserProfile } from '../types'
import { import {
copyTextToClipboard, copyTextToClipboard,
formatNumber, formatNumber,
getTagValue,
getZapAmount, getZapAmount,
unformatNumber unformatNumber
} from '../utils' } from '../utils'
import { LoadingSpinner } from './LoadingSpinner' import { LoadingSpinner } from './LoadingSpinner'
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
type PresetAmountProps = { type PresetAmountProps = {
label: string label: string
@ -460,3 +462,405 @@ export const ZapPopUp = ({
</> </>
) )
} }
type ZapSplitProps = {
pubkey: string
eventId?: string
aTag?: string
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
handleClose: () => void
}
export const ZapSplit = ({
pubkey,
eventId,
aTag,
setTotalZapAmount,
setHasZapped,
handleClose
}: ZapSplitProps) => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
const [message, setMessage] = useState('')
const [authorPercentage, setAuthorPercentage] = useState(95)
const [adminPercentage, setAdminPercentage] = useState(5)
const [author, setAuthor] = useState<UserProfile>()
const [admin, setAdmin] = useState<UserProfile>()
const userState = useAppSelector((state) => state.user)
const [invoices, setInvoices] = useState<Map<string, PaymentRequest>>()
useDidMount(async () => {
const metadataController = await MetadataController.getInstance()
metadataController.findMetadata(pubkey).then((res) => {
setAuthor(res)
})
metadataController.findAdminMetadata().then((res) => {
setAdmin(res)
})
})
const handleAuthorPercentageChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const newValue = parseInt(e.target.value)
setAuthorPercentage(newValue)
setAdminPercentage(100 - newValue)
}
const handleAdminPercentageChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const newValue = parseInt(e.target.value)
setAdminPercentage(newValue)
setAuthorPercentage(100 - newValue) // Update the other slider to maintain 100%
}
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const unformattedValue = unformatNumber(event.target.value)
setAmount(unformattedValue)
}
const generatePaymentInvoices = async () => {
if (!amount) return null
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return null
}
const adminShare = Math.floor((amount * adminPercentage) / 100)
const authorShare = amount - adminShare
const zapController = ZapController.getInstance()
const invoices = new Map<string, PaymentRequest>()
if (authorShare > 0 && author?.pubkey && author?.lud16) {
setLoadingSpinnerDesc('Generating invoice for author')
const invoice = await zapController
.getLightningPaymentRequest(
author.lud16,
authorShare,
author.pubkey as string,
userHexKey,
message,
eventId,
aTag
)
.catch((err) => {
toast.error(err.message || err)
return null
})
if (invoice) {
invoices.set('author', invoice)
}
}
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
setLoadingSpinnerDesc('Generating invoice for site owner')
const invoice = await zapController
.getLightningPaymentRequest(
admin.lud16,
adminShare,
admin.pubkey as string,
userHexKey,
message,
eventId,
aTag
)
.catch((err) => {
toast.error(err.message || err)
return null
})
if (invoice) {
invoices.set('admin', invoice)
}
}
setIsLoading(false)
return invoices
}
const handleGenerateQRCode = async () => {
const paymentInvoices = await generatePaymentInvoices()
if (!paymentInvoices) return
setInvoices(paymentInvoices)
}
const handleSend = async () => {
const paymentInvoices = await generatePaymentInvoices()
if (!paymentInvoices) return
setIsLoading(true)
setLoadingSpinnerDesc('Sending payment!')
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
const authorInvoice = paymentInvoices.get('author')
if (authorInvoice) {
setLoadingSpinnerDesc('Sending payment to author')
const sats = parseInt(getTagValue(authorInvoice, 'amount')![0]) / 1000
await zapController
.sendPayment(authorInvoice.pr)
.then(() => {
toast.success(`Successfully sent ${sats} sats to author!`)
if (setTotalZapAmount) {
setTotalZapAmount((prev) => prev + sats)
if (setHasZapped) setHasZapped(true)
}
})
.catch((err) => {
toast.error(err.message || err)
})
}
const adminInvoice = paymentInvoices.get('admin')
if (adminInvoice) {
setLoadingSpinnerDesc('Sending payment to site owner')
const sats = parseInt(getTagValue(adminInvoice, 'amount')![0]) / 1000
await zapController
.sendPayment(adminInvoice.pr)
.then(() => {
toast.success(`Successfully sent ${sats} sats to site owner!`)
})
.catch((err) => {
toast.error(err.message || err)
})
}
handleClose()
} else {
toast.warn('Webln is not present. Use QR code to send zap.')
setInvoices(paymentInvoices)
}
}
const removeInvoice = (key: string) => {
setInvoices((prev) => {
const newMap = new Map(prev)
newMap.delete(key)
return newMap
})
}
const displayQR = () => {
if (!invoices) return null
const authorInvoice = invoices.get('author')
if (authorInvoice) {
return (
<ZapQR
key={authorInvoice.pr}
paymentRequest={authorInvoice}
handleClose={() => removeInvoice('author')}
handleQRExpiry={() => removeInvoice('author')}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
/>
)
}
const adminInvoice = invoices.get('admin')
if (adminInvoice) {
return (
<ZapQR
key={adminInvoice.pr}
paymentRequest={adminInvoice}
handleClose={() => {
removeInvoice('admin')
handleClose()
}}
handleQRExpiry={() => removeInvoice('admin')}
/>
)
}
return null
}
const authorName = author?.displayName || author?.name || '[name not set up]'
const adminName = admin?.displayName || admin?.name || '[name not set up]'
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Tip/Zap</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='pUMCB_ZapsInsideAmount'>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Amount (Satoshis)
</label>
<input
className='inputMain'
type='text'
inputMode='numeric'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Message (optional)
</label>
<input
type='text'
className='inputMain'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Tip Split</label>
<div className='ZapSplitUserBox'>
<div className='ZapSplitUserBoxUser'>
<div
className='ZapSplitUserBoxUserPic'
style={{
background: `url('${
author?.image || FALLBACK_PROFILE_IMAGE
}') center / cover no-repeat`
}}
></div>
<div className='ZapSplitUserBoxUserDetails'>
<p className='ZapSplitUserBoxUserDetailsName'>
{authorName}
</p>
{author?.nip05 && (
<p className='ZapSplitUserBoxUserDetailsHandle'>
{author.nip05}
</p>
)}
</div>
</div>
<div className='ZapSplitUserBoxRange'>
<input
className='form-range inputRangeMain inputRangeMainZap'
type='range'
max='100'
min='0'
value={authorPercentage}
onChange={handleAuthorPercentageChange}
step='1'
required
name='ZapSplitName'
/>
<p className='ZapSplitUserBoxRangeText'>
{authorPercentage}%
</p>
</div>
<p className='ZapSplitUserBoxText'>
This goes to show your appreciation to the mod creator!
</p>
</div>
<div className='ZapSplitUserBox'>
<div className='ZapSplitUserBoxUser'>
<div
className='ZapSplitUserBoxUserPic'
style={{
background: `url('${
admin?.image || FALLBACK_PROFILE_IMAGE
}') center / cover no-repeat`
}}
></div>
<div className='ZapSplitUserBoxUserDetails'>
<p className='ZapSplitUserBoxUserDetailsName'>
{adminName}
</p>
{admin?.nip05 && (
<p className='ZapSplitUserBoxUserDetailsHandle'></p>
)}
</div>
</div>
<div className='ZapSplitUserBoxRange'>
<input
className='form-range inputRangeMain inputRangeMainZap'
type='range'
max='100'
min='0'
value={adminPercentage}
onChange={handleAdminPercentageChange}
step='1'
required
name='ZapSplitName'
/>
<p className='ZapSplitUserBoxRangeText'>
{adminPercentage}%
</p>
</div>
<p className='ZapSplitUserBoxText'>
Help with the development, maintenance, management, and
growth of DEG Mods.
</p>
</div>
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{displayQR()}
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -1,11 +1,10 @@
import { LoadingSpinner } from 'components/LoadingSpinner' import { ZapSplit } from 'components/Zap'
import { ZapButtons, ZapPopUp, ZapPresets, ZapQR } from 'components/Zap' import { RelayController } from 'controllers'
import { MetadataController, RelayController, ZapController } from 'controllers'
import { useAppSelector, useDidMount } from 'hooks' import { useAppSelector, useDidMount } from 'hooks'
import { useCallback, useState } from 'react' import { useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { ModDetails, PaymentRequest } from 'types' import { ModDetails } from 'types'
import { abbreviateNumber, formatNumber, unformatNumber } from 'utils' import { abbreviateNumber } from 'utils'
type ZapProps = { type ZapProps = {
modDetails: ModDetails modDetails: ModDetails
@ -65,192 +64,15 @@ export const Zap = ({ modDetails }: ZapProps) => {
</div> </div>
</div> </div>
{isOpen && ( {isOpen && (
<ZapPopUp <ZapSplit
title='Tip/Zap' pubkey={modDetails.author}
receiver={modDetails.author}
eventId={modDetails.id} eventId={modDetails.id}
aTag={modDetails.aTag} aTag={modDetails.aTag}
handleClose={() => setIsOpen(false)}
lastNode={<ZapSite />}
notCloseAfterZap
setTotalZapAmount={setTotalZappedAmount} setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped} setHasZapped={setHasZapped}
handleClose={() => setIsOpen(false)}
/> />
)} )}
</> </>
) )
} }
const ZapSite = () => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState(0)
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const unformattedValue = unformatNumber(event.target.value)
setAmount(unformattedValue)
}
const handleClose = useCallback(() => {
setPaymentRequest(undefined)
setIsLoading(false)
}, [])
const handleQRExpiry = useCallback(() => {
setPaymentRequest(undefined)
}, [])
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
setIsLoading(false)
toast.error('Could not get pubkey')
return null
}
setLoadingSpinnerDesc('Getting admin metadata')
const metadataController = await MetadataController.getInstance()
const adminMetadata = await metadataController.findAdminMetadata()
if (!adminMetadata?.lud16) {
setIsLoading(false)
toast.error('Lighting address (lud16) is missing in admin metadata!')
return null
}
if (!adminMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in admin metadata!')
return null
}
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
return await zapController
.getLightningPaymentRequest(
adminMetadata.lud16,
amount,
adminMetadata.pubkey as string,
userHexKey
)
.catch((err) => {
toast.error(err.message || err)
return null
})
.finally(() => {
setIsLoading(false)
})
}, [amount, userState])
const handleSend = useCallback(async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setIsLoading(true)
setLoadingSpinnerDesc('Sending payment!')
const zapController = ZapController.getInstance()
if (await zapController.isWeblnProviderExists()) {
await zapController
.sendPayment(pr.pr)
.then(() => {
toast.success(`Successfully sent ${amount} sats!`)
handleClose()
})
.catch((err) => {
toast.error(err.message || err)
})
} else {
toast.warn('Webln is not present. Use QR code to send zap.')
setPaymentRequest(pr)
}
setIsLoading(false)
}, [amount, handleClose, generatePaymentRequest])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
if (!pr) return
setPaymentRequest(pr)
}
return (
<>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Tip DEG Mods too (Optional)
</label>
<div className='ZapSplitUserBox'>
<div className='ZapSplitUserBoxUser'>
<div
className='ZapSplitUserBoxUserPic'
style={{
background: `url('/assets/img/Logo%20with%20circle.png')
center / cover no-repeat`
}}
></div>
<div className='ZapSplitUserBoxUserDetails'>
<p className='ZapSplitUserBoxUserDetailsName'>DEG Mods</p>
<p className='ZapSplitUserBoxUserDetailsHandle'>
degmods@degmods.com
</p>
</div>
</div>
<p className='ZapSplitUserBoxText'>
Help with the development, maintenance, management, and growth of
DEG Mods.
</p>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Amount (Satoshis)</label>
<input
type='text'
className='inputMain'
inputMode='numeric'
placeholder='69 or 420? or 69,420?'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
</div>
</div>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}