From 36c61b85211c736c5c24df16918b62120006ce83 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Mon, 25 May 2026 15:25:50 +0100 Subject: [PATCH] frontend: allow entering fiat in lightning receive --- .../src/routes/lightning/receive/receive.tsx | 162 ++++++++++++++---- 1 file changed, 126 insertions(+), 36 deletions(-) diff --git a/frontends/web/src/routes/lightning/receive/receive.tsx b/frontends/web/src/routes/lightning/receive/receive.tsx index 7c6ec17519..1933f5f154 100644 --- a/frontends/web/src/routes/lightning/receive/receive.tsx +++ b/frontends/web/src/routes/lightning/receive/receive.tsx @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { ChangeEvent, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Column, Grid, GuideWrapper, GuidedContent, Header, Main } from '../../../components/layout'; import { View, ViewButtons, ViewContent } from '../../../components/view/view'; -import { Button, Input, OptionalLabel } from '../../../components/forms'; +import { Button, Input, NumberInput, OptionalLabel } from '../../../components/forms'; import { TLightningPayment, TReceivePaymentResponse, @@ -13,36 +13,52 @@ import { getReceivePayment, subscribeListPayments } from '../../../api/lightning'; +import { convertFromCurrency, convertToCurrency, getBtcSatsAmount } from '@/api/coins'; import { Status } from '../../../components/status/status'; import { QRCode } from '../../../components/qrcode/qrcode'; import { unsubscribe } from '../../../utils/subscriptions'; import { Spinner } from '../../../components/spinner/Spinner'; import { Checked, Copy, EditActive } from '../../../components/icon'; -import { getBtcSatsAmount } from '../../../api/coins'; import { TAmountWithConversions } from '../../../api/account'; import { AmountWithUnit } from '@/components/amount/amount-with-unit'; import { useNavigate } from 'react-router-dom'; +import { RatesContext } from '@/contexts/RatesContext'; +import { useMountedRef } from '@/hooks/mount'; import styles from './receive.module.css'; type TStep = 'create-invoice' | 'wait' | 'invoice' | 'success'; +const parseSatsAmount = (amount: string): number | undefined => { + if (!/^\d+$/.test(amount)) { + return undefined; + } + const parsedAmount = Number(amount); + return Number.isFinite(parsedAmount) && Number.isInteger(parsedAmount) && parsedAmount > 0 + ? parsedAmount + : undefined; +}; + export function Receive() { const { t } = useTranslation(); const navigate = useNavigate(); + const { defaultCurrency } = useContext(RatesContext); + const mounted = useMountedRef(); + const amountRequestId = useRef(0); const [inputSatsText, setInputSatsText] = useState(''); + const [inputFiatText, setInputFiatText] = useState(''); const [invoiceAmount, setInvoiceAmount] = useState(); const [description, setDescription] = useState(''); - const [disableConfirm, setDisableConfirm] = useState(true); const [receivePaymentResponse, setReceivePaymentResponse] = useState(); const [receiveError, setReceiveError] = useState(); const [step, setStep] = useState('create-invoice'); const [payments, setPayments] = useState(); + const satsAmount = parseSatsAmount(inputSatsText); const newInvoice = useCallback(() => { setInputSatsText(''); + setInputFiatText(''); setInvoiceAmount(undefined); setDescription(''); - setDisableConfirm(true); setReceivePaymentResponse(undefined); setReceiveError(undefined); setStep('create-invoice'); @@ -60,15 +76,96 @@ export function Receive() { setReceiveError(undefined); if (step === 'success') { setInputSatsText(''); + setInputFiatText(''); } break; } }, [step, navigate]); - const onAmountSatsChange = useCallback((event: ChangeEvent) => { - const target = event.target as HTMLInputElement; - setInputSatsText(target.value); - }, []); + const updateAmountFromSats = useCallback(async (satsText: string, updateFiat: boolean) => { + const requestId = ++amountRequestId.current; + setInvoiceAmount(undefined); + if (updateFiat) { + setInputFiatText(''); + } + + if (!satsText) { + return; + } + + const response = await getBtcSatsAmount(satsText); + if (!mounted.current || requestId !== amountRequestId.current) { + return; + } + if (response.success) { + setInvoiceAmount(response.amount); + if (updateFiat) { + const fiatResponse = await convertToCurrency({ + amount: response.amount.amount, + coinCode: 'btc', + fiatUnit: defaultCurrency, + }); + if (!mounted.current || requestId !== amountRequestId.current) { + return; + } + setInputFiatText(fiatResponse.success ? fiatResponse.fiatAmount : ''); + } + } + }, [defaultCurrency, mounted]); + + const updateAmountFromFiat = useCallback(async (fiatText: string) => { + const requestId = ++amountRequestId.current; + setInputSatsText(''); + setInvoiceAmount(undefined); + + if (!fiatText) { + return; + } + + const coinAmountResponse = await convertFromCurrency({ + amount: fiatText, + coinCode: 'btc', + fiatUnit: defaultCurrency, + }); + if (!mounted.current || requestId !== amountRequestId.current) { + return; + } + if (!coinAmountResponse.success) { + return; + } + + const satsAmountResponse = await convertToCurrency({ + amount: coinAmountResponse.amount, + coinCode: 'btc', + fiatUnit: 'sat', + }); + if (!mounted.current || requestId !== amountRequestId.current) { + return; + } + if (!satsAmountResponse.success) { + return; + } + + const satsText = satsAmountResponse.fiatAmount; + setInputSatsText(satsText); + const satsResponse = await getBtcSatsAmount(satsText); + if (!mounted.current || requestId !== amountRequestId.current) { + return; + } + if (satsResponse.success) { + setInvoiceAmount(satsResponse.amount); + } + }, [defaultCurrency, mounted]); + + const onAmountSatsChange = useCallback((amount: string) => { + setInputSatsText(amount); + updateAmountFromSats(amount, true); + }, [updateAmountFromSats]); + + const onAmountFiatChange = useCallback((amount: string) => { + setInputFiatText(amount); + updateAmountFromFiat(amount); + }, [updateAmountFromFiat]); const onDescriptionChange = useCallback((event: ChangeEvent) => { const target = event.target as HTMLInputElement; @@ -84,26 +181,6 @@ export function Receive() { return () => unsubscribe(subscriptions); }, [onPaymentsChange]); - useEffect(() => { - getBtcSatsAmount(inputSatsText).then((response) => { - if (response.success) { - setInvoiceAmount(response.amount); - } - }); - }, [inputSatsText]); - - - useEffect(() => { - (async () => { - const inputSats = Number(inputSatsText); - if (inputSats > 0) { - setDisableConfirm(false); - return; - } - setDisableConfirm(true); - })(); - }, [inputSatsText]); - useEffect(() => { if (payments && receivePaymentResponse && step === 'invoice') { const payment = payments.find((payment) => payment.type === 'receive' && payment.invoice === receivePaymentResponse.invoice); @@ -115,10 +192,14 @@ export function Receive() { const receivePayment = useCallback(async () => { setReceiveError(undefined); + if (satsAmount === undefined) { + setReceiveError(t('send.error.invalidAmount')); + return; + } setStep('wait'); try { const receivePaymentResponse = await getReceivePayment({ - amountSat: Number(inputSatsText), + amountSat: satsAmount, description, }); setReceivePaymentResponse(receivePaymentResponse); @@ -131,7 +212,7 @@ export function Receive() { setReceiveError(String(e)); } } - }, [description, inputSatsText]); + }, [description, satsAmount, t]); const renderSteps = () => { switch (step) { @@ -142,16 +223,25 @@ export function Receive() {

{t('lightning.receive.subtitle')}

- {t('lightning.receive.amountSats.label')} ({}) - + -