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
2 changes: 2 additions & 0 deletions apps/demo-wallet/src/components/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useWalletStore, useWallet } from '@demo/wallet-core';

import { ProtectedRoute } from './ProtectedRoute';
import { LoaderCircle } from './LoaderCircle';
import { SwapNotifications } from './swap/SwapNotifications';
import {
SetupPassword,
UnlockWallet,
Expand Down Expand Up @@ -80,6 +81,7 @@ export const AppRouter: React.FC = () => {

return (
<BrowserRouter>
<SwapNotifications />
<Routes>
{/* Public routes */}
<Route path="/setup-password" element={<SetupPassword />} />
Expand Down
4 changes: 3 additions & 1 deletion apps/demo-wallet/src/components/HoldToSignButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface HoldToSignButtonProps {
isLoading?: boolean;
holdDuration?: number; // Duration in milliseconds
className?: string;
idleLabel?: string;
}

export const HoldToSignButton: React.FC<HoldToSignButtonProps> = ({
Expand All @@ -22,6 +23,7 @@ export const HoldToSignButton: React.FC<HoldToSignButtonProps> = ({
isLoading = false,
holdDuration = 3000,
className = '',
idleLabel = 'Hold to Sign',
}) => {
const [isHolding, setIsHolding] = useState(false);
const [progress, setProgress] = useState(0);
Expand Down Expand Up @@ -221,7 +223,7 @@ export const HoldToSignButton: React.FC<HoldToSignButtonProps> = ({
<span className="font-semibold inline-block min-w-[110px] text-center">
{isHolding
? `Hold (${Math.ceil((holdDuration - (progress * holdDuration) / 100) / 1000)}s)`
: 'Hold to Sign'}
: idleLabel}
</span>
</>
)}
Expand Down
63 changes: 22 additions & 41 deletions apps/demo-wallet/src/components/swap/QuoteTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,66 +9,47 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';

import { Button } from '../Button';

interface QuoteTimerProps {
expiresAt?: number; // Unix timestamp in seconds
onRefresh: () => void;
isLoading?: boolean;
isRefreshing?: boolean;
}

export const QuoteTimer: FC<QuoteTimerProps> = ({ expiresAt, onRefresh, isLoading = false }) => {
const [timeLeft, setTimeLeft] = useState(0);
/**
* Slim, inline indicator that lives inside the quote details block.
* The quote refreshes itself silently before expiry, so this is a passive
* status line — not an interactive banner.
*/
export const QuoteTimer: FC<QuoteTimerProps> = ({ expiresAt, isRefreshing = false }) => {
const [secondsLeft, setSecondsLeft] = useState(0);

useEffect(() => {
if (!expiresAt) {
setTimeLeft(0);
setSecondsLeft(0);
return;
}

const updateTimer = () => {
const now = Math.floor(Date.now() / 1000); // Current time in seconds
const remaining = Math.max(0, expiresAt - now);
setTimeLeft(remaining * 1000); // Convert to milliseconds for display
const update = () => {
const now = Math.floor(Date.now() / 1000);
setSecondsLeft(Math.max(0, expiresAt - now));
};

updateTimer();
const interval = setInterval(updateTimer, 100);

update();
const interval = setInterval(update, 500);
return () => clearInterval(interval);
}, [expiresAt]);

const totalSeconds = Math.ceil(timeLeft / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const isExpired = !expiresAt || timeLeft === 0;

if (isExpired) {
if (isRefreshing || secondsLeft === 0) {
return (
<div className="flex items-center justify-between rounded-lg bg-yellow-50 border border-yellow-200 px-3 py-2">
<span className="text-yellow-800 text-sm font-medium">Quote expired</span>
<Button
onClick={onRefresh}
disabled={isLoading}
isLoading={isLoading}
size="sm"
className="h-7 px-3 text-xs"
>
Refresh
</Button>
</div>
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse" />
Refreshing quote…
</span>
);
}

return (
<div className="flex items-center justify-between rounded-lg bg-blue-50 border border-blue-200 px-3 py-2">
<span className="text-blue-800 text-sm">
Quote valid for{' '}
<span className="font-semibold">
{minutes > 0 && `${minutes}m `}
{seconds}s
</span>
</span>
</div>
<span className="text-muted-foreground text-xs">
Refreshes in <span className="font-medium text-foreground">{secondsLeft}s</span>
</span>
);
};
164 changes: 109 additions & 55 deletions apps/demo-wallet/src/components/swap/SwapInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@
*/

import type { FC } from 'react';
import { useState } from 'react';
import { useSwap } from '@demo/wallet-core';
import { useEffect, useRef, useState } from 'react';
import { useSwap, useAuth, useJettons } from '@demo/wallet-core';
import { useNavigate } from 'react-router-dom';
import type { SwapToken } from '@ton/walletkit';

import { SwapSettings } from './SwapSettings';
import { TokenInput } from './TokenInput';
import { QuoteTimer } from './QuoteTimer';
import { Button } from '../Button';
import { Card } from '../Card';
import { HoldToSignButton } from '../HoldToSignButton';

import { cn } from '@/lib/utils';
import { resolveTokenSymbol } from '@/utils/swapToken';

const QUOTE_DEBOUNCE_MS = 500;
const QUOTE_REFRESH_LEAD_MS = 5000;

function getPriceImpactColor(priceImpact: number): string {
if (priceImpact > 500) return 'text-destructive';
Expand Down Expand Up @@ -51,32 +55,80 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
swapTokens,
getSwapQuote: getQuote,
executeSwap,
validateSwapInputs,
} = useSwap();

const { holdToSign } = useAuth();
const { userJettons } = useJettons();
const navigate = useNavigate();

const [showSlippageSettings, setShowSlippageSettings] = useState(false);
const [useCustomDestination, setUseCustomDestination] = useState(false);
const [isRefreshingQuote, setIsRefreshingQuote] = useState(false);

const getTokenSymbol = (token: SwapToken): string => {
if (token.symbol) return token.symbol;
if (token.address === 'ton') return 'TON';
return 'Unknown';
};
const fromSymbol = resolveTokenSymbol(fromToken, userJettons);
const toSymbol = resolveTokenSymbol(toToken, userJettons);

const fromSymbol = getTokenSymbol(fromToken);
const toSymbol = getTokenSymbol(toToken);
// Debounced auto-quote: refetch the quote whenever the meaningful inputs change.
// We deliberately re-run on quote becoming null (e.g. after a token switch)
// so the new pair fetches a quote without requiring an extra keystroke.
useEffect(() => {
if (!amount || parseFloat(amount) <= 0) return;
if (validateSwapInputs()) return;
if (currentQuote) return;

const handleGetQuote = async () => {
await getQuote();
};
const handle = setTimeout(() => {
void getQuote();
}, QUOTE_DEBOUNCE_MS);

return () => clearTimeout(handle);
}, [
amount,
fromToken.address,
toToken.address,
isReverseSwap,
slippageBps,
currentQuote,
validateSwapInputs,
getQuote,
]);

// Silent refresh: re-fetch the quote ~5s before it expires so the
// hold-to-sign gesture always has a fresh quote available.
useEffect(() => {
if (!currentQuote?.expiresAt) return;

const expiresAtMs = currentQuote.expiresAt * 1000;
const refreshAt = expiresAtMs - QUOTE_REFRESH_LEAD_MS;
const delay = Math.max(0, refreshAt - Date.now());

const handle = setTimeout(async () => {
setIsRefreshingQuote(true);
try {
await getQuote();
} finally {
setIsRefreshingQuote(false);
}
}, delay);

return () => clearTimeout(handle);
}, [currentQuote?.expiresAt, getQuote]);

// Guard against double-firing executeSwap on accidental re-entries (e.g. the
// hold-to-sign button completing twice). Navigate to /wallet only when the
// broadcast actually succeeded; on failure the inline error stays put.
const isExecutingRef = useRef(false);
const handleExecuteSwap = async () => {
await executeSwap();

navigate('/wallet', {
state: { message: `${fromSymbol} sent successfully!` },
});
if (isExecutingRef.current) return;
isExecutingRef.current = true;
try {
const hash = await executeSwap();
if (hash) {
navigate('/wallet');
}
} finally {
isExecutingRef.current = false;
}
};

const handleFromAmountChange = (val: string) => {
Expand All @@ -90,21 +142,24 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
};

const fromAmount = !isReverseSwap ? amount : currentQuote ? currentQuote.fromAmount : '';

const toAmount = isReverseSwap ? amount : currentQuote ? currentQuote.toAmount : '';

const getSwapButtonText = () => {
if (!fromToken || !toToken) return 'Select tokens';
const hasFromAmount = fromAmount && parseFloat(fromAmount) > 0;
const hasToAmount = toAmount && parseFloat(toAmount) > 0;
if (!hasFromAmount && !hasToAmount) return 'Enter amount';
if (isLoadingQuote) return 'Getting quote...';
if (error) return 'Error';
if (!currentQuote) return 'Get Quote';
return `Swap ${fromSymbol} for ${toSymbol}`;
};
const hasFromAmount = !!fromAmount && parseFloat(fromAmount) > 0;
const hasToAmount = !!toAmount && parseFloat(toAmount) > 0;
const hasInput = hasFromAmount || hasToAmount;

const isSwapDisabled = !!error || isLoadingQuote || isSwapping;
const validationError = validateSwapInputs();
const isQuoteReady = !!currentQuote && !error;
const canSwap = isQuoteReady && !isSwapping && !validationError;

const idleCtaLabel = (() => {
if (!fromToken || !toToken) return 'Select tokens';
if (!hasInput) return 'Enter an amount';
if (validationError && validationError !== 'Please enter an amount') return validationError;
if (isLoadingQuote) return 'Getting best quote…';
if (error) return 'Try again';
return null;
})();

return (
<Card className={cn('mx-auto w-full max-w-md', className)}>
Expand All @@ -126,6 +181,7 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
onAmountChange={handleFromAmountChange}
onTokenSelect={setFromToken}
token={fromToken}
isLoading={isReverseSwap && isLoadingQuote}
/>

<div className="w-full h-1 relative -mt-2">
Expand All @@ -152,6 +208,7 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
onTokenSelect={setToToken}
token={toToken}
className="-mt-2"
isLoading={!isReverseSwap && isLoadingQuote}
/>

{/* Destination Address */}
Expand Down Expand Up @@ -189,12 +246,6 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {

{currentQuote && (
<>
<QuoteTimer
expiresAt={currentQuote.expiresAt}
onRefresh={handleGetQuote}
isLoading={isLoadingQuote}
/>

<div className="border-t border-gray-200 my-6" />

<div className="space-y-2 text-sm">
Expand All @@ -210,9 +261,9 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
</span>
</div>

{currentQuote.priceImpact && (
{currentQuote.priceImpact !== undefined && (
<div className="flex justify-between">
<span className="text-gray-500">Price Impact</span>
<span className="text-muted-foreground">Price Impact</span>
<span className={cn(getPriceImpactColor(currentQuote.priceImpact))}>
{(currentQuote.priceImpact / 100).toFixed(2)}%
</span>
Expand All @@ -223,6 +274,14 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
<span className="text-muted-foreground">Slippage</span>
<span className="font-medium">{slippageBps / 100}%</span>
</div>

<div className="flex justify-between pt-1">
<span className="text-muted-foreground text-xs">Quote</span>
<QuoteTimer
expiresAt={currentQuote.expiresAt}
isRefreshing={isRefreshingQuote || isLoadingQuote}
/>
</div>
</div>
</>
)}
Expand All @@ -235,25 +294,20 @@ export const SwapInterface: FC<SwapInterfaceProps> = ({ className }) => {
</div>

<div className="flex flex-col gap-3 mt-6">
{!currentQuote && (
<Button
disabled={isSwapDisabled}
onClick={handleGetQuote}
isLoading={isLoadingQuote}
className="w-full"
>
{isLoadingQuote ? 'Getting Quote...' : 'Get Quote'}
{idleCtaLabel ? (
<Button disabled isLoading={isLoadingQuote} className="w-full">
{idleCtaLabel}
</Button>
)}

{currentQuote && (
<Button
disabled={!currentQuote || isSwapping}
onClick={handleExecuteSwap}
) : holdToSign ? (
<HoldToSignButton
onComplete={handleExecuteSwap}
isLoading={isSwapping}
className="w-full"
>
{isSwapping ? 'Swapping...' : getSwapButtonText()}
disabled={!canSwap}
idleLabel={`Hold to swap ${fromSymbol} for ${toSymbol}`}
/>
) : (
<Button onClick={handleExecuteSwap} isLoading={isSwapping} disabled={!canSwap} className="w-full">
Swap {fromSymbol} for {toSymbol}
</Button>
)}
</div>
Expand Down
Loading
Loading