stacker.news/components/invoice.js

250 lines
7.9 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 { CopyInput } from './form'
import { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import { sleep } from '../lib/time'
import FundError, { isInsufficientFundsError } from './fund-error'
2023-08-12 01:18:56 +00:00
import { usePaymentTokens } from './payment-tokens'
2021-05-06 21:15:22 +00:00
2023-07-13 03:08:32 +00:00
export function Invoice ({ invoice, onConfirmation, successVerb }) {
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
2021-05-11 15:52:50 +00:00
if (invoice.confirmedAt) {
2021-05-13 13:28:38 +00:00
variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
webLn = false
2021-05-11 15:52:50 +00:00
} else if (invoice.cancelled) {
2021-05-13 13:28:38 +00:00
variant = 'failed'
2021-05-11 15:52:50 +00:00
status = 'cancelled'
webLn = false
2021-05-11 15:52:50 +00:00
} else if (invoice.expiresAt <= new Date()) {
2021-05-13 13:28:38 +00:00
variant = 'failed'
2021-05-11 15:52:50 +00:00
status = 'expired'
webLn = false
2021-05-11 15:52:50 +00:00
}
2021-05-06 21:15:22 +00:00
useEffect(() => {
if (invoice.confirmedAt) {
onConfirmation?.(invoice)
}
}, [invoice.confirmedAt])
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='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 Contacts = ({ invoiceHash, invoiceHmac }) => {
2023-08-09 23:45:59 +00:00
const subject = `Support request for payment hash: ${invoiceHash}`
const body = 'Hi, I successfully paid for <insert action> but the action did not work.'
return (
<div className='d-flex flex-column justify-content-center mt-2'>
2023-08-09 23:45:59 +00:00
<div className='w-100'>
2023-08-11 23:43:45 +00:00
<CopyInput
label={<>payment token <small className='text-danger fw-normal ms-2'>save this</small></>}
type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm
/>
</div>
2023-08-09 23:45:59 +00:00
<div className='d-flex flex-row justify-content-center'>
<a
href={`mailto:kk@stacker.news?subject=${subject}&body=${body}`} className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
e-mail
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://tribes.sphinx.chat/t/stackerzchat' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
sphinx
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
telegram
</a>
<span className='mx-2 text-muted'> \ </span>
<a
href='https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FebLYaEFGjsD3uK4fpE326c5QI1RZSxau%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAV086Oj5yCsavWzIbRMCVuF6jq793Tt__rWvCec__viI%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cZwSGoQhyOUulzp7rwCdWQ%3D%3D%22%7D' className='nav-link p-0 d-inline-flex'
target='_blank' rel='noreferrer'
>
simplex
</a>
</div>
</div>
)
}
const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
2023-08-09 23:45:59 +00:00
const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000,
variables: { id }
})
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.\nPlease contact admins for support or to request a refund.'
}
return (
<>
<Invoice invoice={data.invoice} {...props} />
{errorCount > 0
? (
<>
<div className='my-3'>
<InvoiceStatus variant='failed' status={errorStatus} />
</div>
<div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
<Contacts invoiceHash={hash} invoiceHmac={hmac} />
2023-08-09 23:45:59 +00:00
</>
)
: null}
</>
)
}
const defaultOptions = {
forceInvoice: false,
requireSession: false
}
export const useInvoiceable = (fn, options = defaultOptions) => {
const me = useMe()
const [createInvoice, { data }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount, expireSecs: 1800) {
2023-08-09 23:45:59 +00:00
id
hash
hmac
2023-08-09 23:45:59 +00:00
}
}`)
const showModal = useShowModal()
const [fnArgs, setFnArgs] = useState()
2023-08-12 01:18:56 +00:00
const { addPaymentToken, removePaymentToken } = usePaymentTokens()
2023-08-09 23:45:59 +00:00
// fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice
let errorCount = 0
const onConfirmation = useCallback(
(onClose, hmac) => {
2023-08-09 23:45:59 +00:00
return async ({ id, satsReceived, hash }) => {
2023-08-12 01:18:56 +00:00
addPaymentToken(hash, hmac, satsReceived)
2023-08-11 22:53:21 +00:00
await sleep(500)
2023-08-09 23:45:59 +00:00
const repeat = () =>
fn(satsReceived, ...fnArgs, hash, hmac)
2023-08-12 01:18:56 +00:00
.then(() => {
removePaymentToken(hash, hmac)
})
2023-08-09 23:45:59 +00:00
.then(onClose)
.catch((error) => {
console.error(error)
errorCount++
onClose()
showModal(onClose => (
<ActionInvoice
id={id}
hash={hash}
hmac={hmac}
onConfirmation={onConfirmation(onClose, hmac)}
2023-08-09 23:45:59 +00:00
successVerb='received'
errorCount={errorCount}
repeat={repeat}
/>
), { keepOpen: true })
})
// prevents infinite loop of calling `onConfirmation`
if (errorCount === 0) await repeat()
}
}, [fn, fnArgs]
)
const invoice = data?.createInvoice
useEffect(() => {
if (invoice) {
showModal(onClose => (
<ActionInvoice
id={invoice.id}
hash={invoice.hash}
hmac={invoice.hmac}
onConfirmation={onConfirmation(onClose, invoice.hmac)}
2023-08-09 23:45:59 +00:00
successVerb='received'
/>
), { keepOpen: true }
)
}
}, [invoice?.id])
const actionFn = useCallback(async (amount, ...args) => {
if (!me && options.requireSession) {
throw new Error('you must be logged in')
}
if (!amount || (me && !options.forceInvoice)) {
2023-08-09 23:45:59 +00:00
try {
return await fn(amount, ...args)
} catch (error) {
if (isInsufficientFundsError(error)) {
showModal(onClose => {
return (
<FundError
onClose={onClose}
amount={amount}
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }}
2023-08-09 23:45:59 +00:00
/>
)
})
return { keepLocalStorage: true }
2023-08-09 23:45:59 +00:00
}
throw error
2023-08-09 23:45:59 +00:00
}
}
setFnArgs(args)
await createInvoice({ variables: { amount } })
// tell onSubmit handler that we want to keep local storage
// even though the submit handler was "successful"
return { keepLocalStorage: true }
2023-08-09 23:45:59 +00:00
}, [fn, setFnArgs, createInvoice])
return actionFn
}