diff --git a/paybutton/dev/demo/single.html b/paybutton/dev/demo/single.html
new file mode 100644
index 00000000..da24f4d1
--- /dev/null
+++ b/paybutton/dev/demo/single.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Paybutton Bundle Test
+
+
+
+
+
+
+
+
+
+
diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx
index 860e7f0a..f8c83981 100644
--- a/react/lib/components/PayButton/PayButton.tsx
+++ b/react/lib/components/PayButton/PayButton.tsx
@@ -19,9 +19,9 @@ import {
setupChronikWebSocket,
CryptoCurrency,
ButtonSize,
- DEFAULT_DONATION_RATE
+ DEFAULT_DONATION_RATE,
+ createPayment
} from '../../util';
-import { createPayment } from '../../util/api-client';
import { PaymentDialog } from '../PaymentDialog';
import { AltpaymentCoin, AltpaymentError, AltpaymentPair, AltpaymentShift } from '../../altpayment';
export interface PayButtonProps extends ButtonProps {
@@ -129,17 +129,8 @@ export const PayButton = ({
}, [price]);
useEffect(() => {
- cryptoAmountRef.current = cryptoAmount;
- }, [cryptoAmount]);
-
- const waitPrice = (callback: Function) => {
- const intervalId = setInterval(() => {
- if (priceRef.current !== 0) {
- clearInterval(intervalId);
- callback();
- }
- }, 300);
- };
+ cryptoAmountRef.current = convertedCurrencyObj?.string;
+ }, [cryptoAmount, convertedCurrencyObj]);
const getPaymentId = useCallback(async (
@@ -169,67 +160,94 @@ export const PayButton = ({
[disablePaymentId, apiBaseUrl, randomSatoshis, convertedCurrencyObj]
)
- const lastPaymentAmount = useRef(undefined)
+ const lastPaymentAmount = useRef(undefined);
+ const lastOnOpenPaymentId = useRef(undefined);
+ const hasFiredOnOpenRef = useRef(false);
+
useEffect(() => {
+ if (!dialogOpen || disablePaymentId || !to) return;
- if (
- !dialogOpen ||
- disablePaymentId ||
- !to
- ) {
- return;
- }
+ let effectiveAmount: number | null | undefined;
- 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
+ effectiveAmount = convertedCurrencyObj?.float ?? null;
+ } else if (amount !== undefined) {
+ const parsed = Number(amount);
+ effectiveAmount = Number.isNaN(parsed) ? null : parsed;
} else {
- const amountNumber = Number(amount);
- if (Number.isNaN(amountNumber)) {
- return;
- }
- effectiveAmount = amountNumber;
+ effectiveAmount = null;
}
- if (lastPaymentAmount.current === effectiveAmount) {
+
+ if (effectiveAmount === lastPaymentAmount.current) {
return;
}
lastPaymentAmount.current = effectiveAmount;
- void getPaymentId(
- currency,
- to,
- effectiveAmount ?? undefined,
- );
- }, [amount, currency, to, dialogOpen, disablePaymentId, paymentId, getPaymentId, convertedCurrencyObj]);
+ // Clear current paymentId so child widget goes into "waiting" state
+ setPaymentId(undefined);
+
+ void getPaymentId(currency, to, effectiveAmount ?? undefined);
+ }, [
+ dialogOpen,
+ currency,
+ amount,
+ convertedCurrencyObj,
+ disablePaymentId,
+ to,
+ getPaymentId,
+ ]);
+
+ useEffect(() => {
+ if (!dialogOpen) {
+ hasFiredOnOpenRef.current = false;
+ lastOnOpenPaymentId.current = undefined;
+ return;
+ }
+ if (!onOpen || hasFiredOnOpenRef.current) {
+ return;
+ }
- const handleButtonClick = useCallback(async (): Promise => {
+ if (disablePaymentId) {
+ hasFiredOnOpenRef.current = true;
+ lastOnOpenPaymentId.current = '__no_paymentid__';
- if (onOpen) {
if (isFiat(currency)) {
- void waitPrice(() => onOpen(cryptoAmountRef.current, to, paymentId))
+ onOpen(cryptoAmountRef.current, to, undefined);
} else {
- onOpen(amount, to, paymentId)
+ onOpen(amount, to, undefined);
}
+ return;
}
- setDialogOpen(true)
+ if (!paymentId) {
+ return;
+ }
+
+ hasFiredOnOpenRef.current = true;
+ lastOnOpenPaymentId.current = paymentId;
+
+ if (isFiat(currency)) {
+ onOpen(cryptoAmountRef.current, to, paymentId);
+ } else {
+ onOpen(amount, to, paymentId);
+ }
}, [
+ dialogOpen,
onOpen,
+ paymentId,
currency,
amount,
to,
- paymentId,
disablePaymentId,
- getPaymentId,
- ])
+ ]);
+
+
+ const handleButtonClick = useCallback((): void => {
+ setDialogOpen(true);
+ }, []);
+
const handleCloseDialog = (success?: boolean, paymentId?: string): void => {
if (onClose !== undefined) onClose(success, paymentId);
@@ -320,18 +338,16 @@ export const PayButton = ({
}, [dialogOpen, useAltpayment]);
useEffect(() => {
- if (dialogOpen === false && initialAmount && currency) {
+ if (initialAmount != null && currency) {
const obj = getCurrencyObject(
Number(initialAmount),
currency,
randomSatoshis,
);
- setTimeout(() => {
- setAmount(obj.float);
- setCurrencyObj(obj);
- }, 300);
+ setAmount(obj.float);
+ setCurrencyObj(obj);
}
- }, [dialogOpen, initialAmount, currency, randomSatoshis]);
+ }, [initialAmount, currency, randomSatoshis])
const getPrice = useCallback(
async () => {
diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx
index f332360d..56708093 100644
--- a/react/lib/components/Widget/Widget.tsx
+++ b/react/lib/components/Widget/Widget.tsx
@@ -41,7 +41,8 @@ import {
CryptoCurrency,
DEFAULT_DONATION_RATE,
DEFAULT_MINIMUM_DONATION_AMOUNT,
- darkMode,
+ createPayment,
+ darkMode
} from '../../util';
import AltpaymentWidget from './AltpaymentWidget'
import {
@@ -53,7 +54,6 @@ import {
MINIMUM_ALTPAYMENT_CAD_AMOUNT,
} from '../../altpayment'
-import { createPayment } from '../../util/api-client';
export interface WidgetProps {
to: string
@@ -182,10 +182,14 @@ export const Widget: React.FunctionComponent = props => {
const inputRef = React.useRef(null)
const lastEffectiveAmountRef = React.useRef(undefined)
+ const [standalonePaymentPending, setStandalonePaymentPending] = useState(false)
+
const isWaitingForPaymentId =
- isChild === true &&
- !disablePaymentId &&
- paymentId === undefined
+ !disablePaymentId && (
+ (isChild === true && paymentId === undefined) ||
+ (isChild !== true && standalonePaymentPending)
+ )
+
const qrLoading = loading || isWaitingForPaymentId
@@ -599,8 +603,8 @@ export const Widget: React.FunctionComponent = props => {
return;
}
- // For fiat, wait until we have a converted crypto amount
- if (isFiat(currency) && convertedCryptoAmount === undefined) {
+ // For fiat with defined amount, wait until we have a converted crypto amount
+ if (isFiat(currency) && convertedCryptoAmount === undefined && thisAmount !== undefined ) {
return;
}
@@ -631,6 +635,8 @@ export const Widget: React.FunctionComponent = props => {
}
lastEffectiveAmountRef.current = effectiveAmount;
+ setStandalonePaymentPending(true)
+
const responsePaymentId = await createPayment(
effectiveAmount ?? undefined,
to,
@@ -639,6 +645,8 @@ export const Widget: React.FunctionComponent = props => {
setPaymentId(responsePaymentId);
} catch (error) {
console.error('Error creating payment ID:', error);
+ } finally {
+ setStandalonePaymentPending(false)
}
};
@@ -1034,6 +1042,7 @@ export const Widget: React.FunctionComponent = props => {
= props => {
)}
= props => {
endAdornment: (
= props => {
) : null}
-
+
Powered by PayButton.org
{(() => {
diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx
index 73f288c0..e0836d23 100644
--- a/react/lib/components/Widget/WidgetContainer.tsx
+++ b/react/lib/components/Widget/WidgetContainer.tsx
@@ -34,21 +34,21 @@ export interface WidgetContainerProps
currencyObj?: CurrencyObject;
cryptoAmount?: string;
price?: number;
- setCurrencyObj: Function;
+ setCurrencyObj?: Function;
randomSatoshis?: boolean | number;
hideToasts?: boolean;
onSuccess?: (transaction: Transaction) => void;
onTransaction?: (transaction: Transaction) => void;
sound?: boolean;
goalAmount?: number | string;
- disabled: boolean;
- editable: boolean;
+ disabled?: boolean;
+ editable?: boolean;
wsBaseUrl?: string;
apiBaseUrl?: string;
successText?: string;
disableAltpayment?: boolean
contributionOffset?: number
- setNewTxs: Function
+ setNewTxs?: Function
disableSound?: boolean
transactionText?: string
donationAddress?: string
diff --git a/react/lib/tests/components/PayButton.test.tsx b/react/lib/tests/components/PayButton.test.tsx
index 5db7dcf7..23735fd0 100644
--- a/react/lib/tests/components/PayButton.test.tsx
+++ b/react/lib/tests/components/PayButton.test.tsx
@@ -1,293 +1,471 @@
-// 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'
-
+import { isFiat } from '../../util';
jest.mock('../../util', () => ({
...jest.requireActual('../../util'),
- getFiatPrice: jest.fn(async () => 100),
+ getFiatPrice: jest.fn(async (currency: string, to: string) => {
+ if (isFiat(currency)) {
+ return 100
+ }
+ return null
+ }),
setupChronikWebSocket: jest.fn(() => Promise.resolve(undefined)),
setupAltpaymentSocket: jest.fn(() => Promise.resolve(undefined)),
+ getAddressBalance: jest.fn(async () => 0),
+ createPayment: jest.fn(async () => '00112233445566778899aabbccddeeff'),
}))
+jest.mock('qrcode.react', () => ({
+ QRCodeSVG: ({ value, 'data-testid': tid, imageSettings, fgColor, ...rest }: any) =>
+ require('react').createElement(
+ 'svg',
+ { 'data-testid': tid, 'data-value': value, ...rest },
+ null
+ ),
+}));
+
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { PayButton } from '../../components/PayButton'
-jest.mock('../../components/PaymentDialog', () => ({
- PaymentDialog: (props: any) => (
-
-
-
-
- ),
-}))
+const TEST_ADDRESSES = {
+ XEC: 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk',
+ BCH: 'bitcoincash:qq7f38meqgctcnywyx74uputa3yuycnv6qr3c6p6rz',
+}
-jest.mock('../../util/api-client', () => ({
- createPayment: jest.fn(async () => 'mock-payment-id'),
-}))
+const CRYPTO_CASES = [
+ { currency: 'XEC', address: TEST_ADDRESSES.XEC },
+ { currency: 'BCH', address: TEST_ADDRESSES.BCH },
+]
+
+const FIAT_CASES = [
+ { currency: 'USD', address: TEST_ADDRESSES.XEC },
+ { currency: 'CAD', address: TEST_ADDRESSES.XEC },
+]
+
+const ALL_CASES = [...CRYPTO_CASES, ...FIAT_CASES]
+
+
+const realConsoleError = console.error
+beforeAll(() => {
+ console.error = (...args: any[]) => {
+ if (args.some(a => typeof a === 'string' && a.includes('Error creating payment ID'))) {
+ return
+ }
+ realConsoleError(...args)
+ }
+})
+afterAll(() => {
+ console.error = realConsoleError
+})
+
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+
+// ─────────────────────────────────────────────────────────────
+// OPENING & onOpen
+// ─────────────────────────────────────────────────────────────
+describe('PayButton – onOpen behavior', () => {
+
+ test.each(CRYPTO_CASES)(
+ 'onOpen executes immediately for crypto (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+ const onOpen = jest.fn()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => {
+ const { createPayment } = require('../../util');
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ test.each(FIAT_CASES)(
+ 'onOpen waits for FIAT price before responding (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+ const onOpen = jest.fn()
+
+ render(
+
+ )
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything.
+
+ expect(onOpen).toHaveBeenCalledTimes(1)
+
+ await waitFor(() => {
+ const { getFiatPrice } = require('../../util')
+ expect(getFiatPrice).toHaveBeenCalledTimes(1)
+ })
+
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1))
+ }
+ )
+})
-describe('PayButton', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- test('calls onOpen when clicked (crypto path, no timers needed)', async () => {
- const user = userEvent.setup()
- const onOpen = jest.fn()
-
- // using XEC will skip waitPrice()
- render(
-
- )
-
- await user.click(screen.getByRole('button', { name: /donate/i }))
- expect(onOpen).toHaveBeenCalledTimes(1)
- })
-
- it('calls onOpen when clicked (USD)', async () => {
- const user = userEvent.setup()
- const onOpen = jest.fn()
-
- render(
-
- )
-
- // ensure price effect ran (getFiatPrice awaited & setPrice called)
- await waitFor(() => {
+
+
+// ─────────────────────────────────────────────────────────────
+// PAYMENT ID CREATION
+// ─────────────────────────────────────────────────────────────
+describe('PayButton – Payment ID lifecycle', () => {
+
+ test.each(CRYPTO_CASES)(
+ 'onOpen opens with updated paymentId & amount after editing amount (crypto)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+ const onOpen = jest.fn()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+
+ const { createPayment } = require('../../util');
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+ expect(createPayment).toHaveBeenCalledWith(17, address, undefined);
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1));
+ (createPayment as jest.Mock).mockResolvedValueOnce('11112233445566778899aabbccddeeff')
+
+ const input = await screen.findByLabelText(/edit amount/i)
+ await user.clear(input)
+ await user.type(input, '100')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(2)
+ })
+ const backdrop = document.querySelector('.MuiBackdrop-root')!;
+ await user.click(backdrop);
+ await waitFor(() =>
+ expect(screen.queryByText(/send with.*wallet/i)).toBeNull()
+ );
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(2))
+
+ const firstCallArgs = (onOpen as jest.Mock).mock.calls[0]
+ const secondCallArgs = (onOpen as jest.Mock).mock.calls[1]
+ expect(firstCallArgs[0]).toBeCloseTo(17, 8)
+ expect(firstCallArgs[1]).toBe(address)
+ expect(firstCallArgs[2]).toBe('00112233445566778899aabbccddeeff')
+ expect(Number(secondCallArgs[0])).toBeCloseTo(100.00000000, 8)
+ expect(secondCallArgs[1]).toBe(address)
+ expect(secondCallArgs[2]).toBe('11112233445566778899aabbccddeeff')
+
+ }
+ )
+
+ test.each(FIAT_CASES)(
+ 'onOpen opens with updated paymentId & amount after editing amount (fiat)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+ const onOpen = jest.fn()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything.
+
+ const { createPayment } = require('../../util');
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1))
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1))
+ const input = await screen.findByLabelText(/edit amount/i);
+
+ (createPayment as jest.Mock).mockResolvedValueOnce('11112233445566778899aabbccddeeff')
+ // user types something that triggers conversion
+ await user.clear(input)
+ await user.type(input, '100')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ const backdrop = document.querySelector('.MuiBackdrop-root')!;
+ await user.click(backdrop);
+ await waitFor(() =>
+ expect(screen.queryByText(/send with xec wallet/i)).toBeNull()
+ );
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(2))
+
+ const firstCallArgs = (onOpen as jest.Mock).mock.calls[0]
+ const secondCallArgs = (onOpen as jest.Mock).mock.calls[1]
+ expect(Number(firstCallArgs[0])).toBeCloseTo(10, 8)
+ expect(firstCallArgs[1]).toBe(address)
+ expect(firstCallArgs[2]).toBe('00112233445566778899aabbccddeeff')
+ expect(Number(secondCallArgs[0])).toBeCloseTo(1.0000000, 8)
+ expect(secondCallArgs[1]).toBe(address)
+ expect(secondCallArgs[2]).toBe('11112233445566778899aabbccddeeff')
+ }
+ )
+
+ test.each(ALL_CASES)(
+ 'no payment ID when disablePaymentId=true (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => {
+ const { createPayment } = require('../../util');
+ expect(createPayment).not.toHaveBeenCalled()
+ })
+ }
+ )
+})
+
+
+
+// ─────────────────────────────────────────────────────────────
+// REGENERATION RULES
+// ─────────────────────────────────────────────────────────────
+describe('PayButton – Payment ID regeneration', () => {
+ test.each(CRYPTO_CASES)(
+ 'regenerates only when crypto amount changes while open (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ // open
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ const { createPayment } = require('../../util');
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1))
+
+ const input = await screen.findByLabelText(/edit amount/i)
+
+ // 2 → 3
+ await user.clear(input)
+ await user.type(input, '3')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ // 3 → 4
+ await user.clear(input)
+ await user.type(input, '4')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(3))
+
+ const calls = (createPayment as jest.Mock).mock.calls.map(c => c[0])
+ expect(calls).toEqual([2, 3, 4])
+ }
+ )
+
+ test.each(FIAT_CASES)(
+ 'for fiat, raw amount changes also regenerate paymentId (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ // open
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await user.click(screen.getByRole('button', { name: /donate/i })) // for some reason first click here in the tests is not doing anything.
+ const { createPayment } = require('../../util');
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1))
+
+ const input = await screen.findByLabelText(/edit amount/i)
+
+ // change base amount → SHOULD REGEN
+ await user.clear(input)
+ await user.type(input, '55')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ // Now converted changes — mock fiat price returns different value
const { getFiatPrice } = require('../../util')
- expect(getFiatPrice).toHaveBeenCalled()
- })
-
- 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)
- })
- })
+ getFiatPrice.mockResolvedValueOnce(123) // force new conversion
+
+ await act(async () => {
+ // trigger recalculation by modifying the input again
+ await user.clear(input)
+ await user.type(input, '56')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ })
+
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(3))
+ }
+ )
+})
+
+
+
+
+// ─────────────────────────────────────────────────────────────
+// FAILURE BEHAVIOR
+// ─────────────────────────────────────────────────────────────
+describe('PayButton – failure cases', () => {
+ test.each(CRYPTO_CASES)(
+ 'if createPayment fails, load forever',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+ const onOpen = jest.fn()
+
+ const { createPayment } = require('../../util');
+ (createPayment as jest.Mock).mockImplementationOnce(async () => {
+ throw new Error('server offline')
+ })
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+
+ await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(0))
+
+ await expect(screen.findByText(/loading/i)).resolves.toBeDefined()
+ }
+ )
+})
+
+
+describe('PayButton – UI shows updated amount + QR after reopen', () => {
+ test.each(CRYPTO_CASES)(
+ 'reopens using latest amount and paymentId (crypto) (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ const { createPayment } = require('../../util')
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1))
+
+ const input = await screen.findByLabelText(/edit amount/i)
+ ;(createPayment as jest.Mock).mockResolvedValueOnce('ffff2233445566778899aabbccddeeff')
+
+ await user.clear(input)
+ await user.type(input, '1789')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ const backdrop = document.querySelector('.MuiBackdrop-root')!
+ await user.click(backdrop)
+ await waitFor(() =>
+ expect(screen.queryByText(/send with/i)).toBeNull()
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ await expect(screen.findByText(/1,789/)).resolves.toBeDefined()
+
+ const qr = screen.getByTestId('qr-code')
+ expect(qr.getAttribute('data-value')).toBe(`${address}?amount=1789&op_return_raw=0450415900000010ffff2233445566778899aabbccddeeff`);
+ }
+ )
+ test.each(FIAT_CASES)(
+ 'reopens using latest amount and paymentId (fiat) (%s)',
+ async ({ currency, address }) => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ const { createPayment } = require('../../util')
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(1))
+
+ const input = await screen.findByLabelText(/edit amount/i)
+ ;(createPayment as jest.Mock).mockResolvedValueOnce('ffff2233445566778899aabbccddeeff')
+
+ await user.clear(input)
+ await user.type(input, '1789')
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ const backdrop = document.querySelector('.MuiBackdrop-root')!
+ await user.click(backdrop)
+ await waitFor(() =>
+ expect(screen.queryByText(/send with/i)).toBeNull()
+ )
+
+ await user.click(screen.getByRole('button', { name: /donate/i }))
+ await waitFor(() => expect(createPayment).toHaveBeenCalledTimes(2))
+
+ await expect(screen.findByText(/17.89/)).resolves.toBeDefined()
+
+ const qr = screen.getByTestId('qr-code')
+ expect(qr.getAttribute('data-value')).toBe('ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk?amount=17.89&op_return_raw=0450415900000010ffff2233445566778899aabbccddeeff');
+ }
+ )
})
diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx
index a9dcfd90..5b5fd2c7 100644
--- a/react/lib/tests/components/Widget.test.tsx
+++ b/react/lib/tests/components/Widget.test.tsx
@@ -1,272 +1,450 @@
-// lib/tests/components/Widget.test.tsx
-import { render, screen, waitFor } from '@testing-library/react'
+// react/lib/tests/components/Widget.test.tsx
+import { render, screen, waitFor, cleanup } from '@testing-library/react'
+import { act } from 'react'
import userEvent from '@testing-library/user-event'
-import Widget from '../../components/Widget/Widget'
+import { WidgetContainer as Widget } from '../../components/Widget/WidgetContainer'
+import { TEST_ADDRESSES } from '../util/constants'
+import copyToClipboard from 'copy-to-clipboard'
+import type { Currency } from '../../util'
+import { isFiat } from '../../util';
jest.mock('copy-to-clipboard', () => jest.fn())
-jest.mock('../../util', () => ({
- ...jest.requireActual('../../util'),
- // 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', () => {
+ const real = jest.requireActual('../../util')
+
+ return {
+ __esModule: true,
+ ...real,
+ getFiatPrice: jest.fn(async (currency: string, to: string) => {
+ if (isFiat(currency)) {
+ return 100
+ }
+ return null
+ }),
+ setupChronikWebSocket: jest.fn().mockResolvedValue(undefined),
+ setupAltpaymentSocket: jest.fn().mockResolvedValue(undefined),
+ getAddressBalance: jest.fn().mockResolvedValue(0),
+ openCashtabPayment: jest.fn(),
+ createPayment: jest.fn(async () => '00112233445566778899aabbccddeeff'),
+ }
+})
-jest.mock('../../util/api-client', () => ({
- __esModule: true,
- createPayment: jest.fn(),
-}))
-import copyToClipboard from 'copy-to-clipboard'
-import { createPayment } from '../../util/api-client'
+const CRYPTO_CASES: { label: string; currency: Currency; to: string }[] = [
+ { label: 'XEC', currency: 'XEC' as Currency, to: TEST_ADDRESSES.ecash },
+ { label: 'BCH', currency: 'BCH' as Currency, to: TEST_ADDRESSES.bitcoincash },
+]
-const ADDRESS = 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk'
-const API_BASE_URL = 'https://api.example.com'
+const FIAT_CASES: { label: string; currency: Currency; price: number, to: string }[] = [
+ { label: 'USD', currency: 'USD' as Currency, price: 10, to: TEST_ADDRESSES.ecash },
+ { label: 'CAD', currency: 'CAD' as Currency, price: 20, to: TEST_ADDRESSES.ecash},
+]
beforeEach(() => {
jest.clearAllMocks()
+ cleanup()
})
-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(
- ,
- )
+describe('Widget – standalone paymentId (crypto)', () => {
+ test.each(CRYPTO_CASES)(
+ '%s – first render triggers createPayment(amount)',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+ (createPayment as jest.Mock).mockResolvedValue('pid-crypto-1')
+
+ render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledWith(5, to, undefined)
+ })
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – amount change triggers new paymentId',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock)
+ .mockResolvedValueOnce('pid-crypto-1')
+ .mockResolvedValueOnce('pid-crypto-2')
+
+
+ const { rerender } = render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+
+ rerender(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(2)
+ })
+ expect((createPayment as jest.Mock).mock.calls[1][0]).toBe(8)
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – same amount across rerender does NOT regenerate',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto')
+
+
+ const { rerender } = render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+
+ rerender(
+
+ )
- 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(
- ,
- )
+ // still only the first call
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – no paymentId when disablePaymentId=true',
+ async ({ currency, to }) => {
+
+ render(
+
+ )
+
+ // WIP wait for loading to go away
+ const { createPayment } = require('../../util');
+ await waitFor(() => {
+ expect(createPayment).not.toHaveBeenCalled()
+ })
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – no paymentId when isChild=true',
+ async ({ currency, to }) => {
+
+ render(
+
+ )
+
+ // WIP wait for loading to go away
+ const { createPayment } = require('../../util');
+ await waitFor(() => {
+ expect(createPayment).not.toHaveBeenCalled()
+ })
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – undefined amount passes undefined to createPayment',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockResolvedValue('pid-crypto-undef')
+
+ render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+ expect(createPayment).toHaveBeenCalledWith(undefined, to, undefined)
+ }
+ )
+})
- await waitFor(() => {
- expect(createPayment).toHaveBeenCalledTimes(1)
- })
+describe('Widget – standalone paymentId (fiat)', () => {
+ test.each(FIAT_CASES)(
+ '%s – uses internal conversion (amount / price) for paymentId',
+ async ({ currency, price }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockResolvedValue('pid-fiat-1')
+ const amount = 50
+
+ const to = TEST_ADDRESSES.ecash
+
+ render(
+
+ )
+
+ const expectedCrypto = amount / price
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledWith(expectedCrypto, to, undefined)
+ })
+ }
+ )
+
+ test.each(FIAT_CASES)(
+ '%s – new converted amount regenerates paymentId',
+ async ({ currency, price }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock)
+ .mockResolvedValueOnce('pid-fiat-1')
+ .mockResolvedValueOnce('pid-fiat-2')
+
+ const to = TEST_ADDRESSES.ecash
+
+ const { rerender } = render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+
+ rerender(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(2)
+ })
+
+ const lastCall = (createPayment as jest.Mock).mock.calls[1]
+ expect(lastCall[0]).toBe(90 / price)
+ expect(lastCall[1]).toBe(to)
+ }
+ )
+})
- expect(createPayment).toHaveBeenCalledWith(undefined, ADDRESS, API_BASE_URL)
- })
+describe('Widget – editable amount input (crypto)', () => {
+ test.each(CRYPTO_CASES)(
+ '%s – editing amount to a new value regenerates paymentId',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+
+ ;(createPayment as jest.Mock).mockResolvedValueOnce('aa112233445566778899aabbccddeeff')
+ const input = screen.getByLabelText(/edit amount/i)
+
+ await user.clear(input)
+ await user.type(input, '8')
+ await user.keyboard('{Enter}')
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(2)
+ })
+
+ const lastCall = (createPayment as jest.Mock).mock.calls[1]
+ expect(lastCall[0]).toBe(8)
+ expect(lastCall[1]).toBe(to)
+ }
+ )
+
+ test.each(CRYPTO_CASES)(
+ '%s – editing amount to SAME value does NOT regenerate paymentId',
+ async ({ currency, to }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockResolvedValue('pid-edit-same')
+
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+
+ const input = screen.getByLabelText(/edit amount/i)
+
+ await user.clear(input)
+ await user.type(input, '5')
+ await user.keyboard('{Enter}')
+
+ // applyDraftAmount should not fire (isSameAmount=true), so no new call
+ await waitFor(() => {
+ expect(createPayment).toHaveBeenCalledTimes(1)
+ })
+ }
+ )
+})
- test('creates paymentId with numeric amount when amount is given', async () => {
- ;(createPayment as jest.Mock).mockResolvedValue('pid-3')
- const setPaymentId = jest.fn()
+describe('Widget – QR copy interaction', () => {
+ test.each([...CRYPTO_CASES, ...FIAT_CASES])(
+ 'clicking QR copies full URL for %s address',
+ async ({ currency, to, label }) => {
+
+ await act(async () => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockResolvedValue(`pid-qr-${label}`)
+ ;(copyToClipboard as jest.Mock).mockReturnValue(true)
+
+
+ render(
+
+ )
+ })
+
+ const user = userEvent.setup()
+ await waitFor(() => {
+ expect(screen.queryByText(/Click to copy/i)).toBeTruthy()
+ })
+
+ const qrBox = screen.getByTestId('qr-click-area')
+ await user.click(qrBox)
+
+ expect(copyToClipboard).toHaveBeenCalledTimes(1)
+ const copied = (copyToClipboard as jest.Mock).mock.calls[0][0] as string
+
+ if (currency === 'XEC') {
+ expect(copied).toContain('ecash:')
+ } else if (currency === 'BCH') {
+ expect(copied).toContain('bitcoincash:')
+ }
+ }
+ )
+})
- render(
- ,
+describe('Widget – loading behaviour (WIP)', () => {
+ const CASES = [...CRYPTO_CASES, ...FIAT_CASES]
+
+ const WITH_AMOUNTS = CASES.flatMap(base => [
+ { ...base, amount: 3 },
+ { ...base, amount: undefined },
+ ])
+
+ test.each(WITH_AMOUNTS)(
+ 'handleQrCodeClick does nothing while component is loading (%s, amount=%s)',
+ async ({ currency, to, amount }) => {
+ const { createPayment } = require('../../util')
+ ;(createPayment as jest.Mock).mockImplementation(
+ () =>
+ new Promise(resolve =>
+ setTimeout(() => resolve('some-payment-id'), 1000),
+ ),
)
- 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(
- ,
- )
+ render()
await waitFor(() => {
- expect(createPayment).toHaveBeenCalledTimes(1)
+ expect(screen.getByText(/loading/i)).toBeTruthy()
})
- expect(createPayment).toHaveBeenLastCalledWith(5, ADDRESS, API_BASE_URL)
- rerender(
- ,
- )
+ const qr = await screen.findByTestId('qr-click-area')
+ const user = userEvent.setup()
await waitFor(() => {
- expect(createPayment).toHaveBeenCalledTimes(2)
+ expect(screen.getByText(/loading/i)).toBeTruthy()
})
- 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()
+ expect(copyToClipboard).not.toHaveBeenCalled()
+ await user.click(qr)
+ expect(copyToClipboard).not.toHaveBeenCalled()
+ },
+)
- const { rerender } = render(
- ,
+ test.each(WITH_AMOUNTS)(
+ 'widget button is disabled while component is loading', async ({ currency, to, amount }) => {
+ const { createPayment } = require('../../util');
+ ;(createPayment as jest.Mock).mockImplementation(() =>
+ new Promise(resolve => setTimeout(() => resolve('some-payment-id'), 1000)),
)
- await waitFor(() => {
- expect(createPayment).toHaveBeenCalledTimes(1)
- })
-
- rerender(
+ render(
,
)
- await waitFor(() => {
- // still only the first call
- expect(createPayment).toHaveBeenCalledTimes(1)
- })
- })
-})
+ await waitFor(() => expect(screen.getByText(/loading/i)).toBeTruthy())
-describe('Widget – QR copy behaviour', () => {
- test('clicking QR copies payment URL and triggers feedback behaviour', async () => {
- const setPaymentId = jest.fn()
-
- render(
- ,
- )
+ const btn = screen.getByRole('button', { name: /send with.*wallet/i })
await waitFor(() => {
- expect(screen.getByText(/click to copy/i)).toBeTruthy()
+ expect(screen.getByText(/loading/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:')
+ expect(btn.hasAttribute('disabled')).toBeTruthy()
})
})
diff --git a/react/lib/tests/util/constants.ts b/react/lib/tests/util/constants.ts
new file mode 100644
index 00000000..33f70e62
--- /dev/null
+++ b/react/lib/tests/util/constants.ts
@@ -0,0 +1,5 @@
+export const TEST_ADDRESSES = {
+ ecash: 'ecash:qz3wrtmwtuycud3k6w7afkmn3285vw2lfy36y43nvk',
+ bitcoincash: 'bitcoincash:qq7f38meqgctcnywyx74uputa3yuycnv6qr3c6p6rz'
+}
+
diff --git a/react/package.json b/react/package.json
index 622e7823..e4d1e7e6 100644
--- a/react/package.json
+++ b/react/package.json
@@ -56,6 +56,7 @@
"@vitejs/plugin-react": "^4.6.0",
"babel-eslint": "10.1.0",
"babel-loader": "8.2.5",
+ "canvas": "^3.2.0",
"concurrently": "5.3.0",
"cross-env": "7.0.3",
"currency-formatter": "1.5.9",
diff --git a/react/yarn.lock b/react/yarn.lock
index 0c51669e..089d2919 100644
--- a/react/yarn.lock
+++ b/react/yarn.lock
@@ -5563,6 +5563,14 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz#dacd5d9f4baeea841641640139d2b2a4df4226ad"
integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==
+canvas@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/canvas/-/canvas-3.2.0.tgz#877c51aabdb99cbb5b2b378138a6cdd681e9d390"
+ integrity sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==
+ dependencies:
+ node-addon-api "^7.0.0"
+ prebuild-install "^7.1.3"
+
case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
@@ -6384,6 +6392,13 @@ decimal.js@*, decimal.js@^10.4.2, decimal.js@^10.6.0:
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
+decompress-response@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
+ integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
+ dependencies:
+ mimic-response "^3.1.0"
+
dedent@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
@@ -6394,6 +6409,11 @@ dedent@^1.0.0:
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==
+deep-extend@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+ integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
deep-is@^0.1.3, deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@@ -6498,6 +6518,11 @@ detect-indent@^6.1.0:
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
+detect-libc@^2.0.0:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
+ integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+
detect-newline@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@@ -7615,6 +7640,11 @@ exit@^0.1.2:
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==
+expand-template@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
+ integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
+
expect@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74"
@@ -8186,6 +8216,11 @@ giget@^1.0.0:
pathe "^2.0.3"
tar "^6.2.1"
+github-from-package@0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
+ integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
+
github-slugger@^1.0.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d"
@@ -8741,7 +8776,7 @@ inherits@2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
-ini@^1.3.5:
+ini@^1.3.5, ini@~1.3.0:
version "1.3.8"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
@@ -10751,6 +10786,11 @@ mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+mimic-response@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
+ integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
+
mini-css-extract-plugin@^2.4.5:
version "2.9.4"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz#cafa1a42f8c71357f49cd1566810d74ff1cb0200"
@@ -10792,7 +10832,7 @@ minimatch@^9.0.4:
dependencies:
brace-expansion "^2.0.1"
-minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
+minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@@ -10822,7 +10862,7 @@ minizlib@^2.1.1:
minipass "^3.0.0"
yallist "^4.0.0"
-mkdirp-classic@^0.5.2:
+mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
@@ -10886,6 +10926,11 @@ nanoid@^3.3.11:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+napi-build-utils@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e"
+ integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
+
natural-compare-lite@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
@@ -10919,6 +10964,18 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
+node-abi@^3.3.0:
+ version "3.85.0"
+ resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d"
+ integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==
+ dependencies:
+ semver "^7.3.5"
+
+node-addon-api@^7.0.0:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
+ integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
+
node-dir@^0.1.17:
version "0.1.17"
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
@@ -12152,6 +12209,24 @@ postcss@^8.2.1, postcss@^8.3.5, postcss@^8.4.27, postcss@^8.4.33, postcss@^8.4.4
picocolors "^1.1.1"
source-map-js "^1.2.1"
+prebuild-install@^7.1.3:
+ version "7.1.3"
+ resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec"
+ integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==
+ dependencies:
+ detect-libc "^2.0.0"
+ expand-template "^2.0.3"
+ github-from-package "0.0.0"
+ minimist "^1.2.3"
+ mkdirp-classic "^0.5.3"
+ napi-build-utils "^2.0.0"
+ node-abi "^3.3.0"
+ pump "^3.0.0"
+ rc "^1.2.7"
+ simple-get "^4.0.0"
+ tar-fs "^2.0.0"
+ tunnel-agent "^0.6.0"
+
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -12446,6 +12521,16 @@ raw-body@2.5.2:
iconv-lite "0.4.24"
unpipe "1.0.0"
+rc@^1.2.7:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+ integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+ dependencies:
+ deep-extend "^0.6.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
react-app-polyfill@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz#95221e0a9bd259e5ca6b177c7bb1cb6768f68fd7"
@@ -13467,6 +13552,20 @@ signal-exit@^4.0.1:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+simple-concat@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
+ integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
+
+simple-get@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
+ integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==
+ dependencies:
+ decompress-response "^6.0.0"
+ once "^1.3.1"
+ simple-concat "^1.0.0"
+
sisteransi@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@@ -13974,6 +14073,11 @@ strip-json-comments@^3.0.1, strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
+strip-json-comments@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+ integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
+
style-inject@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3"
@@ -14141,7 +14245,7 @@ tapable@^2.0.0, tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6"
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
-tar-fs@^2.1.1:
+tar-fs@^2.0.0, tar-fs@^2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
@@ -14444,6 +14548,13 @@ tsutils@^3.21.0:
dependencies:
tslib "^1.8.1"
+tunnel-agent@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+ integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
+ dependencies:
+ safe-buffer "^5.0.1"
+
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"