stacker.news/pages/wallet.js
SatsAllDay 362f95add9
LUD-18 Wallet implementation (#531)
* LUD-18 Wallet implementation

Query the lightning address provider client-side to learn of capabilities

Conditionally render LUD-12 and LUD-18 fields based on what the remote
server says is supported

Allow identifier, name, and email to be sent from the SN side during the withdrawal flow. Auth seems too complicated for our use case, and idk about pubkey?

* Clear inputs if the new ln addr provier doesn't support those fields

* various ux improvements

* dynamic client-side validation for required payer data

* don't re-init form state on error

* correct min and max amount values

* only send applicable data to graphql api based on payerdata schema

* input type for numeric values (amount, max fee)

* update step for amount and max fee

* Fix identifier optional and field blur

* reuse more code

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-10-03 18:22:56 -05:00

426 lines
12 KiB
JavaScript

import { useRouter } from 'next/router'
import { Checkbox, Form, Input, SubmitButton } from '../components/form'
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import { gql, useMutation, useQuery } from '@apollo/client'
import Qr, { QrSkeleton } from '../components/qr'
import { CenterLayout } from '../components/layout'
import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from './withdrawals/[id]'
import { useMe } from '../components/me'
import { useEffect, useState } from 'react'
import { requestProvider } from 'webln'
import Alert from 'react-bootstrap/Alert'
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '../fragments/wallet'
import { getGetServerSideProps } from '../api/ssrApollo'
import { amountSchema, lnAddrSchema, withdrawlSchema } from '../lib/validate'
import Nav from 'react-bootstrap/Nav'
import { SSR } from '../lib/constants'
import { numWithUnits } from '../lib/format'
import styles from '../components/user-header.module.css'
import HiddenWalletSummary from '../components/hidden-wallet-summary'
import AccordianItem from '../components/accordian-item'
import { lnAddrOptions } from '../lib/lnurl'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Wallet () {
const router = useRouter()
if (router.query.type === 'fund') {
return (
<CenterLayout>
<FundForm />
</CenterLayout>
)
} else if (router.query.type?.includes('withdraw')) {
return (
<CenterLayout>
<WithdrawalForm />
</CenterLayout>
)
} else {
return (
<CenterLayout>
<YouHaveSats />
<WalletForm />
<WalletHistory />
</CenterLayout>
)
}
}
function YouHaveSats () {
const me = useMe()
return (
<h2 className={`${me ? 'visible' : 'invisible'} text-success`}>
you have{' '}
<span className='text-monospace'>{me && (
me.hideWalletBalance
? <HiddenWalletSummary />
: numWithUnits(me.sats, { abbreviate: false, format: true })
)}
</span>
</h2>
)
}
function WalletHistory () {
return (
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'>
wallet history
</Link>
)
}
export function WalletForm () {
return (
<div className='align-items-center text-center py-5'>
<Link href='/wallet?type=fund'>
<Button variant='success'>fund</Button>
</Link>
<span className='mx-3 fw-bold text-muted'>or</span>
<Link href='/wallet?type=withdraw'>
<Button variant='success'>withdraw</Button>
</Link>
</div>
)
}
export function FundForm () {
const me = useMe()
const [showAlert, setShowAlert] = useState(true)
const router = useRouter()
const [createInvoice, { called, error }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount) {
id
}
}`)
useEffect(() => {
setShowAlert(!window.localStorage.getItem('hideLnAddrAlert'))
}, [])
if (called && !error) {
return <QrSkeleton description status='generating' />
}
return (
<>
<YouHaveSats />
<div className='w-100 py-5'>
{me && showAlert &&
<Alert
variant='success' dismissible onClose={() => {
window.localStorage.setItem('hideLnAddrAlert', 'yep')
setShowAlert(false)
}}
>
You can also fund your account via lightning address with <strong>{`${me.name}@stacker.news`}</strong>
</Alert>}
<Form
initial={{
amount: 1000
}}
initialError={error?.toString()}
schema={amountSchema}
onSubmit={async ({ amount }) => {
const { data } = await createInvoice({ variables: { amount: Number(amount) } })
router.push(`/invoices/${data.createInvoice.id}`)
}}
>
<Input
label='amount'
name='amount'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<SubmitButton variant='success' className='mt-2'>generate invoice</SubmitButton>
</Form>
</div>
<WalletHistory />
</>
)
}
export function WithdrawalForm () {
const router = useRouter()
return (
<div className='w-100 d-flex flex-column align-items-center py-5'>
<YouHaveSats />
<Nav
className={styles.nav}
activeKey={router.query.type}
>
<Nav.Item>
<Link href='/wallet?type=withdraw' passHref legacyBehavior>
<Nav.Link eventKey='withdraw'>invoice</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/wallet?type=lnurl-withdraw' passHref legacyBehavior>
<Nav.Link eventKey='lnurl-withdraw'>QR code</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/wallet?type=lnaddr-withdraw' passHref legacyBehavior>
<Nav.Link eventKey='lnaddr-withdraw'>lightning address</Nav.Link>
</Link>
</Nav.Item>
</Nav>
<SelectedWithdrawalForm />
</div>
)
}
export function SelectedWithdrawalForm () {
const router = useRouter()
switch (router.query.type) {
case 'withdraw':
return <InvWithdrawal />
case 'lnurl-withdraw':
return <LnWithdrawal />
case 'lnaddr-withdraw':
return <LnAddrWithdrawal />
}
}
const MAX_FEE_DEFAULT = 10
export function InvWithdrawal () {
const router = useRouter()
const me = useMe()
const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL)
useEffect(() => {
async function effect () {
try {
const provider = await requestProvider()
const { paymentRequest: invoice } = await provider.makeInvoice({
defaultMemo: `Withdrawal for @${me.name} on SN`,
maximumAmount: Math.max(me.sats - MAX_FEE_DEFAULT, 0)
})
const { data } = await createWithdrawl({ variables: { invoice, maxFee: MAX_FEE_DEFAULT } })
router.push(`/withdrawals/${data.createWithdrawl.id}`)
} catch (e) {
console.log(e.message)
}
}
effect()
}, [])
if (called && !error) {
return <WithdrawlSkeleton status='sending' />
}
return (
<>
<Form
initial={{
invoice: '',
maxFee: MAX_FEE_DEFAULT
}}
initialError={error ? error.toString() : undefined}
schema={withdrawlSchema}
onSubmit={async ({ invoice, maxFee }) => {
const { data } = await createWithdrawl({ variables: { invoice, maxFee: Number(maxFee) } })
router.push(`/withdrawals/${data.createWithdrawl.id}`)
}}
>
<Input
label='invoice'
name='invoice'
required
autoFocus
clear
/>
<Input
label='max fee'
name='maxFee'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
</Form>
</>
)
}
function LnQRWith ({ k1, encodedUrl }) {
const router = useRouter()
const query = gql`
{
lnWith(k1: "${k1}") {
withdrawalId
k1
}
}`
const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (data?.lnWith?.withdrawalId) {
router.push(`/withdrawals/${data.lnWith.withdrawalId}`)
}
return <Qr value={encodedUrl} status='waiting for you' />
}
export function LnWithdrawal () {
// query for challenge
const [createWith, { data, error }] = useMutation(gql`
mutation createWith {
createWith {
k1
encodedUrl
}
}`)
useEffect(() => {
createWith()
}, [])
if (error) return <div>error</div>
if (!data) {
return <QrSkeleton status='generating' />
}
return <LnQRWith {...data.createWith} />
}
export function LnAddrWithdrawal () {
const me = useMe()
const router = useRouter()
const [sendToLnAddr, { called, error }] = useMutation(SEND_TO_LNADDR)
const defaultOptions = { min: 1 }
const [addrOptions, setAddrOptions] = useState(defaultOptions)
const [formSchema, setFormSchema] = useState(lnAddrSchema())
const onAddrChange = async (formik, e) => {
let options
try {
options = await lnAddrOptions(e.target.value)
} catch (e) {
console.log(e)
setAddrOptions(defaultOptions)
return
}
setAddrOptions(options)
setFormSchema(lnAddrSchema(options))
}
return (
<>
{called && !error && <WithdrawlSkeleton status='sending' />}
<Form
// hide/show instead of add/remove from react tree to avoid re-initializing the form state on error
style={{ display: !(called && !error) ? 'block' : 'none' }}
initial={{
addr: '',
amount: 1,
maxFee: 10,
comment: '',
identifier: false,
name: '',
email: ''
}}
schema={formSchema}
initialError={error ? error.toString() : undefined}
onSubmit={async ({ amount, maxFee, ...values }) => {
const { data } = await sendToLnAddr({
variables: {
amount: Number(amount),
maxFee: Number(maxFee),
...values
}
})
router.push(`/withdrawals/${data.sendToLnAddr.id}`)
}}
>
<Input
label='lightning address'
name='addr'
required
autoFocus
onChange={onAddrChange}
/>
<Input
label='amount'
name='amount'
type='number'
step={10}
required
min={addrOptions.min}
max={addrOptions.max}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='maxFee'
type='number'
step={10}
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
{(addrOptions?.commentAllowed || addrOptions?.payerData) &&
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<AccordianItem
show
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>attach</div>}
body={
<>
{addrOptions.commentAllowed &&
<Input
label={<>comment <small className='text-muted ms-2'>optional</small></>}
name='comment'
maxLength={addrOptions.commentAllowed}
/>}
{addrOptions.payerData?.identifier &&
<Checkbox
name='identifier'
required={addrOptions.payerData.identifier.mandatory}
label={
<>your {me?.name}@stacker.news identifier
{!addrOptions.payerData.identifier.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
{addrOptions.payerData?.name &&
<Input
name='name'
required={addrOptions.payerData.name.mandatory}
label={
<>name{!addrOptions.payerData.name.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
{addrOptions.payerData?.email &&
<Input
name='email'
required={addrOptions.payerData.email.mandatory}
label={
<>
email{!addrOptions.payerData.email.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
</>
}
/>
</div>
</div>}
<SubmitButton variant='success' className='mt-2'>send</SubmitButton>
</Form>
</>
)
}