import React, { useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { InputAdornment, LinearProgress, ButtonGroup, Slider, Switch, Tooltip, Button, Checkbox, Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, Radio, FormControlLabel, RadioGroup, Box, useTheme, Collapse, IconButton, } from '@mui/material'; import { type LimitList, defaultMaker } from '../../models'; import { LocalizationProvider, MobileTimePicker } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { ConfirmationDialog, F2fMapDialog } from '../Dialogs'; import { apiClient } from '../../services/api'; import { FlagWithProps } from '../Icons'; import AutocompletePayments from './AutocompletePayments'; import AmountRange from './AmountRange'; import currencyDict from '../../../static/assets/currencies.json'; import { amountToString, computeSats, pn } from '../../utils'; import { SelfImprovement, Lock, HourglassTop, DeleteSweep, Edit, Map } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; import { fiatMethods } from '../PaymentMethods'; import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; import SelectCoordinator from './SelectCoordinator'; import { FederationContext, type UseFederationStoreType } from '../../contexts/FederationContext'; import { GarageContext, type UseGarageStoreType } from '../../contexts/GarageContext'; interface MakerFormProps { disableRequest?: boolean; pricingMethods?: boolean; collapseAll?: boolean; onSubmit?: () => void; onReset?: () => void; submitButtonLabel?: string; onOrderCreated?: (shortAlias: string, id: number) => void; onClickGenerateRobot?: () => void; } const MakerForm = ({ pricingMethods = false, disableRequest = false, collapseAll = false, onSubmit = () => {}, onReset = () => {}, submitButtonLabel = 'Create Order', onOrderCreated = () => null, onClickGenerateRobot = () => null, }: MakerFormProps): JSX.Element => { const { fav, setFav, settings, hostUrl, origin } = useContext(AppContext); const { federation, focusedCoordinator, coordinatorUpdatedAt, federationUpdatedAt } = useContext(FederationContext); const { maker, setMaker, garage } = useContext(GarageContext); const { t } = useTranslation(); const theme = useTheme(); const [badRequest, setBadRequest] = useState(null); const [amountLimits, setAmountLimits] = useState([1, 1000]); const [satoshisLimits, setSatoshisLimits] = useState([20000, 4000000]); const [currentPrice, setCurrentPrice] = useState('...'); const [currencyCode, setCurrencyCode] = useState('USD'); const [openDialogs, setOpenDialogs] = useState(false); const [openWorldmap, setOpenWorldmap] = useState(false); const [submittingRequest, setSubmittingRequest] = useState(false); const [amountRangeEnabled, setAmountRangeEnabled] = useState(true); const [limits, setLimits] = useState({}); const maxRangeAmountMultiple = 14.8; const minRangeAmountMultiple = 1.6; const amountSafeThresholds = [1.03, 0.98]; useEffect(() => { setCurrencyCode(currencyDict[fav.currency === 0 ? 1 : fav.currency]); if (focusedCoordinator) { const newLimits = federation.getCoordinator(focusedCoordinator).limits; if (Object.keys(newLimits).length !== 0) { updateAmountLimits(newLimits, fav.currency, maker.premium); updateCurrentPrice(newLimits, fav.currency, maker.premium); updateSatoshisLimits(newLimits); setLimits(newLimits); } } }, [coordinatorUpdatedAt]); const updateAmountLimits = function ( limitList: LimitList, currency: number, premium: number, ): void { const index = currency === 0 ? 1 : currency; let minAmountLimit: number = limitList[index].min_amount * (1 + premium / 100); let maxAmountLimit: number = limitList[index].max_amount * (1 + premium / 100); // apply thresholds to ensure good request minAmountLimit = minAmountLimit * amountSafeThresholds[0]; maxAmountLimit = maxAmountLimit * amountSafeThresholds[1]; setAmountLimits([minAmountLimit, maxAmountLimit]); }; const updateSatoshisLimits = function (limitList: LimitList): void { const minAmount: number = limitList[1000].min_amount * 100000000; const maxAmount: number = limitList[1000].max_amount * 100000000; setSatoshisLimits([minAmount, maxAmount]); }; const updateCurrentPrice = function ( limitsList: LimitList, currency: number, premium: number, ): void { const index = currency === 0 ? 1 : currency; let price = '...'; if (maker.isExplicit && maker.amount > 0 && maker.satoshis > 0) { price = maker.amount / (maker.satoshis / 100000000); } else if (!maker.isExplicit) { price = limitsList[index].price * (1 + premium / 100); } setCurrentPrice(parseFloat(Number(price).toPrecision(5))); }; const handleCurrencyChange = function (newCurrency: number): void { const currencyCode: string = currencyDict[newCurrency]; setCurrencyCode(currencyCode); setFav({ ...fav, currency: newCurrency, mode: newCurrency === 1000 ? 'swap' : 'fiat', }); updateAmountLimits(limits, newCurrency, maker.premium); updateCurrentPrice(limits, newCurrency, maker.premium); if (makerHasAmountRange) { const minAmount = parseFloat(Number(limits[newCurrency].min_amount).toPrecision(2)); const maxAmount = parseFloat(Number(limits[newCurrency].max_amount).toPrecision(2)); if ( parseFloat(maker.minAmount) < minAmount || parseFloat(maker.minAmount) > maxAmount || parseFloat(maker.maxAmount) > maxAmount || parseFloat(maker.maxAmount) < minAmount ) { setMaker({ ...maker, minAmount: (maxAmount * 0.25).toPrecision(2), maxAmount: (maxAmount * 0.75).toPrecision(2), }); } } }; const makerHasAmountRange = useMemo(() => { return maker.advancedOptions && amountRangeEnabled; }, [maker.advancedOptions, amountRangeEnabled]); const handlePaymentMethodChange = function ( paymentArray: Array<{ name: string; icon: string }>, ): void { let str = ''; const arrayLength = paymentArray.length; let includeCoordinates = false; for (let i = 0; i < arrayLength; i++) { str += paymentArray[i].name + ' '; if (paymentArray[i].icon === 'cash') { includeCoordinates = true; if (i === arrayLength - 1) { setOpenWorldmap(true); } } } const paymentMethodText = str.slice(0, -1); setMaker((maker) => { return { ...maker, paymentMethods: paymentArray, paymentMethodsText: paymentMethodText, badPaymentMethod: paymentMethodText.length > 50, latitude: includeCoordinates ? maker.latitude : null, longitude: includeCoordinates ? maker.longitude : null, }; }); }; const handleMinAmountChange = function ( e: React.ChangeEventHandler, ): void { setMaker({ ...maker, minAmount: parseFloat(Number(e.target.value).toPrecision(e.target.value < 100 ? 2 : 3)), }); }; const handleMaxAmountChange = function ( e: React.ChangeEventHandler, ): void { setMaker({ ...maker, maxAmount: parseFloat(Number(e.target.value).toPrecision(e.target.value < 100 ? 2 : 3)), }); }; const handlePremiumChange: React.ChangeEventHandler = function ({ target: { value } }): void { const max = fav.mode === 'fiat' ? 999 : 99; const min = -100; const newPremium = Math.floor(Number(value) * Math.pow(10, 2)) / Math.pow(10, 2); let premium: number = isNaN(newPremium) ? 0 : newPremium; let badPremiumText: string = ''; if (newPremium > 999) { badPremiumText = t('Must be less than {{max}}%', { max }); premium = 999; } else if (newPremium <= -100) { badPremiumText = t('Must be more than {{min}}%', { min }); premium = -99.99; } updateCurrentPrice(limits, fav.currency, premium); updateAmountLimits(limits, fav.currency, premium); setMaker({ ...maker, premium: isNaN(newPremium) || value === '' ? '' : premium, badPremiumText, }); }; const handleSatoshisChange = function (e: object): void { const newSatoshis = e.target.value; let badSatoshisText: string = ''; let satoshis: string = newSatoshis; if (newSatoshis > satoshisLimits[1]) { badSatoshisText = t('Must be less than {{maxSats}', { maxSats: pn(satoshisLimits[1]) }); satoshis = satoshisLimits[1]; } if (newSatoshis < satoshisLimits[0]) { badSatoshisText = t('Must be more than {{minSats}}', { minSats: pn(satoshisLimits[0]) }); satoshis = satoshisLimits[0]; } setMaker({ ...maker, satoshis, badSatoshisText, }); }; const handleClickRelative = function (): void { setMaker({ ...maker, isExplicit: false, }); }; const handleClickExplicit = function (): void { if (!maker.advancedOptions) { setMaker({ ...maker, isExplicit: true, }); } }; const handleCreateOrder = function (): void { const { url, basePath } = federation .getCoordinator(maker.coordinator) ?.getEndpoint(settings.network, origin, settings.selfhostedClient, hostUrl); const auth = { tokenSHA256: garage.getRobot().tokenSHA256, keys: { pubKey: garage.getRobot().pubKey?.split('\n').join('\\'), encPrivKey: garage.getRobot().encPrivKey?.split('\n').join('\\'), }, }; if (!disableRequest && focusedCoordinator) { setSubmittingRequest(true); const body = { type: fav.type === 0 ? 1 : 0, currency: fav.currency === 0 ? 1 : fav.currency, amount: makerHasAmountRange ? null : maker.amount, has_range: makerHasAmountRange, min_amount: makerHasAmountRange ? maker.minAmount : null, max_amount: makerHasAmountRange ? maker.maxAmount : null, payment_method: maker.paymentMethodsText === '' ? 'not specified' : maker.paymentMethodsText, is_explicit: maker.isExplicit, premium: maker.isExplicit ? null : maker.premium === '' ? 0 : maker.premium, satoshis: maker.isExplicit ? maker.satoshis : null, public_duration: maker.publicDuration, escrow_duration: maker.escrowDuration, bond_size: maker.bondSize, latitude: maker.latitude, longitude: maker.longitude, }; const { url } = federation .getCoordinator(focusedCoordinator) .getEndpoint(settings.network, origin, settings.selfhostedClient, hostUrl); apiClient .post(url, `${basePath}/api/make/`, body, auth) .then((data: any) => { setBadRequest(data.bad_request); if (data.id !== undefined) { onOrderCreated(maker.coordinator, data.id); } setSubmittingRequest(false); }) .catch(() => { setBadRequest('Request error'); }); } setOpenDialogs(false); }; const handleChangePublicDuration = function (date: Date): void { const d = new Date(date); const hours: number = d.getHours(); const minutes: number = d.getMinutes(); const totalSecs: number = hours * 60 * 60 + minutes * 60; setMaker({ ...maker, publicExpiryTime: date, publicDuration: totalSecs, }); }; const handleChangeEscrowDuration = function (date: Date): void { const d = new Date(date); const hours: number = d.getHours(); const minutes: number = d.getMinutes(); const totalSecs: number = hours * 60 * 60 + minutes * 60; setMaker({ ...maker, escrowExpiryTime: date, escrowDuration: totalSecs, }); }; const handleClickAdvanced = function (): void { if (maker.advancedOptions) { handleClickRelative(); setMaker({ ...maker, advancedOptions: false }); } else { resetRange(true); } }; const minAmountError = useMemo(() => { return ( maker.minAmount < amountLimits[0] * 0.99 || maker.maxAmount < maker.minAmount || maker.minAmount < maker.maxAmount / (maxRangeAmountMultiple + 0.15) || maker.minAmount * (minRangeAmountMultiple - 0.1) > maker.maxAmount ); }, [maker.minAmount, maker.maxAmount, amountLimits]); const maxAmountError = useMemo(() => { return ( maker.maxAmount > amountLimits[1] * 1.01 || maker.maxAmount < maker.minAmount || maker.minAmount < maker.maxAmount / (maxRangeAmountMultiple + 0.15) || maker.minAmount * (minRangeAmountMultiple - 0.1) > maker.maxAmount ); }, [maker.minAmount, maker.maxAmount, amountLimits]); const resetRange = function (advancedOptions: boolean): void { const index = fav.currency === 0 ? 1 : fav.currency; const minAmount = maker.amount !== '' ? parseFloat((maker.amount / 2).toPrecision(2)) : parseFloat(Number(limits[index].max_amount * 0.25).toPrecision(2)); const maxAmount = maker.amount !== '' ? parseFloat(maker.amount) : parseFloat(Number(limits[index].max_amount * 0.75).toPrecision(2)); setMaker({ ...maker, advancedOptions, minAmount, maxAmount, }); }; const handleRangeAmountChange = function (e: any, newValue, activeThumb: number): void { let minAmount = e.target.value[0]; let maxAmount = e.target.value[1]; minAmount = Math.min( (amountLimits[1] * amountSafeThresholds[1]) / minRangeAmountMultiple, minAmount, ); maxAmount = Math.max( minRangeAmountMultiple * amountLimits[0] * amountSafeThresholds[0], maxAmount, ); if (minAmount > maxAmount / minRangeAmountMultiple) { if (activeThumb === 0) { maxAmount = minRangeAmountMultiple * minAmount; } else { minAmount = maxAmount / minRangeAmountMultiple; } } else if (minAmount < maxAmount / maxRangeAmountMultiple) { if (activeThumb === 0) { maxAmount = maxRangeAmountMultiple * minAmount; } else { minAmount = maxAmount / maxRangeAmountMultiple; } } setMaker({ ...maker, minAmount: parseFloat(Number(minAmount).toPrecision(minAmount < 100 ? 2 : 3)), maxAmount: parseFloat(Number(maxAmount).toPrecision(maxAmount < 100 ? 2 : 3)), }); }; const handleClickAmountRangeEnabled = function ( _e: React.ChangeEvent, checked: boolean, ): void { setAmountRangeEnabled(checked); }; const amountLabel = useMemo(() => { if (!focusedCoordinator) return; const info = federation.getCoordinator(focusedCoordinator)?.info; const defaultRoutingBudget = 0.001; let label = t('Amount'); let helper = ''; let swapSats = 0; if (fav.mode === 'swap') { if (fav.type === 1) { swapSats = computeSats({ amount: Number(maker.amount), premium: Number(maker.premium), fee: -(info?.maker_fee ?? 0), routingBudget: defaultRoutingBudget, }); label = t('Onchain amount to send (BTC)'); helper = t('You receive approx {{swapSats}} LN Sats (fees might vary)', { swapSats, }); } else if (fav.type === 0) { swapSats = computeSats({ amount: Number(maker.amount), premium: Number(maker.premium), fee: info?.maker_fee ?? 0, }); label = t('Onchain amount to receive (BTC)'); helper = t('You send approx {{swapSats}} LN Sats (fees might vary)', { swapSats, }); } } return { label, helper, swapSats }; }, [fav, maker.amount, maker.premium, federationUpdatedAt]); const disableSubmit = useMemo(() => { return ( fav.type == null || (!makerHasAmountRange && maker.amount !== '' && (maker.amount < amountLimits[0] || maker.amount > amountLimits[1])) || maker.badPaymentMethod || (maker.amount == null && (!makerHasAmountRange || Object.keys(limits).lenght < 1)) || (makerHasAmountRange && (minAmountError || maxAmountError)) || (!makerHasAmountRange && maker.amount <= 0) || (maker.isExplicit && (maker.badSatoshisText !== '' || maker.satoshis === '')) || (!maker.isExplicit && maker.badPremiumText !== '') ); }, [maker, amountLimits, coordinatorUpdatedAt, fav.type, makerHasAmountRange]); const clearMaker = function (): void { setFav({ ...fav, type: null }); setMaker(defaultMaker); }; const handleAddLocation = (pos: [number, number]): void => { if (pos?.length === 2) { setMaker((maker) => { return { ...maker, latitude: parseFloat(pos[0].toPrecision(6)), longitude: parseFloat(pos[1].toPrecision(6)), }; }); const cashMethod = maker.paymentMethods.find((method) => method.icon === 'cash'); if (cashMethod !== null) { const newMethods = maker.paymentMethods; const cash = fiatMethods.find((method) => method.icon === 'cash'); if (cash !== null) { newMethods.unshift(cash); handlePaymentMethodChange(newMethods); } } } }; const SummaryText = (): JSX.Element => { return ( {fav.type == null ? fav.mode === 'fiat' ? t('Order for ') : t('Swap of ') : fav.type === 1 ? fav.mode === 'fiat' ? t('Buy BTC for ') : t('Swap into LN ') : fav.mode === 'fiat' ? t('Sell BTC for ') : t('Swap out of LN ')} {fav.mode === 'fiat' ? amountToString(maker.amount, makerHasAmountRange, maker.minAmount, maker.maxAmount) : amountToString( maker.amount * 100000000, makerHasAmountRange, maker.minAmount * 100000000, maker.maxAmount * 100000000, )} {' ' + (fav.mode === 'fiat' ? currencyCode : 'Sats')} {maker.isExplicit ? t(' of {{satoshis}} Satoshis', { satoshis: pn(maker.satoshis) }) : maker.premium === 0 ? fav.mode === 'fiat' ? t(' at market price') : '' : maker.premium > 0 ? t(' at a {{premium}}% premium', { premium: maker.premium }) : t(' at a {{discount}}% discount', { discount: -maker.premium })} ); }; return ( { setOpenDialogs(false); }} onClickDone={handleCreateOrder} hasRobot={garage.getRobot().avatarLoaded} onClickGenerateRobot={onClickGenerateRobot} /> { if (pos != null) handleAddLocation(pos); setOpenWorldmap(false); }} zoom={maker.latitude && maker.longitude ? 6 : undefined} />
{t('Swap?')} { handleCurrencyChange(fav.mode === 'swap' ? 1 : 1000); }} /> {fav.mode === 'fiat' ? t('Buy or Sell Bitcoin?') : t('In or Out of Lightning?')}
} sx={{ paddingLeft: '1em', color: 'text.secondary', marginTop: '-0.5em', paddingBottom: '0.5em', }} label={amountRangeEnabled ? t('Amount Range') : t('Exact Amount')} /> amountLimits[1]) } helperText={ maker.amount < amountLimits[0] && maker.amount !== '' ? t('Must be more than {{minAmount}}', { minAmount: pn(parseFloat(amountLimits[0].toPrecision(2))), }) : maker.amount > amountLimits[1] && maker.amount !== '' ? t('Must be less than {{maxAmount}}', { maxAmount: pn(parseFloat(amountLimits[1].toPrecision(2))), }) : null } label={amountLabel.label} required={true} value={maker.amount} type='number' inputProps={{ min: 0, style: { textAlign: 'center', backgroundColor: theme.palette.background.paper, borderRadius: '4px', }, }} onChange={(e) => { setMaker({ ...maker, amount: e.target.value }); }} /> {fav.mode === 'swap' && maker.amount !== '' ? ( {amountLabel.helper} ) : null} {fav.mode === 'fiat' ? ( ) : null} { setOpenWorldmap(true); }} optionsType={fav.mode} error={maker.badPaymentMethod} helperText={maker.badPaymentMethod ? t('Must be shorter than 65 characters') : ''} label={fav.mode === 'swap' ? t('Swap Destination(s)') : t('Fiat Payment Method(s)')} tooltipTitle={t( fav.mode === 'swap' ? t('Enter the destination of the Lightning swap') : 'Enter your preferred fiat payment methods. Fast methods are highly recommended.', )} listHeaderText={t('You can add new methods')} addNewButtonText={t('Add New')} asFilter={false} value={maker.paymentMethods} /> {maker.badPaymentMethod && ( {t('Must be shorter than 65 characters')} )} {fav.mode === 'fiat' && ( )} {!maker.advancedOptions && pricingMethods ? ( {t('Choose a Pricing Method')} } label={t('Relative')} labelPlacement='end' onClick={handleClickRelative} /> } label={t('Exact')} labelPlacement='end' onClick={handleClickExplicit} /> ) : null}
), }, }, }} label={t('Public Duration (HH:mm)')} value={maker.publicExpiryTime} onChange={handleChangePublicDuration} minTime={new Date(0, 0, 0, 0, 10)} maxTime={new Date(0, 0, 0, 23, 59)} /> ), }, }, }} label={t('Escrow/Invoice Timer (HH:mm)')} value={maker.escrowExpiryTime} onChange={handleChangeEscrowDuration} minTime={new Date(0, 0, 0, 1, 0)} maxTime={new Date(0, 0, 0, 8, 0)} /> {t('Fidelity Bond Size')}{' '} x + '%'} step={0.25} marks={[ { value: 2, label: '2%' }, { value: 5, label: '5%' }, { value: 10, label: '10%' }, { value: 15, label: '15%' }, ]} min={2} max={15} onChange={(e) => { setMaker({ ...maker, bondSize: e.target.value }); }} />
{ setMaker((maker) => { return { ...maker, coordinator }; }); }} /> {/* conditions to disable the make button */} {disableSubmit ? (
) : ( { disableRequest ? onSubmit() : setOpenDialogs(true); }} > {t(submitButtonLabel)} )}
{collapseAll ? ( ) : null}
{badRequest} {(maker.isExplicit ? t('Order rate:') : t('Order current rate:')) + ` ${pn(currentPrice)} ${currencyCode}/BTC`}
); }; export default MakerForm;