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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ VITE_MAINNET_RPC_URL=
# Set to 'mainnet' for production, 'testnet' for development (default).
VITE_STELLAR_NETWORK=testnet
VITE_STELLAR_RPC_URL=
# Horizon base URL used to poll transaction status after submission. Leave
# unset to use the public Horizon endpoint for VITE_STELLAR_NETWORK.
VITE_HORIZON_URL=
VITE_CREATOR_KEYS_CONTRACT_ID=

# Comma-separated wallet addresses allowed to deploy new creator keys through the
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AppErrorBoundary from './components/common/AppErrorBoundary';
import ContractPausedBanner from './components/common/ContractPausedBanner';
import OfflineBanner from './components/common/OfflineBanner';
import GlobalPauseBanner from './components/common/GlobalPauseBanner';
import PendingTransactionBanner from './components/common/PendingTransactionBanner';
import SessionExpiryWatcher from './components/common/SessionExpiryWatcher';
import { useContractPausedStore } from './hooks/useContractPausedStore';
import { routes } from './routes';
Expand Down Expand Up @@ -45,6 +46,7 @@ function App() {
<ContractPausedBanner />
<OfflineBanner />
<GlobalPauseBanner />
<PendingTransactionBanner />
<Toaster
toastOptions={{
ariaProps: {
Expand Down
133 changes: 133 additions & 0 deletions src/components/common/PendingTransactionBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
usePendingTransactions,
type PendingTransaction,
} from '@/contexts/PendingTransactionContext';
import { usePendingTransactionPoller } from '@/hooks/usePendingTransactionPoller';
import { buildStellarExpertTxUrl, truncateTxHash } from '@/constants/stellar';
import { env } from '@/utils/env.utils';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

/**
* Mounted once near the app root. Starts the Horizon poller and renders one
* card per tracked transaction, reflecting its pending/confirmed/failed/
* timeout state (#1082). Confirmed transactions are left to the existing
* showToast.transactionSuccess flow and removed here once their state
* settles, so the two surfaces never show the same confirmation twice.
*/
export default function PendingTransactionBanner() {
usePendingTransactionPoller();
const { transactions, removeTransaction, retryTransaction } =
usePendingTransactions();

const visible = transactions.filter(
(tx): tx is PendingTransaction & { state: 'pending' | 'failed' | 'timeout' } =>
tx.state !== 'confirmed'
);
if (visible.length === 0) return null;

return (
<div
className="fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"
data-testid="pending-transaction-banner"
>
{visible.map(tx => (
<TransactionCard
key={tx.hash}
hash={tx.hash}
state={tx.state}
errorMessage={tx.errorMessage}
onDismiss={() => removeTransaction(tx.hash)}
onRetry={() => retryTransaction(tx.hash)}
/>
))}
</div>
);
}

function TransactionCard({
hash,
state,
errorMessage,
onDismiss,
onRetry,
}: {
hash: string;
state: 'pending' | 'failed' | 'timeout';
errorMessage?: string;
onDismiss: () => void;
onRetry: () => void;
}) {
const explorerUrl = buildStellarExpertTxUrl(hash, env.VITE_STELLAR_NETWORK);

return (
<div
role="status"
aria-live="polite"
data-testid={`pending-transaction-${state}`}
className={cn(
'rounded-xl border bg-slate-900 p-4 shadow-xl',
state === 'pending' && 'border-amber-500/20',
state === 'failed' && 'border-rose-500/30',
state === 'timeout' && 'border-amber-500/30'
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-jakarta text-sm font-bold text-white">
{state === 'pending' && 'Confirming transaction…'}
{state === 'failed' && 'Transaction failed'}
{state === 'timeout' && 'Still confirming'}
</p>
<p className="mt-1 font-mono text-xs text-white/50">
{truncateTxHash(hash)}
</p>
{state === 'failed' && errorMessage && (
<p
data-testid="pending-transaction-error-message"
className="mt-1 text-xs text-rose-300"
>
{errorMessage}
</p>
)}
{state === 'timeout' && (
<p className="mt-1 text-xs text-white/60">
This is taking longer than usual. Check the explorer for the
latest status.
</p>
)}
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="text-white/40 hover:text-white"
>
×
</button>
</div>

<div className="mt-3 flex items-center gap-2">
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
data-testid="pending-transaction-explorer-link"
className="text-xs font-medium text-amber-400/80 underline-offset-2 hover:text-amber-300 hover:underline"
>
View on Stellar Expert
</a>
{state === 'failed' && (
<Button
size="sm"
variant="outline"
onClick={onRetry}
data-testid="pending-transaction-retry"
>
Retry
</Button>
)}
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export interface TradeDialogProps {
launchPenaltyBps?: number | null;
/** Max buy quantity allowed per transaction; null means no limit. */
maxBuyQuantity?: number | null;
/** Keys still available to mint for this creator; null means no ceiling (#753). */
availableSupply?: number | null;
/** Live key trading config carrying the bid-ask spread (#951). */
keyConfig?: KeyConfig | null;
/** Whether the key config query is still loading. */
Expand Down Expand Up @@ -163,6 +165,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
currentLedger,
launchPenaltyBps,
maxBuyQuantity = null,
availableSupply = null,
keyConfig,
isKeyConfigLoading = false,
circuitBreakerThresholdPercent,
Expand Down Expand Up @@ -350,6 +353,9 @@ const TradeDialog: React.FC<TradeDialogProps> = ({

const validationError = useMemo((): string | null => {
const normalized = amountText.trim();
if (side === 'buy' && (!normalized || !Number.isFinite(parsedAmount) || parsedAmount < 1)) {
return 'Minimum quantity is 1';
}
if (!normalized) return 'Please enter an amount.';
if (!Number.isFinite(parsedAmount))
return 'Amount must be a valid number.';
Expand All @@ -366,6 +372,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
parsedAmount > maxBuyQuantity
)
return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`;
if (
side === 'buy' &&
availableSupply != null &&
parsedAmount > availableSupply
)
return 'Exceeds available supply';
if (
(side === 'sell' || side === 'stake' || side === 'transfer') &&
parsedAmount > availableHoldings
Expand All @@ -380,6 +392,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
atCap,
maxBuyable,
maxBuyQuantity,
availableSupply,
isPaused,
]);

Expand Down
64 changes: 64 additions & 0 deletions src/components/common/__tests__/PendingTransactionBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Unit tests for the pending-transaction status banner (#1082).
*/
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
PendingTransactionProvider,
usePendingTransactions,
} from '@/contexts/PendingTransactionContext';
import PendingTransactionBanner from '../PendingTransactionBanner';

vi.mock('@/lib/stellar/horizonClient', () => ({
getTransactionStatus: vi.fn().mockResolvedValue({ status: 'pending' }),
}));

function Harness({ hash }: { hash: string }) {
const { addPendingTransaction } = usePendingTransactions();
addPendingTransaction(hash);
return <PendingTransactionBanner />;
}

function renderBanner(hash: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<PendingTransactionProvider>
<Harness hash={hash} />
</PendingTransactionProvider>
</QueryClientProvider>
);
}

describe('PendingTransactionBanner (#1082)', () => {
it('renders nothing when there are no tracked transactions', () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<PendingTransactionProvider>
<PendingTransactionBanner />
</PendingTransactionProvider>
</QueryClientProvider>
);
expect(
screen.queryByTestId('pending-transaction-banner')
).not.toBeInTheDocument();
});

it('shows a pending card for a tracked transaction with an explorer link', () => {
renderBanner('abcd1234');

expect(
screen.getByTestId('pending-transaction-pending')
).toBeInTheDocument();
const link = screen.getByTestId(
'pending-transaction-explorer-link'
) as HTMLAnchorElement;
expect(link.href).toContain('stellar.expert');
expect(link.href).toContain('abcd1234');
expect(link.target).toBe('_blank');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Unit tests for the buy-side quantity input enforcing a minimum of 1 and a
* maximum equal to the creator's available supply (#753).
*/
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TradeDialog from '../TradeDialog';

describe('TradeDialog available-supply quantity validation (#753)', () => {
function renderBuyDialog(
overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}
) {
return render(
<TradeDialog
open={true}
side="buy"
creatorName="Alex Rivers"
availableHoldings={0}
availableSupply={100}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
{...overrides}
/>
);
}

it("shows 'Minimum quantity is 1' when the quantity is 0", () => {
renderBuyDialog();
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '0' } });

expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
'Minimum quantity is 1'
);
});

it("shows 'Minimum quantity is 1' when the quantity is negative", () => {
renderBuyDialog();
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '-4' } });

expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
'Minimum quantity is 1'
);
});

it("shows 'Exceeds available supply' when the quantity is above the available supply", () => {
renderBuyDialog({ availableSupply: 50 });
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '51' } });

expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
'Exceeds available supply'
);
});

it('passes validation when the quantity exactly equals the available supply', () => {
renderBuyDialog({ availableSupply: 50 });
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '50' } });

expect(
screen.queryByTestId('trade-dialog-amount-error')
).not.toBeInTheDocument();
});

it('shows no error for a valid quantity within range', () => {
renderBuyDialog({ availableSupply: 50 });
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '10' } });

expect(
screen.queryByTestId('trade-dialog-amount-error')
).not.toBeInTheDocument();
});

it('disables the confirm button while the available-supply error is active', () => {
renderBuyDialog({ availableSupply: 50 });
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '51' } });

expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
});

it('disables the confirm button while the minimum-quantity error is active', () => {
renderBuyDialog();
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '0' } });

expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
});

it('does not apply the available-supply ceiling when availableSupply is null (unlimited)', () => {
renderBuyDialog({ availableSupply: null });
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '100000' } });

expect(
screen.queryByTestId('trade-dialog-amount-error')
).not.toBeInTheDocument();
});

it('does not apply the minimum-quantity or available-supply rules on the sell side', () => {
render(
<TradeDialog
open={true}
side="sell"
creatorName="Alex Rivers"
availableHoldings={5}
availableSupply={50}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>
);
const input = screen.getByTestId('trade-dialog-amount');
fireEvent.change(input, { target: { value: '0' } });

// Sell side keeps its own zero-quantity message, not the buy-side one.
const error = screen.getByTestId('trade-dialog-amount-error');
expect(error).toHaveTextContent('Amount must be greater than zero.');
expect(error).not.toHaveTextContent('Minimum quantity is 1');
});
});
Loading
Loading