stacker.news/components/fee-button.js
ekzyis 94cce9155d
Frontend payment UX cleanup (#1194)
* Replace useInvoiceable with usePayment hook

* Show WebLnError in QR code fallback

* Fix missing removal of old zap undo code

* Fix payment timeout message

* Fix unused arg in super()

* Also bail if invoice expired

* Fix revert on reply error

* Use JIT_INVOICE_TIMEOUT_MS constant

* Remove unnecessary PaymentContext

* Fix me as a dependency in FeeButtonContext

* Fix anon sats added before act success

* Optimistic updates for zaps

* Fix modal not closed after custom zap

* Optimistic update for custom zaps

* Optimistic update for bounty payments

* Consistent error handling for zaps and bounty payments

* Optimistic update for poll votes

* Use var balance in payment.request

* Rename invoiceable to prepaid

* Log cancelled invoices

* Client notifications

We now show notifications that are stored on the client to inform the user about following errors in the prepaid payment flow:

- if a payment fails
- if an invoice expires before it is paid
- if a payment was interrupted (for example via page refresh)
- if the action fails after payment

* Remove unnecessary passing of act

* Use AbortController for zap undos

* Fix anon zap update not updating bolt color

* Fix zap counted towards anon sats even if logged in

* Fix duplicate onComplete call

* Fix downzap type error

* Fix "missing field 'path' while writing result" error

* Pass full item in downzap props

The previous commit fixed cache updates for downzaps but then the cache update for custom zaps failed because 'path' wasn't included in the server response.

This commit is the proper fix.

* Parse lnc rpc error messages

* Add hash to InvoiceExpiredError
2024-05-28 12:18:54 -05:00

182 lines
5.8 KiB
JavaScript

import { useEffect, useContext, createContext, useState, useCallback, useMemo } from 'react'
import Table from 'react-bootstrap/Table'
import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { ANON_FEE_MULTIPLIER, FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { numWithUnits } from '@/lib/format'
import { useMe } from './me'
import AnonIcon from '@/svgs/spy-fill.svg'
import { useShowModal } from './modal'
import Link from 'next/link'
import { SubmitButton } from './form'
const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, allowFreebies = true, me }) {
const anonCharge = me
? {}
: {
anonCharge: {
term: `x ${ANON_FEE_MULTIPLIER}`,
label: 'anon mult',
modifier: (cost) => cost * ANON_FEE_MULTIPLIER
}
}
return {
baseCost: {
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
modifier: (cost) => cost + baseCost,
allowFreebies
},
...anonCharge
}
}
export function postCommentUseRemoteLineItems ({ parentId } = {}) {
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
return function useRemoteLineItems () {
const [line, setLine] = useState({})
const { data } = useQuery(query, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' })
useEffect(() => {
const repetition = data?.itemRepetition
if (!repetition) return setLine({})
setLine({
itemRepetition: {
term: <>x 10<sup>{repetition}</sup></>,
label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>,
modifier: (cost) => cost * Math.pow(10, repetition)
}
})
}, [data?.itemRepetition])
return line
}
}
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false)
const me = useMe()
const remoteLineItems = useRemoteLineItems()
const mergeLineItems = useCallback((newLineItems) => {
setLineItems(lineItems => ({
...lineItems,
...newLineItems
}))
}, [setLineItems])
const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
// freebies: there's only a base cost and we don't have enough sats
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
return {
lines,
merge: mergeLineItems,
total,
disabled,
setDisabled,
free
}
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
return (
<FeeButtonContext.Provider value={value}>
{children}
</FeeButtonContext.Provider>
)
}
export function useFeeButton () {
const context = useContext(FeeButtonContext)
return context
}
function FreebieDialog () {
return (
<>
<div className='fw-bold'>you don't have enough sats, so this one is on us</div>
<ul className='mt-2'>
<li>Free items have limited visibility until other stackers zap them.</li>
<li>To get fully visible right away, fund your account with a few sats or earn some on Stacker News.</li>
</ul>
</>
)
}
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe()
const { lines, total, disabled: ctxDisabled, free } = useFeeButton()
const feeText = free
? 'free'
: total > 1
? numWithUnits(total, { abbreviate: false, format: true })
: undefined
disabled ||= ctxDisabled
return (
<div className={styles.feeButton}>
<ActionTooltip overlayText={!free && total === 1 ? '1 sat' : feeText}>
<ChildButton variant={variant} disabled={disabled} nonDisabledText={feeText}>{text}</ChildButton>
</ActionTooltip>
{!me && <AnonInfo />}
{(free && <Info><FreebieDialog /></Info>) ||
(total > 1 && <Info><Receipt lines={lines} total={total} /></Info>)}
</div>
)
}
function Receipt ({ lines, total }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
{Object.entries(lines).map(([key, { term, label, omit }]) => (
!omit &&
<tr key={key}>
<td>{term}</td>
<td align='right' className='font-weight-light'>{label}</td>
</tr>))}
</tbody>
<tfoot>
<tr>
<td className='fw-bold'>{numWithUnits(total, { abbreviate: false, format: true })}</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
function AnonInfo () {
const showModal = useShowModal()
return (
<AnonIcon
className='ms-2 fill-theme-color' height={22} width={22}
onClick={
(e) =>
showModal(onClose =>
<div><div className='fw-bold text-center'>You are posting without an account</div>
<ol className='my-3'>
<li>You'll pay by invoice</li>
<li>Your content will be content-joined (get it?!) under the <Link href='/anon' target='_blank'>@anon</Link> account</li>
<li>Any sats your content earns will go toward <Link href='/rewards' target='_blank'>rewards</Link></li>
<li>We won't be able to notify you when you receive replies</li>
</ol>
<small className='text-center fst-italic text-muted'>btw if you don't need to be anonymous, posting is cheaper with an account</small>
</div>)
}
/>
)
}