Skip to content

Commit ae8f514

Browse files
authored
Merge pull request #1125 from ElizabethChi/fix/accesslayer-client-assigned-batch
test/feat: time-ago formatter tests, buy-quantity validation, and a Horizon transaction poller
2 parents 69ae297 + f05d858 commit ae8f514

15 files changed

Lines changed: 1075 additions & 1 deletion

‎.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ VITE_MAINNET_RPC_URL=
3030
# Set to 'mainnet' for production, 'testnet' for development (default).
3131
VITE_STELLAR_NETWORK=testnet
3232
VITE_STELLAR_RPC_URL=
33+
# Horizon base URL used to poll transaction status after submission. Leave
34+
# unset to use the public Horizon endpoint for VITE_STELLAR_NETWORK.
35+
VITE_HORIZON_URL=
3336
VITE_CREATOR_KEYS_CONTRACT_ID=
3437

3538
# Comma-separated wallet addresses allowed to deploy new creator keys through the

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AppErrorBoundary from './components/common/AppErrorBoundary';
77
import ContractPausedBanner from './components/common/ContractPausedBanner';
88
import OfflineBanner from './components/common/OfflineBanner';
99
import GlobalPauseBanner from './components/common/GlobalPauseBanner';
10+
import PendingTransactionBanner from './components/common/PendingTransactionBanner';
1011
import SessionExpiryWatcher from './components/common/SessionExpiryWatcher';
1112
import { useContractPausedStore } from './hooks/useContractPausedStore';
1213
import { routes } from './routes';
@@ -45,6 +46,7 @@ function App() {
4546
<ContractPausedBanner />
4647
<OfflineBanner />
4748
<GlobalPauseBanner />
49+
<PendingTransactionBanner />
4850
<Toaster
4951
toastOptions={{
5052
ariaProps: {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
usePendingTransactions,
3+
type PendingTransaction,
4+
} from '@/contexts/PendingTransactionContext';
5+
import { usePendingTransactionPoller } from '@/hooks/usePendingTransactionPoller';
6+
import { buildStellarExpertTxUrl, truncateTxHash } from '@/constants/stellar';
7+
import { env } from '@/utils/env.utils';
8+
import { Button } from '@/components/ui/button';
9+
import { cn } from '@/lib/utils';
10+
11+
/**
12+
* Mounted once near the app root. Starts the Horizon poller and renders one
13+
* card per tracked transaction, reflecting its pending/confirmed/failed/
14+
* timeout state (#1082). Confirmed transactions are left to the existing
15+
* showToast.transactionSuccess flow and removed here once their state
16+
* settles, so the two surfaces never show the same confirmation twice.
17+
*/
18+
export default function PendingTransactionBanner() {
19+
usePendingTransactionPoller();
20+
const { transactions, removeTransaction, retryTransaction } =
21+
usePendingTransactions();
22+
23+
const visible = transactions.filter(
24+
(tx): tx is PendingTransaction & { state: 'pending' | 'failed' | 'timeout' } =>
25+
tx.state !== 'confirmed'
26+
);
27+
if (visible.length === 0) return null;
28+
29+
return (
30+
<div
31+
className="fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"
32+
data-testid="pending-transaction-banner"
33+
>
34+
{visible.map(tx => (
35+
<TransactionCard
36+
key={tx.hash}
37+
hash={tx.hash}
38+
state={tx.state}
39+
errorMessage={tx.errorMessage}
40+
onDismiss={() => removeTransaction(tx.hash)}
41+
onRetry={() => retryTransaction(tx.hash)}
42+
/>
43+
))}
44+
</div>
45+
);
46+
}
47+
48+
function TransactionCard({
49+
hash,
50+
state,
51+
errorMessage,
52+
onDismiss,
53+
onRetry,
54+
}: {
55+
hash: string;
56+
state: 'pending' | 'failed' | 'timeout';
57+
errorMessage?: string;
58+
onDismiss: () => void;
59+
onRetry: () => void;
60+
}) {
61+
const explorerUrl = buildStellarExpertTxUrl(hash, env.VITE_STELLAR_NETWORK);
62+
63+
return (
64+
<div
65+
role="status"
66+
aria-live="polite"
67+
data-testid={`pending-transaction-${state}`}
68+
className={cn(
69+
'rounded-xl border bg-slate-900 p-4 shadow-xl',
70+
state === 'pending' && 'border-amber-500/20',
71+
state === 'failed' && 'border-rose-500/30',
72+
state === 'timeout' && 'border-amber-500/30'
73+
)}
74+
>
75+
<div className="flex items-start justify-between gap-3">
76+
<div>
77+
<p className="font-jakarta text-sm font-bold text-white">
78+
{state === 'pending' && 'Confirming transaction…'}
79+
{state === 'failed' && 'Transaction failed'}
80+
{state === 'timeout' && 'Still confirming'}
81+
</p>
82+
<p className="mt-1 font-mono text-xs text-white/50">
83+
{truncateTxHash(hash)}
84+
</p>
85+
{state === 'failed' && errorMessage && (
86+
<p
87+
data-testid="pending-transaction-error-message"
88+
className="mt-1 text-xs text-rose-300"
89+
>
90+
{errorMessage}
91+
</p>
92+
)}
93+
{state === 'timeout' && (
94+
<p className="mt-1 text-xs text-white/60">
95+
This is taking longer than usual. Check the explorer for the
96+
latest status.
97+
</p>
98+
)}
99+
</div>
100+
<button
101+
type="button"
102+
onClick={onDismiss}
103+
aria-label="Dismiss"
104+
className="text-white/40 hover:text-white"
105+
>
106+
×
107+
</button>
108+
</div>
109+
110+
<div className="mt-3 flex items-center gap-2">
111+
<a
112+
href={explorerUrl}
113+
target="_blank"
114+
rel="noopener noreferrer"
115+
data-testid="pending-transaction-explorer-link"
116+
className="text-xs font-medium text-amber-400/80 underline-offset-2 hover:text-amber-300 hover:underline"
117+
>
118+
View on Stellar Expert
119+
</a>
120+
{state === 'failed' && (
121+
<Button
122+
size="sm"
123+
variant="outline"
124+
onClick={onRetry}
125+
data-testid="pending-transaction-retry"
126+
>
127+
Retry
128+
</Button>
129+
)}
130+
</div>
131+
</div>
132+
);
133+
}

‎src/components/common/TradeDialog.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export interface TradeDialogProps {
124124
launchPenaltyBps?: number | null;
125125
/** Max buy quantity allowed per transaction; null means no limit. */
126126
maxBuyQuantity?: number | null;
127+
/** Keys still available to mint for this creator; null means no ceiling (#753). */
128+
availableSupply?: number | null;
127129
/** Live key trading config carrying the bid-ask spread (#951). */
128130
keyConfig?: KeyConfig | null;
129131
/** Whether the key config query is still loading. */
@@ -168,6 +170,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
168170
currentLedger,
169171
launchPenaltyBps,
170172
maxBuyQuantity = null,
173+
availableSupply = null,
171174
keyConfig,
172175
isKeyConfigLoading = false,
173176
circuitBreakerThresholdPercent,
@@ -355,6 +358,9 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
355358

356359
const validationError = useMemo((): string | null => {
357360
const normalized = amountText.trim();
361+
if (side === 'buy' && (!normalized || !Number.isFinite(parsedAmount) || parsedAmount < 1)) {
362+
return 'Minimum quantity is 1';
363+
}
358364
if (!normalized) return 'Please enter an amount.';
359365
if (!Number.isFinite(parsedAmount))
360366
return 'Amount must be a valid number.';
@@ -371,6 +377,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
371377
parsedAmount > maxBuyQuantity
372378
)
373379
return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`;
380+
if (
381+
side === 'buy' &&
382+
availableSupply != null &&
383+
parsedAmount > availableSupply
384+
)
385+
return 'Exceeds available supply';
374386
if (
375387
(side === 'sell' || side === 'stake' || side === 'transfer') &&
376388
parsedAmount > availableHoldings
@@ -385,6 +397,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
385397
atCap,
386398
maxBuyable,
387399
maxBuyQuantity,
400+
availableSupply,
388401
isPaused,
389402
]);
390403

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Unit tests for the pending-transaction status banner (#1082).
3+
*/
4+
import { describe, expect, it, vi } from 'vitest';
5+
import { render, screen } from '@testing-library/react';
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
7+
import {
8+
PendingTransactionProvider,
9+
usePendingTransactions,
10+
} from '@/contexts/PendingTransactionContext';
11+
import PendingTransactionBanner from '../PendingTransactionBanner';
12+
13+
vi.mock('@/lib/stellar/horizonClient', () => ({
14+
getTransactionStatus: vi.fn().mockResolvedValue({ status: 'pending' }),
15+
}));
16+
17+
function Harness({ hash }: { hash: string }) {
18+
const { addPendingTransaction } = usePendingTransactions();
19+
addPendingTransaction(hash);
20+
return <PendingTransactionBanner />;
21+
}
22+
23+
function renderBanner(hash: string) {
24+
const queryClient = new QueryClient({
25+
defaultOptions: { queries: { retry: false } },
26+
});
27+
return render(
28+
<QueryClientProvider client={queryClient}>
29+
<PendingTransactionProvider>
30+
<Harness hash={hash} />
31+
</PendingTransactionProvider>
32+
</QueryClientProvider>
33+
);
34+
}
35+
36+
describe('PendingTransactionBanner (#1082)', () => {
37+
it('renders nothing when there are no tracked transactions', () => {
38+
const queryClient = new QueryClient();
39+
render(
40+
<QueryClientProvider client={queryClient}>
41+
<PendingTransactionProvider>
42+
<PendingTransactionBanner />
43+
</PendingTransactionProvider>
44+
</QueryClientProvider>
45+
);
46+
expect(
47+
screen.queryByTestId('pending-transaction-banner')
48+
).not.toBeInTheDocument();
49+
});
50+
51+
it('shows a pending card for a tracked transaction with an explorer link', () => {
52+
renderBanner('abcd1234');
53+
54+
expect(
55+
screen.getByTestId('pending-transaction-pending')
56+
).toBeInTheDocument();
57+
const link = screen.getByTestId(
58+
'pending-transaction-explorer-link'
59+
) as HTMLAnchorElement;
60+
expect(link.href).toContain('stellar.expert');
61+
expect(link.href).toContain('abcd1234');
62+
expect(link.target).toBe('_blank');
63+
});
64+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Unit tests for the buy-side quantity input enforcing a minimum of 1 and a
3+
* maximum equal to the creator's available supply (#753).
4+
*/
5+
import { describe, expect, it, vi } from 'vitest';
6+
import { render, screen, fireEvent } from '@testing-library/react';
7+
import TradeDialog from '../TradeDialog';
8+
9+
describe('TradeDialog available-supply quantity validation (#753)', () => {
10+
function renderBuyDialog(
11+
overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}
12+
) {
13+
return render(
14+
<TradeDialog
15+
open={true}
16+
side="buy"
17+
creatorName="Alex Rivers"
18+
availableHoldings={0}
19+
availableSupply={100}
20+
onOpenChange={vi.fn()}
21+
onConfirm={vi.fn()}
22+
{...overrides}
23+
/>
24+
);
25+
}
26+
27+
it("shows 'Minimum quantity is 1' when the quantity is 0", () => {
28+
renderBuyDialog();
29+
const input = screen.getByTestId('trade-dialog-amount');
30+
fireEvent.change(input, { target: { value: '0' } });
31+
32+
expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
33+
'Minimum quantity is 1'
34+
);
35+
});
36+
37+
it("shows 'Minimum quantity is 1' when the quantity is negative", () => {
38+
renderBuyDialog();
39+
const input = screen.getByTestId('trade-dialog-amount');
40+
fireEvent.change(input, { target: { value: '-4' } });
41+
42+
expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
43+
'Minimum quantity is 1'
44+
);
45+
});
46+
47+
it("shows 'Exceeds available supply' when the quantity is above the available supply", () => {
48+
renderBuyDialog({ availableSupply: 50 });
49+
const input = screen.getByTestId('trade-dialog-amount');
50+
fireEvent.change(input, { target: { value: '51' } });
51+
52+
expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
53+
'Exceeds available supply'
54+
);
55+
});
56+
57+
it('passes validation when the quantity exactly equals the available supply', () => {
58+
renderBuyDialog({ availableSupply: 50 });
59+
const input = screen.getByTestId('trade-dialog-amount');
60+
fireEvent.change(input, { target: { value: '50' } });
61+
62+
expect(
63+
screen.queryByTestId('trade-dialog-amount-error')
64+
).not.toBeInTheDocument();
65+
});
66+
67+
it('shows no error for a valid quantity within range', () => {
68+
renderBuyDialog({ availableSupply: 50 });
69+
const input = screen.getByTestId('trade-dialog-amount');
70+
fireEvent.change(input, { target: { value: '10' } });
71+
72+
expect(
73+
screen.queryByTestId('trade-dialog-amount-error')
74+
).not.toBeInTheDocument();
75+
});
76+
77+
it('disables the confirm button while the available-supply error is active', () => {
78+
renderBuyDialog({ availableSupply: 50 });
79+
const input = screen.getByTestId('trade-dialog-amount');
80+
fireEvent.change(input, { target: { value: '51' } });
81+
82+
expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
83+
});
84+
85+
it('disables the confirm button while the minimum-quantity error is active', () => {
86+
renderBuyDialog();
87+
const input = screen.getByTestId('trade-dialog-amount');
88+
fireEvent.change(input, { target: { value: '0' } });
89+
90+
expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
91+
});
92+
93+
it('does not apply the available-supply ceiling when availableSupply is null (unlimited)', () => {
94+
renderBuyDialog({ availableSupply: null });
95+
const input = screen.getByTestId('trade-dialog-amount');
96+
fireEvent.change(input, { target: { value: '100000' } });
97+
98+
expect(
99+
screen.queryByTestId('trade-dialog-amount-error')
100+
).not.toBeInTheDocument();
101+
});
102+
103+
it('does not apply the minimum-quantity or available-supply rules on the sell side', () => {
104+
render(
105+
<TradeDialog
106+
open={true}
107+
side="sell"
108+
creatorName="Alex Rivers"
109+
availableHoldings={5}
110+
availableSupply={50}
111+
onOpenChange={vi.fn()}
112+
onConfirm={vi.fn()}
113+
/>
114+
);
115+
const input = screen.getByTestId('trade-dialog-amount');
116+
fireEvent.change(input, { target: { value: '0' } });
117+
118+
// Sell side keeps its own zero-quantity message, not the buy-side one.
119+
const error = screen.getByTestId('trade-dialog-amount-error');
120+
expect(error).toHaveTextContent('Amount must be greater than zero.');
121+
expect(error).not.toHaveTextContent('Minimum quantity is 1');
122+
});
123+
});

0 commit comments

Comments
 (0)