Skip to content
Open
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: 2 additions & 1 deletion src/components/common/BatchBuyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ export default function BatchBuyModal({

// Cleanup timers on unmount
useEffect(() => {
const timers = debounceTimers.current;
return () => {
debounceTimers.current.forEach(t => clearTimeout(t));
timers.forEach(t => clearTimeout(t));
};
}, []);

Expand Down
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
176 changes: 101 additions & 75 deletions src/components/common/SlippageToleranceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,13 @@ import {
SLIPPAGE_TOLERANCE_PRESETS,
SLIPPAGE_TOLERANCE_BOUNDS,
validateSlippageTolerancePercent,
} from '@/utils/slippageTolerance.utils';

export interface SlippageToleranceSelectorProps {
/** Currently selected tolerance, as a percentage (e.g. 1 = 1%). */
value: number;
onChange: (percent: number) => void;
disabled?: boolean;
computeSlippagePriceBounds,
validateSlippageTolerance,
SLIPPAGE_TOLERANCE_PRESETS,
type TradeSide,
} from '@/utils/slippageTolerance.utils';

export interface SlippageToleranceSelectorProps {
/** New (#877) prop interface — self-contained with preview price and side. */
export interface SlippageToleranceSelectorNewProps {
/** The quoted/preview price the tolerance is applied against. */
previewPrice: number;
/** Whether this trade is a buy (computes max_price) or sell (min_price). */
Expand All @@ -37,17 +30,50 @@ export interface SlippageToleranceSelectorProps {
className?: string;
}

/** Legacy (#872) prop interface — controlled value managed by the parent. */
export interface SlippageToleranceSelectorLegacyProps {
/** Currently selected tolerance, as a percentage (e.g. 1 = 1%). */
value: number;
onChange: (percent: number) => void;
disabled?: boolean;
className?: string;
}

export type SlippageToleranceSelectorProps =
| SlippageToleranceSelectorNewProps
| SlippageToleranceSelectorLegacyProps;

function isNewProps(
props: SlippageToleranceSelectorProps
): props is SlippageToleranceSelectorNewProps {
return 'previewPrice' in props && 'side' in props;
}

/**
* Preset + custom slippage tolerance picker used by the buy/sell trade
* dialogs (#872). Presets are 0.5% / 1% / 5%; a custom input accepts any
* value in [0, 50]. Selecting a preset clears any custom-input error state.
* Slippage tolerance selector — issue #872 / #877 trade flow.
*
* Supports two prop interfaces:
* - **New (#877)**: self-contained with `previewPrice`/`side`, shows
* XLM-denominated bounds and a confirm button.
* - **Legacy (#872)**: controlled via `value`/`onChange`/`disabled`,
* used by TradeDialog.
*/
const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = ({
value,
onChange,
disabled = false,
className,
}) => {
const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = (
props
) => {
if (isNewProps(props)) {
return <SlippageToleranceSelectorNew {...props} />;
}
return <SlippageToleranceSelectorLegacy {...props} />;
};

// ---------------------------------------------------------------------------
// Legacy (#872) — controlled value/onChange, preset buttons + custom input
// ---------------------------------------------------------------------------

const SlippageToleranceSelectorLegacy: React.FC<
SlippageToleranceSelectorLegacyProps
> = ({ value, onChange, disabled = false, className }) => {
const isPresetSelected = (
SLIPPAGE_TOLERANCE_PRESETS as readonly number[]
).includes(value);
Expand Down Expand Up @@ -79,63 +105,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,6 +178,63 @@ 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>
);
};

// ---------------------------------------------------------------------------
// New (#877) — self-contained with preview price, shows XLM bounds + confirm
// ---------------------------------------------------------------------------

const SlippageToleranceSelectorNew: React.FC<
SlippageToleranceSelectorNewProps
> = ({ 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);
}
};

return (
<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">
Expand Down
Loading
Loading