stacker.news/components/invoice.js

254 lines
7.6 KiB
JavaScript
Raw Normal View History

2023-08-09 23:45:59 +00:00
import { useState, useCallback, useEffect } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { gql } from 'graphql-tag'
import { numWithUnits } from '../lib/format'
2023-08-09 23:45:59 +00:00
import AccordianItem from './accordian-item'
import Qr, { QrSkeleton } from './qr'
import { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import { sleep } from '../lib/time'
import Countdown from './countdown'
export function Invoice ({ invoice, onPayment, info, successVerb }) {
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
2021-05-06 21:15:22 +00:00
2021-05-13 13:28:38 +00:00
let variant = 'default'
2021-05-11 15:52:50 +00:00
let status = 'waiting for you'
let webLn = true
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
2021-05-13 13:28:38 +00:00
variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
webLn = false
} else if (expired) {
2021-05-13 13:28:38 +00:00
variant = 'failed'
status = 'expired'
webLn = false
} else if (invoice.cancelled) {
2021-05-13 13:28:38 +00:00
variant = 'failed'
status = 'cancelled'
webLn = false
2021-05-11 15:52:50 +00:00
}
2021-05-06 21:15:22 +00:00
useEffect(() => {
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) {
onPayment?.(invoice)
}
}, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
const { nostr } = invoice
return (
<>
2023-08-11 00:58:33 +00:00
<Qr
webLn={webLn} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status}
/>
<div className='text-muted text-center'>
<Countdown
date={invoice.expiresAt} onComplete={() => {
setExpired(true)
}}
/>
</div>
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
<div className='w-100'>
{nostr
? <AccordianItem
header='Nostr Zap Request'
body={
<pre>
<code>
{JSON.stringify(nostr, null, 2)}
</code>
</pre>
}
/>
: null}
</div>
</>
)
2021-05-06 21:15:22 +00:00
}
2023-08-09 23:45:59 +00:00
const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
2023-08-09 23:45:59 +00:00
const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000,
variables: { id }
})
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
2023-08-09 23:45:59 +00:00
if (error) {
if (error.message?.includes('invoice not found')) {
return
}
return <div>error</div>
}
if (!data || loading) {
2023-08-11 21:52:24 +00:00
return <QrSkeleton description status='loading' />
2023-08-09 23:45:59 +00:00
}
let errorStatus = 'Something went wrong trying to perform the action after payment.'
2023-08-09 23:45:59 +00:00
if (errorCount > 1) {
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
2023-08-09 23:45:59 +00:00
}
2023-08-09 23:45:59 +00:00
return (
<>
<Invoice invoice={data.invoice} {...props} />
2023-08-09 23:45:59 +00:00
{errorCount > 0
? (
<>
<div className='my-3'>
<InvoiceStatus variant='failed' status={errorStatus} />
</div>
<div className='d-flex flex-row mt-3 justify-content-center'>
<Button className='mx-1' variant='info' onClick={repeat}>Retry</Button>
<Button
className='mx-1'
variant='danger' onClick={async () => {
await cancelInvoice({ variables: { hash, hmac } })
onClose()
}}
>Cancel
</Button>
</div>
2023-08-09 23:45:59 +00:00
</>
)
: null}
</>
)
}
const defaultOptions = {
forceInvoice: false,
requireSession: false,
2023-08-31 15:20:01 +00:00
callback: null, // (formValues) => void
replaceModal: false
2023-08-09 23:45:59 +00:00
}
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
2023-08-09 23:45:59 +00:00
const me = useMe()
const [createInvoice, { data }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
2023-08-09 23:45:59 +00:00
id
hash
hmac
expiresAt
2023-08-09 23:45:59 +00:00
}
}`)
const showModal = useShowModal()
const [formValues, setFormValues] = useState()
const [submitArgs, setSubmitArgs] = useState()
2023-08-09 23:45:59 +00:00
let errorCount = 0
const onPayment = useCallback(
(onClose, hmac) => {
return async ({ id, satsReceived, expiresAt, hash }) => {
2023-08-11 22:53:21 +00:00
await sleep(500)
const repeat = () => {
onClose()
// call onSubmit handler and pass invoice data
onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
.then(() => {
options?.callback?.(formValues)
})
2023-08-09 23:45:59 +00:00
.catch((error) => {
// if error happened after payment, show repeat and cancel options
// by passing `errorCount` and `repeat`
2023-08-09 23:45:59 +00:00
console.error(error)
errorCount++
showModal(onClose => (
<MutationInvoice
2023-08-09 23:45:59 +00:00
id={id}
hash={hash}
hmac={hmac}
expiresAt={expiresAt}
onClose={onClose}
onPayment={onPayment(onClose, hmac)}
2023-08-09 23:45:59 +00:00
successVerb='received'
errorCount={errorCount}
repeat={repeat}
/>
), { keepOpen: true })
})
}
// prevents infinite loop of calling `onPayment`
2023-08-09 23:45:59 +00:00
if (errorCount === 0) await repeat()
}
}, [onSubmit, submitArgs]
2023-08-09 23:45:59 +00:00
)
const invoice = data?.createInvoice
useEffect(() => {
if (invoice) {
showModal(onClose => (
<MutationInvoice
2023-08-09 23:45:59 +00:00
id={invoice.id}
hash={invoice.hash}
hmac={invoice.hmac}
expiresAt={invoice.expiresAt}
onClose={onClose}
onPayment={onPayment(onClose, invoice.hmac)}
2023-08-09 23:45:59 +00:00
successVerb='received'
/>
), { replaceModal: options.replaceModal, keepOpen: true }
2023-08-09 23:45:59 +00:00
)
}
}, [invoice?.id])
// this function will be called before the Form's onSubmit handler is called
// and the form must include `cost` or `amount` as a value
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => {
let { cost, amount } = formValues
cost ??= amount
// action only allowed if logged in
2023-08-09 23:45:59 +00:00
if (!me && options.requireSession) {
throw new Error('you must be logged in')
}
// if no cost is passed, just try the action first
if (!cost || (me && !options.forceInvoice)) {
2023-08-09 23:45:59 +00:00
try {
return await onSubmit(formValues, ...submitArgs)
2023-08-09 23:45:59 +00:00
} catch (error) {
if (!payOrLoginError(error)) {
throw error
2023-08-09 23:45:59 +00:00
}
}
}
setFormValues(formValues)
setSubmitArgs(submitArgs)
await createInvoice({ variables: { amount: cost } })
// tell onSubmit handler that we want to keep local storage
// even though the submit handler was "successful"
return { keepLocalStorage: true }
}, [onSubmit, setFormValues, setSubmitArgs, createInvoice])
2023-08-09 23:45:59 +00:00
return onSubmitWrapper
2023-08-09 23:45:59 +00:00
}
export const InvoiceModal = ({ onPayment, amount }) => {
const createInvoice = useInvoiceable(onPayment, { replaceModal: true })
useEffect(() => {
createInvoice({ amount })
}, [])
}
export const payOrLoginError = (error) => {
const matches = ['insufficient funds', 'you must be logged in or pay']
if (Array.isArray(error)) {
return error.some(({ message }) => matches.some(m => message.includes(m)))
}
return matches.some(m => error.toString().includes(m))
}