diff --git a/src/components/earn/EarnForm.tsx b/src/components/earn/EarnForm.tsx new file mode 100644 index 00000000..5fe3e732 --- /dev/null +++ b/src/components/earn/EarnForm.tsx @@ -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 = ( + +) + +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 = { + offerType: OFFERTYPE_ABS, + offerRelativeFee: 0.03, + offerAbsoluteFee: 250, + offerMinAmount: 100_000, +} + +const schema = yup + .object() + .shape({ + offerType: yup.string().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) => { + const { t } = useTranslation() + const id = useId() + + return ( + + + + + + + {t('earn.radio_abs_offer_label')} + + + + + + + + + {t('earn.radio_rel_offer_label')} + + + + + ) +} +interface EarnFormProps { + className?: string + isWaitingMakerStart: boolean + onSubmit: SubmitHandler + disabled?: boolean +} + +export function EarnForm({ className, isWaitingMakerStart, onSubmit, disabled }: EarnFormProps) { + const { t } = useTranslation() + + const { + control, + register, + handleSubmit, + formState: { errors, isSubmitting, isValid }, + getValues, + setValue, + } = useForm({ + mode: 'all', + defaultValues: FORM_INPUT_DEFAULT_VALUES, + // force type (see https://github.com/react-hook-form/resolvers/issues/807) + resolver: yupResolver(schema) as Resolver, + }) + + const watchOfferType = useWatch({ control, name: 'offerType' }) + + return ( + + { + setValue('offerType', value, { + shouldValidate: true, // trigger validation + shouldTouch: true, // update touched fields form state + shouldDirty: true, // update dirty and dirty fields form state + }) + }} + /> + + + + + {t('earn.label_abs_fee', { + fee: '', // empty on purpose + })} + + {t('earn.description_abs_fee')} + + {FieldPrefixSatSymbol} + + + + {errors.offerMinAmount && ( + + ERROR + + )} + + + + + + {t('earn.label_rel_fee', { + fee: getValues('offerRelativeFee') ? `(${getValues('offerRelativeFee')!}%)` : '', + })} + + {t('earn.description_rel_fee')} + + % + + + + {errors.offerMinAmount && ( + + ERROR + + )} + + + + + + {t('earn.label_min_amount_input')} + + {t('rescan_chain.description_blockheight')} + + {FieldPrefixSatSymbol} + + + + {errors.offerMinAmount && ( + + ERROR + + )} + + + {isSubmitting || isWaitingMakerStart ? ( + <> + + {t('earn.text_starting')} + > + ) : ( + <>{t('earn.button_start')}> + )} + + + ) +} diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 9cba77e2..0f2f637b 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -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 = ( - -) - -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 = { - offerType: OFFERTYPE_ABS, - offerRelativeFee: 0.03, - offerAbsoluteFee: 250, - offerMinAmount: 100_000, -} - -const schema = yup - .object() - .shape({ - offerType: yup.string().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) => { - const { t } = useTranslation() - const id = useId() - - return ( - - - - - - - {t('earn.radio_abs_offer_label')} - - - - - - - - - {t('earn.radio_rel_offer_label')} - - - - - ) -} -interface EarnFormProps { - className?: string - isWaitingMakerStart: boolean - onSubmit: SubmitHandler - disabled?: boolean -} - -function EarnForm({ className, isWaitingMakerStart, onSubmit, disabled }: EarnFormProps) { - const { t } = useTranslation() - - const { - control, - register, - handleSubmit, - formState: { errors, isSubmitting, isValid }, - getValues, - setValue, - } = useForm({ - mode: 'all', - defaultValues: FORM_INPUT_DEFAULT_VALUES, - // force type (see https://github.com/react-hook-form/resolvers/issues/807) - resolver: yupResolver(schema) as Resolver, - }) - - const watchOfferType = useWatch({ control, name: 'offerType' }) - - return ( - - { - setValue('offerType', value, { - shouldValidate: true, // trigger validation - shouldTouch: true, // update touched fields form state - shouldDirty: true, // update dirty and dirty fields form state - }) - }} - /> - - - - - {t('earn.label_abs_fee', { - fee: '', // empty on purpose - })} - - {t('earn.description_abs_fee')} - - {FieldPrefixSatSymbol} - - - - {errors.offerMinAmount && ( - - ERROR - - )} - - - - - - {t('earn.label_rel_fee', { - fee: getValues('offerRelativeFee') ? `(${getValues('offerRelativeFee')!}%)` : '', - })} - - {t('earn.description_rel_fee')} - - % - - - - {errors.offerMinAmount && ( - - ERROR - - )} - - - - - - {t('earn.label_min_amount_input')} - - {t('rescan_chain.description_blockheight')} - - {FieldPrefixSatSymbol} - - - - {errors.offerMinAmount && ( - - ERROR - - )} - - - {isSubmitting || isWaitingMakerStart ? ( - <> - - {t('earn.text_starting')} - > - ) : ( - <>{t('earn.button_start')}> - )} - - - ) -} - -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 = async (data) => { + const onSubmit: SubmitHandler = async (data) => { return await startMaker.mutateAsync({ path: { walletname: encodeURIComponent(walletFileName),
{t('earn.description_abs_fee')}
{t('earn.description_rel_fee')}
{t('rescan_chain.description_blockheight')}