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 && ( + + )} +
+ + 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 ( = ({ - {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 && ( - - )} -
- - 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 - )} - - - )} -

- )} -
- )} -
+ {bodyContent} {/* * Focus order is intentional: amount input → Cancel → Confirm. @@ -477,38 +577,7 @@ const TradeDialog: React.FC = ({ * `__tests__/TradeDialog.focusOrder.test.tsx` guards this. */} - - + {actionButtons}
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;