From 342a97c56a64da4e55f05da7b4ab0581b3b5d0c9 Mon Sep 17 00:00:00 2001 From: lissavxo Date: Thu, 25 Sep 2025 14:51:01 -0300 Subject: [PATCH 01/24] feat: generate paymentId on server --- react/lib/components/PayButton/PayButton.tsx | 20 ++++++++++++-- .../lib/components/Widget/WidgetContainer.tsx | 26 +++++++++++++------ react/lib/util/api-client.ts | 14 ++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 0824c486..078cd9cd 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -13,7 +13,6 @@ import { isValidCashAddress, isValidXecAddress, CurrencyObject, - generatePaymentId, getCurrencyObject, isPropsTrue, setupAltpaymentSocket, @@ -22,6 +21,7 @@ import { ButtonSize, DEFAULT_DONATION_RATE } from '../../util'; +import { createPayment } from '../../util/api-client'; import { PaymentDialog } from '../PaymentDialog'; import { AltpaymentCoin, AltpaymentError, AltpaymentPair, AltpaymentShift } from '../../altpayment'; export interface PayButtonProps extends ButtonProps { @@ -116,11 +116,27 @@ export const PayButton = ({ const cryptoAmountRef = useRef(cryptoAmount); - const [paymentId] = useState(!disablePaymentId ? generatePaymentId(8) : undefined); + + const [paymentId, setPaymentId] = useState(undefined); const [addressType, setAddressType] = useState( getCurrencyTypeFromAddress(to), ); + useEffect(() => { + const initializePaymentId = async () => { + if (!disablePaymentId && to) { + try { + const responsePaymentId = await createPayment(amount, to, apiBaseUrl); + setPaymentId(responsePaymentId); + } catch (error) { + console.error('Error creating payment ID:', error); + } + } + }; + + initializePaymentId(); + }, [disablePaymentId, amount, to, apiBaseUrl]); + useEffect(() => { priceRef.current = price; }, [price]); diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index af95d88c..d7d6d0b6 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -11,7 +11,6 @@ import { Currency, CurrencyObject, Transaction, - generatePaymentId, getCurrencyTypeFromAddress, isCrypto, isGreaterThanZero, @@ -21,6 +20,7 @@ import { isPropsTrue, DEFAULT_DONATION_RATE, } from '../../util'; +import { createPayment } from '../../util/api-client'; import Widget, { WidgetProps } from './Widget'; @@ -151,13 +151,23 @@ export const WidgetContainer: React.FunctionComponent = const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); useEffect(() => { - if ((paymentId === undefined || paymentId === '') && !disablePaymentId) { - const newPaymentId = generatePaymentId(8); - setThisPaymentId(newPaymentId) - } else { - setThisPaymentId(paymentId) - } - }, [paymentId, disablePaymentId]); + const initializePaymentId = async () => { + if ((paymentId === undefined || paymentId === '') && !disablePaymentId) { + if (to) { + try { + const responsePaymentId = await createPayment(amount, to, apiBaseUrl); + setThisPaymentId(responsePaymentId); + } catch (error) { + console.error('Error creating payment ID:', error); + } + } + } else { + setThisPaymentId(paymentId); + } + }; + + initializePaymentId(); + }, [paymentId, disablePaymentId, amount, to, apiBaseUrl]); const [success, setSuccess] = useState(false); const { enqueueSnackbar } = useSnackbar(); diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index cdcb1e13..3d608e58 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -89,6 +89,20 @@ export const getTransactionDetails = async ( return res.json(); }; +export const createPayment = async ( + amount: string | number | undefined, + address: string, + rootUrl = config.apiBaseUrl, +): Promise => { + const { data } = await axios.post( + `${rootUrl}/api/payments/paymentId`, + { amount, address } + ); + return data.paymentId; + +}; + + export default { getAddressDetails, getTransactionDetails, From aae1c95b426e19b153b7d76a7611f12e3008fe5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Tue, 14 Oct 2025 15:24:45 -0300 Subject: [PATCH 02/24] fix: solve double calling of paymentId on server --- react/lib/components/PayButton/PayButton.tsx | 9 +++++++-- react/lib/components/Widget/WidgetContainer.tsx | 9 ++++++++- react/lib/util/api-client.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 078cd9cd..ba0568db 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -115,23 +115,28 @@ export const PayButton = ({ const priceRef = useRef(price); const cryptoAmountRef = useRef(cryptoAmount); - - const [paymentId, setPaymentId] = useState(undefined); + const [fetchingPaymentId, setFetchingPaymentId] = useState(); const [addressType, setAddressType] = useState( getCurrencyTypeFromAddress(to), ); useEffect(() => { + if (fetchingPaymentId !== undefined) { + return + } + setFetchingPaymentId(true) const initializePaymentId = async () => { if (!disablePaymentId && to) { try { const responsePaymentId = await createPayment(amount, to, apiBaseUrl); setPaymentId(responsePaymentId); + setFetchingPaymentId(false); } catch (error) { console.error('Error creating payment ID:', error); } } + setFetchingPaymentId(false); }; initializePaymentId(); diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index d7d6d0b6..0df5c22a 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -104,7 +104,7 @@ export const WidgetContainer: React.FunctionComponent = let { to, opReturn, - disablePaymentId, + disablePaymentId = isPropsTrue(props.disablePaymentId), paymentId, amount, setAmount, @@ -148,21 +148,28 @@ export const WidgetContainer: React.FunctionComponent = donationRate = DEFAULT_DONATION_RATE } const [thisPaymentId, setThisPaymentId] = useState(); + const [fetchingPaymentId, setFetchingPaymentId] = useState(); const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); useEffect(() => { + if (fetchingPaymentId !== undefined) { + return + } + setFetchingPaymentId(true) const initializePaymentId = async () => { if ((paymentId === undefined || paymentId === '') && !disablePaymentId) { if (to) { try { const responsePaymentId = await createPayment(amount, to, apiBaseUrl); setThisPaymentId(responsePaymentId); + setFetchingPaymentId(false); } catch (error) { console.error('Error creating payment ID:', error); } } } else { setThisPaymentId(paymentId); + setFetchingPaymentId(false); } }; diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index 3d608e58..7ea64ce7 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -94,12 +94,16 @@ export const createPayment = async ( address: string, rootUrl = config.apiBaseUrl, ): Promise => { - const { data } = await axios.post( + const { data, status } = await axios.post( `${rootUrl}/api/payments/paymentId`, { amount, address } ); - return data.paymentId; + if (status === 200) { + return data.paymentId; + } + throw new Error("Failed to generate payment Id.") // WIP + }; From 19672799faa5248eae7234220f3a3a66629ac59d Mon Sep 17 00:00:00 2001 From: lissavxo Date: Thu, 16 Oct 2025 21:18:46 -0300 Subject: [PATCH 03/24] fix: converted amount --- react/lib/components/PayButton/PayButton.tsx | 42 ++++++------ .../PaymentDialog/PaymentDialog.tsx | 7 +- react/lib/components/Widget/Widget.tsx | 68 +++++++++++-------- .../lib/components/Widget/WidgetContainer.tsx | 6 ++ 4 files changed, 73 insertions(+), 50 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index ba0568db..be0d7adb 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -110,6 +110,9 @@ export const PayButton = ({ const [currencyObj, setCurrencyObj] = useState(); const [cryptoAmount, setCryptoAmount] = useState(); + const [convertedAmount, setConvertedAmount] = useState(); + const [convertedCurrencyObj, setConvertedCurrencyObj] = useState(); + const [price, setPrice] = useState(0); const [newTxs, setNewTxs] = useState(); const priceRef = useRef(price); @@ -121,26 +124,7 @@ export const PayButton = ({ getCurrencyTypeFromAddress(to), ); - useEffect(() => { - if (fetchingPaymentId !== undefined) { - return - } - setFetchingPaymentId(true) - const initializePaymentId = async () => { - if (!disablePaymentId && to) { - try { - const responsePaymentId = await createPayment(amount, to, apiBaseUrl); - setPaymentId(responsePaymentId); - setFetchingPaymentId(false); - } catch (error) { - console.error('Error creating payment ID:', error); - } - } - setFetchingPaymentId(false); - }; - initializePaymentId(); - }, [disablePaymentId, amount, to, apiBaseUrl]); useEffect(() => { priceRef.current = price; @@ -289,14 +273,27 @@ export const PayButton = ({ useEffect(() => { if (currencyObj && isFiat(currency) && price) { - const addressType: Currency = getCurrencyTypeFromAddress(to); + if(!convertedCurrencyObj) { + const addressType: Currency = getCurrencyTypeFromAddress(to); + const convertedObj = getCurrencyObject( + currencyObj.float / price, + addressType, + randomSatoshis, + ); + setCryptoAmount(convertedObj.string); + setConvertedAmount(convertedObj.float); + setConvertedCurrencyObj(convertedObj); + } + } else if (!isFiat(currency) && randomSatoshis && !convertedAmount){ const convertedObj = getCurrencyObject( - currencyObj.float / price, + amount as number, addressType, randomSatoshis, ); setCryptoAmount(convertedObj.string); - } else if (!isFiat(currency)) { + setConvertedAmount(convertedObj.float); + setConvertedCurrencyObj(convertedObj); + } else if (!isFiat(currency) && !randomSatoshis) { setCryptoAmount(amount?.toString()); } }, [price, currencyObj, amount, currency, randomSatoshis, to]); @@ -375,6 +372,7 @@ export const PayButton = ({ transactionText={transactionText} donationAddress={donationAddress} donationRate={donationRate} + convertedCurrencyObj={convertedCurrencyObj} /> {errorMsg && (

= props => { altpaymentError, setAltpaymentError, isChild, + convertedCurrencyObj, donationAddress = config.donationAddress, - donationRate = DEFAULT_DONATION_RATE + donationRate = DEFAULT_DONATION_RATE, } = props; const [loading, setLoading] = useState(true); @@ -595,14 +598,18 @@ export const Widget: React.FunctionComponent = props => { } } if (userEditedAmount !== undefined && thisAmount && thisAddressType) { - const obj = getCurrencyObject(+thisAmount, currency, false) - setThisCurrencyObject(obj) - if (props.setCurrencyObject) props.setCurrencyObject(obj) + const obj = convertedCurrencyObj ?? getCurrencyObject(+thisAmount, currency, false); + setThisCurrencyObject(obj); + if (props.setCurrencyObject) { + props.setCurrencyObject(obj); + } } else if (thisAmount && thisAddressType) { - cleanAmount = +thisAmount - const obj = getCurrencyObject(cleanAmount, currency, randomSatoshis) - setThisCurrencyObject(obj) - if (props.setCurrencyObject) props.setCurrencyObject(obj) + cleanAmount = +thisAmount; + const obj = convertedCurrencyObj ?? getCurrencyObject(cleanAmount, currency, randomSatoshis); + setThisCurrencyObject(obj); + if (props.setCurrencyObject) { + props.setCurrencyObject(obj); + } } }, [thisAmount, currency, userEditedAmount]) @@ -636,11 +643,18 @@ export const Widget: React.FunctionComponent = props => { } else { setWidgetButtonText(`Send with ${thisAddressType} wallet`) } + + console.log('convertedAmount -> ', {convertedCurrencyObj, a: props.convertedAmount, thisCurrencyObject}) if (thisCurrencyObject && hasPrice) { - const convertedAmount = thisCurrencyObject.float / price - const convertedObj = price - ? getCurrencyObject(convertedAmount, thisAddressType, randomSatoshis) - : null + // Use convertedAmount prop if available, otherwise calculate locally + const convertedAmount = props.convertedAmount ?? thisCurrencyObject.float / price + const convertedObj = convertedCurrencyObj ? convertedCurrencyObj : price + ? getCurrencyObject( + convertedAmount, + thisAddressType, + randomSatoshis, + ) + : null; if (convertedObj) { // Store converted crypto amount for donation UI visibility check setConvertedCryptoAmount(convertedObj.float) diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 0df5c22a..1eb9280c 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -54,6 +54,8 @@ export interface WidgetContainerProps transactionText?: string donationAddress?: string donationRate?: number + convertedAmount?: number; + convertedCurrencyObj?: CurrencyObject; } const snackbarOptionsSuccess: OptionsObject = { @@ -136,6 +138,8 @@ export const WidgetContainer: React.FunctionComponent = transactionText, donationAddress, donationRate, + convertedAmount, + convertedCurrencyObj, ...widgetProps } = props; const [internalCurrencyObj, setInternalCurrencyObj] = useState(); @@ -343,6 +347,8 @@ export const WidgetContainer: React.FunctionComponent = transactionText={transactionText} donationAddress={donationAddress} donationRate={donationRate} + convertedAmount={convertedAmount} + convertedCurrencyObj={convertedCurrencyObj} /> ); From 944404068a8f993c2447b6b63e4d7f33fed957b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Fri, 17 Oct 2025 16:58:35 -0300 Subject: [PATCH 04/24] fix: double request & fetching on open --- react/lib/components/PayButton/PayButton.tsx | 59 +++++++++++++++---- .../lib/components/Widget/WidgetContainer.tsx | 7 ++- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index be0d7adb..5a96e3e1 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -119,7 +119,6 @@ export const PayButton = ({ const cryptoAmountRef = useRef(cryptoAmount); const [paymentId, setPaymentId] = useState(undefined); - const [fetchingPaymentId, setFetchingPaymentId] = useState(); const [addressType, setAddressType] = useState( getCurrencyTypeFromAddress(to), ); @@ -142,16 +141,57 @@ export const PayButton = ({ } }, 300); }; + + const getPaymentId = useCallback(async ( + currency: Currency, + amount: number, + convertedAmount: number | undefined, + to: string | undefined, + ): Promise => { + if (disablePaymentId || !to) return paymentId + try { + const amountToUse = + (isFiat(currency) || randomSatoshis) && convertedAmount + ? convertedAmount + : amount + + console.log('Creating payment ID with amount:', amountToUse) + const responsePaymentId = await createPayment(amountToUse, to, apiBaseUrl) + + setPaymentId(responsePaymentId) + return responsePaymentId + } catch (err) { + console.error('Error creating payment ID:', err) + return undefined + } + }, [disablePaymentId, apiBaseUrl, isFiat, randomSatoshis]) + const handleButtonClick = useCallback(async (): Promise => { - if (onOpen !== undefined) { + + if (onOpen) { if (isFiat(currency)) { - void waitPrice(() => { onOpen(cryptoAmountRef.current, to, paymentId) }) + void waitPrice(() => onOpen(cryptoAmountRef.current, to, paymentId)) } else { onOpen(amount, to, paymentId) } } - setDialogOpen(true); - }, [cryptoAmount, to, paymentId, price]) + + if (!disablePaymentId && !paymentId) { + await getPaymentId(currency, Number(amount), convertedAmount, to) + } + + setDialogOpen(true) + }, [ + onOpen, + isFiat, + currency, + amount, + to, + paymentId, + disablePaymentId, + getPaymentId, + convertedAmount, + ]) const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); @@ -265,9 +305,9 @@ export const PayButton = ({ useEffect(() => { (async () => { - if (isFiat(currency) && price === 0) { - await getPrice(); - } + if (isFiat(currency) && price === 0) { + await getPrice(); + } })() }, [currency, getPrice, to, price]); @@ -289,8 +329,7 @@ export const PayButton = ({ amount as number, addressType, randomSatoshis, - ); - setCryptoAmount(convertedObj.string); + ); setCryptoAmount(convertedObj.string); setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); } else if (!isFiat(currency) && !randomSatoshis) { diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 1eb9280c..f718ade3 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -156,12 +156,15 @@ export const WidgetContainer: React.FunctionComponent = const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); useEffect(() => { - if (fetchingPaymentId !== undefined) { + if ( + fetchingPaymentId !== undefined || + thisPaymentId !== undefined + ) { return } setFetchingPaymentId(true) const initializePaymentId = async () => { - if ((paymentId === undefined || paymentId === '') && !disablePaymentId) { + if (paymentId === undefined && !disablePaymentId) { if (to) { try { const responsePaymentId = await createPayment(amount, to, apiBaseUrl); From f64ca385882cec97c561060105d4c0d0ef41abb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Fri, 17 Oct 2025 17:07:41 -0300 Subject: [PATCH 05/24] fix: type --- react/lib/util/api-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index 7ea64ce7..dc3fd8fa 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -93,7 +93,7 @@ export const createPayment = async ( amount: string | number | undefined, address: string, rootUrl = config.apiBaseUrl, -): Promise => { +): Promise => { const { data, status } = await axios.post( `${rootUrl}/api/payments/paymentId`, { amount, address } From b74c9990d1807635074057d54895f17cb41bbd04 Mon Sep 17 00:00:00 2001 From: lissavxo Date: Wed, 22 Oct 2025 13:23:06 -0300 Subject: [PATCH 06/24] fix: payment dialog --- react/lib/components/PaymentDialog/PaymentDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/lib/components/PaymentDialog/PaymentDialog.tsx b/react/lib/components/PaymentDialog/PaymentDialog.tsx index 5c9558c6..0682d7fa 100644 --- a/react/lib/components/PaymentDialog/PaymentDialog.tsx +++ b/react/lib/components/PaymentDialog/PaymentDialog.tsx @@ -126,7 +126,7 @@ export const PaymentDialog = ({ transactionText, disabled, convertedAmount, - convertedCurrencyObj + convertedCurrencyObj, theme: themeProp, donationAddress, donationRate From 824c392df37b6e8d504774bafec700dae971e7a2 Mon Sep 17 00:00:00 2001 From: lissavxo Date: Wed, 29 Oct 2025 19:46:42 -0300 Subject: [PATCH 07/24] fix: amount convert --- react/lib/components/PayButton/PayButton.tsx | 5 +++-- .../PaymentDialog/PaymentDialog.tsx | 5 +++-- react/lib/components/Widget/Widget.tsx | 20 ++++++++++++++----- .../lib/components/Widget/WidgetContainer.tsx | 5 ++--- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 5a96e3e1..16da35fc 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -155,7 +155,6 @@ export const PayButton = ({ ? convertedAmount : amount - console.log('Creating payment ID with amount:', amountToUse) const responsePaymentId = await createPayment(amountToUse, to, apiBaseUrl) setPaymentId(responsePaymentId) @@ -329,7 +328,8 @@ export const PayButton = ({ amount as number, addressType, randomSatoshis, - ); setCryptoAmount(convertedObj.string); + ); + setCryptoAmount(convertedObj.string); setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); } else if (!isFiat(currency) && !randomSatoshis) { @@ -412,6 +412,7 @@ export const PayButton = ({ donationAddress={donationAddress} donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} + setConvertedCurrencyObj={setConvertedCurrencyObj} /> {errorMsg && (

= props => { convertedCurrencyObj, donationAddress = config.donationAddress, donationRate = DEFAULT_DONATION_RATE, + setConvertedCurrencyObj = () => {}, } = props; const [loading, setLoading] = useState(true); @@ -598,14 +599,24 @@ export const Widget: React.FunctionComponent = props => { } } if (userEditedAmount !== undefined && thisAmount && thisAddressType) { - const obj = convertedCurrencyObj ?? getCurrencyObject(+thisAmount, currency, false); + const obj = getCurrencyObject(+thisAmount, currency, false); setThisCurrencyObject(obj); if (props.setCurrencyObject) { props.setCurrencyObject(obj); } + const convertedAmount = obj.float / price + const convertedObj = price + ? getCurrencyObject( + convertedAmount, + thisAddressType, + randomSatoshis, + ) + : null; + setConvertedCurrencyObj(convertedObj) } else if (thisAmount && thisAddressType) { cleanAmount = +thisAmount; - const obj = convertedCurrencyObj ?? getCurrencyObject(cleanAmount, currency, randomSatoshis); + + const obj = getCurrencyObject(cleanAmount, currency, randomSatoshis); setThisCurrencyObject(obj); if (props.setCurrencyObject) { props.setCurrencyObject(obj); @@ -644,10 +655,9 @@ export const Widget: React.FunctionComponent = props => { setWidgetButtonText(`Send with ${thisAddressType} wallet`) } - console.log('convertedAmount -> ', {convertedCurrencyObj, a: props.convertedAmount, thisCurrencyObject}) if (thisCurrencyObject && hasPrice) { // Use convertedAmount prop if available, otherwise calculate locally - const convertedAmount = props.convertedAmount ?? thisCurrencyObject.float / price + const convertedAmount = convertedCurrencyObj ? convertedCurrencyObj.float : thisCurrencyObject.float / price const convertedObj = convertedCurrencyObj ? convertedCurrencyObj : price ? getCurrencyObject( convertedAmount, diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index f718ade3..7d106d06 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -54,7 +54,6 @@ export interface WidgetContainerProps transactionText?: string donationAddress?: string donationRate?: number - convertedAmount?: number; convertedCurrencyObj?: CurrencyObject; } @@ -138,8 +137,8 @@ export const WidgetContainer: React.FunctionComponent = transactionText, donationAddress, donationRate, - convertedAmount, convertedCurrencyObj, + setConvertedCurrencyObj, ...widgetProps } = props; const [internalCurrencyObj, setInternalCurrencyObj] = useState(); @@ -350,8 +349,8 @@ export const WidgetContainer: React.FunctionComponent = transactionText={transactionText} donationAddress={donationAddress} donationRate={donationRate} - convertedAmount={convertedAmount} convertedCurrencyObj={convertedCurrencyObj} + setConvertedCurrencyObj={setConvertedCurrencyObj} /> ); From f264fe95fefae7b77f2ef0bc05b974df37f1c694 Mon Sep 17 00:00:00 2001 From: lissavxo Date: Tue, 18 Nov 2025 21:11:34 -0300 Subject: [PATCH 08/24] fix: generate paymentId --- react/lib/components/Widget/Widget.tsx | 64 ++++++++++++++++++- .../lib/components/Widget/WidgetContainer.tsx | 34 ++-------- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 35ad5d80..6099d88b 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -50,6 +50,8 @@ import { MINIMUM_ALTPAYMENT_CAD_AMOUNT, } from '../../altpayment' +import { createPayment } from '../../util/api-client'; + export interface WidgetProps { to: string isChild?: boolean @@ -110,6 +112,9 @@ export interface WidgetProps { transactionText?: string; convertedCurrencyObj?: CurrencyObject; setConvertedCurrencyObj?: Function; + setPaymentId?: Function; + setFetchingPaymentId?: Function; + fetchingPaymentId?: boolean; } interface StyleProps { @@ -169,6 +174,9 @@ export const Widget: React.FunctionComponent = props => { donationAddress = config.donationAddress, donationRate = DEFAULT_DONATION_RATE, setConvertedCurrencyObj = () => {}, + setPaymentId, + setFetchingPaymentId, + fetchingPaymentId, } = props; const [loading, setLoading] = useState(true); @@ -295,6 +303,15 @@ export const Widget: React.FunctionComponent = props => { const [opReturn, setOpReturn] = useState() const [isCashtabAvailable, setIsCashtabAvailable] = useState(false) const [convertedCryptoAmount, setConvertedCryptoAmount] = useState(undefined) + const updateConvertedCurrencyObj = useCallback((convertedObj: CurrencyObject | null) => { + setConvertedCurrencyObj(convertedObj); + if(setPaymentId){ + setPaymentId(undefined); + } + if(setFetchingPaymentId){ + setFetchingPaymentId(undefined); + } + }, [setConvertedCurrencyObj, setPaymentId, setFetchingPaymentId]); const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null) @@ -553,6 +570,48 @@ export const Widget: React.FunctionComponent = props => { })() }, [thisNewTxs, to, apiBaseUrl]) + useEffect(() => { + if ( + fetchingPaymentId !== undefined || + paymentId !== undefined + ) { + return + } + if (setFetchingPaymentId) { + setFetchingPaymentId(true) + } + const initializePaymentId = async () => { + if (paymentId === undefined && !disablePaymentId) { + if (to) { + try { + const amountToUse = + (isFiat(currency) || randomSatoshis) && convertedCurrencyObj + ? convertedCurrencyObj.float + : props.amount + const responsePaymentId = await createPayment(amountToUse || undefined, to, apiBaseUrl); + if (setPaymentId) { + setPaymentId(responsePaymentId); + } + if (setFetchingPaymentId) { + setFetchingPaymentId(false); + } + } catch (error) { + console.error('Error creating payment ID:', error); + } + } + } else { + if (setPaymentId) { + setPaymentId(paymentId); + } + if (setFetchingPaymentId) { + setFetchingPaymentId(false); + } + } + }; + + initializePaymentId(); + }, [paymentId, disablePaymentId, props.amount, to, apiBaseUrl, setPaymentId, setFetchingPaymentId, fetchingPaymentId, convertedCurrencyObj]); + useEffect(() => { const invalidAmount = thisAmount !== undefined && thisAmount && isNaN(+thisAmount) if (isValidCashAddress(to) || isValidXecAddress(to)) { @@ -612,12 +671,15 @@ export const Widget: React.FunctionComponent = props => { randomSatoshis, ) : null; - setConvertedCurrencyObj(convertedObj) + updateConvertedCurrencyObj(convertedObj) } else if (thisAmount && thisAddressType) { cleanAmount = +thisAmount; const obj = getCurrencyObject(cleanAmount, currency, randomSatoshis); setThisCurrencyObject(obj); + if(!isFiat(currency)) { + updateConvertedCurrencyObj(obj); + } if (props.setCurrencyObject) { props.setCurrencyObject(obj); } diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 7d106d06..cf173b51 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -20,12 +20,11 @@ import { isPropsTrue, DEFAULT_DONATION_RATE, } from '../../util'; -import { createPayment } from '../../util/api-client'; import Widget, { WidgetProps } from './Widget'; export interface WidgetContainerProps - extends Omit { + extends Omit { active?: boolean; amount?: number; opReturn?: string; @@ -151,36 +150,10 @@ export const WidgetContainer: React.FunctionComponent = donationRate = DEFAULT_DONATION_RATE } const [thisPaymentId, setThisPaymentId] = useState(); + const [fetchingPaymentId, setFetchingPaymentId] = useState(); const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); - useEffect(() => { - if ( - fetchingPaymentId !== undefined || - thisPaymentId !== undefined - ) { - return - } - setFetchingPaymentId(true) - const initializePaymentId = async () => { - if (paymentId === undefined && !disablePaymentId) { - if (to) { - try { - const responsePaymentId = await createPayment(amount, to, apiBaseUrl); - setThisPaymentId(responsePaymentId); - setFetchingPaymentId(false); - } catch (error) { - console.error('Error creating payment ID:', error); - } - } - } else { - setThisPaymentId(paymentId); - setFetchingPaymentId(false); - } - }; - - initializePaymentId(); - }, [paymentId, disablePaymentId, amount, to, apiBaseUrl]); const [success, setSuccess] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -351,6 +324,9 @@ export const WidgetContainer: React.FunctionComponent = donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} + setPaymentId={setThisPaymentId} + setFetchingPaymentId={setFetchingPaymentId} + fetchingPaymentId={fetchingPaymentId} /> ); From ce27eb37a0774c69fd438b99fc8a4acbdc0119ca Mon Sep 17 00:00:00 2001 From: lissavxo Date: Wed, 26 Nov 2025 21:04:22 -0300 Subject: [PATCH 09/24] refactor: clean up --- react/lib/components/PayButton/PayButton.tsx | 7 +++---- react/lib/components/Widget/Widget.tsx | 2 +- react/lib/util/api-client.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 16da35fc..4279b9ed 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -148,7 +148,7 @@ export const PayButton = ({ convertedAmount: number | undefined, to: string | undefined, ): Promise => { - if (disablePaymentId || !to) return paymentId + if (disablePaymentId || !to) return undefined try { const amountToUse = (isFiat(currency) || randomSatoshis) && convertedAmount @@ -163,7 +163,7 @@ export const PayButton = ({ console.error('Error creating payment ID:', err) return undefined } - }, [disablePaymentId, apiBaseUrl, isFiat, randomSatoshis]) + }, [disablePaymentId, apiBaseUrl, randomSatoshis, setPaymentId]) const handleButtonClick = useCallback(async (): Promise => { @@ -182,7 +182,6 @@ export const PayButton = ({ setDialogOpen(true) }, [ onOpen, - isFiat, currency, amount, to, @@ -323,7 +322,7 @@ export const PayButton = ({ setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); } - } else if (!isFiat(currency) && randomSatoshis && !convertedAmount){ + } else if (!isFiat(currency) && randomSatoshis && !convertedCurrencyObj){ const convertedObj = getCurrencyObject( amount as number, addressType, diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 6099d88b..f879084f 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -610,7 +610,7 @@ export const Widget: React.FunctionComponent = props => { }; initializePaymentId(); - }, [paymentId, disablePaymentId, props.amount, to, apiBaseUrl, setPaymentId, setFetchingPaymentId, fetchingPaymentId, convertedCurrencyObj]); + }, [paymentId, disablePaymentId, props.amount, to, apiBaseUrl, setPaymentId, setFetchingPaymentId, convertedCurrencyObj]); useEffect(() => { const invalidAmount = thisAmount !== undefined && thisAmount && isNaN(+thisAmount) diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index dc3fd8fa..66e06921 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -102,7 +102,7 @@ export const createPayment = async ( if (status === 200) { return data.paymentId; } - throw new Error("Failed to generate payment Id.") // WIP + throw new Error(`Failed to generate payment ID. Status: ${status}, Response: ${JSON.stringify(data)}`) }; From 89fc2258e74e98b880521402eb8b8a5e937d63ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 00:47:35 -0300 Subject: [PATCH 10/24] fix: paymentId creation --- react/lib/components/Widget/Widget.tsx | 98 ++++++++----------- .../lib/components/Widget/WidgetContainer.tsx | 10 +- 2 files changed, 48 insertions(+), 60 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index f879084f..cfcdccc2 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -113,8 +113,6 @@ export interface WidgetProps { convertedCurrencyObj?: CurrencyObject; setConvertedCurrencyObj?: Function; setPaymentId?: Function; - setFetchingPaymentId?: Function; - fetchingPaymentId?: boolean; } interface StyleProps { @@ -175,8 +173,6 @@ export const Widget: React.FunctionComponent = props => { donationRate = DEFAULT_DONATION_RATE, setConvertedCurrencyObj = () => {}, setPaymentId, - setFetchingPaymentId, - fetchingPaymentId, } = props; const [loading, setLoading] = useState(true); @@ -257,10 +253,10 @@ export const Widget: React.FunctionComponent = props => { const [goalText, setGoalText] = useState('') const [goalPercent, setGoalPercent] = useState(0) const [altpaymentEditable, setAltpaymentEditable] = useState(false) - + const price = props.price ?? 0 const [hasPrice, setHasPrice] = useState(props.price !== undefined && props.price > 0) - + // Helper to clamp donation rate to valid range (1-99 if > 0, or 0) const clampDonationRate = useCallback((value: number): number => { if (value <= 0) return 0 @@ -305,13 +301,10 @@ export const Widget: React.FunctionComponent = props => { const [convertedCryptoAmount, setConvertedCryptoAmount] = useState(undefined) const updateConvertedCurrencyObj = useCallback((convertedObj: CurrencyObject | null) => { setConvertedCurrencyObj(convertedObj); - if(setPaymentId){ + if (!isChild && !disablePaymentId && setPaymentId !== undefined) { setPaymentId(undefined); } - if(setFetchingPaymentId){ - setFetchingPaymentId(undefined); - } - }, [setConvertedCurrencyObj, setPaymentId, setFetchingPaymentId]); + }, [setConvertedCurrencyObj, setPaymentId]); const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null) @@ -572,45 +565,40 @@ export const Widget: React.FunctionComponent = props => { useEffect(() => { if ( - fetchingPaymentId !== undefined || - paymentId !== undefined + isChild || + disablePaymentId || + paymentId !== undefined || + setPaymentId === undefined || + to === '' ) { return } - if (setFetchingPaymentId) { - setFetchingPaymentId(true) - } const initializePaymentId = async () => { - if (paymentId === undefined && !disablePaymentId) { - if (to) { - try { - const amountToUse = - (isFiat(currency) || randomSatoshis) && convertedCurrencyObj - ? convertedCurrencyObj.float - : props.amount - const responsePaymentId = await createPayment(amountToUse || undefined, to, apiBaseUrl); - if (setPaymentId) { - setPaymentId(responsePaymentId); - } - if (setFetchingPaymentId) { - setFetchingPaymentId(false); - } - } catch (error) { - console.error('Error creating payment ID:', error); - } - } - } else { - if (setPaymentId) { - setPaymentId(paymentId); - } - if (setFetchingPaymentId) { - setFetchingPaymentId(false); - } + try { + const amountToUse = + (isFiat(currency) || randomSatoshis) && convertedCurrencyObj + ? convertedCurrencyObj.float + : props.amount + const responsePaymentId = await createPayment(amountToUse || undefined, to, apiBaseUrl); + setPaymentId(responsePaymentId) + } catch (error) { + console.error('Error creating payment ID:', error); } }; - initializePaymentId(); - }, [paymentId, disablePaymentId, props.amount, to, apiBaseUrl, setPaymentId, setFetchingPaymentId, convertedCurrencyObj]); + void initializePaymentId(); + }, [ + isChild, + disablePaymentId, + paymentId, + to, + currency, + randomSatoshis, + convertedCurrencyObj, + props.amount, + apiBaseUrl, + setPaymentId + ]); useEffect(() => { const invalidAmount = thisAmount !== undefined && thisAmount && isNaN(+thisAmount) @@ -732,7 +720,7 @@ export const Widget: React.FunctionComponent = props => { setConvertedCryptoAmount(convertedObj.float) let amountToDisplay = thisCurrencyObject.string; let convertedAmountToDisplay = convertedObj.string - + // Only apply donation if 1% of converted crypto amount is >= minimum donation amount if (shouldApplyDonation(convertedObj.float, thisAddressType)) { const thisDonationAmount = thisCurrencyObject.float * (userDonationRate / 100) @@ -767,7 +755,7 @@ export const Widget: React.FunctionComponent = props => { if (!isFiat(currency) && thisCurrencyObject && notZeroValue) { const cur: string = thisCurrencyObject.currency const baseAmount = thisCurrencyObject.float // Base amount without donation - + // Only apply donation if 1% of amount is >= minimum donation amount let amountToDisplay = thisCurrencyObject.string if (shouldApplyDonation(baseAmount, cur)) { @@ -780,7 +768,7 @@ export const Widget: React.FunctionComponent = props => { ) amountToDisplay = amountWithDonationObj.string } - + setText(`Send ${amountToDisplay} ${cur}`) // Pass base amount (without donation) to resolveUrl nextUrl = resolveUrl(cur, baseAmount) @@ -923,7 +911,7 @@ export const Widget: React.FunctionComponent = props => { if (amount) { // Check if donation should be applied (1% of amount >= minimum) const currencyType = currency.toUpperCase() - + if (donationAddress && shouldApplyDonation(amount, currencyType)) { const decimals = DECIMALS[currencyType] || DECIMALS.XEC; const donationPercent = userDonationRate / 100 @@ -1191,17 +1179,17 @@ export const Widget: React.FunctionComponent = props => { Powered by PayButton.org - + {(() => { // For fiat conversions, check the converted crypto amount // For crypto-only, check the currency object amount - const amountToCheck = hasPrice && convertedCryptoAmount !== undefined - ? convertedCryptoAmount + const amountToCheck = hasPrice && convertedCryptoAmount !== undefined + ? convertedCryptoAmount : thisCurrencyObject?.float // Show donation UI if amount meets minimum (1% >= 10 XEC), regardless of enabled state - return (thisAddressType === 'XEC' || thisAddressType === 'BCH') && - amountToCheck !== undefined && - amountToCheck > 0 && + return (thisAddressType === 'XEC' || thisAddressType === 'BCH') && + amountToCheck !== undefined && + amountToCheck > 0 && shouldShowDonationUI(amountToCheck, thisAddressType) })() ? ( <> @@ -1245,8 +1233,8 @@ export const Widget: React.FunctionComponent = props => { const value = parseFloat(e.target.value) || 0 handleDonationRateChange(value) }} - inputProps={{ - min: 1, + inputProps={{ + min: 1, max: 99, step: 1, }} diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index cf173b51..73f288c0 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -24,7 +24,7 @@ import { import Widget, { WidgetProps } from './Widget'; export interface WidgetContainerProps - extends Omit { + extends Omit { active?: boolean; amount?: number; opReturn?: string; @@ -149,9 +149,11 @@ export const WidgetContainer: React.FunctionComponent = if (donationRate === undefined){ donationRate = DEFAULT_DONATION_RATE } - const [thisPaymentId, setThisPaymentId] = useState(); - const [fetchingPaymentId, setFetchingPaymentId] = useState(); + const [internalPaymentId, setInternalPaymentId] = useState(undefined) + const thisPaymentId = paymentId ?? internalPaymentId + const setThisPaymentId = setInternalPaymentId + const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); const [success, setSuccess] = useState(false); @@ -325,8 +327,6 @@ export const WidgetContainer: React.FunctionComponent = convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} setPaymentId={setThisPaymentId} - setFetchingPaymentId={setFetchingPaymentId} - fetchingPaymentId={fetchingPaymentId} /> ); From 0a683d4407f2f6405b4c58188fc0c8f4fcf84481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 01:17:38 -0300 Subject: [PATCH 11/24] fix: new request when editing amount --- react/lib/components/PayButton/PayButton.tsx | 24 +++++++++++++++++++- react/lib/components/Widget/Widget.tsx | 17 ++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 4279b9ed..cf24048e 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -165,6 +165,28 @@ export const PayButton = ({ } }, [disablePaymentId, apiBaseUrl, randomSatoshis, setPaymentId]) + useEffect(() => { + const noAmount = + amount === undefined || amount === null || amount === '' + + if ( + !dialogOpen || + disablePaymentId || + !to || + noAmount + ) { + return + } + + void getPaymentId( + currency, + Number(amount), + convertedAmount, + to + ) + }, [amount, convertedAmount, currency, to, dialogOpen, disablePaymentId, getPaymentId]) + + const handleButtonClick = useCallback(async (): Promise => { if (onOpen) { @@ -327,7 +349,7 @@ export const PayButton = ({ amount as number, addressType, randomSatoshis, - ); + ); setCryptoAmount(convertedObj.string); setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index cfcdccc2..f8e69709 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -175,6 +175,13 @@ export const Widget: React.FunctionComponent = props => { setPaymentId, } = props; const [loading, setLoading] = useState(true); + const isWaitingForPaymentId = + isChild === true && + !disablePaymentId && + paymentId === undefined + + const qrLoading = loading || isWaitingForPaymentId + // websockets if standalone const [internalTxsSocket, setInternalTxsSocket] = useState(undefined) @@ -336,7 +343,7 @@ export const Widget: React.FunctionComponent = props => { }, []) const classes = useMemo(() => { - const base: StyleProps = { success, loading, theme, recentlyCopied, copied } + const base: StyleProps = { success, loading: qrLoading, theme, recentlyCopied, copied } return { root: { minWidth: '240px', @@ -462,7 +469,7 @@ export const Widget: React.FunctionComponent = props => { animationDelay: '0.4s', }, } - }, [success, loading, theme, recentlyCopied, copied]) + }, [success, qrLoading, theme, recentlyCopied, copied]) const bchSvg = useMemo((): string => { const color = theme.palette.logo ?? theme.palette.primary @@ -994,7 +1001,7 @@ export const Widget: React.FunctionComponent = props => { {(() => { if (errorMsg) return errorMsg if (disabled) return 'Not yet ready for payment' - if (loading) return 'Loading...' + if (qrLoading) return 'Loading...' if (success) return successText return text })()} @@ -1074,7 +1081,7 @@ export const Widget: React.FunctionComponent = props => { sx={classes.qrCode} onClick={handleQrCodeClick} > - + {/* one single child for Fade, cast to any to satisfy MUI/React types */} {qrCode} @@ -1092,7 +1099,7 @@ export const Widget: React.FunctionComponent = props => { - {loading ? ( + {qrLoading ? ( Date: Thu, 27 Nov 2025 01:43:57 -0300 Subject: [PATCH 12/24] fix: don't recreate upon closing and opening --- react/lib/components/PayButton/PayButton.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index cf24048e..8bda67c3 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -142,6 +142,7 @@ export const PayButton = ({ }, 300); }; + const getPaymentId = useCallback(async ( currency: Currency, amount: number, @@ -165,6 +166,7 @@ export const PayButton = ({ } }, [disablePaymentId, apiBaseUrl, randomSatoshis, setPaymentId]) + const lastPaymentAmount = useRef(null); useEffect(() => { const noAmount = amount === undefined || amount === null || amount === '' @@ -178,13 +180,20 @@ export const PayButton = ({ return } + const amountNumber = Number(amount) + if (paymentId && lastPaymentAmount.current === amountNumber) { + return + } + + lastPaymentAmount.current = amountNumber + void getPaymentId( currency, Number(amount), convertedAmount, to ) - }, [amount, convertedAmount, currency, to, dialogOpen, disablePaymentId, getPaymentId]) + }, [amount, convertedAmount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId]) const handleButtonClick = useCallback(async (): Promise => { @@ -197,10 +206,6 @@ export const PayButton = ({ } } - if (!disablePaymentId && !paymentId) { - await getPaymentId(currency, Number(amount), convertedAmount, to) - } - setDialogOpen(true) }, [ onOpen, From 70952ba2eb93e0b6227b44807eb9dfe47e7dd214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 01:44:09 -0300 Subject: [PATCH 13/24] fix: recreate paymentId for widget amount change --- react/lib/components/Widget/Widget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index f8e69709..9fbe309e 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -585,7 +585,7 @@ export const Widget: React.FunctionComponent = props => { const amountToUse = (isFiat(currency) || randomSatoshis) && convertedCurrencyObj ? convertedCurrencyObj.float - : props.amount + : thisAmount const responsePaymentId = await createPayment(amountToUse || undefined, to, apiBaseUrl); setPaymentId(responsePaymentId) } catch (error) { @@ -602,7 +602,7 @@ export const Widget: React.FunctionComponent = props => { currency, randomSatoshis, convertedCurrencyObj, - props.amount, + thisAmount, apiBaseUrl, setPaymentId ]); From 970eac6144e2b3278a1176e325294b9b0fb48e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 02:45:36 -0300 Subject: [PATCH 14/24] feat: confirming input --- react/lib/components/Widget/Widget.tsx | 80 ++++++++++++++++++++------ react/package.json | 1 + react/yarn.lock | 36 +++++++++++- 3 files changed, 98 insertions(+), 19 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 9fbe309e..1132a9aa 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -12,6 +12,7 @@ import copyToClipboard from 'copy-to-clipboard' import { QRCodeSVG } from 'qrcode.react' import { Socket } from 'socket.io-client' import { Theme, ThemeName, ThemeProvider, useTheme } from '../../themes' +import { NumericFormat } from 'react-number-format'; import { Button, animation } from '../Button/Button' import BarChart from '../BarChart/BarChart' import config from '../../paybutton-config.json' @@ -175,6 +176,9 @@ export const Widget: React.FunctionComponent = props => { setPaymentId, } = props; const [loading, setLoading] = useState(true); + const [draftAmount, setDraftAmount] = useState("") + const inputRef = React.useRef(null) + const isWaitingForPaymentId = isChild === true && !disablePaymentId && @@ -488,6 +492,13 @@ export const Widget: React.FunctionComponent = props => { )}' stroke='%23fff' stroke-width='.6'/%3E%3Cpath d='m7.2979 14.697-2.6964-2.6966 0.89292-0.8934c0.49111-0.49137 0.90364-0.88958 0.91675-0.88491 0.013104 0.0047 0.71923 0.69866 1.5692 1.5422 0.84994 0.84354 1.6548 1.6397 1.7886 1.7692l0.24322 0.23547 7.5834-7.5832 1.8033 1.8033-9.4045 9.4045z' fill='%23fff' stroke-width='.033708'/%3E%3C/svg%3E%0A` }, [theme]) + useEffect(() => { + if (thisCurrencyObject?.string !== undefined) { + const raw = stripFormatting(thisCurrencyObject.string); + setDraftAmount(raw); + } + }, [thisCurrencyObject?.string]); + useEffect(() => { if (!recentlyCopied) return const timer = setTimeout(() => { @@ -942,16 +953,37 @@ export const Widget: React.FunctionComponent = props => { [disabled, to, opReturn, userDonationRate, donationAddress, donationEnabled, shouldApplyDonation] ) - const handleAmountChange = (e: React.ChangeEvent) => { - let amount = e.target.value - if (amount === '') { - amount = '0' - } - const userEdited = getCurrencyObject(+amount, currency, false) - setUserEditedAmount(userEdited) - updateAmount(amount) + const stripFormatting = (s: string) => { + return s.replace(/,/g, '').replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, ''); } + + const applyDraftAmount = () => { + if (!draftAmount) return + + const raw = draftAmount.trim() + + if (raw === '' || isNaN(+raw)) return + + const numeric = +raw + + const newObj = getCurrencyObject(numeric, currency, false) + setUserEditedAmount(newObj) + + updateAmount(String(numeric)) + } + + const isDraftValid = + draftAmount.trim() !== '' && + !isNaN(+draftAmount) && + +draftAmount > 0 + + const isSameAmount = + isDraftValid && + +draftAmount === thisCurrencyObject?.float + + + const updateAmount = (amount: string) => { setThisAmount(amount) if (props.setAmount) { @@ -1121,17 +1153,33 @@ export const Widget: React.FunctionComponent = props => { {isPropsTrue(editable) ? ( - { + setDraftAmount(values.value); // raw numeric value without commas + }} + thousandSeparator + allowLeadingZeros={false} + decimalScale={8} + inputRef={inputRef} + customInput={TextField} label="Edit amount" - value={thisCurrencyObject?.float || 0} - onChange={handleAmountChange} - inputProps={{ maxLength: 12 }} - name="Amount" - placeholder="Enter Amount" - id="userEditedAmount" disabled={success} + InputProps={{ + endAdornment: ( + + ✓ + + ), + }} /> - {currency} + {currency} + ) : null} diff --git a/react/package.json b/react/package.json index 1f377e53..060160f7 100644 --- a/react/package.json +++ b/react/package.json @@ -105,6 +105,7 @@ "notistack": "3.0.0", "qrcode.react": "3", "react-jss": "10.10.0", + "react-number-format": "^5.4.4", "socket.io-client": "4.7.4", "ts-jest": "^29.4.5", "xecaddrjs": "^0.0.1" diff --git a/react/yarn.lock b/react/yarn.lock index 54d8bc0d..e2c5ade0 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -12768,6 +12768,11 @@ react-jss@10.10.0: theming "^3.3.0" tiny-warning "^1.0.2" +react-number-format@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.4.4.tgz#d31f0e260609431500c8d3f81bbd3ae1fb7cacad" + integrity sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA== + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -13972,7 +13977,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13990,6 +13995,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -14100,7 +14114,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14121,6 +14135,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -15512,7 +15533,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15539,6 +15560,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From ad505fff4447951931a095412577299cdb445687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 03:01:18 -0300 Subject: [PATCH 15/24] fix: extra decimals not passed to request --- paybutton/yarn.lock | 5 +++++ react/lib/util/api-client.ts | 12 +++++++++--- react/lib/util/constants.ts | 5 +++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/paybutton/yarn.lock b/paybutton/yarn.lock index 70648451..16941ac5 100644 --- a/paybutton/yarn.lock +++ b/paybutton/yarn.lock @@ -3655,6 +3655,11 @@ react-jss@10.10.0: theming "^3.3.0" tiny-warning "^1.0.2" +react-number-format@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.4.4.tgz#d31f0e260609431500c8d3f81bbd3ae1fb7cacad" + integrity sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA== + react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index 66e06921..ab271df0 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import _ from 'lodash'; import config from '../paybutton-config.json' -import { isValidCashAddress, isValidXecAddress } from './address'; +import { getAddressPrefix, isValidCashAddress, isValidXecAddress } from './address'; import { Transaction, UtxoDetails, @@ -10,6 +10,7 @@ import { Currency, } from './types'; import { isFiat } from './currency'; +import { CURRENCY_TYPES_MAP, DECIMALS } from './constants'; export const getAddressDetails = async ( address: string, @@ -94,11 +95,16 @@ export const createPayment = async ( address: string, rootUrl = config.apiBaseUrl, ): Promise => { + const prefix = getAddressPrefix(address) + const decimals = DECIMALS[CURRENCY_TYPES_MAP[prefix]] + const safeAmount = amount !== undefined && amount !== null + ? Number(amount).toFixed(decimals) + : undefined const { data, status } = await axios.post( `${rootUrl}/api/payments/paymentId`, - { amount, address } + { amount: safeAmount, address } ); - + if (status === 200) { return data.paymentId; } diff --git a/react/lib/util/constants.ts b/react/lib/util/constants.ts index 03000ddc..586838c7 100644 --- a/react/lib/util/constants.ts +++ b/react/lib/util/constants.ts @@ -16,6 +16,11 @@ export const CURRENCY_PREFIXES_MAP: Record = { + bitcoincash: 'BCH', + ecash: 'XEC' +}; + export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | "extrasmall" | "small" | "medium" | "large" | "extralarge" | undefined; export const DEFAULT_DONATION_RATE = 2; From 6da8d92889ba16b940a83e6db2b01ff01a27f312 Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Thu, 27 Nov 2025 00:16:40 -0800 Subject: [PATCH 16/24] Better amount edit confirm button --- react/lib/components/Widget/Widget.tsx | 30 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 1132a9aa..6eadb1ce 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -1158,6 +1158,11 @@ export const Widget: React.FunctionComponent = props => { onValueChange={(values) => { setDraftAmount(values.value); // raw numeric value without commas }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isDraftValid && !isSameAmount) { + applyDraftAmount(); + } + }} thousandSeparator allowLeadingZeros={false} decimalScale={8} @@ -1167,14 +1172,27 @@ export const Widget: React.FunctionComponent = props => { disabled={success} InputProps={{ endAdornment: ( - - ✓ - + Confirm + ), }} /> From 5716c170002b71cb174dc0da3441f91efd24ccf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 11:07:48 -0300 Subject: [PATCH 17/24] fix: fiat case for paymentId generation --- react/lib/components/PayButton/PayButton.tsx | 88 +++++++++++--------- 1 file changed, 50 insertions(+), 38 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 8bda67c3..9f575a6c 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -110,7 +110,6 @@ export const PayButton = ({ const [currencyObj, setCurrencyObj] = useState(); const [cryptoAmount, setCryptoAmount] = useState(); - const [convertedAmount, setConvertedAmount] = useState(); const [convertedCurrencyObj, setConvertedCurrencyObj] = useState(); const [price, setPrice] = useState(0); @@ -144,32 +143,36 @@ export const PayButton = ({ const getPaymentId = useCallback(async ( - currency: Currency, - amount: number, - convertedAmount: number | undefined, - to: string | undefined, - ): Promise => { - if (disablePaymentId || !to) return undefined - try { - const amountToUse = - (isFiat(currency) || randomSatoshis) && convertedAmount - ? convertedAmount - : amount - - const responsePaymentId = await createPayment(amountToUse, to, apiBaseUrl) - - setPaymentId(responsePaymentId) - return responsePaymentId - } catch (err) { - console.error('Error creating payment ID:', err) - return undefined - } - }, [disablePaymentId, apiBaseUrl, randomSatoshis, setPaymentId]) + currency: Currency, + amount: number, + to: string | undefined, + ): Promise => { + if (disablePaymentId || !to) return undefined + + try { + const convertedBaseAmount = convertedCurrencyObj?.float + + const amountToUse = + (isFiat(currency) || randomSatoshis) && convertedBaseAmount !== undefined + ? convertedBaseAmount + : amount + + const responsePaymentId = await createPayment(amountToUse, to, apiBaseUrl) + + setPaymentId(responsePaymentId) + return responsePaymentId + } catch (err) { + console.error('Error creating payment ID:', err) + return undefined + } + }, + [disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj] + ) - const lastPaymentAmount = useRef(null); + const lastPaymentAmount = useRef(null) useEffect(() => { const noAmount = - amount === undefined || amount === null || amount === '' + amount === undefined || amount === null || amount === ''; if ( !dialogOpen || @@ -177,23 +180,35 @@ export const PayButton = ({ !to || noAmount ) { - return + return; } - const amountNumber = Number(amount) - if (paymentId && lastPaymentAmount.current === amountNumber) { - return + let effectiveAmount: number + if (isFiat(currency)) { + if (!convertedCurrencyObj) { + // Conversion not ready yet – wait for convertedCurrencyObj update + return; + } + effectiveAmount = convertedCurrencyObj.float; + } else { + const amountNumber = Number(amount); + if (Number.isNaN(amountNumber)) { + return; + } + effectiveAmount = amountNumber; + } + if (paymentId && lastPaymentAmount.current === effectiveAmount) { + return; } - lastPaymentAmount.current = amountNumber + lastPaymentAmount.current = effectiveAmount; void getPaymentId( currency, - Number(amount), - convertedAmount, + effectiveAmount, to - ) - }, [amount, convertedAmount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId]) + ); + }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]); const handleButtonClick = useCallback(async (): Promise => { @@ -215,7 +230,6 @@ export const PayButton = ({ paymentId, disablePaymentId, getPaymentId, - convertedAmount, ]) const handleCloseDialog = (success?: boolean, paymentId?: string): void => { @@ -338,7 +352,7 @@ export const PayButton = ({ useEffect(() => { if (currencyObj && isFiat(currency) && price) { - if(!convertedCurrencyObj) { + if (!convertedCurrencyObj) { const addressType: Currency = getCurrencyTypeFromAddress(to); const convertedObj = getCurrencyObject( currencyObj.float / price, @@ -346,17 +360,15 @@ export const PayButton = ({ randomSatoshis, ); setCryptoAmount(convertedObj.string); - setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); } - } else if (!isFiat(currency) && randomSatoshis && !convertedCurrencyObj){ + } else if (!isFiat(currency) && randomSatoshis && !convertedCurrencyObj) { const convertedObj = getCurrencyObject( amount as number, addressType, randomSatoshis, ); setCryptoAmount(convertedObj.string); - setConvertedAmount(convertedObj.float); setConvertedCurrencyObj(convertedObj); } else if (!isFiat(currency) && !randomSatoshis) { setCryptoAmount(amount?.toString()); From 8e284524421a8a8e7b6b29c8d487a9c8f80715a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 11:58:13 -0300 Subject: [PATCH 18/24] fix: creating paymentId twice in pb --- react/lib/components/PayButton/PayButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 9f575a6c..888448fb 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -197,7 +197,7 @@ export const PayButton = ({ } effectiveAmount = amountNumber; } - if (paymentId && lastPaymentAmount.current === effectiveAmount) { + if (lastPaymentAmount.current === effectiveAmount) { return; } From 4d28251c0a2caaeb050381723a38f828c1d11ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 11:58:48 -0300 Subject: [PATCH 19/24] fix: widget paymentId amount with fiat --- react/lib/components/Widget/Widget.tsx | 55 ++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 6eadb1ce..bde072d7 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -178,6 +178,7 @@ export const Widget: React.FunctionComponent = props => { const [loading, setLoading] = useState(true); const [draftAmount, setDraftAmount] = useState("") const inputRef = React.useRef(null) + const lastEffectiveAmountRef = React.useRef(undefined) const isWaitingForPaymentId = isChild === true && @@ -585,20 +586,51 @@ export const Widget: React.FunctionComponent = props => { if ( isChild || disablePaymentId || - paymentId !== undefined || setPaymentId === undefined || to === '' ) { - return + return; } + + // For fiat, wait until we have a converted crypto amount + if (isFiat(currency) && convertedCryptoAmount === undefined) { + return; + } + const initializePaymentId = async () => { try { - const amountToUse = - (isFiat(currency) || randomSatoshis) && convertedCurrencyObj - ? convertedCurrencyObj.float - : thisAmount - const responsePaymentId = await createPayment(amountToUse || undefined, to, apiBaseUrl); - setPaymentId(responsePaymentId) + let effectiveAmount: number | undefined; + + if (typeof convertedCryptoAmount === 'number') { + effectiveAmount = convertedCryptoAmount; + } else if (convertedCurrencyObj && typeof convertedCurrencyObj.float === 'number') { + effectiveAmount = convertedCurrencyObj.float; + } else if ( + thisAmount !== undefined && + thisAmount !== null && + thisAmount !== '' + ) { + const n = Number(thisAmount); + if (!Number.isNaN(n)) { + effectiveAmount = n; + } + } + + if (effectiveAmount === undefined) { + return; + } + + if (lastEffectiveAmountRef.current === effectiveAmount) { + return; + } + lastEffectiveAmountRef.current = effectiveAmount; + + const responsePaymentId = await createPayment( + effectiveAmount, + to, + apiBaseUrl, + ); + setPaymentId(responsePaymentId); } catch (error) { console.error('Error creating payment ID:', error); } @@ -608,16 +640,17 @@ export const Widget: React.FunctionComponent = props => { }, [ isChild, disablePaymentId, - paymentId, to, currency, - randomSatoshis, + convertedCryptoAmount, convertedCurrencyObj, thisAmount, apiBaseUrl, - setPaymentId + setPaymentId, + lastEffectiveAmountRef, ]); + useEffect(() => { const invalidAmount = thisAmount !== undefined && thisAmount && isNaN(+thisAmount) if (isValidCashAddress(to) || isValidXecAddress(to)) { From d300bc874f2b1972821a527cbbc09ef31070a3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 12:27:22 -0300 Subject: [PATCH 20/24] test: added tests for creating paymentid --- react/lib/tests/components/PayButton.test.tsx | 242 +++++++++++++++++- 1 file changed, 241 insertions(+), 1 deletion(-) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index dd7354ac..f4ea84ad 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -1,6 +1,8 @@ +// FILE: react/lib/tests/components/PayButton.test.tsx import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PayButton } from '../../components/PayButton' +import { createPayment } from '../../util/api-client' jest.mock('../../util', () => ({ ...jest.requireActual('../../util'), @@ -9,7 +11,44 @@ jest.mock('../../util', () => ({ setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)), })) +jest.mock('../../components/PaymentDialog', () => ({ + PaymentDialog: (props: any) => ( +

+ + +
+ ), +})) + +jest.mock('../../util/api-client', () => ({ + createPayment: jest.fn(async () => 'mock-payment-id'), +})) + describe('PayButton', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + test('calls onOpen when clicked (crypto path, no timers needed)', async () => { const user = userEvent.setup() const onOpen = jest.fn() @@ -24,7 +63,7 @@ describe('PayButton', () => { /> ) - await user.click(await screen.findByRole('button')) + await user.click(screen.getByRole('button', { name: /donate/i })) expect(onOpen).toHaveBeenCalledTimes(1) }) @@ -50,4 +89,205 @@ describe('PayButton', () => { await user.click(screen.getByRole('button', { name: /donate/i })) await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)) }) + + it('creates a payment id exactly once for crypto when dialog opens', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + expect(createPayment).toHaveBeenCalledWith( + 1, + 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk', + undefined + ) + }) + + it('creates a payment id exactly once for fiat using converted amount', async () => { + const user = userEvent.setup() + + render( + + ) + + // simulate dialog child computing conversion and updating parent + await user.click( + screen.getByRole('button', { name: 'mock-set-converted' }) + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + const [amountUsed, addrUsed] = + (createPayment as jest.Mock).mock.calls[0] + + expect(addrUsed).toBe( + 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk' + ) + expect(amountUsed).toBeCloseTo(0.12345678, 8) + }) + + it('does not create payment id when disablePaymentId is true', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + }) + + it('does not create payment id when amount is missing', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + }) + + it('creates a new payment id when amount changes while dialog is open (crypto)', async () => { + const user = userEvent.setup() + + render( + + ) + + // open dialog: first payment id + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // change amount via mocked dialog control + await user.click( + screen.getByRole('button', { name: 'mock-change-amount' }) + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + + const firstCall = (createPayment as jest.Mock).mock.calls[0] + const secondCall = (createPayment as jest.Mock).mock.calls[1] + + expect(firstCall[0]).toBe(1) + expect(secondCall[0]).toBe(2) + }) + + it('does not create extra payment ids for repeated renders with same effective amount (crypto)', async () => { + const user = userEvent.setup() + + render( + + ) + + // open dialog – first id + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + // clicking the mock-change-amount twice will move from 2 -> 3 -> 4 + await user.click( + screen.getByRole('button', { name: 'mock-change-amount' }) + ) + await user.click( + screen.getByRole('button', { name: 'mock-change-amount' }) + ) + + await waitFor(() => { + // 2 (initial) + 3 + 4 = 3 distinct effective amounts => 3 calls total + expect(createPayment).toHaveBeenCalledTimes(3) + }) + + const calls = (createPayment as jest.Mock).mock.calls.map( + (c: any[]) => c[0] + ) + + expect(calls).toEqual([2, 3, 4]) + }) + + it('prefers convertedCurrencyObj for fiat and does not create extra ids when only base amount changes', async () => { + const user = userEvent.setup() + + render( + + ) + + // simulate conversion done by dialog + await user.click( + screen.getByRole('button', { name: 'mock-set-converted' }) + ) + + // open dialog (this will use convertedCurrencyObj.float) + await user.click(screen.getByRole('button', { name: /donate/i })) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + const [firstAmount] = (createPayment as jest.Mock).mock.calls[0] + expect(firstAmount).toBeCloseTo(0.12345678, 8) + + // change the base amount via mocked dialog, convertedCurrencyObj remains the same + await user.click( + screen.getByRole('button', { name: 'mock-change-amount' }) + ) + + // effectiveAmount is still the converted one, so no extra call should happen + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) }) + From 1b8bf414eca1f44e30ad3fe74bc3c5e7dc933ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 14:33:32 -0300 Subject: [PATCH 21/24] fix: paymentId for no amount --- react/lib/components/PayButton/PayButton.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 888448fb..860e7f0a 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -144,8 +144,8 @@ export const PayButton = ({ const getPaymentId = useCallback(async ( currency: Currency, - amount: number, to: string | undefined, + amount?: number, ): Promise => { if (disablePaymentId || !to) return undefined @@ -169,27 +169,26 @@ export const PayButton = ({ [disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj] ) - const lastPaymentAmount = useRef(null) + const lastPaymentAmount = useRef(undefined) useEffect(() => { - const noAmount = - amount === undefined || amount === null || amount === ''; if ( !dialogOpen || disablePaymentId || - !to || - noAmount + !to ) { return; } - let effectiveAmount: number + let effectiveAmount: number | null if (isFiat(currency)) { if (!convertedCurrencyObj) { // Conversion not ready yet – wait for convertedCurrencyObj update return; } effectiveAmount = convertedCurrencyObj.float; + } else if (amount === undefined) { + effectiveAmount = null } else { const amountNumber = Number(amount); if (Number.isNaN(amountNumber)) { @@ -205,8 +204,8 @@ export const PayButton = ({ void getPaymentId( currency, - effectiveAmount, - to + to, + effectiveAmount ?? undefined, ); }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]); From e9ead351c083fd8f91973d47378e06832ae2c434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 27 Nov 2025 14:43:46 -0300 Subject: [PATCH 22/24] fix: paymentId for no amount (widget) --- react/lib/components/Widget/Widget.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index bde072d7..11753ed9 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -178,7 +178,7 @@ export const Widget: React.FunctionComponent = props => { const [loading, setLoading] = useState(true); const [draftAmount, setDraftAmount] = useState("") const inputRef = React.useRef(null) - const lastEffectiveAmountRef = React.useRef(undefined) + const lastEffectiveAmountRef = React.useRef(undefined) const isWaitingForPaymentId = isChild === true && @@ -599,7 +599,7 @@ export const Widget: React.FunctionComponent = props => { const initializePaymentId = async () => { try { - let effectiveAmount: number | undefined; + let effectiveAmount: number | null; if (typeof convertedCryptoAmount === 'number') { effectiveAmount = convertedCryptoAmount; @@ -611,13 +611,12 @@ export const Widget: React.FunctionComponent = props => { thisAmount !== '' ) { const n = Number(thisAmount); - if (!Number.isNaN(n)) { - effectiveAmount = n; + if (Number.isNaN(n)) { + return } - } - - if (effectiveAmount === undefined) { - return; + effectiveAmount = n; + } else { + effectiveAmount = null } if (lastEffectiveAmountRef.current === effectiveAmount) { @@ -626,7 +625,7 @@ export const Widget: React.FunctionComponent = props => { lastEffectiveAmountRef.current = effectiveAmount; const responsePaymentId = await createPayment( - effectiveAmount, + effectiveAmount ?? undefined, to, apiBaseUrl, ); From 283837c3ce6be216f95052f5cb49fd66b517fc02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Fri, 28 Nov 2025 00:32:15 -0300 Subject: [PATCH 23/24] test: paymentId for no amount --- react/lib/tests/components/PayButton.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index f4ea84ad..5db7dcf7 100644 --- a/react/lib/tests/components/PayButton.test.tsx +++ b/react/lib/tests/components/PayButton.test.tsx @@ -164,7 +164,7 @@ describe('PayButton', () => { }) }) - it('does not create payment id when amount is missing', async () => { + it('create payment id when amount is missing', async () => { const user = userEvent.setup() render( @@ -177,7 +177,7 @@ describe('PayButton', () => { await user.click(screen.getByRole('button', { name: /donate/i })) await waitFor(() => { - expect(createPayment).not.toHaveBeenCalled() + expect(createPayment).toHaveBeenCalledTimes(1) }) }) From 6270cfff70f68a13beedc99c6b47af8d461e7a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Fri, 28 Nov 2025 00:57:37 -0300 Subject: [PATCH 24/24] test: widget tests --- react/lib/components/Widget/Widget.tsx | 2 +- react/lib/tests/components/Widget.test.tsx | 272 +++++++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 react/lib/tests/components/Widget.test.tsx diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 11753ed9..4ce710e9 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -77,7 +77,7 @@ export interface WidgetProps { price?: number | undefined usdPrice?: number | undefined editable?: boolean - setNewTxs: Function + setNewTxs?: Function newTxs?: Transaction[] wsBaseUrl?: string apiBaseUrl?: string diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx new file mode 100644 index 00000000..7c3da136 --- /dev/null +++ b/react/lib/tests/components/Widget.test.tsx @@ -0,0 +1,272 @@ +// lib/tests/components/Widget.test.tsx +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Widget from '../../components/Widget/Widget' + +jest.mock('copy-to-clipboard', () => jest.fn()) + +jest.mock('../../util', () => ({ + __esModule: true, + // network / balance + getAddressBalance: jest.fn().mockResolvedValue(0), + // address / currency helpers + isFiat: jest.fn((c: string) => ['USD', 'CAD', 'EUR'].includes(c)), + isValidCashAddress: jest.fn().mockReturnValue(false), + isValidXecAddress: jest.fn().mockReturnValue(true), + getCurrencyTypeFromAddress: jest.fn().mockReturnValue('XEC'), + CURRENCY_PREFIXES_MAP: { + xec: 'ecash', + bch: 'bitcoincash', + }, + CRYPTO_CURRENCIES: ['xec', 'bch'], + DECIMALS: { + XEC: 2, + BCH: 8, + USD: 2, + CAD: 2, + }, + // cashtab / sockets + openCashtabPayment: jest.fn(), + initializeCashtabStatus: jest.fn().mockResolvedValue(false), + setupChronikWebSocket: jest.fn().mockResolvedValue(undefined), + setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined), + // misc helpers + getCurrencyObject: jest.fn((amount: number, currency: string) => ({ + float: Number(amount), + string: String(amount), + currency, + })), + formatPrice: jest.fn((value: number, currency: string) => `${value} ${currency}`), + encodeOpReturnProps: jest.fn(() => 'deadbeef'), + isPropsTrue: (v: unknown) => Boolean(v), + DEFAULT_DONATION_RATE: 0, + DEFAULT_MINIMUM_DONATION_AMOUNT: { + XEC: 10, + BCH: 0.0001, + }, +})) + +jest.mock('../../util/api-client', () => ({ + __esModule: true, + createPayment: jest.fn(), +})) + +import copyToClipboard from 'copy-to-clipboard' +import { createPayment } from '../../util/api-client' + +const ADDRESS = 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk' +const API_BASE_URL = 'https://api.example.com' + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('Widget – paymentId creation (standalone)', () => { + test('creates paymentId once on initial render when standalone', async () => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-1') + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL) + expect(setPaymentId).toHaveBeenCalledWith('pid-1') + }) + + test('does not create paymentId when isChild is true', async () => { + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + expect(setPaymentId).not.toHaveBeenCalled() + }) + + test('does not create paymentId when disablePaymentId is true', async () => { + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(createPayment).not.toHaveBeenCalled() + }) + expect(setPaymentId).not.toHaveBeenCalled() + }) + + test('creates paymentId with undefined amount when amount is not provided', async () => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-2') + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL) + }) + + test('creates paymentId with numeric amount when amount is given', async () => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-3') + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + expect(createPayment).toHaveBeenCalledWith(10, ADDRESS, API_BASE_URL) + }) + + test('creates new paymentId if amount changes to a different value', async () => { + ;(createPayment as jest.Mock) + .mockResolvedValueOnce('pid-1') + .mockResolvedValueOnce('pid-2') + + const setPaymentId = jest.fn() + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + expect(createPayment).toHaveBeenLastCalledWith(5, ADDRESS, API_BASE_URL) + + rerender( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(2) + }) + expect(createPayment).toHaveBeenLastCalledWith(10, ADDRESS, API_BASE_URL) + }) + + test('does not create new paymentId if amount changes to the same effective value', async () => { + ;(createPayment as jest.Mock).mockResolvedValue('pid-1') + const setPaymentId = jest.fn() + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(createPayment).toHaveBeenCalledTimes(1) + }) + + rerender( + , + ) + + await waitFor(() => { + // still only the first call + expect(createPayment).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('Widget – QR copy behaviour', () => { + test('clicking QR copies payment URL and triggers feedback behaviour', async () => { + const setPaymentId = jest.fn() + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText(/click to copy/i)).toBeTruthy() + }) + + const user = userEvent.setup() + await user.click(screen.getByText(/click to copy/i)) + + expect(copyToClipboard).toHaveBeenCalledTimes(1) + expect((copyToClipboard as jest.Mock).mock.calls[0][0]).toContain('ecash:') + }) +}) +