diff --git a/apps/kyberswap-interface/.env b/apps/kyberswap-interface/.env index 3127fa437b..f4c4678d07 100644 --- a/apps/kyberswap-interface/.env +++ b/apps/kyberswap-interface/.env @@ -44,6 +44,7 @@ VITE_ZAP_API_URL=https://zap-api.kyberswap.com VITE_ZAP_EARN_URL=https://earn-service.kyberswap.com/api # VITE_ZAP_EARN_URL=https://pre-zap-earn-service.kyberengineering.io/api VITE_KYBER_AI_API_URL=https://kd-api.kyberswap.com/api +VITE_COPY_TRADING_API_URL=https://pre-copy-trade-api.kyberengineering.io/api/v1 VITE_AFFILIATE_SERVICE=https://affiliate-service.kyberswap.com/api VITE_SOLANA_RPC=https://solana-rpc.kyberswap.com diff --git a/apps/kyberswap-interface/index.html b/apps/kyberswap-interface/index.html index 89eaca41d7..9b47087e66 100644 --- a/apps/kyberswap-interface/index.html +++ b/apps/kyberswap-interface/index.html @@ -322,6 +322,12 @@ + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/agents.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/agents.svg new file mode 100644 index 0000000000..3c13dfd445 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/agents.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_aum.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_aum.svg new file mode 100644 index 0000000000..6357c2f568 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_aum.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_cash.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_cash.svg new file mode 100644 index 0000000000..f5e7ce254a --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_cash.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_history.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_history.svg new file mode 100644 index 0000000000..87d70aa17e --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_history.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_opens.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_opens.svg new file mode 100644 index 0000000000..6f65efb1e8 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_opens.svg @@ -0,0 +1,9 @@ + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_pnl.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_pnl.svg new file mode 100644 index 0000000000..e5abe124fd --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_pnl.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/ic_winrate.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_winrate.svg new file mode 100644 index 0000000000..2000611f89 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/ic_winrate.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/money.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/money.svg new file mode 100644 index 0000000000..c46c09c710 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/money.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/position-close.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/position-close.svg new file mode 100644 index 0000000000..139d56c2ec --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/position-close.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/position-open.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/position-open.svg new file mode 100644 index 0000000000..bd6ed7ac1e --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/position-open.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/users.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/users.svg new file mode 100644 index 0000000000..1288e51912 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/verified.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/verified.svg new file mode 100644 index 0000000000..d6b6f8f1ee --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/kyberswap-interface/src/assets/images/copy-trading/volume.svg b/apps/kyberswap-interface/src/assets/images/copy-trading/volume.svg new file mode 100644 index 0000000000..8d4a217813 --- /dev/null +++ b/apps/kyberswap-interface/src/assets/images/copy-trading/volume.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/kyberswap-interface/src/components/CollapsiblePresetControl/index.tsx b/apps/kyberswap-interface/src/components/CollapsiblePresetControl/index.tsx new file mode 100644 index 0000000000..a171d9b318 --- /dev/null +++ b/apps/kyberswap-interface/src/components/CollapsiblePresetControl/index.tsx @@ -0,0 +1,210 @@ +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'react-feather' + +import { Stack } from 'components/Stack' +import { cn } from 'utils/cn' + +export type CollapsiblePresetControlOption = { + label: ReactNode + value: number +} + +export type CollapsiblePresetControlProps = { + children?: ReactNode + className?: string + collapseButtonAriaLabel?: string + customInputAriaLabel?: string + customPlaceholder?: string + customPrefix?: ReactNode + customSuffix?: ReactNode + defaultExpanded?: boolean + defaultValue?: number + disabled?: boolean + expanded?: boolean + formatCustomInput?: (value: number) => string + formatValue: (value: number) => ReactNode + isValueAllowed?: (value: number) => boolean + label: ReactNode + maxFractionDigits?: number + maxIntegerDigits?: number + onChange?: (value: number) => void + onExpandedChange?: (expanded: boolean) => void + options: readonly CollapsiblePresetControlOption[] + value?: number + valueClassName?: string +} + +const defaultFormatCustomInput = (value: number) => String(value) + +const CollapsiblePresetControl = ({ + children, + className, + collapseButtonAriaLabel = 'Toggle preset options', + customInputAriaLabel = 'Custom value', + customPlaceholder = 'Custom', + customPrefix, + customSuffix, + defaultExpanded = false, + defaultValue, + disabled = false, + expanded, + formatCustomInput = defaultFormatCustomInput, + formatValue, + isValueAllowed = () => true, + label, + maxFractionDigits = 2, + maxIntegerDigits = 6, + onChange, + onExpandedChange, + options, + value, + valueClassName, +}: CollapsiblePresetControlProps) => { + const initialValue = defaultValue ?? options[0]?.value ?? 0 + const inputRef = useRef(null) + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded) + const [internalValue, setInternalValue] = useState(initialValue) + const currentValue = value ?? internalValue + const [isCustom, setIsCustom] = useState(!options.some(option => option.value === currentValue)) + const [customInput, setCustomInput] = useState(() => (isCustom ? formatCustomInput(currentValue) : '')) + const [isInputFocused, setIsInputFocused] = useState(false) + + const isExpanded = expanded ?? internalExpanded + const isReadOnly = value !== undefined && !onChange + const inputPattern = useMemo(() => { + const fraction = maxFractionDigits > 0 ? `(\\.\\d{0,${maxFractionDigits}})?` : '' + return new RegExp(`^\\d{0,${maxIntegerDigits}}${fraction}$`) + }, [maxFractionDigits, maxIntegerDigits]) + + useEffect(() => { + if (isInputFocused) return + + const nextIsCustom = !options.some(option => option.value === currentValue) + setIsCustom(nextIsCustom) + setCustomInput(nextIsCustom ? formatCustomInput(currentValue) : '') + }, [currentValue, formatCustomInput, isInputFocused, options]) + + useEffect(() => { + if (value !== undefined) setInternalValue(value) + }, [value]) + + const setExpanded = (nextExpanded: boolean) => { + if (expanded === undefined) setInternalExpanded(nextExpanded) + onExpandedChange?.(nextExpanded) + } + + const resetCustomInput = () => { + const nextIsCustom = !options.some(option => option.value === currentValue) + setIsCustom(nextIsCustom) + setCustomInput(nextIsCustom ? formatCustomInput(currentValue) : '') + } + + const handleValueChange = (nextValue: number) => { + if (value === undefined) setInternalValue(nextValue) + onChange?.(nextValue) + } + + const handleCustomInputChange = (nextInput: string) => { + if (!inputPattern.test(nextInput)) return + + setCustomInput(nextInput) + setIsCustom(true) + if (!nextInput) return + + const nextValue = Number(nextInput) + if (Number.isFinite(nextValue) && isValueAllowed(nextValue)) handleValueChange(nextValue) + } + + return ( + +
+ {label} + +
+ +
+
+ +
+ {options.map(option => { + const active = !isCustom && option.value === currentValue + + return ( + + ) + })} + +
!disabled && !isReadOnly && inputRef.current?.focus()} + className="flex min-h-8 min-w-0 flex-1 cursor-text items-center justify-center gap-1 rounded-[20px] px-2 text-sm text-subText hover:bg-buttonGray data-[disabled=true]:cursor-not-allowed data-[active=true]:bg-tabActive data-[active=true]:font-medium data-[active=true]:text-text data-[disabled=true]:opacity-50" + > + {customPrefix} + { + setIsInputFocused(false) + if (!customInput || !isValueAllowed(Number(customInput))) resetCustomInput() + }} + onChange={event => handleCustomInputChange(event.target.value)} + onFocus={() => { + setIsInputFocused(true) + setIsCustom(true) + }} + className="w-14 min-w-0 border-0 bg-transparent p-0 text-right text-[13px] font-medium text-inherit outline-none placeholder:text-inherit placeholder:opacity-60 disabled:cursor-not-allowed" + /> + {customSuffix} +
+
+ {children} +
+
+
+
+ ) +} + +export default CollapsiblePresetControl diff --git a/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx b/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx index 2b341301d8..d92217ede2 100644 --- a/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx +++ b/apps/kyberswap-interface/src/components/CurrencyInputPanel/index.tsx @@ -133,9 +133,11 @@ const StyledBalanceMax = ({ className, ...props }: React.ButtonHTMLAttributes void onHalf?: () => void onMax?: () => void positionLabel: 'in' | 'out' @@ -145,23 +147,27 @@ type BalanceRowProps = { const BalanceRow = ({ account, + balanceActions, currency, customBalanceText, label, + onBalanceClick, onHalf, onMax, positionLabel, positionMax, selectedCurrencyBalance, }: BalanceRowProps) => { - const showTopActions = (onMax || onHalf) && positionMax === 'top' && currency && account + const showTopActions = (balanceActions || onMax || onHalf) && positionMax === 'top' && currency && account const balance = customBalanceText ?? selectedCurrencyBalance?.toSignificant(10) ?? 0 + const handleBalanceClick = onBalanceClick || onMax return (
{(label && positionLabel === 'in') || showTopActions ? (
{label && positionLabel === 'in' && label} + {showTopActions && balanceActions} {showTopActions && onMax && ( Max @@ -177,12 +183,21 @@ const BalanceRow = ({
)} -
+
+
) } @@ -314,6 +329,8 @@ const PoolLockContent = ( interface CurrencyInputPanelProps { value: string + balanceActions?: ReactNode + onBalanceClick?: () => void onMax?: () => void onHalf?: () => void onUserInput?: (value: string) => void @@ -333,7 +350,7 @@ interface CurrencyInputPanelProps { id: string dataTestId?: string showPinnedTokens?: boolean - customBalanceText?: string + customBalanceText?: ReactNode hideLogo?: boolean highlightCurrencySelect?: boolean fontSize?: string @@ -357,6 +374,8 @@ interface CurrencyInputPanelProps { export default function CurrencyInputPanel({ value, + balanceActions, + onBalanceClick, error, onUserInput, onMax, @@ -423,9 +442,11 @@ export default function CurrencyInputPanel({ {!hideBalance && ( + {!upToExtraSmall && ( + + Copy Trading + + New + + + )} {!upToExtraSmall && ( diff --git a/apps/kyberswap-interface/src/components/Menu/components/MainMenuSection.tsx b/apps/kyberswap-interface/src/components/Menu/components/MainMenuSection.tsx index ca2d3d16a2..e5668db929 100644 --- a/apps/kyberswap-interface/src/components/Menu/components/MainMenuSection.tsx +++ b/apps/kyberswap-interface/src/components/Menu/components/MainMenuSection.tsx @@ -1,5 +1,5 @@ import { Trans, t } from '@lingui/macro' -import { BookOpen, FileText, Info, MessageCircle, PieChart } from 'react-feather' +import { BookOpen, FileText, Info, MessageCircle, PieChart, Users } from 'react-feather' import { NavLink } from 'react-router-dom' import { useMedia } from 'react-use' @@ -74,16 +74,28 @@ export const MainMenuSection = ({ openTipLinkGenerator, toggle }: MainMenuSectio )} {upToExtraSmall && ( - - - - - - Market - - - - + <> + + + + + + Copy Trading + + + + + + + + + + Market + + + + + )} {upToMedium && ( diff --git a/apps/kyberswap-interface/src/components/ScrollableWithSignal/index.tsx b/apps/kyberswap-interface/src/components/ScrollableWithSignal/index.tsx index bcba1fbe5b..264ff8057c 100644 --- a/apps/kyberswap-interface/src/components/ScrollableWithSignal/index.tsx +++ b/apps/kyberswap-interface/src/components/ScrollableWithSignal/index.tsx @@ -1,4 +1,5 @@ import { type HTMLAttributes, PropsWithChildren, forwardRef, useCallback, useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'react-feather' import { cn } from 'utils/cn' @@ -6,13 +7,21 @@ type Props = HTMLAttributes & PropsWithChildren<{ isOpen?: boolean showArrow?: boolean - showBackground?: boolean + wrapperClassName?: string 'data-open'?: 'true' | 'false' 'data-signal'?: 'true' | 'false' }> const ScrollableWithSignal = forwardRef(function ScrollableWithSignal( - { children, showArrow = false, showBackground = false, className, ...rest }: Props, + { + children, + showArrow = false, + className, + wrapperClassName, + 'data-open': dataOpen, + 'data-signal': dataSignal, + ...rest + }: Props, forwardedRef, ) { const localRef = useRef(null) @@ -37,23 +46,40 @@ const ScrollableWithSignal = forwardRef(function Scrollab }, []) useEffect(() => { - updateSignal(localRef.current) + const target = localRef.current + updateSignal(target) + + if (!target || typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => updateSignal(target)) + observer.observe(target) + + return () => observer.disconnect() }, [children, updateSignal]) - const isOpen = rest['data-open'] === 'true' - const disabledSignal = rest['data-signal'] === 'false' + const isOpen = dataOpen === 'true' + const disabledSignal = dataSignal === 'false' return (
updateSignal(e.currentTarget)} - className={cn('ks-scrollable-with-signal overflow-auto', className)} > - {children} +
updateSignal(e.currentTarget)} + className={cn('overflow-auto', className)} + > + {children} +
+ {showArrow && ( + + + + )}
) }) diff --git a/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts b/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts index b5600663f4..7baf86516f 100644 --- a/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts +++ b/apps/kyberswap-interface/src/components/Web3Provider/BitcoinProvider/providers/ledger.ts @@ -267,7 +267,7 @@ export const createLedgerProvider = ({ outputs: [ { amount: amountToBuffer(+amount), - script: Buffer.from(bitcoin.address.toOutputScript(recipient, bitcoin.networks.bitcoin)), + script: Buffer.from(new Uint8Array(bitcoin.address.toOutputScript(recipient, bitcoin.networks.bitcoin))), }, ], } @@ -275,7 +275,7 @@ export const createLedgerProvider = ({ if (changeAmount > 546) { transaction.outputs.push({ amount: amountToBuffer(changeAmount), - script: Buffer.from(bitcoin.address.toOutputScript(sender, bitcoin.networks.bitcoin)), + script: Buffer.from(new Uint8Array(bitcoin.address.toOutputScript(sender, bitcoin.networks.bitcoin))), }) } diff --git a/apps/kyberswap-interface/src/constants/index.ts b/apps/kyberswap-interface/src/constants/index.ts index 91bcd1e1d1..31cd6d5c95 100644 --- a/apps/kyberswap-interface/src/constants/index.ts +++ b/apps/kyberswap-interface/src/constants/index.ts @@ -31,6 +31,7 @@ export const APP_PATHS = { POOL_DETAIL: '/pools/:chain/:protocol/:address', DISCOVER: '/discover', CROSS_CHAIN: '/cross-chain', + COPY_TRADING: '/copy-trading', KYBERDAO: '/kyberdao', KYBERDAO_STAKE: '/kyberdao/stake-knc', KYBERDAO_VOTE: '/kyberdao/vote', diff --git a/apps/kyberswap-interface/src/hooks/useIsWalletRestoring.ts b/apps/kyberswap-interface/src/hooks/useIsWalletRestoring.ts new file mode 100644 index 0000000000..d29629eb2f --- /dev/null +++ b/apps/kyberswap-interface/src/hooks/useIsWalletRestoring.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react' + +import { useAccount } from 'hooks/useAccount' + +/** + * Indicates whether the connected wallet address may still be hydrating. + * + * Wagmi can temporarily report no account on the first render before restoring a persisted connection. This hook + * keeps account-dependent screens in their loading state during that render and while the wallet is connecting or + * reconnecting, preventing them from briefly showing an empty state. It does not restore the connection itself. + */ +export default function useIsWalletRestoring() { + const { status } = useAccount() + const [hasPassedInitialRender, setHasPassedInitialRender] = useState(false) + + useEffect(() => { + setHasPassedInitialRender(true) + }, []) + + return !hasPassedInitialRender || status === 'connecting' || status === 'reconnecting' +} diff --git a/apps/kyberswap-interface/src/hooks/useTokenBalance.ts b/apps/kyberswap-interface/src/hooks/useTokenBalance.ts index 4b577d196b..c8bed82216 100644 --- a/apps/kyberswap-interface/src/hooks/useTokenBalance.ts +++ b/apps/kyberswap-interface/src/hooks/useTokenBalance.ts @@ -11,12 +11,14 @@ import { Address } from 'utils/viem' interface BalanceProps { value: bigint decimals: number + isLoading: boolean } -const EMPTY_BALANCE: BalanceProps = { value: 0n, decimals: 18 } +const EMPTY_BALANCE: BalanceProps = { value: 0n, decimals: 18, isLoading: false } +const INITIAL_BALANCE: BalanceProps = { ...EMPTY_BALANCE, isLoading: true } function useTokenBalance(tokenAddress: string, customChainId?: ChainId) { - const [balance, setBalance] = useState(EMPTY_BALANCE) + const [balance, setBalance] = useState(INITIAL_BALANCE) const { account, chainId: activeChainId } = useActiveWeb3React() const chainId = customChainId || activeChainId const publicClient = usePublicClient({ chainId: chainId as number }) @@ -33,10 +35,11 @@ function useTokenBalance(tokenAddress: string, customChainId?: ChainId) { setBalance(EMPTY_BALANCE) return } + setBalance(currentBalance => ({ ...currentBalance, isLoading: true })) try { if (addressCheckSum === WETH[chainId].address) { const ethBalance = await publicClient.getBalance({ address: account as Address }) - setBalance({ value: ethBalance, decimals: 18 }) + setBalance({ value: ethBalance, decimals: 18, isLoading: false }) return } const cacheKey = `${chainId}:${addressCheckSum}` @@ -48,7 +51,7 @@ function useTokenBalance(tokenAddress: string, customChainId?: ChainId) { functionName: 'balanceOf', args: [account], })) as bigint - setBalance({ value: rawBalance, decimals: cachedDecimals }) + setBalance({ value: rawBalance, decimals: cachedDecimals, isLoading: false }) return } const [rawBalance, decimals] = await Promise.all([ @@ -65,7 +68,7 @@ function useTokenBalance(tokenAddress: string, customChainId?: ChainId) { }) as Promise, ]) decimalsCacheRef.current.set(cacheKey, decimals) - setBalance({ value: rawBalance, decimals }) + setBalance({ value: rawBalance, decimals, isLoading: false }) } catch { setBalance(EMPTY_BALANCE) } diff --git a/apps/kyberswap-interface/src/pages/App.tsx b/apps/kyberswap-interface/src/pages/App.tsx index 8fb787aff3..d0f22e219a 100644 --- a/apps/kyberswap-interface/src/pages/App.tsx +++ b/apps/kyberswap-interface/src/pages/App.tsx @@ -50,6 +50,7 @@ const Consent = lazy(() => import('pages/Oauth/Consent')) const ElasticSnapshot = lazy(() => import('pages/ElasticSnapshot')) const MarketOverview = lazy(() => import('pages/MarketOverview')) +const CopyTrading = lazy(() => import('pages/CopyTrading')) const PartnerSwap = lazy(() => import('pages/PartnerSwap')) const MyPool = lazy(() => import('pages/MyPool')) @@ -210,6 +211,7 @@ export default function App() { useGlobalTrackingEvents() const showFooter = !pathname.includes(APP_PATHS.ABOUT) && !isEmbeddedSwap + const isFullPage = pathname.includes(APP_PATHS.COPY_TRADING) return ( @@ -343,6 +345,7 @@ export default function App() { } /> } /> + } /> } /> } /> @@ -370,7 +373,7 @@ export default function App() { } /> - {showFooter &&