ac45fdc234
* Use HODL invoices * Fix expiry check comparing string with Date * Fix unconfirmed user balance for HODL invoices This is done by syncing the data from LND to the Invoice table. If the columns is_held and msatsReceived are set, the frontend is told that we're ready to execute the action. We then update the user balance in the same tx as the action. We need to still keep checking the invoice for expiration though. * Fix worker acting upon deleted invoices * Prevent usage of invoice after expiration * Use onComplete from <Countdown> to show expired status * Remove unused lnd argument * Fix item destructuring from query * Fix balance added to every stacker * Fix hmac required * Fix invoices not used when logged in * refactor: move invoiceable code into form * renamed invoiceHash, invoiceHmac to hash, hmac since it's less verbose all over the place * form now supports `invoiceable` in its props * form then wraps `onSubmit` with `useInvoiceable` and passes optional invoice options * Show expired if expired and canceled * Also use useCallback for zapping * Always expire modal invoices after 3m * little styling thing --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
276 lines
8.7 KiB
JavaScript
276 lines
8.7 KiB
JavaScript
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
|
|
import Row from 'react-bootstrap/Row'
|
|
import Col from 'react-bootstrap/Col'
|
|
import InputGroup from 'react-bootstrap/InputGroup'
|
|
import Image from 'react-bootstrap/Image'
|
|
import BootstrapForm from 'react-bootstrap/Form'
|
|
import Alert from 'react-bootstrap/Alert'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import Info from './info'
|
|
import AccordianItem from './accordian-item'
|
|
import styles from '../styles/post.module.css'
|
|
import { useLazyQuery, gql, useMutation } from '@apollo/client'
|
|
import { useRouter } from 'next/router'
|
|
import Link from 'next/link'
|
|
import { usePrice } from './price'
|
|
import Avatar from './avatar'
|
|
import ActionTooltip from './action-tooltip'
|
|
import { jobSchema } from '../lib/validate'
|
|
import CancelButton from './cancel-button'
|
|
|
|
function satsMin2Mo (minute) {
|
|
return minute * 30 * 24 * 60
|
|
}
|
|
|
|
function PriceHint ({ monthly }) {
|
|
const { price, fiatSymbol } = usePrice()
|
|
|
|
if (!price || !monthly) {
|
|
return null
|
|
}
|
|
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
|
|
const fiat = fixed((price / 100000000) * monthly, 0)
|
|
|
|
return <span className='text-muted'>{monthly} sats/mo which is {fiatSymbol}{fiat}/mo</span>
|
|
}
|
|
|
|
// need to recent list items
|
|
export default function JobForm ({ item, sub }) {
|
|
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
|
|
const router = useRouter()
|
|
const [logoId, setLogoId] = useState(item?.uploadId)
|
|
const [upsertJob] = useMutation(gql`
|
|
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
|
|
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
|
|
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
|
|
location: $location, remote: $remote, text: $text,
|
|
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
|
|
id
|
|
}
|
|
}`
|
|
)
|
|
|
|
const onSubmit = useCallback(
|
|
async ({ maxBid, start, stop, ...values }) => {
|
|
let status
|
|
if (start) {
|
|
status = 'ACTIVE'
|
|
} else if (stop) {
|
|
status = 'STOPPED'
|
|
}
|
|
|
|
const { error } = await upsertJob({
|
|
variables: {
|
|
id: item?.id,
|
|
sub: item?.subName || sub?.name,
|
|
maxBid: Number(maxBid),
|
|
status,
|
|
logo: Number(logoId),
|
|
...values
|
|
}
|
|
})
|
|
if (error) {
|
|
throw new Error({ message: error.toString() })
|
|
}
|
|
|
|
if (item) {
|
|
await router.push(`/items/${item.id}`)
|
|
} else {
|
|
await router.push(`/~${sub.name}/recent`)
|
|
}
|
|
}, [upsertJob, router]
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Form
|
|
className='pb-5 pt-3'
|
|
initial={{
|
|
title: item?.title || '',
|
|
company: item?.company || '',
|
|
location: item?.location || '',
|
|
remote: item?.remote || false,
|
|
text: item?.text || '',
|
|
url: item?.url || '',
|
|
maxBid: item?.maxBid || 0,
|
|
stop: false,
|
|
start: false
|
|
}}
|
|
schema={jobSchema}
|
|
storageKeyPrefix={storageKeyPrefix}
|
|
invoiceable={{ requireSession: true }}
|
|
onSubmit={onSubmit}
|
|
>
|
|
<div className='form-group'>
|
|
<label className='form-label'>logo</label>
|
|
<div className='position-relative' style={{ width: 'fit-content' }}>
|
|
<Image
|
|
src={logoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
|
/>
|
|
<Avatar onSuccess={setLogoId} />
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label='job title'
|
|
name='title'
|
|
required
|
|
autoFocus
|
|
clear
|
|
/>
|
|
<Input
|
|
label='company'
|
|
name='company'
|
|
required
|
|
clear
|
|
/>
|
|
<Row className='me-0'>
|
|
<Col>
|
|
<Input
|
|
label='location'
|
|
name='location'
|
|
clear
|
|
/>
|
|
</Col>
|
|
<Col className='d-flex ps-0' xs='auto'>
|
|
<Checkbox
|
|
label={<div className='fw-bold'>remote</div>} name='remote' hiddenLabel
|
|
groupClassName={styles.inlineCheckGroup}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
<MarkdownInput
|
|
topLevel
|
|
label='description'
|
|
name='text'
|
|
minRows={6}
|
|
required
|
|
/>
|
|
<Input
|
|
label={<>how to apply <small className='text-muted ms-2'>url or email address</small></>}
|
|
name='url'
|
|
required
|
|
clear
|
|
/>
|
|
<PromoteJob item={item} sub={sub} />
|
|
{item && <StatusControl item={item} />}
|
|
<div className='d-flex align-items-center justify-content-end mt-3'>
|
|
{item
|
|
? (
|
|
<div className='d-flex'>
|
|
<CancelButton />
|
|
<SubmitButton variant='secondary'>save</SubmitButton>
|
|
</div>
|
|
)
|
|
: (
|
|
<ActionTooltip overlayText='1000 sats'>
|
|
<SubmitButton cost={1000} variant='secondary'>post <small> 1000 sats</small></SubmitButton>
|
|
</ActionTooltip>
|
|
)}
|
|
</div>
|
|
</Form>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function PromoteJob ({ item, sub }) {
|
|
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
|
|
const [getAuctionPosition, { data }] = useLazyQuery(gql`
|
|
query AuctionPosition($id: ID, $bid: Int!) {
|
|
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
|
}`,
|
|
{ fetchPolicy: 'cache-and-network' })
|
|
const position = data?.auctionPosition
|
|
|
|
useEffect(() => {
|
|
const initialMaxBid = Number(item?.maxBid) || 0
|
|
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
|
|
setMonthly(satsMin2Mo(initialMaxBid))
|
|
}, [])
|
|
|
|
return (
|
|
<AccordianItem
|
|
show={item?.maxBid > 0}
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
|
|
body={
|
|
<>
|
|
<Input
|
|
label={
|
|
<div className='d-flex align-items-center'>bid
|
|
<Info>
|
|
<ol className='fw-bold'>
|
|
<li>The higher your bid the higher your job will rank</li>
|
|
<li>You can increase, decrease, or remove your bid at anytime</li>
|
|
<li>You can edit or stop your job at anytime</li>
|
|
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
|
|
</ol>
|
|
</Info>
|
|
<small className='text-muted ms-2'>optional</small>
|
|
</div>
|
|
}
|
|
name='maxBid'
|
|
onChange={async (formik, e) => {
|
|
if (e.target.value >= 0 && e.target.value <= 100000000) {
|
|
setMonthly(satsMin2Mo(e.target.value))
|
|
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
|
|
} else {
|
|
setMonthly(satsMin2Mo(0))
|
|
}
|
|
}}
|
|
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
|
|
hint={<PriceHint monthly={monthly} />}
|
|
/>
|
|
<><div className='fw-bold text-muted'>This bid puts your job in position: {position}</div></>
|
|
</>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function StatusControl ({ item }) {
|
|
let StatusComp
|
|
|
|
if (item.status !== 'STOPPED') {
|
|
StatusComp = () => {
|
|
return (
|
|
<>
|
|
|
|
<AccordianItem
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to stop my job</div>}
|
|
headerColor='var(--bs-danger)'
|
|
body={
|
|
<Checkbox
|
|
label={<div className='fw-bold text-danger'>stop my job</div>} name='stop' inline
|
|
/>
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
} else if (item.status === 'STOPPED') {
|
|
StatusComp = () => {
|
|
return (
|
|
<AccordianItem
|
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>I want to resume my job</div>}
|
|
headerColor='var(--bs-success)'
|
|
body={
|
|
<Checkbox
|
|
label={<div className='fw-bold text-success'>resume my job</div>} name='start' inline
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className='my-3 border border-3 rounded'>
|
|
<div className='p-3'>
|
|
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
|
{item.status === 'NOSATS' &&
|
|
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
|
|
<StatusComp />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|