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
154 changes: 38 additions & 116 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import PercentageBadge from '@/components/common/PercentageBadge';
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
import { clampBuyQuantity } from '@/utils/buyQuantity';
import {
fetchTradeNetworkFeeEstimate,
formatTransactionFeeDisplay,
type NetworkFeeDataProvider,
} from '@/utils/transactionFee.utils';
import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils';
import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils';

export type TradeSide = 'buy' | 'sell';

Expand All @@ -35,13 +29,8 @@ export interface TradeDialogProps {
onOpenChange: (open: boolean) => void;
onConfirm: (amount: number) => Promise<void> | void;
isSubmitting?: boolean;
networkFeeEstimateProvider?: NetworkFeeDataProvider;
}

type NetworkFeeEstimateState =
| { status: 'idle' | 'loading' | 'error'; fee: null }
| { status: 'success'; fee: number };

const TradeDialog: React.FC<TradeDialogProps> = ({
open,
side,
Expand All @@ -51,111 +40,43 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
onOpenChange,
onConfirm,
isSubmitting = false,
networkFeeEstimateProvider,
}) => {
const [amountText, setAmountText] = useState('1');
const [networkFeeEstimate, setNetworkFeeEstimate] =
useState<NetworkFeeEstimateState>({ status: 'idle', fee: null });
const [adjustmentNote, setAdjustmentNote] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const amountInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
if (open) {
setAmountText('1');
setAdjustmentNote(null);
setTouched(false);
}
}, [open]);

const handleBlur = () => {
if (side !== 'buy') return;

const trimmed = amountText.trim();
const res = clampBuyQuantity(trimmed);

if (res.adjusted) {
setAmountText(res.value.toString());
if (res.reason === 'below_min') {
setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`);
} else if (res.reason === 'above_max') {
setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`);
} else {
setAdjustmentNote(`Quantity rounded to ${res.value}.`);
}
} else {
setAdjustmentNote(null);
}
};

const parsedAmount = useMemo(() => {
const normalized = amountText.trim();
if (!normalized) return NaN;
return Number(normalized);
}, [amountText]);

const amountValid =
Number.isFinite(parsedAmount) &&
parsedAmount > 0 &&
(side !== 'sell' || parsedAmount <= availableHoldings);
const validationError = useMemo((): string | null => {
const normalized = amountText.trim();
if (!normalized) return 'Please enter an amount.';
if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.';
if (parsedAmount <= 0) return 'Amount must be greater than zero.';
if (side === 'sell' && parsedAmount > availableHoldings)
return `You can't sell more than your holdings (${formatNumber(availableHoldings)} keys).`;
return null;
}, [amountText, parsedAmount, side, availableHoldings]);

const amountValid = validationError === null;
const showError = touched && validationError !== null;

const displayCreatorName =
normalizeCreatorDisplayName(creatorName) || 'Unnamed creator';
const title = side === 'buy' ? 'Buy keys' : 'Sell keys';
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';
const estimatedNetworkFee = formatTransactionFeeDisplay(
networkFeeEstimate.status === 'success'
? networkFeeEstimate.fee
: TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
{ unit: TRADE_FEE_ESTIMATE.UNIT }
);
const networkFeeCopy =
networkFeeEstimate.status === 'loading'
? 'Estimating...'
: networkFeeEstimate.status === 'error'
? 'Cannot estimate network fee'
: estimatedNetworkFee;

useEffect(() => {
if (!open) {
setNetworkFeeEstimate({ status: 'idle', fee: null });
return;
}

if (!amountValid || !networkFeeEstimateProvider) {
setNetworkFeeEstimate({ status: 'error', fee: null });
return;
}

let cancelled = false;
setNetworkFeeEstimate({ status: 'loading', fee: null });

fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, {
side,
amount: parsedAmount,
})
.then(fee => {
if (cancelled) return;
setNetworkFeeEstimate(
fee == null
? { status: 'error', fee: null }
: { status: 'success', fee }
);
})
.catch(() => {
if (!cancelled) {
setNetworkFeeEstimate({ status: 'error', fee: null });
}
});

return () => {
cancelled = true;
};
}, [
amountValid,
networkFeeEstimateProvider,
open,
parsedAmount,
side,
]);

return (
<Dialog
Expand All @@ -181,8 +102,8 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{side === 'buy'
? `Purchase creator keys for ${displayCreatorName}.`
: `Sell creator keys for ${displayCreatorName}.`}
? `Purchase creator keys for ${creatorName}.`
: `Sell creator keys for ${creatorName}.`}
</DialogDescription>
</DialogHeader>

Expand All @@ -203,25 +124,30 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
value={amountText}
onChange={event => {
setAmountText(event.target.value);
setAdjustmentNote(null);
setTouched(true);
}}
onBlur={handleBlur}
onBlur={() => setTouched(true)}
disabled={isSubmitting}
className={cn(
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15',
!amountValid && amountText.trim()
? 'border-red-500/40'
: ''
showError ? 'border-red-500/60' : ''
)}
aria-label="Trade amount"
aria-describedby={showError ? 'trade-amount-error' : undefined}
aria-invalid={showError || undefined}
data-focus-order="1"
data-testid="trade-dialog-amount"
/>
{side === 'buy' && adjustmentNote && (
<div className="text-xs text-amber-400 font-medium animate-in fade-in duration-200" data-testid="buy-qty-adjustment-note">
{adjustmentNote}
</div>
{showError && (
<p
id="trade-amount-error"
role="alert"
className="text-xs text-red-300"
data-testid="trade-dialog-amount-error"
>
{validationError}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
<span
Expand All @@ -244,16 +170,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
/>
)}
</div>
<NetworkFeeHint
variant="text"
label="Approx. network fee"
fee={networkFeeCopy}
className="text-white/45"
/>
{side === 'sell' && parsedAmount > availableHoldings && (
<div className="text-xs text-red-300">
You can’t sell more than your current holdings.
</div>
{side === 'buy' && (
<NetworkFeeHint
variant="text"
fee={estimatedNetworkFee}
className="text-white/45"
/>
)}
</div>

Expand Down
3 changes: 0 additions & 3 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionT
import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge';
import { useEthersProvider } from '@/hooks/useEthersProvider';
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
import showToast from '@/utils/toast.util';
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
Expand Down Expand Up @@ -265,7 +264,6 @@ function LandingPage() {
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const tradeFeeEstimateProvider = useEthersProvider();
const prefersReducedMotion = usePrefersReducedMotion();
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'featured';
Expand Down Expand Up @@ -1246,7 +1244,6 @@ function LandingPage() {
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
isSubmitting={tradeSubmitting}
networkFeeEstimateProvider={tradeFeeEstimateProvider}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
/>
Expand Down
Loading