Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 452 additions & 0 deletions src/__tests__/prize-wallet-alert-panel.test.tsx

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions src/__tests__/realunit-referral.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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());
Expand Down
47 changes: 45 additions & 2 deletions src/__tests__/realunit.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
}));

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -137,6 +143,24 @@ const TX = {
async function renderScreen() {
const view = render(<RealunitScreen />);
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;
}

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
231 changes: 231 additions & 0 deletions src/components/realunit/prize-wallet-alert-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<RealUnitPrizeWalletAlert[]>([]);
const [listError, setListError] = useState<string>();
const [formError, setFormError] = useState<string>();
const [actionError, setActionError] = useState<string>();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showForm, setShowForm] = useState(false);
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
const isSubmittingRef = useRef(false);
const deactivatingIdsRef = useRef<Set<number>>(new Set());
const loadGenRef = useRef(0);

const [asset, setAsset] = useState<RealUnitPrizeWalletAlertAsset>(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 (
<div className="bg-white rounded-lg shadow-sm p-4 flex flex-col gap-4 text-left w-full">
<StyledButton
label={translate('screens/referral', 'Notify on low balance')}
onClick={() => setShowForm((open) => !open)}
width={StyledButtonWidth.MIN}
disabled={isLoading}
/>

{showForm && (
<form className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end" onSubmit={onSubmit}>
<label className="flex flex-col gap-1 text-sm text-dfxBlue-800">
{translate('screens/referral', 'Asset')}
<select
className="border border-dfxGray-400 rounded px-2 py-1"
value={asset}
onChange={(e) => setAsset(e.target.value as RealUnitPrizeWalletAlertAsset)}
>
<option value={RealUnitPrizeWalletAlertAsset.ETH}>{translate('screens/referral', 'ETH')}</option>
<option value={RealUnitPrizeWalletAlertAsset.REALU}>{translate('screens/referral', 'REALU')}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-sm text-dfxBlue-800">
{translate('screens/referral', 'Threshold')}
<input
className="border border-dfxGray-400 rounded px-2 py-1"
type="number"
step={isRealu ? 1 : 'any'}
min={isRealu ? 1 : undefined}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-sm text-dfxBlue-800">
{translate('screens/referral', 'Mail')}
<input
className="border border-dfxGray-400 rounded px-2 py-1"
type="email"
value={mail}
onChange={(e) => setMail(e.target.value)}
autoComplete="off"
/>
</label>
<StyledButton
label={translate('screens/referral', 'Submit')}
onClick={() => onSubmit()}
width={StyledButtonWidth.MIN}
disabled={!canSubmit}
isLoading={isSubmitting}
/>
</form>
)}
{formError && <ErrorHint message={formError} />}

{isLoading && <div data-testid="prize-wallet-alert-loading">{translate('screens/referral', 'Loading')}</div>}
{listError && !isLoading && (
<>
<ErrorHint message={listError} />
<StyledButton
label={translate('general/actions', 'Retry')}
onClick={loadAlerts}
width={StyledButtonWidth.MIN}
disabled={isSubmitting}
/>
</>
)}
{actionError && <ErrorHint message={actionError} />}
{alerts.length > 0 && (
<div className="overflow-auto">
<table className="w-full border-collapse text-sm">
<thead className="bg-dfxGray-300">
<tr>
<th className="px-3 py-2 text-left font-semibold text-dfxBlue-800">
{translate('screens/referral', 'Asset')}
</th>
<th className="px-3 py-2 text-left font-semibold text-dfxBlue-800">
{translate('screens/referral', 'Threshold')}
</th>
<th className="px-3 py-2 text-left font-semibold text-dfxBlue-800">
{translate('screens/referral', 'Mail')}
</th>
<th className="px-3 py-2 text-left font-semibold text-dfxBlue-800" />
</tr>
</thead>
<tbody>
{alerts.map((row) => (
<tr key={row.id} className="border-b border-dfxGray-300">
<td className="px-3 py-2 text-dfxBlue-800">{row.asset}</td>
<td className="px-3 py-2 text-dfxBlue-800">{row.threshold}</td>
<td className="px-3 py-2 text-dfxBlue-800 break-all">{row.mail}</td>
<td className="px-3 py-2">
<button
type="button"
className={`text-dfxRed-100 underline text-sm${deletingIds.has(row.id) ? ' opacity-50' : ''}`}
aria-disabled={deletingIds.has(row.id)}
onClick={() => onDelete(row.id)}
>
{translate('general/actions', 'Delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
Loading
Loading