refactor(earn): EarnForm to own file

This commit is contained in:
theborakompanioni 2026-01-06 13:08:57 +01:00
parent 25b3b37265
commit 3209bc140c
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
2 changed files with 236 additions and 229 deletions

View file

@ -0,0 +1,229 @@
import { useId } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { HandshakeIcon, Loader2Icon, PercentIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { OFFER_FEE_ABS_MIN, OFFER_FEE_REL_MIN, OFFER_MINSIZE_MIN } from '@/constants/jam'
import type { OfferType } from '@/constants/jm'
import { cn, factorToPercentage } from '@/lib/utils'
import { SatSymbol } from '../CurrencySymbol'
const FieldPrefixSatSymbol = (
<SatSymbol
width={'18px'}
height={'18px'}
style={{
margin: '5px -1px',
}}
/>
)
const OFFERTYPE_ABS: OfferType = 'sw0absoffer'
const OFFERTYPE_REL: OfferType = 'sw0reloffer'
export interface EarnFormValues {
offerType: OfferType
offerAbsoluteFee?: number
offerRelativeFee?: number
offerMinAmount: number
}
const FORM_INPUT_DEFAULT_VALUES: Required<EarnFormValues> = {
offerType: OFFERTYPE_ABS,
offerRelativeFee: 0.03,
offerAbsoluteFee: 250,
offerMinAmount: 100_000,
}
const schema = yup
.object()
.shape({
offerType: yup.string<OfferType>().default(FORM_INPUT_DEFAULT_VALUES.offerType).required(),
offerAbsoluteFee: yup.number().integer().min(OFFER_FEE_ABS_MIN).optional(),
offerRelativeFee: yup.number().min(factorToPercentage(OFFER_FEE_REL_MIN)).optional(),
offerMinAmount: yup.number().integer().min(OFFER_MINSIZE_MIN).required(),
})
.required()
const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
const { t } = useTranslation()
const id = useId()
return (
<RadioGroup className="w-full max-w-96 justify-items-center sm:grid-cols-2" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none">
<RadioGroupItem
value={OFFERTYPE_ABS}
id={`${id}-sw0absoffer`}
className="order-1 size-5 cursor-pointer after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow justify-items-center gap-2">
<HandshakeIcon />
<Label htmlFor={`${id}-sw0absoffer`} className="justify-center">
{t('earn.radio_abs_offer_label')}
</Label>
</div>
</div>
<div className="border-input has-data-[state=checked]:border-primary/50 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none">
<RadioGroupItem
value={OFFERTYPE_REL}
id={`${id}-sw0reloffer`}
className="order-1 size-5 cursor-pointer after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow justify-items-center gap-2">
<PercentIcon />
<Label htmlFor={`${id}-sw0reloffer`} className="justify-center">
{t('earn.radio_rel_offer_label')}
</Label>
</div>
</div>
</RadioGroup>
)
}
interface EarnFormProps {
className?: string
isWaitingMakerStart: boolean
onSubmit: SubmitHandler<EarnFormValues>
disabled?: boolean
}
export function EarnForm({ className, isWaitingMakerStart, onSubmit, disabled }: EarnFormProps) {
const { t } = useTranslation()
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
getValues,
setValue,
} = useForm<EarnFormValues, unknown, EarnFormValues>({
mode: 'all',
defaultValues: FORM_INPUT_DEFAULT_VALUES,
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
resolver: yupResolver(schema) as Resolver<EarnFormValues, unknown, EarnFormValues>,
})
const watchOfferType = useWatch({ control, name: 'offerType' })
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-4', className)}>
<OfferTypeInput
disabled={disabled}
defaultValue={FORM_INPUT_DEFAULT_VALUES.offerType}
onValueChange={(value) => {
setValue('offerType', value, {
shouldValidate: true, // trigger validation
shouldTouch: true, // update touched fields form state
shouldDirty: true, // update dirty and dirty fields form state
})
}}
/>
<Tabs value={watchOfferType}>
<TabsContent value={OFFERTYPE_ABS}>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_abs_fee', {
fee: '', // empty on purpose
})}
</Label>
<p className="text-muted-foreground text-xs">{t('earn.description_abs_fee')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">{FieldPrefixSatSymbol}</div>
<Input
{...register('offerAbsoluteFee', {
disabled,
})}
type="number"
step={1}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
</TabsContent>
<TabsContent value={OFFERTYPE_REL}>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_rel_fee', {
fee: getValues('offerRelativeFee') ? `(${getValues('offerRelativeFee')!}%)` : '',
})}
</Label>
<p className="text-muted-foreground text-xs">{t('earn.description_rel_fee')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">%</div>
<Input
{...register('offerRelativeFee', {
disabled,
})}
type="number"
step={0.0001}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_min_amount_input')}
</Label>
<p className="text-muted-foreground text-xs">{t('rescan_chain.description_blockheight')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">{FieldPrefixSatSymbol}</div>
<Input
{...register('offerMinAmount', {
disabled,
})}
type="number"
step={1}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
<Button
type="submit"
variant={disabled && !isWaitingMakerStart ? 'outline' : undefined}
disabled={disabled || !isValid || isSubmitting}
className="w-full"
size="lg"
>
{isSubmitting || isWaitingMakerStart ? (
<>
<Loader2Icon className="h-8 w-8 animate-spin text-gray-400 motion-reduce:hidden" />
{t('earn.text_starting')}
</>
) : (
<>{t('earn.button_start')}</>
)}
</Button>
</form>
)
}

