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/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index 0824c486..860e7f0a 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 { @@ -110,17 +110,20 @@ export const PayButton = ({ const [currencyObj, setCurrencyObj] = useState(); const [cryptoAmount, setCryptoAmount] = useState(); + const [convertedCurrencyObj, setConvertedCurrencyObj] = useState(); + const [price, setPrice] = useState(0); const [newTxs, setNewTxs] = useState(); const priceRef = useRef(price); const cryptoAmountRef = useRef(cryptoAmount); - - const [paymentId] = useState(!disablePaymentId ? generatePaymentId(8) : undefined); + const [paymentId, setPaymentId] = useState(undefined); const [addressType, setAddressType] = useState( getCurrencyTypeFromAddress(to), ); + + useEffect(() => { priceRef.current = price; }, [price]); @@ -137,16 +140,96 @@ export const PayButton = ({ } }, 300); }; + + + const getPaymentId = useCallback(async ( + currency: Currency, + to: string | undefined, + amount?: number, + ): 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(undefined) + useEffect(() => { + + if ( + !dialogOpen || + disablePaymentId || + !to + ) { + return; + } + + 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)) { + return; + } + effectiveAmount = amountNumber; + } + if (lastPaymentAmount.current === effectiveAmount) { + return; + } + + lastPaymentAmount.current = effectiveAmount; + + void getPaymentId( + currency, + to, + effectiveAmount ?? undefined, + ); + }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]); + + 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]) + + setDialogOpen(true) + }, [ + onOpen, + currency, + amount, + to, + paymentId, + disablePaymentId, + getPaymentId, + ]) const handleCloseDialog = (success?: boolean, paymentId?: string): void => { if (onClose !== undefined) onClose(success, paymentId); @@ -260,22 +343,33 @@ export const PayButton = ({ useEffect(() => { (async () => { - if (isFiat(currency) && price === 0) { - await getPrice(); - } + if (isFiat(currency) && price === 0) { + await getPrice(); + } })() }, [currency, getPrice, to, price]); 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); + setConvertedCurrencyObj(convertedObj); + } + } else if (!isFiat(currency) && randomSatoshis && !convertedCurrencyObj) { const convertedObj = getCurrencyObject( - currencyObj.float / price, + amount as number, addressType, randomSatoshis, ); setCryptoAmount(convertedObj.string); - } else if (!isFiat(currency)) { + setConvertedCurrencyObj(convertedObj); + } else if (!isFiat(currency) && !randomSatoshis) { setCryptoAmount(amount?.toString()); } }, [price, currencyObj, amount, currency, randomSatoshis, to]); @@ -354,6 +448,8 @@ export const PayButton = ({ transactionText={transactionText} donationAddress={donationAddress} donationRate={donationRate} + convertedCurrencyObj={convertedCurrencyObj} + setConvertedCurrencyObj={setConvertedCurrencyObj} /> {errorMsg && (

= props => { altpaymentError, setAltpaymentError, isChild, + convertedCurrencyObj, donationAddress = config.donationAddress, - donationRate = DEFAULT_DONATION_RATE + donationRate = DEFAULT_DONATION_RATE, + setConvertedCurrencyObj = () => {}, + setPaymentId, } = props; const [loading, setLoading] = useState(true); + const [draftAmount, setDraftAmount] = useState("") + const inputRef = React.useRef(null) + const lastEffectiveAmountRef = React.useRef(undefined) + + const isWaitingForPaymentId = + isChild === true && + !disablePaymentId && + paymentId === undefined + + const qrLoading = loading || isWaitingForPaymentId + // websockets if standalone const [internalTxsSocket, setInternalTxsSocket] = useState(undefined) @@ -245,10 +265,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 @@ -291,6 +311,12 @@ 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 (!isChild && !disablePaymentId && setPaymentId !== undefined) { + setPaymentId(undefined); + } + }, [setConvertedCurrencyObj, setPaymentId]); const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null) @@ -322,7 +348,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', @@ -448,7 +474,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 @@ -467,6 +493,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(() => { @@ -549,6 +582,74 @@ export const Widget: React.FunctionComponent = props => { })() }, [thisNewTxs, to, apiBaseUrl]) + useEffect(() => { + if ( + isChild || + disablePaymentId || + setPaymentId === undefined || + to === '' + ) { + return; + } + + // For fiat, wait until we have a converted crypto amount + if (isFiat(currency) && convertedCryptoAmount === undefined) { + return; + } + + const initializePaymentId = async () => { + try { + let effectiveAmount: number | null; + + 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)) { + return + } + effectiveAmount = n; + } else { + effectiveAmount = null + } + + if (lastEffectiveAmountRef.current === effectiveAmount) { + return; + } + lastEffectiveAmountRef.current = effectiveAmount; + + const responsePaymentId = await createPayment( + effectiveAmount ?? undefined, + to, + apiBaseUrl, + ); + setPaymentId(responsePaymentId); + } catch (error) { + console.error('Error creating payment ID:', error); + } + }; + + void initializePaymentId(); + }, [ + isChild, + disablePaymentId, + to, + currency, + convertedCryptoAmount, + convertedCurrencyObj, + thisAmount, + apiBaseUrl, + setPaymentId, + lastEffectiveAmountRef, + ]); + + useEffect(() => { const invalidAmount = thisAmount !== undefined && thisAmount && isNaN(+thisAmount) if (isValidCashAddress(to) || isValidXecAddress(to)) { @@ -595,14 +696,31 @@ 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 = 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; + updateConvertedCurrencyObj(convertedObj) } else if (thisAmount && thisAddressType) { - cleanAmount = +thisAmount - const obj = getCurrencyObject(cleanAmount, currency, randomSatoshis) - setThisCurrencyObject(obj) - if (props.setCurrencyObject) props.setCurrencyObject(obj) + cleanAmount = +thisAmount; + + const obj = getCurrencyObject(cleanAmount, currency, randomSatoshis); + setThisCurrencyObject(obj); + if(!isFiat(currency)) { + updateConvertedCurrencyObj(obj); + } + if (props.setCurrencyObject) { + props.setCurrencyObject(obj); + } } }, [thisAmount, currency, userEditedAmount]) @@ -636,17 +754,23 @@ export const Widget: React.FunctionComponent = props => { } else { setWidgetButtonText(`Send with ${thisAddressType} wallet`) } + 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 = convertedCurrencyObj ? convertedCurrencyObj.float : 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) 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) @@ -681,7 +805,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)) { @@ -694,7 +818,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) @@ -837,7 +961,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 @@ -861,16 +985,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) { @@ -920,7 +1065,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 })()} @@ -1000,7 +1145,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} @@ -1018,7 +1163,7 @@ export const Widget: React.FunctionComponent = props => { - {loading ? ( + {qrLoading ? ( = props => { {isPropsTrue(editable) ? ( - { + setDraftAmount(values.value); // raw numeric value without commas + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isDraftValid && !isSameAmount) { + applyDraftAmount(); + } + }} + 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: ( + + Confirm + + ), + }} /> - {currency} + {currency} + ) : null} @@ -1105,17 +1284,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) })() ? ( <> @@ -1159,8 +1338,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 af95d88c..73f288c0 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, @@ -25,7 +24,7 @@ import { import Widget, { WidgetProps } from './Widget'; export interface WidgetContainerProps - extends Omit { + extends Omit { active?: boolean; amount?: number; opReturn?: string; @@ -54,6 +53,7 @@ export interface WidgetContainerProps transactionText?: string donationAddress?: string donationRate?: number + convertedCurrencyObj?: CurrencyObject; } const snackbarOptionsSuccess: OptionsObject = { @@ -104,7 +104,7 @@ export const WidgetContainer: React.FunctionComponent = let { to, opReturn, - disablePaymentId, + disablePaymentId = isPropsTrue(props.disablePaymentId), paymentId, amount, setAmount, @@ -136,6 +136,8 @@ export const WidgetContainer: React.FunctionComponent = transactionText, donationAddress, donationRate, + convertedCurrencyObj, + setConvertedCurrencyObj, ...widgetProps } = props; const [internalCurrencyObj, setInternalCurrencyObj] = useState(); @@ -147,17 +149,13 @@ export const WidgetContainer: React.FunctionComponent = if (donationRate === undefined){ donationRate = DEFAULT_DONATION_RATE } - const [thisPaymentId, setThisPaymentId] = useState(); + + const [internalPaymentId, setInternalPaymentId] = useState(undefined) + const thisPaymentId = paymentId ?? internalPaymentId + const setThisPaymentId = setInternalPaymentId + 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 [success, setSuccess] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -326,6 +324,9 @@ export const WidgetContainer: React.FunctionComponent = transactionText={transactionText} donationAddress={donationAddress} donationRate={donationRate} + convertedCurrencyObj={convertedCurrencyObj} + setConvertedCurrencyObj={setConvertedCurrencyObj} + setPaymentId={setThisPaymentId} /> ); diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx index dd7354ac..5db7dcf7 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('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).toHaveBeenCalledTimes(1) + }) + }) + + 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) + }) + }) }) + 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:') + }) +}) + diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index cdcb1e13..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, @@ -89,6 +90,29 @@ export const getTransactionDetails = async ( return res.json(); }; +export const createPayment = async ( + amount: string | number | undefined, + 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: safeAmount, address } + ); + + if (status === 200) { + return data.paymentId; + } + throw new Error(`Failed to generate payment ID. Status: ${status}, Response: ${JSON.stringify(data)}`) + +}; + + export default { getAddressDetails, getTransactionDetails, 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; 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"