diff --git a/src/components/common/KeySimulationTool.tsx b/src/components/common/KeySimulationTool.tsx
index 626998bc..72958912 100644
--- a/src/components/common/KeySimulationTool.tsx
+++ b/src/components/common/KeySimulationTool.tsx
@@ -181,155 +181,6 @@ const KeySimulationTool: React.FC = ({
-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 = ({
- keyId,
- spotPrice,
- initialQuantity = 1,
-}) => {
- const [quantityInput, setQuantityInput] = useState(
- String(initialQuantity)
- );
- const [simulatedPrice, setSimulatedPrice] = useState(null);
- const [resolvedSpotPrice, setResolvedSpotPrice] = useState(spotPrice);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const debounceRef = useRef | 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 (
-
-
-
- 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"
- />
-
-
- {loading && (
-
- )}
-
- {!loading && error && (
-
- {error}
-
- )}
-
- {!loading && !error && impact != null && (
-
- {formatPriceImpact(impact)}
-
)}
);
diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx
index f249bbf1..60b1f9c9 100644
--- a/src/components/common/SlippageToleranceSelector.tsx
+++ b/src/components/common/SlippageToleranceSelector.tsx
@@ -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;
}
@@ -79,63 +56,6 @@ const SlippageToleranceSelector: React.FC = ({
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 = ({
- previewPrice,
- side,
- onToleranceChange,
- onValidityChange,
- onConfirm,
- className,
-}) => {
- const [selectedPreset, setSelectedPreset] = useState(
- 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);
}
};
@@ -209,70 +129,6 @@ const SlippageToleranceSelector: React.FC = ({
{SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the
price moves beyond your tolerance before it executes.
-
-
Slippage tolerance
-
- {SLIPPAGE_TOLERANCE_PRESETS.map(preset => (
-
- ))}
- 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' : ''
- )}
- />
-
-
- {isCustom && !validation.valid && (
-
- {validation.error}
-
- )}
-
- {validation.valid && (
-
- {side === 'buy'
- ? `Max price: ${bounds.maxPrice} XLM`
- : `Min price: ${bounds.minPrice} XLM`}
-
- )}
-
-
);
};
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 309e5e60..8c095f9b 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -9,6 +9,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
+import {
+ BottomSheet,
+ BottomSheetContent,
+ BottomSheetDescription,
+ BottomSheetHandle,
+ BottomSheetTitle,
+} from '@/components/ui/bottom-sheet';
+import { useIsMobile } from '@/hooks/useIsMobile';
import { cn } from '@/lib/utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import {
@@ -290,6 +298,240 @@ const TradeDialog: React.FC = ({
}
}, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]);
+ const isMobile = useIsMobile();
+
+ const bodyContent = (
+ <>
+ {side === 'buy' && keyPriceStroops != null && (
+
+ Unit price:{' '}
+
+ {formatDisplayKeyPrice(keyPriceStroops)}
+
+
+ )}
+
+
+
Amount
+
{
+ setAmountText(event.target.value);
+ setTouched(true);
+ }}
+ onBlur={handleBlur}
+ 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',
+ 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"
+ />
+ {showError && (
+
+ {validationError}
+
+ )}
+
+
+ Holdings: {formatNumber(availableHoldings)} keys
+
+ {side === 'sell' &&
+ availableHoldings > 0 &&
+ Number.isFinite(parsedAmount) &&
+ parsedAmount > 0 && (
+
availableHoldings
+ ? 'negative'
+ : 'neutral'
+ }
+ />
+ )}
+
+ {side === 'buy' && (
+
+ )}
+ {side === 'buy' && amountValid && (
+
{
+ setPreviewError(null);
+ setPreviewLoading(true);
+ }}
+ />
+ )}
+ {side === 'buy' && estimatedTotalStroops != null && (
+
+ Estimated total (approximate):{' '}
+
+ {formatDisplayKeyPrice(estimatedTotalStroops)}
+
+
+ )}
+ {side === 'sell' && (
+
+ {estimatedProceedsStroops != null ? (
+ <>
+ Estimated proceeds (approximate):{' '}
+
+ {formatDisplayKeyPrice(estimatedProceedsStroops)}
+
+ >
+ ) : (
+ <>Estimated proceeds unavailable>
+ )}
+
+ )}
+ {amountValid && (
+
+
+ {slippageBounds && (
+
+ {side === 'buy'
+ ? slippageBounds.maxPriceStroops != null && (
+ <>
+ Max price:{' '}
+
+ {formatDisplayKeyPrice(
+ slippageBounds.maxPriceStroops
+ )}
+
+ >
+ )
+ : slippageBounds.minPriceStroops != null && (
+ <>
+ Min price:{' '}
+
+ {formatDisplayKeyPrice(
+ slippageBounds.minPriceStroops
+ )}
+
+ >
+ )}
+
+ )}
+
+ )}
+
+ >
+ );
+
+ const actionButtons = (
+ <>
+
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+ !isSubmitting && onOpenChange(next)}
+ >
+ {
+ event.preventDefault();
+ amountInputRef.current?.focus();
+ }}
+ onCloseAutoFocus={event => {
+ event.preventDefault();
+ triggerElementRef.current?.focus();
+ }}
+ onEscapeKeyDown={event => {
+ if (isSubmitting) event.preventDefault();
+ }}
+ onInteractOutside={event => {
+ if (isSubmitting) event.preventDefault();
+ }}
+ >
+
+
+
+ {title}
+
+
+ {side === 'buy'
+ ? `Purchase creator keys for ${creatorName}.`
+ : `Sell creator keys for ${creatorName}.`}
+
+
+
+ {bodyContent}
+
+
+ {actionButtons}
+
+
+
+ );
+ }
+
return (
diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
index 2e0cd20b..e4a5d953 100644
--- a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
+++ b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
@@ -88,112 +88,5 @@ describe('SlippageToleranceSelector', () => {
fireEvent.change(input, { target: { value: '3' } });
fireEvent.click(screen.getByTestId('slippage-preset-0.5'));
expect(input).toHaveValue('');
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import React from 'react';
-
-import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector';
-
-describe('SlippageToleranceSelector (#877)', () => {
- it('shows max_price of 100.5 XLM for the 0.5% preset on a 100 XLM buy preview', () => {
- render();
-
- // 0.5% is the default-selected preset.
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100.5 XLM'
- );
- });
-
- it('shows max_price of 105 XLM after selecting the 5% preset', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByTestId('slippage-preset-5'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 105 XLM'
- );
- });
-
- it('shows min_price of 99 XLM after selecting the 1% preset on a sell', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByTestId('slippage-preset-1'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Min price: 99 XLM'
- );
- });
-
- it('sets max_price equal to the preview price for a custom 0% tolerance', async () => {
- const user = userEvent.setup();
- render();
-
- await user.type(screen.getByTestId('slippage-custom-input'), '0');
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100 XLM'
- );
- expect(
- screen.queryByTestId('slippage-validation-error')
- ).not.toBeInTheDocument();
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('shows a validation error and disables the confirm button for a custom tolerance above 50%', async () => {
- const user = userEvent.setup();
- const onValidityChange = vi.fn();
- render(
-
- );
-
- await user.type(screen.getByTestId('slippage-custom-input'), '51');
-
- expect(screen.getByTestId('slippage-validation-error')).toHaveTextContent(
- /50%/
- );
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
- expect(onValidityChange).toHaveBeenLastCalledWith(false);
- // No stale price-bound should be shown once the input is invalid.
- expect(
- screen.queryByTestId('slippage-price-bound')
- ).not.toBeInTheDocument();
- });
-
- it('re-enables the confirm button once a custom tolerance is corrected back into range', async () => {
- const user = userEvent.setup();
- render();
-
- const input = screen.getByTestId('slippage-custom-input');
- await user.type(input, '75');
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, '10');
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('calls onConfirm with the computed bounds when the confirm button is clicked', async () => {
- const user = userEvent.setup();
- const onConfirm = vi.fn();
- render(
-
- );
-
- await user.click(screen.getByTestId('slippage-confirm-button'));
-
- expect(onConfirm).toHaveBeenCalledWith({
- maxPrice: 100.5,
- minPrice: null,
- });
});
});
diff --git a/src/components/common/__tests__/TradeDialog.mobileBottomSheet.test.tsx b/src/components/common/__tests__/TradeDialog.mobileBottomSheet.test.tsx
new file mode 100644
index 00000000..a58f04d5
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.mobileBottomSheet.test.tsx
@@ -0,0 +1,269 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import TradeDialog, { type TradeDialogProps } from '@/components/common/TradeDialog';
+
+// ---------------------------------------------------------------------------
+// Viewport / matchMedia mock helpers
+// ---------------------------------------------------------------------------
+
+type MQCallback = (event: Pick) => void;
+
+interface MockMQL {
+ matches: boolean;
+ addEventListener: (event: string, cb: MQCallback) => void;
+ removeEventListener: (event: string, cb: MQCallback) => void;
+ _fire: (newMatches: boolean) => void;
+}
+
+function mockViewportWidth(widthPx: number): MockMQL {
+ const matches = widthPx < 768;
+ const listeners: MQCallback[] = [];
+ const mql: MockMQL = {
+ matches,
+ addEventListener: (_event: string, cb: MQCallback) => listeners.push(cb),
+ removeEventListener: (_event: string, cb: MQCallback) => {
+ const idx = listeners.indexOf(cb);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ _fire: (newMatches: boolean) => {
+ mql.matches = newMatches;
+ listeners.forEach(cb => cb({ matches: newMatches }));
+ },
+ };
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockReturnValue(mql),
+ });
+
+ return mql;
+}
+
+function TestTradeDialog(props: Partial = {}) {
+ const [open, setOpen] = useState(props.open ?? true);
+ return (
+
+ );
+}
+
+describe('TradeDialog mobile slide-up sheet (#865)', () => {
+ let mql: MockMQL;
+
+ beforeEach(() => {
+ mql = mockViewportWidth(375); // mobile iPhone width by default (< 768px)
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('Viewport-responsive rendering', () => {
+ it('renders as a bottom sheet with drag handle on mobile viewports (< 768px)', () => {
+ mockViewportWidth(500);
+ render();
+
+ // Bottom sheet content slot is rendered
+ const sheet = screen.getByTestId('bottom-sheet-handle');
+ expect(sheet).toBeInTheDocument();
+
+ const content = document.querySelector('[data-slot="bottom-sheet-content"]');
+ expect(content).toBeInTheDocument();
+ expect(document.querySelector('[data-slot="dialog-content"]')).not.toBeInTheDocument();
+
+ // Sheet title and description
+ expect(screen.getByText('Buy keys')).toBeInTheDocument();
+ expect(screen.getByText('Purchase creator keys for Alice.')).toBeInTheDocument();
+ });
+
+ it('renders as a centered modal without drag handle on desktop viewports (>= 768px)', () => {
+ mockViewportWidth(1024);
+ render();
+
+ // Centered dialog content slot is rendered
+ expect(document.querySelector('[data-slot="dialog-content"]')).toBeInTheDocument();
+ expect(document.querySelector('[data-slot="bottom-sheet-content"]')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('bottom-sheet-handle')).not.toBeInTheDocument();
+
+ // Dialog title and description
+ expect(screen.getByText('Buy keys')).toBeInTheDocument();
+ expect(screen.getByText('Purchase creator keys for Alice.')).toBeInTheDocument();
+ });
+
+ it('switches between bottom sheet and centered modal dynamically when viewport changes', () => {
+ render();
+
+ // Initially mobile: bottom sheet
+ expect(document.querySelector('[data-slot="bottom-sheet-content"]')).toBeInTheDocument();
+ expect(screen.getByTestId('bottom-sheet-handle')).toBeInTheDocument();
+
+ // Resize to desktop
+ act(() => {
+ mql._fire(false);
+ });
+
+ expect(document.querySelector('[data-slot="dialog-content"]')).toBeInTheDocument();
+ expect(screen.queryByTestId('bottom-sheet-handle')).not.toBeInTheDocument();
+
+ // Resize back to mobile
+ act(() => {
+ mql._fire(true);
+ });
+
+ expect(document.querySelector('[data-slot="bottom-sheet-content"]')).toBeInTheDocument();
+ expect(screen.getByTestId('bottom-sheet-handle')).toBeInTheDocument();
+ });
+ });
+
+ describe('Height constraint & top margin', () => {
+ it('enforces maximum height leaving at least 80px of viewport visible at top on mobile', () => {
+ render();
+
+ const content = document.querySelector('[data-slot="bottom-sheet-content"]');
+ expect(content).toHaveClass('max-h-[calc(100vh-80px)]');
+ expect(content).toHaveClass('overflow-y-auto');
+ });
+ });
+
+ describe('Slide-up animation', () => {
+ it('applies slide-up CSS animation classes on the bottom sheet content', () => {
+ render();
+
+ const content = document.querySelector('[data-slot="bottom-sheet-content"]');
+ expect(content).toHaveClass('data-[state=open]:slide-in-from-bottom-8');
+ expect(content).toHaveClass('data-[state=closed]:slide-out-to-bottom-8');
+ });
+ });
+
+ describe('Swipe-down to dismiss gesture', () => {
+ it('dismisses the sheet when dragging downward past the threshold', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 220 }); // 120px > 96px threshold
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 220 });
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not dismiss when dragging below the threshold', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 130 }); // 30px < 96px threshold
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 130 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('does not dismiss on upward drag', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 200, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 100 }); // -100px upward
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 100 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('Sell modal variant on mobile', () => {
+ it('renders the sell variant as a bottom sheet with correct titles and sell details', () => {
+ render(
+
+ );
+
+ expect(document.querySelector('[data-slot="bottom-sheet-content"]')).toBeInTheDocument();
+ expect(screen.getByTestId('bottom-sheet-handle')).toBeInTheDocument();
+ expect(screen.getByText('Sell keys')).toBeInTheDocument();
+ expect(screen.getByText('Sell creator keys for Bob.')).toBeInTheDocument();
+ expect(screen.getByTestId('trade-dialog-confirm')).toHaveTextContent('Confirm sell');
+ });
+ });
+
+ describe('Submitting state on mobile', () => {
+ it('disables drag-to-dismiss and hides close button when isSubmitting is true', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ // Close button is hidden
+ expect(screen.queryByRole('button', { name: 'Close panel' })).not.toBeInTheDocument();
+
+ // Dragging handle does not dismiss
+ const handle = screen.getByTestId('bottom-sheet-handle');
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 400 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('Form interactions & accessibility on mobile', () => {
+ it('allows changing amount and confirms trade on mobile', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render();
+
+ const input = screen.getByTestId('trade-dialog-amount');
+ await user.clear(input);
+ await user.type(input, '3');
+
+ const confirmBtn = screen.getByTestId('trade-dialog-confirm');
+ await user.click(confirmBtn);
+
+ expect(onConfirm).toHaveBeenCalledWith(
+ 3,
+ null,
+ expect.objectContaining({ toleranceZPercent: 1 })
+ );
+ });
+
+ it('allows cancelling trade via cancel button on mobile', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render();
+
+ const cancelBtn = screen.getByTestId('trade-dialog-cancel');
+ await user.click(cancelBtn);
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('allows closing via the close button on mobile', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ const closeBtn = screen.getByRole('button', { name: 'Close panel' });
+ fireEvent.click(closeBtn);
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+});
diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts
index d30ac864..77c71c42 100644
--- a/src/hooks/useIsMobile.ts
+++ b/src/hooks/useIsMobile.ts
@@ -10,11 +10,12 @@ const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`;
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => {
- if (typeof window === 'undefined') return false;
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
});
useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches);
diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts
index 6ae36f0c..ba9bdb57 100644
--- a/src/utils/__tests__/slippageTolerance.utils.test.ts
+++ b/src/utils/__tests__/slippageTolerance.utils.test.ts
@@ -123,7 +123,9 @@ describe('slippageTolerance.utils', () => {
expect(computeSlippageBounds('buy', null, 1).maxPriceStroops).toBeNull();
expect(computeSlippageBounds('sell', undefined, 1).minPriceStroops).toBeNull();
});
-import { describe, expect, it } from 'vitest';
+ });
+});
+
import {
computeSlippagePriceBounds,
validateSlippageTolerance,
diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts
index 6b29ec3e..7d822a84 100644
--- a/src/utils/slippageTolerance.utils.ts
+++ b/src/utils/slippageTolerance.utils.ts
@@ -114,6 +114,9 @@ export function computeSlippageBounds(
? computeMinPriceStroops(previewPriceStroops, toleranceZPercent)
: null,
};
+}
+
+/**
* Slippage tolerance selector logic — issue #877.
*
* A trade preview's `max_price` (for buys) or `min_price` (for sells) is
@@ -122,9 +125,6 @@ export function computeSlippageBounds(
* accept receiving up to `tolerance%` less.
*/
-/** Preset tolerance options shown in the slippage selector, in percent. */
-export const SLIPPAGE_TOLERANCE_PRESETS = [0.5, 1, 5] as const;
-
/** Tolerances above this percentage are rejected as invalid. */
export const MAX_SLIPPAGE_TOLERANCE_PERCENT = 50;