import React, { useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ButtonGroup, Slider, Switch, Tooltip, Button, Checkbox, Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, FormControlLabel, Box, useTheme, Collapse, IconButton, } from '@mui/material'; import { type LimitList, defaultMaker, type Order } from '../../models'; import { LocalizationProvider, MobileTimePicker } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { ConfirmationDialog, F2fMapDialog } from '../Dialogs'; import { FlagWithProps } from '../Icons'; import AutocompletePayments from './AutocompletePayments'; import AmountRange from './AmountRange'; import currencyDict from '../../../static/assets/currencies.json'; import { amountToString, computeSats, genBase62Token, pn } from '../../utils'; import { SelfImprovement, Lock, DeleteSweep, Edit, Map } from '@mui/icons-material'; import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'; 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'; import { useNavigate } from 'react-router-dom'; import { sha256 } from 'js-sha256'; import AddNewPaymentMethodDialog from '../Dialogs/AddNewPaymentMethodDialog'; interface MakerFormProps { disableRequest?: boolean; collapseAll?: boolean; onSubmit?: () => void; onReset?: () => void; submitButtonLabel?: string; } const MakerForm = ({ disableRequest = false, collapseAll = false, onSubmit = () => {}, onReset = () => {}, submitButtonLabel = 'Create Order', }: MakerFormProps): React.JSX.Element => { const { fav, setFav, settings } = useContext(AppContext); const { federation, federationUpdatedAt } = useContext(FederationContext); const { maker, setMaker, garage } = useContext(GarageContext); const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); const [badRequest, setBadRequest] = useState(null); const [amountLimits, setAmountLimits] = useState([1, 1000]); const [currentPrice, setCurrentPrice] = useState(); const [currencyCode, setCurrencyCode] = useState('USD'); const [addNewPaymentMethodOpen, setAddNewPaymentMethodOpen] = useState(false); const [hasCustomPaymentMethod, setHasCustomPaymentMethod] = useState(false); const [openDialogs, setOpenDialogs] = useState(false); const [openWorldmap, setOpenWorldmap] = useState(false); const [submittingRequest, setSubmittingRequest] = useState(false); const [amountRangeEnabled, setAmountRangeEnabled] = useState(true); const [hasRangeError, setHasRangeError] = useState(false); const [openPublicDuration, setOpenPublicDuration] = useState(false); const [openEscrowTimer, setOpenEscrowTimer] = useState(false); const [limits, setLimits] = useState({}); const amountSafeThresholds = [1.03, 0.98]; useEffect(() => { federation .loadInfo() .then(() => {}) .catch((error) => { console.error('Error loading info:', error); }); }, []); useEffect(() => { setCurrencyCode(currencyDict[fav.currency === 0 ? 1 : fav.currency]); }, [federationUpdatedAt]); useEffect(() => { updateCoordinatorInfo(); }, [maker.coordinator, federationUpdatedAt]); const updateCoordinatorInfo = (): void => { if (maker.coordinator != null) { const newLimits = federation.getCoordinator(maker.coordinator)?.limits; if (newLimits && Object.keys(newLimits).length !== 0) { updateAmountLimits(newLimits, fav.currency, maker.premium); updateCurrentPrice(newLimits, fav.currency, maker.premium); setLimits(newLimits); } } }; 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); const coordinatorSizeLimit = (federation.getCoordinator(maker.coordinator).size_limit / 100000000) * limitList[index].price; maxAmountLimit = Math.min(coordinatorSizeLimit, maxAmountLimit); // apply thresholds to ensure good request minAmountLimit = minAmountLimit * amountSafeThresholds[0]; maxAmountLimit = maxAmountLimit * amountSafeThresholds[1]; setAmountLimits([minAmountLimit, maxAmountLimit]); }; const updateCurrentPrice = function ( limitsList: LimitList, currency: number, premium: number, ): void { const index = currency === 0 ? 1 : currency; const 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: string[]): void { let str = ''; const arrayLength = paymentArray.length; let includeCoordinates = false; for (let i = 0; i < arrayLength; i++) { str += paymentArray[i] + ' '; if (paymentArray[i] === '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 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 handleCreateOrder = function (): void { const slot = garage.getSlot(); if (slot?.activeOrder?.id) { setBadRequest(t('You are already maker of an active order')); return; } if (!disableRequest && maker.coordinator && slot) { setSubmittingRequest(true); const orderAttributes = { 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, premium: !maker.premium ? 0 : maker.premium, satoshis: null, public_duration: maker.publicDuration, escrow_duration: maker.escrowDuration, bond_size: maker.bondSize, latitude: maker.latitude, longitude: maker.longitude, shortAlias: maker.coordinator, password: maker.password ? sha256(maker.password) : null, }; void slot .makeOrder(federation, orderAttributes) .then((order: Order) => { if (order.id) { navigate(`/order/${order.shortAlias}/${order.id}`); } else if (order?.bad_request) { setBadRequest(order?.bad_request); } setSubmittingRequest(false); }) .catch(() => { setBadRequest('Request error'); setSubmittingRequest(false); }); } 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 handlePasswordChange = function (event: React.ChangeEvent): void { setMaker({ ...maker, password: event.target.value, }); }; 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) { setMaker({ ...maker, advancedOptions: false }); } else { resetRange(true); } }; const handleAddNewPaymentMethod: (newPaymentMethod: string) => void = (newPaymentMethod) => { handlePaymentMethodChange([...maker.paymentMethods, newPaymentMethod]); setAddNewPaymentMethodOpen(false); }; const resetRange = function (advancedOptions: boolean): void { const index = fav.currency === 0 ? 1 : fav.currency; const minAmount = maker.amount !== null ? (maker.amount / 2).toPrecision(2) : parseFloat(Number(limits[index].max_amount * 0.25).toPrecision(2)); const maxAmount = maker.amount !== null ? maker.amount : parseFloat(Number(limits[index].max_amount * 0.75).toPrecision(2)); setMaker({ ...maker, advancedOptions, minAmount, maxAmount, }); }; const handleClickAmountRangeEnabled = function ( _e: React.ChangeEvent, checked: boolean, ): void { setAmountRangeEnabled(checked); }; const amountLabel = useMemo(() => { if (!(maker.coordinator != null)) return; const info = federation.getCoordinator(maker.coordinator)?.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)?.length ?? 0) < 1)) || (makerHasAmountRange && hasRangeError) || (!makerHasAmountRange && maker.amount && maker.amount <= 0) || maker.badPremiumText !== '' || federation.getCoordinator(maker.coordinator)?.limits === undefined || typeof maker.premium !== 'number' || maker.paymentMethods.length === 0 ); }, [maker, maker.premium, amountLimits, federationUpdatedAt, 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 === 'cash'); if (cashMethod !== null) { const newMethods = maker.paymentMethods; const cash = fiatMethods.find((method) => method.icon === 'cash'); if (cash) { newMethods.unshift(cash.name); handlePaymentMethodChange(newMethods); } } } }; const currencyFormatter = new Intl.NumberFormat(settings.language); const SummaryText = (): React.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.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={Boolean(garage.getSlot()?.hashId)} onClickGenerateRobot={() => { setOpenDialogs(false); const token = genBase62Token(36); garage .createRobot(federation, token) .then(() => { setOpenDialogs(true); }) .catch((e) => { console.log(e); }); }} /> { if (pos != null) handleAddLocation(pos); setOpenWorldmap(false); }} zoom={maker.latitude === 0 && maker.longitude === 0 ? 2 : 6} />
{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 && maker.amount < amountLimits[0] ? t('Must be more than {{minAmount}}', { minAmount: pn(parseFloat(amountLimits[0].toPrecision(2))), }) : maker.amount && maker.amount > amountLimits[1] ? t('Must be less than {{maxAmount}}', { maxAmount: pn(parseFloat(amountLimits[1].toPrecision(2))), }) : null } label={amountLabel.label} required={true} value={maker.amount} type='number' onChange={(e) => { setMaker({ ...maker, amount: Number(e.target.value) }); }} /> {fav.mode === 'swap' && maker.amount ? ( {amountLabel.helper} ) : null} {fav.mode === 'fiat' ? ( ) : null} {maker.badPaymentMethod && ( {t('Must be shorter than 65 characters')} )} {fav.mode === 'fiat' && ( )} setOpenPublicDuration(false)} ampm={false} localeText={{ toolbarTitle: t('Public order length') }} openTo='hours' views={['hours', 'minutes']} slotProps={{ textField: { fullWidth: true, InputProps: { style: { backgroundColor: theme.palette.background.paper, borderRadius: '4px', marginBottom: 8, }, onClick: () => setOpenPublicDuration(true), }, }, }} 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)} /> setOpenEscrowTimer(false)} localeText={{ toolbarTitle: t('Escrow/invoice step length') }} openTo='hours' views={['hours', 'minutes']} inputFormat='HH:mm' mask='__:__' slotProps={{ textField: { fullWidth: true, InputProps: { style: { backgroundColor: theme.palette.background.paper, borderRadius: '4px', marginBottom: 8, }, onClick: () => setOpenEscrowTimer(true), }, }, }} 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: coordinatorAlias }; }); }} /> {/* conditions to disable the make button */} {disableSubmit ? (
) : ( (disableRequest ? onSubmit() : setOpenDialogs(true))} > {t(submitButtonLabel)} )}
{collapseAll ? ( ) : null}
{badRequest} {`${t('Order current rate:')} ${currentPrice ? currencyFormatter.format(currentPrice) : '-'} ${currencyCode}/BTC`}
setAddNewPaymentMethodOpen(false)} onConfirm={handleAddNewPaymentMethod} />
); }; export default MakerForm;