Skip to content
Closed
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
149 changes: 0 additions & 149 deletions src/components/common/KeySimulationTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,155 +181,6 @@ const KeySimulationTool: React.FC<KeySimulationToolProps> = ({
</span>
</div>
</div>
import React, { useEffect, useRef, useState } from 'react';
import { courseService } from '@/services/course.service';
import {
calculatePriceImpact,
formatPriceImpact,
} from '@/utils/priceImpact.utils';

export interface KeySimulationToolProps {
/** Key identifier used for GET /keys/:keyId/simulate?quantity=N */
keyId: string;
/** Current spot price in the same unit as simulated_price (e.g. XLM or stroops) */
spotPrice: number;
/** Optional initial quantity */
initialQuantity?: number;
}

interface SimulateResult {
simulated_price?: number;
simulatedPrice?: number;
spot_price?: number;
spotPrice?: number;
}

/**
* Key price simulation tool (#887).
*
* Lets the user enter a custom quantity, debounces the input by 300ms,
* fetches GET /keys/:keyId/simulate?quantity=N, computes price impact as
* (simulated_price - spot_price) / spot_price * 100 and displays it.
*
* Loading shows a skeleton, fetch errors show 'Unable to simulate price'
* and hide the impact value.
*/
const KeySimulationTool: React.FC<KeySimulationToolProps> = ({
keyId,
spotPrice,
initialQuantity = 1,
}) => {
const [quantityInput, setQuantityInput] = useState(
String(initialQuantity)
);
const [simulatedPrice, setSimulatedPrice] = useState<number | null>(null);
const [resolvedSpotPrice, setResolvedSpotPrice] = useState<number>(spotPrice);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Keep spot price in sync when prop changes
useEffect(() => {
setResolvedSpotPrice(spotPrice);
}, [spotPrice]);

useEffect(() => {
const quantity = Number(quantityInput);
// Empty or invalid quantity: clear simulation
if (quantityInput.trim() === '' || isNaN(quantity) || quantity <= 0) {
setSimulatedPrice(null);
setError(null);
setLoading(false);
return;
}

if (debounceRef.current) clearTimeout(debounceRef.current);

setLoading(true);
setError(null);

debounceRef.current = setTimeout(async () => {
try {
const result: SimulateResult =
await courseService.simulateBuy(keyId, quantity);
// Support both snake_case and camelCase shapes
const sim =
result.simulated_price ?? result.simulatedPrice ?? null;
const spot =
result.spot_price ?? result.spotPrice ?? spotPrice;
if (sim != null) {
setSimulatedPrice(sim);
if (spot != null) setResolvedSpotPrice(spot);
setError(null);
} else {
setSimulatedPrice(null);
}
} catch {
setError('Unable to simulate price');
setSimulatedPrice(null);
} finally {
setLoading(false);
}
}, 300);

return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [quantityInput, keyId, spotPrice]);

const impact =
simulatedPrice != null
? calculatePriceImpact(simulatedPrice, resolvedSpotPrice)
: null;

return (
<div className="space-y-4" data-testid="key-simulation-tool">
<div className="space-y-1.5">
<label
htmlFor="simulation-quantity"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Quantity
</label>
<input
id="simulation-quantity"
data-testid="simulation-quantity-input"
aria-label="Custom quantity"
type="number"
inputMode="numeric"
min={1}
value={quantityInput}
onChange={e => setQuantityInput(e.target.value)}
className="w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none"
placeholder="Enter quantity"
/>
</div>

{loading && (
<div
data-testid="simulation-skeleton"
aria-label="Loading simulation"
className="h-6 w-32 animate-pulse rounded bg-white/10"
/>
)}

{!loading && error && (
<p
role="alert"
data-testid="simulation-error"
className="text-sm text-red-400"
>
{error}
</p>
)}

{!loading && !error && impact != null && (
<p
data-testid="price-impact"
className="text-sm font-bold text-white"
>
{formatPriceImpact(impact)}
</p>
)}
</div>
);
Expand Down
144 changes: 0 additions & 144 deletions src/components/common/SlippageToleranceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,6 @@ export interface SlippageToleranceSelectorProps {
value: number;
onChange: (percent: number) => void;
disabled?: boolean;
computeSlippagePriceBounds,
validateSlippageTolerance,
SLIPPAGE_TOLERANCE_PRESETS,
type TradeSide,
} from '@/utils/slippageTolerance.utils';

export interface SlippageToleranceSelectorProps {
/** The quoted/preview price the tolerance is applied against. */
previewPrice: number;
/** Whether this trade is a buy (computes max_price) or sell (min_price). */
side: TradeSide;
/** Called whenever the selected tolerance changes with a valid value. */
onToleranceChange?: (tolerancePercent: number) => void;
/**
* Called with the confirm-eligibility state whenever it changes, so a
* parent trade dialog can disable its own confirm button in lockstep.
*/
onValidityChange?: (canConfirm: boolean) => void;
/** Called when the confirm button is clicked while the tolerance is valid. */
onConfirm?: (bounds: {
maxPrice: number | null;
minPrice: number | null;
}) => void;
className?: string;
}

Expand Down Expand Up @@ -79,63 +56,6 @@ const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = ({
const parsed = Number(normalized);
if (validateSlippageTolerancePercent(parsed) === null) {
onChange(parsed);
* Slippage tolerance selector — issue #877 / #784 trade flow.
*
* Lets the user pick a preset tolerance (0.5% / 1% / 5%) or enter a custom
* percentage, and displays the resulting max_price (buy) / min_price (sell)
* bound. A custom tolerance above 50% is rejected with a validation error
* and disables the confirm action.
*/
const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = ({
previewPrice,
side,
onToleranceChange,
onValidityChange,
onConfirm,
className,
}) => {
const [selectedPreset, setSelectedPreset] = useState<number | null>(
SLIPPAGE_TOLERANCE_PRESETS[0]
);
const [customValue, setCustomValue] = useState('');
const [isCustom, setIsCustom] = useState(false);

const activeToleranceText = isCustom
? customValue
: String(selectedPreset ?? '');
const parsedTolerance = activeToleranceText.trim()
? Number(activeToleranceText)
: NaN;

const validation = useMemo(
() => validateSlippageTolerance(parsedTolerance),
[parsedTolerance]
);

const bounds = useMemo(() => {
if (!validation.valid) return { maxPrice: null, minPrice: null };
return computeSlippagePriceBounds(previewPrice, parsedTolerance, side);
}, [validation.valid, previewPrice, parsedTolerance, side]);

const canConfirm = validation.valid;

const selectPreset = (preset: number) => {
setIsCustom(false);
setSelectedPreset(preset);
onToleranceChange?.(preset);
onValidityChange?.(true);
};

const handleCustomChange = (rawValue: string) => {
setIsCustom(true);
setSelectedPreset(null);
setCustomValue(rawValue);

const parsed = rawValue.trim() ? Number(rawValue) : NaN;
const result = validateSlippageTolerance(parsed);
onValidityChange?.(result.valid);
if (result.valid) {
onToleranceChange?.(parsed);
}
};

Expand Down Expand Up @@ -209,70 +129,6 @@ const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = ({
{SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the
price moves beyond your tolerance before it executes.
</p>
<div className={cn('space-y-2', className)}>
<div className="text-sm text-white/70">Slippage tolerance</div>
<div className="flex flex-wrap items-center gap-2">
{SLIPPAGE_TOLERANCE_PRESETS.map(preset => (
<button
key={preset}
type="button"
onClick={() => selectPreset(preset)}
aria-pressed={!isCustom && selectedPreset === preset}
data-testid={`slippage-preset-${preset}`}
className={cn(
'rounded-full px-3 py-1 text-xs font-semibold transition-colors',
!isCustom && selectedPreset === preset
? 'bg-amber-500/20 text-amber-300'
: 'bg-white/5 text-white/60 hover:bg-white/10'
)}
>
{preset}%
</button>
))}
<input
type="text"
inputMode="decimal"
placeholder="Custom %"
value={customValue}
onChange={event => handleCustomChange(event.target.value)}
onFocus={() => setIsCustom(true)}
aria-label="Custom slippage tolerance"
data-testid="slippage-custom-input"
className={cn(
'w-24 rounded-md border bg-white/[0.04] px-2 py-1 text-xs text-white outline-none transition-colors',
'border-white/10 focus:border-amber-500/50',
isCustom && !validation.valid ? 'border-red-500/60' : ''
)}
/>
</div>

{isCustom && !validation.valid && (
<p
role="alert"
data-testid="slippage-validation-error"
className="text-xs text-red-300"
>
{validation.error}
</p>
)}

{validation.valid && (
<p className="text-xs text-white/45" data-testid="slippage-price-bound">
{side === 'buy'
? `Max price: ${bounds.maxPrice} XLM`
: `Min price: ${bounds.minPrice} XLM`}
</p>
)}

<button
type="button"
onClick={() => onConfirm?.(bounds)}
disabled={!canConfirm}
data-testid="slippage-confirm-button"
className="rounded-md bg-amber-500/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition-colors hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Confirm
</button>
</div>
);
};
Expand Down
Loading
Loading