View file

@ -1,33 +1,24 @@
import { useId, useMemo, useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { useMemo, useState } from 'react'
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AlertTriangle, HandshakeIcon, Loader2Icon, PercentIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import { AlertTriangle, Loader2Icon } from 'lucide-react'
import type { SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import * as yup from 'yup'
import { useStore } from 'zustand'
import { FeeLimitDialog } from '@/components/settings/FeeLimitDialog'
import { FeeConfigErrorAlert } from '@/components/ui/FeeConfigErrorAlert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { OFFER_FEE_ABS_MIN, OFFER_FEE_REL_MIN, OFFER_MINSIZE_MIN } from '@/constants/jam'
import type { OfferType } from '@/constants/jm'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import { withQueryDelay } from '@/lib/queryClient'
import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer, percentageToFactor } from '@/lib/utils'
import { cn, isAbsoluteOffer, isRelativeOffer, percentageToFactor } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { Milliseconds } from '@/types/global'
import { SatSymbol } from '../CurrencySymbol'
import { EarnForm, type EarnFormValues } from './EarnForm'
import { OfferCard } from './OfferCard'
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
@ -36,220 +27,7 @@ import { OfferCard } from './OfferCard'
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
const MAKER_STOP_RESPONSE_DELAY: Milliseconds = 2_000
const FieldPrefixSatSymbol = (
<SatSymbol
width={'18px'}
height={'18px'}
style={{
margin: '5px -1px',
}}
/>
)
const OFFERTYPE_ABS: OfferType = 'sw0absoffer'
const OFFERTYPE_REL: OfferType = 'sw0reloffer'
interface Inputs {
offerType: OfferType
offerAbsoluteFee?: number
offerRelativeFee?: number
offerMinAmount: number
}
const FORM_INPUT_DEFAULT_VALUES: Required<Inputs> = {
offerType: OFFERTYPE_ABS,
offerRelativeFee: 0.03,
offerAbsoluteFee: 250,
offerMinAmount: 100_000,
}
const schema = yup
.object()
.shape({
offerType: yup.string<OfferType>().default(FORM_INPUT_DEFAULT_VALUES.offerType).required(),
offerAbsoluteFee: yup.number().integer().min(OFFER_FEE_ABS_MIN).optional(),
offerRelativeFee: yup.number().min(factorToPercentage(OFFER_FEE_REL_MIN)).optional(),
offerMinAmount: yup.number().integer().min(OFFER_MINSIZE_MIN).required(),
})
.required()
const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
const { t } = useTranslation()
const id = useId()
return (
<RadioGroup className="w-full max-w-96 justify-items-center sm:grid-cols-2" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none">
<RadioGroupItem
value={OFFERTYPE_ABS}
id={`${id}-sw0absoffer`}
className="order-1 size-5 cursor-pointer after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow justify-items-center gap-2">
<HandshakeIcon />
<Label htmlFor={`${id}-sw0absoffer`} className="justify-center">
{t('earn.radio_abs_offer_label')}
</Label>
</div>
</div>
<div className="border-input has-data-[state=checked]:border-primary/50 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none">
<RadioGroupItem
value={OFFERTYPE_REL}
id={`${id}-sw0reloffer`}
className="order-1 size-5 cursor-pointer after:absolute after:inset-0 [&_svg]:size-3"
/>
<div className="grid grow justify-items-center gap-2">
<PercentIcon />
<Label htmlFor={`${id}-sw0reloffer`} className="justify-center">
{t('earn.radio_rel_offer_label')}
</Label>
</div>
</div>
</RadioGroup>
)
}
interface EarnFormProps {
className?: string
isWaitingMakerStart: boolean
onSubmit: SubmitHandler<Inputs>
disabled?: boolean
}
function EarnForm({ className, isWaitingMakerStart, onSubmit, disabled }: EarnFormProps) {
const { t } = useTranslation()
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
getValues,
setValue,
} = useForm<Inputs, unknown, Inputs>({
mode: 'all',
defaultValues: FORM_INPUT_DEFAULT_VALUES,
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
resolver: yupResolver(schema) as Resolver<Inputs, unknown, Inputs>,
})
const watchOfferType = useWatch({ control, name: 'offerType' })
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-4', className)}>
<OfferTypeInput
disabled={disabled}
defaultValue={FORM_INPUT_DEFAULT_VALUES.offerType}
onValueChange={(value) => {
setValue('offerType', value, {
shouldValidate: true, // trigger validation
shouldTouch: true, // update touched fields form state
shouldDirty: true, // update dirty and dirty fields form state
})
}}
/>
<Tabs value={watchOfferType}>
<TabsContent value={OFFERTYPE_ABS}>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_abs_fee', {
fee: '', // empty on purpose
})}
</Label>
<p className="text-muted-foreground text-xs">{t('earn.description_abs_fee')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">{FieldPrefixSatSymbol}</div>
<Input
{...register('offerAbsoluteFee', {
disabled,
})}
type="number"
step={1}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
</TabsContent>
<TabsContent value={OFFERTYPE_REL}>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_rel_fee', {
fee: getValues('offerRelativeFee') ? `(${getValues('offerRelativeFee')!}%)` : '',
})}
</Label>
<p className="text-muted-foreground text-xs">{t('earn.description_rel_fee')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">%</div>
<Input
{...register('offerRelativeFee', {
disabled,
})}
type="number"
step={0.0001}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('earn.label_min_amount_input')}
</Label>
<p className="text-muted-foreground text-xs">{t('rescan_chain.description_blockheight')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">{FieldPrefixSatSymbol}</div>
<Input
{...register('offerMinAmount', {
disabled,
})}
type="number"
step={1}
className="bg-background pl-10"
placeholder={t('earn.placeholder_min_amount_input')}
/>
</div>
{errors.offerMinAmount && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>ERROR</span>
</div>
)}
</div>
<Button
type="submit"
variant={disabled && !isWaitingMakerStart ? 'outline' : undefined}
disabled={disabled || !isValid || isSubmitting}
className="w-full"
size="lg"
>
{isSubmitting || isWaitingMakerStart ? (
<>
<Loader2Icon className="h-8 w-8 animate-spin text-gray-400 motion-reduce:hidden" />
{t('earn.text_starting')}
</>
) : (
<>{t('earn.button_start')}</>
)}
</Button>
</form>
)
}
const toStartMakerRequest = (values: Inputs): StartMakerRequest => {
const toStartMakerRequest = (values: EarnFormValues): StartMakerRequest => {
// both fee properties need to be provided.
// prevent providing an invalid value by setting the ignored prop to zero
const cjfee_a = isAbsoluteOffer(values.offerType) ? values.offerAbsoluteFee! : 0
@ -343,7 +121,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
}
}
const onSubmit: SubmitHandler<Inputs> = async (data) => {
const onSubmit: SubmitHandler<EarnFormValues> = async (data) => {
return await startMaker.mutateAsync({
path: {
walletname: encodeURIComponent(walletFileName),