stacker.news/components/territory-form.js
Keyan ca11ac9fb8
backend payment optimism (#1195)
* wip backend optimism

* another inch

* make action state transitions only happen once

* another inch

* almost ready for testing

* use interactive txs

* another inch

* ready for basic testing

* lint fix

* inches

* wip item update

* get item update to work

* donate and downzap

* inchy inch

* fix territory paid actions

* wip usePaidMutation

* usePaidMutation error handling

* PENDING_HELD and HELD transitions, gql paidAction return types

* mostly working pessimism

* make sure invoice field is present in optimisticResponse

* inches

* show optimistic values to current me

* first pass at notifications and payment status reporting

* fix migration to have withdrawal hash

* reverse optimism on payment failure

* Revert "Optimistic updates via pending sats in item context (#1229)"

This reverts commit 93713b33df9bc3701dc5a692b86a04ff64e8cfb1.

* add onCompleted to usePaidMutation

* onPaid and onPayError for new comments

* use 'IS DISTINCT FROM' for NULL invoiceActionState columns

* make usePaidMutation easier to read

* enhance invoice qr

* prevent actions on unpaid items

* allow navigation to action's invoice

* retry create item

* start edit window after item is paid for

* fix ux of retries from notifications

* refine retries

* fix optimistic downzaps

* remember item updates can't be retried

* store reference to action item in invoice

* remove invoice modal layout shift

* fix destructuring

* fix zap undos

* make sure ItemAct is paid in aggregate queries

* dont toast on long press zap undo

* fix delete and remindme bots

* optimistic poll votes with retries

* fix retry notifications and invoice item context

* fix pessimisitic typo

* item mentions and mention notifications

* dont show payment retry on item popover

* make bios work

* refactor paidAction transitions

* remove stray console.log

* restore docker compose nwc settings

* add new todos

* persist qr modal on post submission + unify item form submission

* fix post edit threshold

* make bounty payments work

* make job posting work

* remove more store procedure usage ... document serialization concerns

* dont use dynamic imports for paid action modules

* inline comment denormalization

* create item starts with median votes

* fix potential of serialization anomalies in zaps

* dont trigger notification indicator on successful paid action invoices

* ignore invoiceId on territory actions and add optimistic concurrency control

* begin docs for paid actions

* better error toasts and fix apollo cache warnings

* small documentation enhancements

* improve paid action docs

* optimistic concurrency control for territory updates

* use satsToMsats and msatsToSats helpers

* explictly type raw query template parameters

* improve consistency of nested relation names

* complete paid action docs

* useEffect for canEdit on payment

* make sure invoiceId is provided when required

* don't return null when expecting array

* remove buy credits

* move verifyPayment to paidAction

* fix comments invoicePaidAt time zone

* close nwc connections once

* grouped logs for paid actions

* stop invoiceWaitUntilPaid if not attempting to pay

* allow actionState to transition directly from HELD to PAID

* make paid mutation wait until pessimistic are fully paid

* change button text when form submits/pays

* pulsing form submit button

* ignore me in notification indicator for territory subscription

* filter unpaid items from more queries

* fix donation stike timing

* fix pending poll vote

* fix recent item notifcation padding

* no default form submitting button text

* don't show paying on submit button on free edits

* fix territory autorenew with fee credits

* reorg readme

* allow jobs to be editted forever

* fix image uploads

* more filter fixes for aggregate views

* finalize paid action invoice expirations

* remove unnecessary async

* keep clientside cache normal/consistent

* add more detail to paid action doc

* improve paid action table

* remove actionType guard

* fix top territories

* typo api/paidAction/README.md

Co-authored-by: ekzyis <ek@stacker.news>

* typo components/use-paid-mutation.js

Co-authored-by: ekzyis <ek@stacker.news>

* Apply suggestions from code review

Co-authored-by: ekzyis <ek@stacker.news>

* encorporate ek feeback

* more ek suggestions

* fix 'cost to post' hover on items

* Apply suggestions from code review

Co-authored-by: ekzyis <ek@stacker.news>

---------

Co-authored-by: ekzyis <ek@stacker.news>
2024-07-01 12:02:29 -05:00

290 lines
9.9 KiB
JavaScript

import AccordianItem from './accordian-item'
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
import FeeButton, { FeeButtonProvider } from './fee-button'
import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { territorySchema } from '@/lib/validate'
import { useMe } from './me'
import Info from './info'
import { abbrNum } from '@/lib/format'
import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
const client = useApolloClient()
const me = useMe()
const [upsertSub] = usePaidMutation(UPSERT_SUB)
const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)
const schema = territorySchema({ client, me, sub })
const [fetchSub] = useLazyQuery(SUB)
const [archived, setArchived] = useState(false)
const onNameChange = useCallback(async (formik, e) => {
// never show "territory archived" warning during edits
if (sub) return
const name = e.target.value
const { data } = await fetchSub({ variables: { sub: name } })
setArchived(data?.sub?.status === 'STOPPED')
}, [fetchSub, setArchived])
const onSubmit = useCallback(
async ({ ...variables }) => {
const { error, payError } = archived
? await unarchiveTerritory({ variables })
: await upsertSub({ variables: { oldName: sub?.name, ...variables } })
if (error) throw error
if (payError) throw new Error('payment required')
// modify graphql cache to include new sub
client.cache.modify({
fields: {
subs (existing = [], { readField }) {
const newSubRef = client.cache.writeFragment({
data: { __typename: 'Sub', name: variables.name },
fragment: gql`
fragment SubSubmitFragment on Sub {
name
}`
})
if (existing.some(ref => readField('name', ref) === variables.name)) {
return existing
}
return [...existing, newSubRef]
}
}
})
await router.push(`/~${variables.name}`)
}, [client, upsertSub, unarchiveTerritory, router, archived]
)
const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase())
const lineItems = useMemo(() => {
const lines = { territory: TERRITORY_BILLING_OPTIONS('first')[billing] }
if (!sub) return lines
// we are changing billing type so prorate the change
if (sub?.billingType?.toLowerCase() !== billing) {
const alreadyBilled = TERRITORY_PERIOD_COST(purchasedType(sub))
lines.paid = {
term: `- ${abbrNum(alreadyBilled)} sats`,
label: 'already paid',
modifier: cost => cost - alreadyBilled
}
return lines
}
}, [sub, billing])
return (
<FeeButtonProvider baseLineItems={lineItems}>
<Form
initial={{
name: sub?.name || '',
desc: sub?.desc || '',
baseCost: sub?.baseCost || 10,
postTypes: sub?.postTypes || POST_TYPES,
allowFreebies: typeof sub?.allowFreebies === 'undefined' ? true : sub?.allowFreebies,
billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false,
moderated: sub?.moderated || false,
nsfw: sub?.nsfw || false
}}
schema={schema}
onSubmit={onSubmit}
className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'}
>
<Input
label='name'
name='name'
required
autoFocus
clear
maxLength={32}
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
onChange={onNameChange}
warn={archived && (
<div className='d-flex align-items-center'>this territory is archived
<Info>
<ul className='fw-bold'>
<li>This territory got archived because the previous founder did not pay for the upkeep</li>
<li>You can proceed but will inherit the old content</li>
</ul>
</Info>
</div>
)}
/>
<MarkdownInput
label='description'
name='desc'
maxLength={MAX_TERRITORY_DESC_LENGTH}
required
minRows={3}
/>
<Input
label='post cost'
name='baseCost'
type='number'
groupClassName='mb-2'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Checkbox
label='allow free posts'
name='allowFreebies'
groupClassName='ms-1'
/>
<CheckboxGroup label='post types' name='postTypes'>
<Row>
<Col xs={4} sm='auto'>
<Checkbox
inline
label='links'
value='LINK'
name='postTypes'
id='links-checkbox'
groupClassName='ms-1 mb-0'
/>
</Col>
<Col xs={4} sm='auto'>
<Checkbox
inline
label='discussions'
value='DISCUSSION'
name='postTypes'
id='discussions-checkbox'
groupClassName='ms-1 mb-0'
/>
</Col>
<Col xs={4} sm='auto'>
<Checkbox
inline
label='bounties'
value='BOUNTY'
name='postTypes'
id='bounties-checkbox'
groupClassName='ms-1 mb-0'
/>
</Col>
<Col xs={4} sm='auto'>
<Checkbox
inline
label='polls'
value='POLL'
name='postTypes'
id='polls-checkbox'
groupClassName='ms-1 mb-0'
/>
</Col>
</Row>
</CheckboxGroup>
{sub?.billingType !== 'ONCE' &&
<>
<CheckboxGroup
label={
<span className='d-flex align-items-center'>billing
{sub && sub.billingType !== 'ONCE' &&
<Info>
You will be credited what you paid for your current billing period when you change your billing period to a longer duration.
If you change from yearly to monthly, when your year ends, your monthly billing will begin.
</Info>}
</span>
}
name='billing'
groupClassName={billing !== 'once' ? 'mb-0' : ''}
>
<Checkbox
type='radio'
label='100k sats/month'
value='MONTHLY'
name='billingType'
id='monthly-checkbox'
handleChange={checked => checked && setBilling('monthly')}
groupClassName='ms-1 mb-0'
/>
<Checkbox
type='radio'
label='1m sats/year'
value='YEARLY'
name='billingType'
id='yearly-checkbox'
handleChange={checked => checked && setBilling('yearly')}
groupClassName='ms-1 mb-0'
/>
<Checkbox
type='radio'
label='3m sats once'
value='ONCE'
name='billingType'
id='once-checkbox'
handleChange={checked => checked && setBilling('once')}
groupClassName='ms-1 mb-0'
/>
</CheckboxGroup>
{billing !== 'once' &&
<Checkbox
label='auto-renew'
name='billingAutoRenew'
groupClassName='ms-1 mt-2'
/>}
</>}
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={
<>
<BootstrapForm.Label>moderation</BootstrapForm.Label>
<Checkbox
inline
label={
<div className='d-flex align-items-center'>enable moderation
<Info>
<ol>
<li>Outlaw posts and comments with a click</li>
<li>Your territory will get a <Badge bg='secondary'>moderated</Badge> badge</li>
</ol>
</Info>
</div>
}
name='moderated'
groupClassName='ms-1'
/>
<BootstrapForm.Label>nsfw</BootstrapForm.Label>
<Checkbox
inline
label={
<div className='d-flex align-items-center'>mark as nsfw
<Info>
<ol>
<li>Let stackers know that your territory may contain explicit content</li>
<li>Your territory will get a <Badge bg='secondary'>nsfw</Badge> badge</li>
</ol>
</Info>
</div>
}
name='nsfw'
groupClassName='ms-1'
/>
</>
}
/>
<div className='mt-3 d-flex justify-content-end'>
<FeeButton
text={sub ? 'save' : 'found it'}
variant='secondary'
disabled={sub?.status === 'STOPPED'}
/>
</div>
</Form>
</FeeButtonProvider>
)
}