stacker.news/components/invoice.js

270 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 { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import Countdown from './countdown'
import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
2023-10-24 00:58:33 +00:00
export function Invoice ({ invoice, modal, 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.cancelled) {
variant = 'failed'
status = 'cancelled'
webLn = false
} else 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
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, comment, lud18Data, bolt11, confirmedPreimage } = 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}
/>
{!invoice.confirmedAt &&
<div className='text-muted text-center'>
<Countdown
date={invoice.expiresAt} onComplete={() => {
setExpired(true)
}}
/>
</div>}
2023-10-24 00:58:33 +00:00
{!modal &&
<>
{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>
}
2023-10-24 00:58:33 +00:00
/>
: null}
</div>
{lud18Data &&
<div className='w-100'>
<AccordianItem
header='sender information'
body={<PayerData data={lud18Data} className='text-muted ms-3 mb-3' />}
/>
</div>}
{comment &&
<div className='w-100'>
<AccordianItem
header='sender comments'
body={<span className='text-muted ms-3'>{comment}</span>}
/>
</div>}
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />
</>}
</>
)
2021-05-06 21:15:22 +00:00
}
2023-08-09 23:45:59 +00:00
const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => {
2023-08-09 23:45:59 +00:00
const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000,
variables: { id }
})
const [retryError, setRetryError] = useState(0)
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
}
const retry = !!onRetry
let errorStatus = 'Something went wrong trying to perform the action after payment.'
if (retryError > 0) {
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} modal onPayment={onPayment} successVerb='received' />
{retry
2023-08-09 23:45:59 +00:00
? (
<>
<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={async () => {
try {
await onRetry()
} catch (err) {
console.error('retry error:', err)
setRetryError(retryError => retryError + 1)
}
}}
>Retry
</Button>
<Button
className='mx-1'
variant='danger'
onClick={onCancel}
>Cancel
</Button>
</div>
2023-08-09 23:45:59 +00:00
</>
)
: null}
</>
)
}
const defaultOptions = {
requireSession: false,
forceInvoice: 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] = useMutation(gql`
2023-08-09 23:45:59 +00:00
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 [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
2023-08-09 23:45:59 +00:00
}
}
`)
const showModal = useShowModal()
2023-08-09 23:45:59 +00:00
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
// some actions require a session
2023-08-09 23:45:59 +00:00
if (!me && options.requireSession) {
throw new Error('you must be logged in')
}
// educated guesses where action might pass in the invoice amount
// (field 'cost' has highest precedence)
2023-11-14 02:02:34 +00:00
cost ??= formValues.amount
// attempt action for the first time
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) || !cost) {
// can't handle error here - bail
throw error
2023-08-09 23:45:59 +00:00
}
}
}
// initial attempt of action failed. we will create an invoice, pay and retry now.
const { data, error } = await createInvoice({ variables: { amount: cost } })
if (error) {
throw error
}
const inv = data.createInvoice
// wait until invoice is paid or modal is closed
let modalClose
await new Promise((resolve, reject) => {
showModal(onClose => {
modalClose = onClose
return (
<JITInvoice
invoice={inv}
onPayment={resolve}
/>
)
}, { keepOpen: true, onClose: reject })
})
const retry = () => onSubmit({ hash: inv.hash, hmac: inv.hmac, ...formValues }, ...submitArgs)
// first retry
try {
const ret = await retry()
modalClose()
return ret
} catch (error) {
console.error('retry error:', error)
}
// retry until success or cancel
return await new Promise((resolve, reject) => {
const cancelAndReject = async () => {
await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } })
reject(new Error('invoice canceled'))
}
showModal(onClose => {
return (
<JITInvoice
invoice={inv}
onCancel={async () => {
await cancelAndReject()
onClose()
}}
onRetry={async () => {
resolve(await retry())
}}
/>
)
}, { keepOpen: true, onClose: cancelAndReject })
})
}, [onSubmit, createInvoice, !!me])
2023-08-09 23:45:59 +00:00
return onSubmitWrapper
2023-08-09 23:45:59 +00:00
}
2023-10-06 20:01:51 +00:00
export const useInvoiceModal = (onPayment, deps) => {
const onPaymentMemo = useCallback(onPayment, deps)
return useInvoiceable(onPaymentMemo, { replaceModal: true })
}
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))
}