diff --git a/src/__tests__/prize-wallet-alert-panel.test.tsx b/src/__tests__/prize-wallet-alert-panel.test.tsx new file mode 100644 index 000000000..fc6271d1a --- /dev/null +++ b/src/__tests__/prize-wallet-alert-panel.test.tsx @@ -0,0 +1,452 @@ +jest.mock('@dfx.swiss/react', () => ({})); +jest.mock('@dfx.swiss/react-components', () => ({ + SpinnerSize: { SM: 'sm', LG: 'lg' }, + StyledButtonWidth: { MIN: 'min' }, + StyledLoadingSpinner: () =>
, + StyledButton: ({ label, onClick, disabled }: { label: string; onClick: () => void; disabled?: boolean }) => ( + + ), +})); +jest.mock('src/components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); + +const mockListPrizeWalletAlerts = jest.fn(); +const mockCreatePrizeWalletAlert = jest.fn(); +const mockDeletePrizeWalletAlert = jest.fn(); +jest.mock('src/hooks/realunit-referral.hook', () => ({ + useRealunitReferral: () => ({ + listPrizeWalletAlerts: (...args: unknown[]) => mockListPrizeWalletAlerts(...args), + createPrizeWalletAlert: (...args: unknown[]) => mockCreatePrizeWalletAlert(...args), + deletePrizeWalletAlert: (...args: unknown[]) => mockDeletePrizeWalletAlert(...args), + }), +})); + +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { RealunitPrizeWalletAlertPanel } from 'src/components/realunit/prize-wallet-alert-panel'; +import { RealUnitPrizeWalletAlertAsset } from 'src/dto/realunit-referral.dto'; + +const translate = (_ns: string, key: string) => key; + +const ALERT = { + id: 3, + asset: RealUnitPrizeWalletAlertAsset.ETH, + threshold: 0.05, + mail: 'ops@example.com', + created: '2026-09-10T00:00:00.000Z', +}; + +const NOTIFY = 'Notify on low balance'; + +async function openForm() { + await waitFor(() => expect(screen.getByRole('button', { name: NOTIFY })).not.toBeDisabled()); + fireEvent.click(screen.getByRole('button', { name: NOTIFY })); + await waitFor(() => expect(screen.getByLabelText('Asset')).toBeInTheDocument()); +} + +async function fillValidEthForm() { + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '0.05' } }); + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'ops@example.com' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()); +} + +describe('RealunitPrizeWalletAlertPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockListPrizeWalletAlerts.mockResolvedValue([]); + mockCreatePrizeWalletAlert.mockResolvedValue(ALERT); + mockDeletePrizeWalletAlert.mockResolvedValue(undefined); + }); + + it('shows the notify button and a loading spinner while alerts load', () => { + mockListPrizeWalletAlerts.mockImplementation(() => new Promise(() => undefined)); + render(); + expect(screen.getByRole('button', { name: NOTIFY })).toBeInTheDocument(); + expect(screen.getByTestId('prize-wallet-alert-loading')).toHaveTextContent('Loading'); + expect(screen.queryByLabelText('Asset')).not.toBeInTheDocument(); + }); + + it('keeps Notify disabled while alerts are still loading', () => { + mockListPrizeWalletAlerts.mockImplementation(() => new Promise(() => undefined)); + render(); + expect(screen.getByRole('button', { name: NOTIFY })).toBeDisabled(); + }); + + it('toggles the form open and closed', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + expect(screen.queryByLabelText('Asset')).not.toBeInTheDocument(); + + await openForm(); + expect(screen.getByLabelText('Threshold')).toHaveAttribute('step', 'any'); + expect(screen.getByLabelText('Threshold')).not.toHaveAttribute('min'); + + fireEvent.click(screen.getByRole('button', { name: NOTIFY })); + expect(screen.queryByLabelText('Asset')).not.toBeInTheDocument(); + }); + + it('switches threshold constraints when REALU is selected', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + + fireEvent.change(screen.getByLabelText('Asset'), { target: { value: RealUnitPrizeWalletAlertAsset.REALU } }); + expect(screen.getByLabelText('Threshold')).toHaveAttribute('step', '1'); + expect(screen.getByLabelText('Threshold')).toHaveAttribute('min', '1'); + }); + + it('keeps Submit disabled for invalid ETH, REALU, and mail values', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'ops@example.com' } }); + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '0' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '-1' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: 'Infinity' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '0.05' } }); + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'not-an-email' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: ' ' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Asset'), { target: { value: RealUnitPrizeWalletAlertAsset.REALU } }); + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'ops@example.com' } }); + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '1.5' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '0' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + }); + + it('rejects comma-separated, semicolon-separated, and whitespace-separated mail', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '0.05' } }); + const form = screen.getByRole('button', { name: 'Submit' }).closest('form') as HTMLFormElement; + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@b,c@d' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + fireEvent.submit(form); + expect(mockCreatePrizeWalletAlert).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@b;c@d' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + fireEvent.submit(form); + expect(mockCreatePrizeWalletAlert).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@b c@d' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + fireEvent.submit(form); + expect(mockCreatePrizeWalletAlert).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@@b' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: '@b' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@' } }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: 'a@b' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()); + }); + + it('does not create on form submit when the form is incomplete', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form') as HTMLFormElement); + expect(mockCreatePrizeWalletAlert).not.toHaveBeenCalled(); + }); + + it('creates an ETH alert, prepends it, resets and closes the form', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + await fillValidEthForm(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(mockCreatePrizeWalletAlert).toHaveBeenCalledWith({ + asset: RealUnitPrizeWalletAlertAsset.ETH, + threshold: 0.05, + mail: 'ops@example.com', + }), + ); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + expect(screen.queryByLabelText('Asset')).not.toBeInTheDocument(); + }); + + it('creates a REALU alert via form submit', async () => { + const created = { + ...ALERT, + id: 9, + asset: RealUnitPrizeWalletAlertAsset.REALU, + threshold: 10, + mail: 'realu@example.com', + }; + mockCreatePrizeWalletAlert.mockResolvedValue(created); + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + + fireEvent.change(screen.getByLabelText('Asset'), { target: { value: RealUnitPrizeWalletAlertAsset.REALU } }); + fireEvent.change(screen.getByLabelText('Threshold'), { target: { value: '10' } }); + fireEvent.change(screen.getByLabelText('Mail'), { target: { value: ' realu@example.com ' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()); + fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form') as HTMLFormElement); + + await waitFor(() => + expect(mockCreatePrizeWalletAlert).toHaveBeenCalledWith({ + asset: RealUnitPrizeWalletAlertAsset.REALU, + threshold: 10, + mail: 'realu@example.com', + }), + ); + await waitFor(() => expect(screen.getByText('realu@example.com')).toBeInTheDocument()); + }); + + it('shows a form error when create fails', async () => { + mockCreatePrizeWalletAlert.mockRejectedValue(new Error('taken')); + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + await fillValidEthForm(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('taken')); + expect(screen.getByLabelText('Asset')).toBeInTheDocument(); + }); + + it('falls back to Unknown error when create rejects without a message', async () => { + mockCreatePrizeWalletAlert.mockRejectedValue({ message: undefined }); + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + await fillValidEthForm(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error')); + }); + + it('shows loaded alerts in the list', async () => { + mockListPrizeWalletAlerts.mockResolvedValue([ALERT]); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByText('0.05')).toBeInTheDocument(); + }); + + it('shows a list error when alerts fail to load', async () => { + mockListPrizeWalletAlerts.mockRejectedValue(new Error('list-fail')); + render(); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('list-fail')); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('keeps Submit enabled after the alert list fails and clears listError on successful create', async () => { + mockListPrizeWalletAlerts.mockRejectedValue(new Error('list-fail')); + mockCreatePrizeWalletAlert.mockResolvedValue(ALERT); + render(); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('list-fail')); + await openForm(); + await fillValidEthForm(); + expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(mockCreatePrizeWalletAlert).toHaveBeenCalledWith({ + asset: RealUnitPrizeWalletAlertAsset.ETH, + threshold: 0.05, + mail: 'ops@example.com', + }), + ); + await waitFor(() => expect(screen.queryByText('list-fail')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + }); + + it('retries the alert list after a load failure', async () => { + mockListPrizeWalletAlerts.mockRejectedValueOnce(new Error('list-fail')).mockResolvedValueOnce([ALERT]); + render(); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('list-fail')); + expect(mockListPrizeWalletAlerts).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText('list-fail')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + }); + + it('falls back to Unknown error when the alert list rejects without a message', async () => { + mockListPrizeWalletAlerts.mockRejectedValue({ message: undefined }); + render(); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error')); + }); + + it('deletes an alert and removes the row', async () => { + const other = { ...ALERT, id: 4, mail: 'keep@example.com' }; + mockListPrizeWalletAlerts.mockResolvedValue([ALERT, other]); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + const row = screen.getByText('ops@example.com').closest('tr') as HTMLElement; + fireEvent.click(within(row).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mockDeletePrizeWalletAlert).toHaveBeenCalledWith(3)); + await waitFor(() => expect(screen.queryByText('ops@example.com')).not.toBeInTheDocument()); + expect(screen.getByText('keep@example.com')).toBeInTheDocument(); + }); + + it('shows a list error when delete fails', async () => { + mockListPrizeWalletAlerts.mockResolvedValue([ALERT]); + mockDeletePrizeWalletAlert.mockRejectedValue(new Error('nope')); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('nope')); + expect(screen.getByText('ops@example.com')).toBeInTheDocument(); + }); + + it('clears a leftover delete error after a successful create', async () => { + const created = { ...ALERT, id: 11, mail: 'new@example.com' }; + mockListPrizeWalletAlerts.mockResolvedValue([ALERT]); + mockDeletePrizeWalletAlert.mockRejectedValue(new Error('nope')); + mockCreatePrizeWalletAlert.mockResolvedValue(created); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('nope')); + + await openForm(); + await fillValidEthForm(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(mockCreatePrizeWalletAlert).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByText('nope')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('new@example.com')).toBeInTheDocument()); + }); + + it('falls back to Unknown error when delete rejects without a message', async () => { + mockListPrizeWalletAlerts.mockResolvedValue([ALERT]); + mockDeletePrizeWalletAlert.mockRejectedValue({ message: undefined }); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error')); + }); + + it('exposes Notify on low balance as the notify button accessible name', async () => { + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + expect(screen.getByRole('button', { name: 'Notify on low balance' })).toBeInTheDocument(); + }); + + it('ignores a second Submit click while create is in flight', async () => { + const deferred: { resolve: (value: typeof ALERT) => void } = { resolve: () => undefined }; + mockCreatePrizeWalletAlert.mockImplementation( + () => + new Promise((resolve) => { + deferred.resolve = resolve; + }), + ); + render(); + await waitFor(() => expect(mockListPrizeWalletAlerts).toHaveBeenCalled()); + await openForm(); + await fillValidEthForm(); + + const submit = screen.getByRole('button', { name: 'Submit' }); + fireEvent.click(submit); + fireEvent.click(submit); + expect(mockCreatePrizeWalletAlert).toHaveBeenCalledTimes(1); + + deferred.resolve(ALERT); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + }); + + it('disables Retry while create is in flight', async () => { + mockListPrizeWalletAlerts.mockRejectedValueOnce(new Error('list-fail')); + const deferred: { resolve: (value: typeof ALERT) => void } = { resolve: () => undefined }; + mockCreatePrizeWalletAlert.mockImplementation( + () => + new Promise((resolve) => { + deferred.resolve = resolve; + }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('error-hint')).toHaveTextContent('list-fail')); + await openForm(); + await fillValidEthForm(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(mockCreatePrizeWalletAlert).toHaveBeenCalledTimes(1)); + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled(); + + deferred.resolve(ALERT); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + }); + + it('allows deleting two rows in parallel', async () => { + const other = { ...ALERT, id: 4, mail: 'keep@example.com' }; + mockListPrizeWalletAlerts.mockResolvedValue([ALERT, other]); + const resolvers: Record void> = {}; + mockDeletePrizeWalletAlert.mockImplementation( + (id: number) => + new Promise((resolve) => { + resolvers[id] = resolve; + }), + ); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + const firstRow = screen.getByText('ops@example.com').closest('tr') as HTMLElement; + const secondRow = screen.getByText('keep@example.com').closest('tr') as HTMLElement; + fireEvent.click(within(firstRow).getByRole('button', { name: 'Delete' })); + + expect(within(firstRow).getByRole('button', { name: 'Delete' })).toHaveAttribute('aria-disabled', 'true'); + expect(within(secondRow).getByRole('button', { name: 'Delete' })).toHaveAttribute('aria-disabled', 'false'); + + fireEvent.click(within(secondRow).getByRole('button', { name: 'Delete' })); + expect(mockDeletePrizeWalletAlert).toHaveBeenCalledTimes(2); + + resolvers[3](); + await waitFor(() => expect(screen.queryByText('ops@example.com')).not.toBeInTheDocument()); + resolvers[4](); + await waitFor(() => expect(screen.queryByText('keep@example.com')).not.toBeInTheDocument()); + }); + + it('ignores a second Delete click on the same row while in flight', async () => { + mockListPrizeWalletAlerts.mockResolvedValue([ALERT]); + mockDeletePrizeWalletAlert.mockImplementation(() => new Promise(() => undefined)); + render(); + await waitFor(() => expect(screen.getByText('ops@example.com')).toBeInTheDocument()); + + const row = screen.getByText('ops@example.com').closest('tr') as HTMLElement; + const del = within(row).getByRole('button', { name: 'Delete' }); + fireEvent.click(del); + fireEvent.click(del); + + expect(mockDeletePrizeWalletAlert).toHaveBeenCalledTimes(1); + expect(mockDeletePrizeWalletAlert).toHaveBeenCalledWith(3); + }); +}); diff --git a/src/__tests__/realunit-referral.hook.test.ts b/src/__tests__/realunit-referral.hook.test.ts index f4f8a5656..13b483f56 100644 --- a/src/__tests__/realunit-referral.hook.test.ts +++ b/src/__tests__/realunit-referral.hook.test.ts @@ -14,6 +14,7 @@ jest.mock('../hooks/navigation.hook', () => ({ useNavigation: () => ({ navigate: jest.fn() }), })); +import { RealUnitPrizeWalletAlertAsset } from '../dto/realunit-referral.dto'; import { useRealunitReferral } from '../hooks/realunit-referral.hook'; describe('useRealunitReferral', () => { @@ -74,6 +75,43 @@ describe('useRealunitReferral', () => { expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/referral/admin/prize-wallet', method: 'GET' }); }); + it('lists prize-wallet alerts', async () => { + mockCall.mockResolvedValue([]); + const { result } = renderHook(() => useRealunitReferral()); + + await result.current.listPrizeWalletAlerts(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/referral/admin/prize-wallet/alerts', method: 'GET' }); + }); + + it('creates a prize-wallet alert', async () => { + const { result } = renderHook(() => useRealunitReferral()); + const body = { + asset: RealUnitPrizeWalletAlertAsset.ETH, + threshold: 0.05, + mail: 'ops@example.com', + }; + + await result.current.createPrizeWalletAlert(body); + + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/referral/admin/prize-wallet/alerts', + method: 'POST', + data: body, + }); + }); + + it('soft-deletes a prize-wallet alert', async () => { + const { result } = renderHook(() => useRealunitReferral()); + + await result.current.deletePrizeWalletAlert(4); + + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/referral/admin/prize-wallet/alerts/4/delete', + method: 'PUT', + }); + }); + it('lists promo codes', async () => { mockCall.mockResolvedValue([]); const { result } = renderHook(() => useRealunitReferral()); diff --git a/src/__tests__/realunit.screen.test.tsx b/src/__tests__/realunit.screen.test.tsx index e71e9843a..fb225c453 100644 --- a/src/__tests__/realunit.screen.test.tsx +++ b/src/__tests__/realunit.screen.test.tsx @@ -60,9 +60,15 @@ jest.mock('src/hooks/guard.hook', () => ({ })); const mockGetPrizeWallet = jest.fn(); +const mockListPrizeWalletAlerts = jest.fn(); +const mockCreatePrizeWalletAlert = jest.fn(); +const mockDeletePrizeWalletAlert = jest.fn(); jest.mock('src/hooks/realunit-referral.hook', () => ({ useRealunitReferral: () => ({ getPrizeWallet: (...args: unknown[]) => mockGetPrizeWallet(...args), + listPrizeWalletAlerts: (...args: unknown[]) => mockListPrizeWalletAlerts(...args), + createPrizeWalletAlert: (...args: unknown[]) => mockCreatePrizeWalletAlert(...args), + deletePrizeWalletAlert: (...args: unknown[]) => mockDeletePrizeWalletAlert(...args), }), })); @@ -96,7 +102,7 @@ jest.mock('src/util/utils', () => ({ })); import { StrictMode } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import RealunitScreen from 'src/screens/realunit.screen'; const HOLDER = { @@ -137,6 +143,24 @@ const TX = { async function renderScreen() { const view = render(); await waitFor(() => expect(mockGetPrizeWallet).toHaveBeenCalled()); + let wallet: unknown; + try { + wallet = await mockGetPrizeWallet.mock.results[0].value; + } catch { + wallet = undefined; + } + if (wallet) { + // Alert panel mounts only on the main dashboard branch once the wallet card is shown. + await waitFor(() => { + if (!screen.queryByText('Bonus and Referral')) return; + expect(mockListPrizeWalletAlerts).toHaveBeenCalled(); + }); + } else { + await waitFor(() => { + if (!screen.queryByText('Bonus and Referral')) return; + expect(screen.queryByText('Prize wallet is not configured') || screen.queryByTestId('error-hint')).toBeTruthy(); + }); + } return view; } @@ -181,6 +205,15 @@ describe('RealunitScreen', () => { beforeEach(() => { jest.clearAllMocks(); mockGetPrizeWallet.mockResolvedValue({ address: '0xprizewallet', eth: 0.5, realu: 80 }); + mockListPrizeWalletAlerts.mockResolvedValue([]); + mockCreatePrizeWalletAlert.mockResolvedValue({ + id: 1, + asset: 'ETH', + threshold: 0.1, + mail: 'ops@example.com', + created: '2026-09-10T00:00:00.000Z', + }); + mockDeletePrizeWalletAlert.mockResolvedValue(undefined); setContext(); }); @@ -358,7 +391,11 @@ describe('RealunitScreen', () => { } fireEvent.click(holderButton); expect(mockNavigate).toHaveBeenCalledWith(`/realunit/user/${encodeURIComponent(HOLDER.address)}`); - fireEvent.click(screen.getAllByTestId('copy-button')[0]); + const holderRow = holderButton.closest('tr'); + if (!holderRow) { + throw new Error('holder row missing'); + } + fireEvent.click(within(holderRow).getByTestId('copy-button')); expect(mockCopy).toHaveBeenCalledWith(HOLDER.address); fireEvent.click(screen.getByRole('button', { name: 'More' })); expect(mockNavigate).toHaveBeenCalledWith('/realunit/holders'); @@ -507,6 +544,12 @@ describe('RealunitScreen', () => { expect(screen.getByTestId('payouts-panel')).toBeInTheDocument(); }); + it('shows the low-balance notify button when the prize wallet loaded', async () => { + await renderScreen(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Notify on low balance' })).toBeInTheDocument()); + expect(mockListPrizeWalletAlerts).toHaveBeenCalled(); + }); + it('shows a not-configured hint when the prize wallet is missing', async () => { mockGetPrizeWallet.mockRejectedValue(new Error('Prize wallet is not configured')); await renderScreen(); diff --git a/src/components/realunit/prize-wallet-alert-panel.tsx b/src/components/realunit/prize-wallet-alert-panel.tsx new file mode 100644 index 000000000..e4ac4baf8 --- /dev/null +++ b/src/components/realunit/prize-wallet-alert-panel.tsx @@ -0,0 +1,231 @@ +import { StyledButton, StyledButtonWidth } from '@dfx.swiss/react-components'; +import { FormEvent, useEffect, useRef, useState } from 'react'; +import { ErrorHint } from 'src/components/error-hint'; +import { RealUnitPrizeWalletAlert, RealUnitPrizeWalletAlertAsset } from 'src/dto/realunit-referral.dto'; +import { useRealunitReferral } from 'src/hooks/realunit-referral.hook'; + +interface PrizeWalletAlertPanelProps { + translate: (ns: string, key: string) => string; +} + +function isThresholdValid(asset: RealUnitPrizeWalletAlertAsset, raw: string): boolean { + const value = Number(raw); + if (asset === RealUnitPrizeWalletAlertAsset.ETH) { + return Number.isFinite(value) && value > 0; + } + return Number.isInteger(value) && value >= 1; +} + +/** One address only: no commas/semicolons/whitespace lists; exactly one @; non-empty local and domain (a@b min). */ +function isMailValid(value: string): boolean { + if (!value || value.includes(',') || value.includes(';') || /\s/.test(value)) return false; + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; + return true; +} + +export function RealunitPrizeWalletAlertPanel({ translate }: PrizeWalletAlertPanelProps): JSX.Element { + const { listPrizeWalletAlerts, createPrizeWalletAlert, deletePrizeWalletAlert } = useRealunitReferral(); + + const [alerts, setAlerts] = useState([]); + const [listError, setListError] = useState(); + const [formError, setFormError] = useState(); + const [actionError, setActionError] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showForm, setShowForm] = useState(false); + const [deletingIds, setDeletingIds] = useState>(new Set()); + const isSubmittingRef = useRef(false); + const deactivatingIdsRef = useRef>(new Set()); + const loadGenRef = useRef(0); + + const [asset, setAsset] = useState(RealUnitPrizeWalletAlertAsset.ETH); + const [threshold, setThreshold] = useState(''); + const [mail, setMail] = useState(''); + + useEffect(() => { + loadAlerts(); + }, []); + + function loadAlerts(): void { + const gen = ++loadGenRef.current; + setIsLoading(true); + setListError(undefined); + listPrizeWalletAlerts() + .then((rows) => { + if (gen !== loadGenRef.current) return; + setAlerts(rows); + }) + .catch((e: Error) => { + if (gen !== loadGenRef.current) return; + setAlerts([]); + setListError(e.message ?? 'Unknown error'); + }) + .finally(() => { + if (gen !== loadGenRef.current) return; + setIsLoading(false); + }); + } + + const trimmedMail = mail.trim(); + const canSubmit = !isLoading && isThresholdValid(asset, threshold) && isMailValid(trimmedMail); + + function resetForm(): void { + setAsset(RealUnitPrizeWalletAlertAsset.ETH); + setThreshold(''); + setMail(''); + } + + function onSubmit(event?: FormEvent): void { + event?.preventDefault(); + if (isSubmittingRef.current) return; + if (isLoading || !isThresholdValid(asset, threshold) || !isMailValid(trimmedMail)) return; + isSubmittingRef.current = true; + setIsSubmitting(true); + setFormError(undefined); + setActionError(undefined); + createPrizeWalletAlert({ + asset, + threshold: Number(threshold), + mail: trimmedMail, + }) + .then((created) => { + loadGenRef.current += 1; + setListError(undefined); + setActionError(undefined); + setAlerts((prev) => [created, ...prev]); + resetForm(); + setShowForm(false); + }) + .catch((e: Error) => setFormError(e.message ?? 'Unknown error')) + .finally(() => { + isSubmittingRef.current = false; + setIsSubmitting(false); + }); + } + + function onDelete(id: number): void { + if (deactivatingIdsRef.current.has(id)) return; + deactivatingIdsRef.current.add(id); + setDeletingIds(new Set(deactivatingIdsRef.current)); + setActionError(undefined); + deletePrizeWalletAlert(id) + .then(() => setAlerts((prev) => prev.filter((row) => row.id !== id))) + .catch((e: Error) => setActionError(e.message ?? 'Unknown error')) + .finally(() => { + deactivatingIdsRef.current.delete(id); + setDeletingIds(new Set(deactivatingIdsRef.current)); + }); + } + + const isRealu = asset === RealUnitPrizeWalletAlertAsset.REALU; + + return ( +
+ setShowForm((open) => !open)} + width={StyledButtonWidth.MIN} + disabled={isLoading} + /> + + {showForm && ( +
+ + + + onSubmit()} + width={StyledButtonWidth.MIN} + disabled={!canSubmit} + isLoading={isSubmitting} + /> + + )} + {formError && } + + {isLoading &&
{translate('screens/referral', 'Loading')}
} + {listError && !isLoading && ( + <> + + + + )} + {actionError && } + {alerts.length > 0 && ( +
+ + + + + + + + + + {alerts.map((row) => ( + + + + + + + ))} + +
+ {translate('screens/referral', 'Asset')} + + {translate('screens/referral', 'Threshold')} + + {translate('screens/referral', 'Mail')} + +
{row.asset}{row.threshold}{row.mail} + +
+
+ )} +
+ ); +} diff --git a/src/dto/realunit-referral.dto.ts b/src/dto/realunit-referral.dto.ts index 5390099af..fffedbfd5 100644 --- a/src/dto/realunit-referral.dto.ts +++ b/src/dto/realunit-referral.dto.ts @@ -73,6 +73,25 @@ export interface RealUnitPrizeWallet { realu: number; } +export enum RealUnitPrizeWalletAlertAsset { + ETH = 'ETH', + REALU = 'REALU', +} + +export interface RealUnitPrizeWalletAlert { + id: number; + asset: RealUnitPrizeWalletAlertAsset; + threshold: number; + mail: string; + created: string; // ISO +} + +export interface CreateRealUnitPrizeWalletAlert { + asset: RealUnitPrizeWalletAlertAsset; + threshold: number; + mail: string; +} + export interface RealUnitPromoCode { id: number; code: string; diff --git a/src/hooks/realunit-referral.hook.ts b/src/hooks/realunit-referral.hook.ts index 8fe239580..91e861dc7 100644 --- a/src/hooks/realunit-referral.hook.ts +++ b/src/hooks/realunit-referral.hook.ts @@ -1,9 +1,11 @@ import { useMemo } from 'react'; import { + CreateRealUnitPrizeWalletAlert, CreateRealUnitPromoBatch, CreateRealUnitPromoCode, RealUnitAdminPayout, RealUnitPrizeWallet, + RealUnitPrizeWalletAlert, RealUnitPromoCode, RealUnitReferralRelation, } from 'src/dto/realunit-referral.dto'; @@ -53,6 +55,28 @@ export function useRealunitReferral() { }); } + async function listPrizeWalletAlerts(): Promise { + return call({ + url: 'realunit/referral/admin/prize-wallet/alerts', + method: 'GET', + }); + } + + async function createPrizeWalletAlert(body: CreateRealUnitPrizeWalletAlert): Promise { + return call({ + url: 'realunit/referral/admin/prize-wallet/alerts', + method: 'POST', + data: body, + }); + } + + async function deletePrizeWalletAlert(id: number): Promise { + return call({ + url: `realunit/referral/admin/prize-wallet/alerts/${id}/delete`, + method: 'PUT', + }); + } + async function getPromoCodes(): Promise { return call({ url: 'realunit/referral/promo', @@ -97,6 +121,9 @@ export function useRealunitReferral() { rejectRelation, createManualPrize, getPrizeWallet, + listPrizeWalletAlerts, + createPrizeWalletAlert, + deletePrizeWalletAlert, getPromoCodes, createPromoCode, createPromoCodes, diff --git a/src/screens/realunit.screen.tsx b/src/screens/realunit.screen.tsx index 9ad58d6a0..037bcfb61 100644 --- a/src/screens/realunit.screen.tsx +++ b/src/screens/realunit.screen.tsx @@ -15,6 +15,7 @@ import { CopyableAddress } from 'src/components/realunit/copyable-address'; import { HolderCountChart } from 'src/components/realunit/holder-count-chart'; import { PayoutsPanel } from 'src/components/realunit/payouts-panel'; import { PriceHistoryChart } from 'src/components/realunit/price-history-chart'; +import { RealunitPrizeWalletAlertPanel } from 'src/components/realunit/prize-wallet-alert-panel'; import { RegistrationFunnel } from 'src/components/realunit/registration-funnel'; import { useRealunitContext } from 'src/contexts/realunit.context'; import { useSettingsContext } from 'src/contexts/settings.context'; @@ -161,22 +162,25 @@ export default function RealunitScreen(): JSX.Element {

{translate('screens/referral', 'Bonus and Referral')}

{prizeWalletLoading ? null : prizeWallet ? ( -
- -
-
-
{translate('screens/realunit', 'Address')}
- -
-
- {translate('screens/referral', 'ETH')}:{' '} - {prizeWallet.eth.toLocaleString(undefined, { maximumFractionDigits: 6 })} -
-
- {translate('screens/referral', 'REALU')}:{' '} - {prizeWallet.realu.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
+ +
+
+
{translate('screens/realunit', 'Address')}
+ +
+
+ {translate('screens/referral', 'ETH')}:{' '} + {prizeWallet.eth.toLocaleString(undefined, { maximumFractionDigits: 6 })} +
+
+ {translate('screens/referral', 'REALU')}:{' '} + {prizeWallet.realu.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
) : prizeWalletError?.includes('not configured') ? (

diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index d3abde771..7e3749476 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -1237,6 +1237,12 @@ "Amount": "Anzahl", "CHF": "CHF", "Tx hash": "Tx-Hash", - "Qualifying buy": "Qualifizierender Kauf" + "Qualifying buy": "Qualifizierender Kauf", + "Notify on low balance": "Bei niedrigem Bestand benachrichtigen", + "Asset": "Asset", + "Threshold": "Schwelle", + "Mail": "E-Mail", + "Submit": "Absenden", + "Loading": "Laden" } } diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 7a0bafc0b..a7a97568f 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -1236,6 +1236,12 @@ "Amount": "Quantité", "CHF": "CHF", "Tx hash": "Hash tx", - "Qualifying buy": "Achat qualifiant" + "Qualifying buy": "Achat qualifiant", + "Notify on low balance": "Notifier en cas de stock faible", + "Asset": "Actif", + "Threshold": "Seuil", + "Mail": "E-mail", + "Submit": "Envoyer", + "Loading": "Chargement" } } diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 1e98a0e14..9fdb214a6 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -1236,6 +1236,12 @@ "Amount": "Quantità", "CHF": "CHF", "Tx hash": "Hash tx", - "Qualifying buy": "Acquisto qualificante" + "Qualifying buy": "Acquisto qualificante", + "Notify on low balance": "Notifica in caso di scorte basse", + "Asset": "Asset", + "Threshold": "Soglia", + "Mail": "E-mail", + "Submit": "Invia", + "Loading": "Caricamento" } }