diff --git a/src/assets/svg/ic_send.svg b/src/assets/svg/ic_send.svg new file mode 100644 index 0000000000..5221354aeb --- /dev/null +++ b/src/assets/svg/ic_send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svg/kai_avatar.svg b/src/assets/svg/kai_avatar.svg new file mode 100644 index 0000000000..76b7104300 --- /dev/null +++ b/src/assets/svg/kai_avatar.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/svg/kai_avatar2.svg b/src/assets/svg/kai_avatar2.svg new file mode 100644 index 0000000000..d47a1a1d74 --- /dev/null +++ b/src/assets/svg/kai_avatar2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/Kai/KaiPanel.tsx b/src/components/Kai/KaiPanel.tsx new file mode 100644 index 0000000000..63decccab8 --- /dev/null +++ b/src/components/Kai/KaiPanel.tsx @@ -0,0 +1,289 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { ChangeEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { Flex } from 'rebass' +import { useGetQuoteByChainQuery } from 'services/marketOverview' + +import { ReactComponent as KaiAvatar } from 'assets/svg/kai_avatar.svg' +import NavGroup from 'components/Header/groups/NavGroup' +import { DropdownTextAnchor } from 'components/Header/styleds' +import { MAINNET_NETWORKS } from 'constants/networks' +import { useAllTokens } from 'hooks/Tokens' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' + +import { ActionType, KAI_ACTIONS, KaiAction, KaiOption } from './actions' +import { + ActionButton, + ActionPanel, + ActionText, + ChainAnchorBackground, + ChainAnchorWrapper, + ChainItem, + ChainSelectorWrapper, + ChatInput, + ChatPanel, + ChatWrapper, + Divider, + HeaderSubText, + HeaderTextName, + KaiHeaderLeft, + KaiHeaderWrapper, + Loader, + LoadingWrapper, + MainActionButton, + SelectedChainImg, + SendIcon, + UserMessage, + UserMessageWrapper, +} from './styled' + +const DEFAULT_LOADING_TEXT = 'KAI is checking the data ...' +const DEFAULT_CHAT_PLACEHOLDER_TEXT = 'Write a message...' +const DEFAULT_CHAIN_ID = 1 + +const KaiPanel = () => { + const chatPanelRef = useRef(null) + + const [chatPlaceHolderText, setChatPlaceHolderText] = useState(DEFAULT_CHAT_PLACEHOLDER_TEXT) + const [loading, setLoading] = useState(false) + const [loadingText, setLoadingText] = useState(DEFAULT_LOADING_TEXT) + const [listActions, setListActions] = useState([KAI_ACTIONS.MAIN_MENU]) + const [chainId, setChainId] = useState(DEFAULT_CHAIN_ID) + + const whitelistTokens = useAllTokens(true, chainId) + const whitelistTokenAddress = useMemo(() => Object.keys(whitelistTokens), [whitelistTokens]) + + const { data: quoteData } = useGetQuoteByChainQuery() + const quoteSymbol = useMemo( + () => quoteData?.data?.onchainPrice?.usdQuoteTokenByChainId?.[chainId || 1]?.symbol, + [chainId, quoteData], + ) + + const lastAction = useMemo(() => { + const cloneListActions = [...listActions] + cloneListActions.reverse() + + return cloneListActions.find( + (action: KaiAction) => + action.type !== ActionType.INVALID && + action.type !== ActionType.INVALID_AND_BACK && + action.type !== ActionType.USER_MESSAGE, + ) + }, [listActions]) + + const lastActiveActionIndex = useMemo(() => { + const clonelistActions = [...listActions] + let index = clonelistActions.length - 1 + for (let i = clonelistActions.length - 1; i >= 0; i--) { + if (clonelistActions[i].type !== ActionType.INVALID && clonelistActions[i].type !== ActionType.USER_MESSAGE) { + index = i + break + } + } + + return index + }, [listActions]) + + const onSubmitChat = (text: string) => { + if (loading || !lastAction) return + if (lastAction.loadingText) setLoadingText(lastAction.loadingText) + setLoading(true) + onChangeListActions([ + { + title: text, + type: ActionType.USER_MESSAGE, + }, + ]) + } + + const onChangeListActions = (newActions: KaiAction[]) => { + const cloneListActions = [...listActions] + setListActions(cloneListActions.concat(newActions)) + } + + const getActionResponse = async () => { + const lastUserAction = listActions[listActions.length - 1] + if (lastUserAction?.type === ActionType.USER_MESSAGE) { + const newActions: KaiAction[] = + (await lastAction?.response?.({ + answer: lastUserAction?.title?.toLowerCase() || '', + chainId, + whitelistTokenAddress, + arg: lastAction.arg, + quoteSymbol, + })) || [] + if (newActions.length) onChangeListActions(newActions) + + setLoading(false) + setLoadingText(DEFAULT_LOADING_TEXT) + } + } + + useEffect(() => { + if (lastAction?.placeholder) setChatPlaceHolderText(lastAction.placeholder) + else setChatPlaceHolderText(DEFAULT_CHAT_PLACEHOLDER_TEXT) + }, [lastAction]) + + useEffect(() => { + if (chatPanelRef.current) + chatPanelRef.current.scrollTo({ top: chatPanelRef.current.scrollHeight, behavior: 'smooth' }) + }, [listActions]) + + useEffect(() => { + getActionResponse() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listActions]) + + return ( + <> + + + +
GM! What can I do for you today? πŸ‘‹
+ {listActions.map((action: KaiAction, index: number) => { + const disabled = index !== lastActiveActionIndex + + return action.type === ActionType.MAIN_OPTION ? ( + + {action.data?.map((option: KaiOption, optionIndex: number) => ( + !disabled && onSubmitChat(option.title)} + > + {option.title} + + ))} + + ) : action.type === ActionType.OPTION || action.type === ActionType.INVALID_AND_BACK ? ( + + {action.data?.map((option: KaiOption, optionIndex: number) => ( + !disabled && onSubmitChat(option.title)} + > + {option.title} + + ))} + + ) : action.type === ActionType.TEXT || action.type === ActionType.INVALID ? ( + {action.title} + ) : action.type === ActionType.HTML && action.title ? ( + + ) : action.type === ActionType.USER_MESSAGE ? ( + + + {action.title} + + + ) : null + })} +
+ + {loading && } + + + ) +} + +const KaiHeader = ({ chainId, setChainId }: { chainId: ChainId; setChainId: (value: number) => void }) => { + return ( + <> + + + + + I'm KAI + Kyber Assistant Interface + + + + + + + + + } + dropdownContent={ + + {MAINNET_NETWORKS.map(item => ( + setChainId(item)} active={item === chainId}> + + {NETWORKS_INFO[item].displayName} + + ))} + + } + /> + + + + ) +} + +const KaiLoading = ({ loadingText }: { loadingText: string }) => { + return ( + + + {loadingText} + + ) +} + +const KaiChat = ({ + chatPlaceHolderText, + onSubmitChat, + disabled = false, +}: { + chatPlaceHolderText: string + onSubmitChat: (text: string) => void + disabled?: boolean +}) => { + const [chatInput, setChatInput] = useState('') + + const onChangeChatInput = (e: ChangeEvent) => setChatInput(e.target.value) + + const handleSubmitChatInput = () => { + if (disabled || !chatInput) return + onSubmitChat(chatInput.trim()) + setChatInput('') + } + + const handleEnter = (e: KeyboardEvent) => { + if (e.key !== 'Enter') return + handleSubmitChatInput() + } + + return ( + + + + + ) +} + +export default KaiPanel diff --git a/src/components/Kai/actions.ts b/src/components/Kai/actions.ts new file mode 100644 index 0000000000..9bd75a4c2d --- /dev/null +++ b/src/components/Kai/actions.ts @@ -0,0 +1,978 @@ +import { formatDisplayNumber } from 'utils/numbers' + +import { isNumber } from './utils' + +export enum Space { + HALF_WIDTH = 'calc(50% - 6px)', + FULL_WIDTH = '100%', + ONE_THIRD_WIDTH = 'calc((100% - 24px) / 3)', +} + +export enum ActionType { + TEXT, + OPTION, + MAIN_OPTION, + USER_MESSAGE, + INVALID, + HTML, + INVALID_AND_BACK, +} + +export interface KaiAction { + uuid?: string + title?: string + type: ActionType + data?: KaiOption[] + arg?: any + placeholder?: string + loadingText?: string + response?: (...args: any[]) => KaiAction[] | Promise +} + +export interface KaiOption { + title: string + space: Space +} + +interface ListOptions { + [optionKey: string]: KaiOption +} + +interface ListActions { + [actionKey: string]: KaiAction +} + +const KAI_OPTIONS: ListOptions = { + CHECK_TOKEN_PRICE: { + title: 'Check the token price', + space: Space.FULL_WIDTH, + }, + SEE_MARKET_TRENDS: { + title: 'See market trends', + space: Space.FULL_WIDTH, + }, + FIND_HIGH_APY_POOLS: { + title: 'Find high APY pools', + space: Space.FULL_WIDTH, + }, + SWAP_TOKEN: { + title: 'Buy/Sell tokens', + space: Space.HALF_WIDTH, + }, + LIMIT_ORDER: { + title: 'Limit Order', + space: Space.HALF_WIDTH, + }, + ADD_LIQUIDITY: { + title: 'Add liquidity', + space: Space.FULL_WIDTH, + }, + TOP_BIG_SPREAD: { + title: 'Top 24h Big Spread', + space: Space.FULL_WIDTH, + }, + TOP_GAINERS: { + title: 'Top 24h Gainers', + space: Space.HALF_WIDTH, + }, + TOP_VOLUME: { + title: 'Top 24h Volume', + space: Space.HALF_WIDTH, + }, + SEARCH_ANOTHER_TOKEN: { + title: 'Search another token', + space: Space.FULL_WIDTH, + }, + CUSTOM_MAX_SLIPPAGE: { + title: 'Custom', + space: Space.ONE_THIRD_WIDTH, + }, + BACK_TO_MENU: { + title: '↩ Back to the main menu', + space: Space.FULL_WIDTH, + }, + CONFIRM_SWAP: { + title: 'Confirm trade', + space: Space.FULL_WIDTH, + }, +} + +export const MAIN_MENU: KaiOption[] = [ + KAI_OPTIONS.CHECK_TOKEN_PRICE, + KAI_OPTIONS.SEE_MARKET_TRENDS, + KAI_OPTIONS.SWAP_TOKEN, + KAI_OPTIONS.LIMIT_ORDER, + KAI_OPTIONS.FIND_HIGH_APY_POOLS, + KAI_OPTIONS.ADD_LIQUIDITY, +] + +export const KAI_ACTIONS: ListActions = { + MAIN_MENU: { + type: ActionType.MAIN_OPTION, + data: MAIN_MENU, + placeholder: 'Ask me anything or select...', + response: ({ answer }: { answer: string }) => { + if (answer === KAI_OPTIONS.CHECK_TOKEN_PRICE.title.toLowerCase()) return [KAI_ACTIONS.TYPE_TOKEN_TO_CHECK_PRICE] + if (answer === KAI_OPTIONS.SEE_MARKET_TRENDS.title.toLowerCase()) + return [KAI_ACTIONS.SEE_MARKET_TRENDS_WELCOME, KAI_ACTIONS.SEE_MARKET_TRENDS] + if (answer === KAI_OPTIONS.SWAP_TOKEN.title.toLowerCase()) + return [KAI_ACTIONS.SWAP_TOKEN, KAI_ACTIONS.SWAP_INPUT_TOKEN_IN] + if (MAIN_MENU.find((option: KaiOption) => answer.trim().toLowerCase() === option.title.toLowerCase())) + return [KAI_ACTIONS.COMING_SOON] + return [KAI_ACTIONS.INVALID] + }, + }, + BACK_TO_MENU: { + type: ActionType.OPTION, + data: [KAI_OPTIONS.BACK_TO_MENU], + response: ({ answer }: { answer: string }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + return [KAI_ACTIONS.INVALID] + }, + }, + INVALID_BACK_TO_MENU: { + type: ActionType.INVALID_AND_BACK, + data: [KAI_OPTIONS.BACK_TO_MENU], + }, + DO_SOMETHING_AFTER_CHECK_PRICE: { + type: ActionType.OPTION, + data: [ + { ...KAI_OPTIONS.SWAP_TOKEN, space: Space.FULL_WIDTH }, + KAI_OPTIONS.SEARCH_ANOTHER_TOKEN, + KAI_OPTIONS.BACK_TO_MENU, + ], + response: ({ answer }: { answer: string }) => { + if (answer === KAI_OPTIONS.SWAP_TOKEN.title.toLowerCase()) + return [KAI_ACTIONS.COMING_SOON, KAI_ACTIONS.INVALID_BACK_TO_MENU] + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (answer === KAI_OPTIONS.SEARCH_ANOTHER_TOKEN.title.toLowerCase()) + return [ + { + title: 'πŸ’ͺ🏼 Okay! Which token are you interested in? Just type the name or address.', + type: ActionType.TEXT, + response: KAI_ACTIONS.TYPE_TOKEN_TO_CHECK_PRICE.response, + }, + ] + return [KAI_ACTIONS.INVALID] + }, + }, + WOULD_LIKE_TO_DO_SOMETHING_ELSE: { + title: 'Would you like to do something else with this token?', + type: ActionType.TEXT, + }, + COMING_SOON: { + title: 'πŸƒπŸ» Coming soon ...', + type: ActionType.INVALID, + }, + INVALID: { + title: '❌ Invalid input, please follow the instruction!', + type: ActionType.INVALID, + }, + ERROR: { + title: '❌ Something went wrong, please try again!', + type: ActionType.INVALID, + }, + TOKEN_NOT_FOUND: { + title: 'πŸ”­ I can not find your token, please enter others!', + type: ActionType.INVALID, + }, + TOKEN_FOUND: { + title: 'πŸ‘€ Here are the tokens I found. Which one do you mean?', + type: ActionType.TEXT, + }, + TYPE_TOKEN_TO_CHECK_PRICE: { + title: '🫑 Great! Which token are you interested in? Just type the name or address.', + type: ActionType.TEXT, + response: async ({ + answer, + chainId, + whitelistTokenAddress, + quoteSymbol, + }: { + answer: string + chainId: number + whitelistTokenAddress: string[] + quoteSymbol: string + }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + + const filter: any = { + chainId: chainId, + search: answer, + page: 1, + pageSize: 50, + chainIds: chainId, + sort: '', + } + + try { + const res = await fetch( + `${import.meta.env.VITE_TOKEN_API_URL}/v1/public/assets?` + new URLSearchParams(filter).toString(), + { + method: 'GET', + }, + ) + const { data } = await res.json() + const result = data.assets + .filter( + (token: any) => + token.marketCap && token.tokens.find((item: any) => whitelistTokenAddress.includes(item.address)), + ) + .map((token: any) => ({ + ...token, + token: token.tokens.find( + (item: any) => item.chainId === chainId.toString() && whitelistTokenAddress.includes(item.address), + ), + })) + .filter((token: any) => token.token) + .sort((a: any, b: any) => b.marketCap - a.marketCap) + + // const result = data.assets + // .filter((token: any) => token.marketCap) + // .map((token: any) => ({ + // ...token, + // token: token.tokens.find((item: any) => item.chainId === chainId.toString()), + // })) + // .filter((token: any) => token.token) + // .sort((a: any, b: any) => b.marketCap - a.marketCap) + + if (result.length === 1) { + const token = result[0] + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + title: `Here’s what I’ve got for ${token.symbol}`, + type: ActionType.TEXT, + }, + { + type: ActionType.HTML, + title: ` +
${ + showAddress ? 'πŸ“Œ Token contract: ' + token.token.address : '' + }
+
πŸ“ˆ Buy Price: ${ + token.token.priceBuy + ? `${formatDisplayNumber(token.token.priceBuy, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+
πŸ“ˆ Sell Price: ${ + token.token.priceSell + ? `${formatDisplayNumber(token.token.priceSell, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+
πŸ”„ 24h Buy Price Change: ${ + token.token.priceBuyChange24h + ? `${token.token.priceBuyChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceBuyChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ”„ 24h Sell Price Change: ${ + token.token.priceSellChange24h + ? `${token.token.priceSellChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceSellChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ’Έ 24h Volume: ${ + token.volume24h + ? formatDisplayNumber(token.volume24h, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+
🏦 Market Cap: ${ + token.marketCap + ? formatDisplayNumber(token.marketCap, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+ `, + }, + KAI_ACTIONS.WOULD_LIKE_TO_DO_SOMETHING_ELSE, + KAI_ACTIONS.DO_SOMETHING_AFTER_CHECK_PRICE, + ] + } else if (result.length > 1) { + return [ + KAI_ACTIONS.TOKEN_FOUND, + { + type: ActionType.OPTION, + data: result + .map((item: any) => ({ + title: item.symbol, + space: item.symbol.length <= 10 ? Space.HALF_WIDTH : Space.FULL_WIDTH, + })) + .concat(KAI_ACTIONS.INVALID_BACK_TO_MENU.data), + response: ({ answer: tokenSymbolSelected }: { answer: string }) => { + if (tokenSymbolSelected === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + + const token = result.find((item: any) => item.symbol.toLowerCase() === tokenSymbolSelected) + if (token) { + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + title: `Here’s what I’ve got for ${tokenSymbolSelected}`, + type: ActionType.TEXT, + }, + { + type: ActionType.HTML, + title: ` +
${ + showAddress ? 'πŸ“Œ Token contract: ' + token.token.address : '' + }
+
πŸ“ˆ Buy Price: ${ + token.token.priceBuy + ? `${formatDisplayNumber(token.token.priceBuy, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+
πŸ“ˆ Sell Price: ${ + token.token.priceSell + ? `${formatDisplayNumber(token.token.priceSell, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+
πŸ”„ 24h Buy Price Change: ${ + token.token.priceBuyChange24h + ? `${token.token.priceBuyChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceBuyChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ”„ 24h Sell Price Change: ${ + token.token.priceSellChange24h + ? `${token.token.priceSellChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceSellChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ’Έ 24h Volume: ${ + token.volume24h + ? formatDisplayNumber(token.volume24h, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+
🏦 Market Cap: ${ + token.marketCap + ? formatDisplayNumber(token.marketCap, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+ `, + }, + KAI_ACTIONS.WOULD_LIKE_TO_DO_SOMETHING_ELSE, + KAI_ACTIONS.DO_SOMETHING_AFTER_CHECK_PRICE, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + }, + }, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } catch (error) { + return [KAI_ACTIONS.ERROR, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } + }, + }, + SEE_MARKET_TRENDS_WELCOME: { + title: '🫑 Got it! What would you like to see the trend in 24 hours?', + type: ActionType.TEXT, + }, + SEE_MARKET_TRENDS: { + type: ActionType.OPTION, + data: [KAI_OPTIONS.TOP_GAINERS, KAI_OPTIONS.TOP_VOLUME, KAI_OPTIONS.TOP_BIG_SPREAD, KAI_OPTIONS.BACK_TO_MENU], + response: ({ answer }: { answer: string }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (answer === KAI_OPTIONS.TOP_BIG_SPREAD.title.toLowerCase()) + return [KAI_ACTIONS.COMING_SOON, KAI_ACTIONS.INVALID_BACK_TO_MENU] + if ( + answer === KAI_OPTIONS.TOP_GAINERS.title.toLowerCase() || + answer === KAI_OPTIONS.TOP_VOLUME.title.toLowerCase() + ) + return [{ ...KAI_ACTIONS.SEE_MARKET_TRENDS_CHOOSE_AMOUNT, arg: answer }] + + return [KAI_ACTIONS.INVALID] + }, + }, + SEE_MARKET_TRENDS_CHOOSE_AMOUNT: { + type: ActionType.OPTION, + data: [5, 10, 15].map((item: number) => ({ title: item.toString(), space: Space.ONE_THIRD_WIDTH })), + response: async ({ + answer, + chainId, + arg, + quoteSymbol, + }: { + answer: string + chainId: number + arg: any + quoteSymbol: string + }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (!['5', '10', '15'].includes(answer.toString())) return [KAI_ACTIONS.INVALID, KAI_ACTIONS.INVALID_BACK_TO_MENU] + + const filter: any = { + chainId: chainId, + search: '', + page: 1, + pageSize: answer, + chainIds: chainId, + sort: + arg === KAI_OPTIONS.TOP_GAINERS.title.toLowerCase() + ? `price_sell_change_24h-${chainId} desc` + : arg === KAI_OPTIONS.TOP_VOLUME.title.toLowerCase() + ? 'volume_24h desc' + : '', + } + + try { + const res = await fetch( + `${import.meta.env.VITE_TOKEN_API_URL}/v1/public/assets?` + new URLSearchParams(filter).toString(), + { + method: 'GET', + }, + ) + const { data } = await res.json() + const result = data.assets.map((token: any) => ({ + ...token, + token: token.tokens.find((item: any) => item.chainId === chainId.toString()), + })) + + const resultToActionData = result.map((item: any) => { + const price = item.token.priceSell + + const priceSellChange24h = item.token.priceSellChange24h + ? `${item.token.priceSellChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(item.token.priceSellChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + const volume24h = item.volume24h + ? formatDisplayNumber(item.volume24h, { style: 'currency', fractionDigits: 2 }) + : '--' + const metricValue = + arg === KAI_OPTIONS.TOP_GAINERS.title.toLowerCase() + ? priceSellChange24h + : arg === KAI_OPTIONS.TOP_VOLUME.title.toLowerCase() + ? volume24h + : '' + return { + title: `πŸ’Έ ${item.symbol} - ${metricValue} - ${ + price + ? `${formatDisplayNumber(price, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }`, + space: Space.FULL_WIDTH, + } + }) + + return [ + { + title: `Here’s the list of ${arg + .replace('24h', '') + .trim()} for the last 24h, click on a token to see more details!`, + type: ActionType.TEXT, + }, + { + type: ActionType.OPTION, + data: resultToActionData.concat([ + { ...KAI_OPTIONS.SWAP_TOKEN, space: Space.FULL_WIDTH }, + KAI_OPTIONS.BACK_TO_MENU, + ]), + response: ({ answer, quoteSymbol }: { answer: string; quoteSymbol: string }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (answer === KAI_OPTIONS.SWAP_TOKEN.title.toLowerCase()) + return [KAI_ACTIONS.COMING_SOON, KAI_ACTIONS.INVALID_BACK_TO_MENU] + + const index = result.findIndex( + (item: any) => item.symbol.toLowerCase() === answer.split(' ')?.[1]?.toLowerCase(), + ) + + if (index > -1) { + const token = result[index] + + return [ + { + type: ActionType.HTML, + title: ` +
${ + 'πŸ“Œ Token contract: ' + token.token.address + }
+
πŸ“ˆ Buy Price: ${ + token.token.priceBuy + ? `${formatDisplayNumber(token.token.priceBuy, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+
πŸ“ˆ Sell Price: ${ + token.token.priceSell + ? formatDisplayNumber(token.token.priceSell, { + fractionDigits: 2, + significantDigits: 7, + }) + : '--' + }
+
πŸ”„ 24h Buy Price Change: ${ + token.token.priceBuyChange24h + ? `${token.token.priceBuyChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceBuyChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ”„ 24h Sell Price Change: ${ + token.token.priceSellChange24h + ? `${token.token.priceSellChange24h < 0 ? '-' : ''}${formatDisplayNumber( + Math.abs(token.token.priceSellChange24h), + { + style: 'decimal', + fractionDigits: 2, + }, + )}%` + : '--' + }
+
πŸ’Έ 24h Volume: ${ + token.volume24h + ? formatDisplayNumber(token.volume24h, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+
🏦 Market Cap: ${ + token.marketCap + ? formatDisplayNumber(token.marketCap, { style: 'currency', fractionDigits: 2 }) + : '--' + }
+ `, + }, + KAI_ACTIONS.WOULD_LIKE_TO_DO_SOMETHING_ELSE, + KAI_ACTIONS.DO_SOMETHING_AFTER_CHECK_PRICE, + ] + } + + return [KAI_ACTIONS.INVALID, KAI_ACTIONS.INVALID_BACK_TO_MENU] + }, + }, + ] + } catch (error) { + return [KAI_ACTIONS.ERROR, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } + }, + }, + SWAP_TOKEN: { + title: 'πŸ’° Ready to trade! What are you swapping?', + type: ActionType.TEXT, + }, + SWAP_INPUT_TOKEN_IN: { + title: 'πŸ‘‰ Input the token you want to sell (Token in)', + type: ActionType.TEXT, + placeholder: 'Enter the token in', + response: async ({ + answer, + chainId, + whitelistTokenAddress, + quoteSymbol, + }: { + answer: string + chainId: number + whitelistTokenAddress: string[] + quoteSymbol: string + }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + const filter: any = { + chainId: chainId, + search: answer, + page: 1, + pageSize: 50, + chainIds: chainId, + sort: '', + } + + try { + const res = await fetch( + `${import.meta.env.VITE_TOKEN_API_URL}/v1/public/assets?` + new URLSearchParams(filter).toString(), + { + method: 'GET', + }, + ) + const { data } = await res.json() + const result = data.assets + .filter( + (token: any) => + token.marketCap && token.tokens.find((item: any) => whitelistTokenAddress.includes(item.address)), + ) + .map((token: any) => ({ + ...token, + token: token.tokens.find( + (item: any) => item.chainId === chainId.toString() && whitelistTokenAddress.includes(item.address), + ), + })) + .filter((token: any) => token.token) + .sort((a: any, b: any) => b.marketCap - a.marketCap) + + if (result.length === 1) { + const token = result[0] + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + type: ActionType.HTML, + title: ` +
πŸ“Œ ${ + showAddress ? `Token contract: ${token.token.address}` : token.symbol + }
+
πŸ“ˆ Sell Price: ${ + token.token.priceSell + ? `${formatDisplayNumber(token.token.priceSell, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+ `, + }, + { + ...KAI_ACTIONS.SWAP_INPUT_AMOUNT_IN, + arg: { + tokenIn: token, + }, + }, + ] + } else if (result.length > 1) { + return [ + KAI_ACTIONS.TOKEN_FOUND, + { + type: ActionType.OPTION, + data: result + .map((item: any) => ({ + title: item.symbol, + space: item.symbol.length <= 10 ? Space.HALF_WIDTH : Space.FULL_WIDTH, + })) + .concat(KAI_ACTIONS.INVALID_BACK_TO_MENU.data), + response: ({ answer: tokenSymbolSelected }: { answer: string }) => { + if (tokenSymbolSelected === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + + const token = result.find((item: any) => item.symbol.toLowerCase() === tokenSymbolSelected) + if (token) { + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + type: ActionType.HTML, + title: ` +
πŸ“Œ ${ + showAddress ? `Token contract: ${token.token.address}` : token.symbol + }
+
πŸ“ˆ Sell Price: ${ + token.token.priceSell + ? `${formatDisplayNumber(token.token.priceSell, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+ `, + }, + { + ...KAI_ACTIONS.SWAP_INPUT_AMOUNT_IN, + arg: { + tokenIn: token, + }, + }, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + }, + }, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } catch (error) { + return [KAI_ACTIONS.ERROR, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } + }, + }, + SWAP_INPUT_AMOUNT_IN: { + title: 'πŸ‘‰ Input amount of token you want to sell', + type: ActionType.TEXT, + placeholder: 'Enter the amount in', + response: ({ answer, arg }: { answer: string; arg: any }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (!isNumber(answer)) return [KAI_ACTIONS.INVALID, KAI_ACTIONS.INVALID_BACK_TO_MENU] + + return [ + { + ...KAI_ACTIONS.SWAP_INPUT_TOKEN_OUT, + arg: { + ...arg, + amountIn: answer, + }, + }, + ] + }, + }, + SWAP_INPUT_TOKEN_OUT: { + title: 'πŸ‘‰ Input the token you want to buy (Token out)', + type: ActionType.TEXT, + placeholder: 'Enter the token out', + response: async ({ + answer, + chainId, + whitelistTokenAddress, + arg, + quoteSymbol, + }: { + answer: string + chainId: number + whitelistTokenAddress: string[] + arg: any + quoteSymbol: string + }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + const filter: any = { + chainId: chainId, + search: answer, + page: 1, + pageSize: 50, + chainIds: chainId, + sort: '', + } + + try { + const res = await fetch( + `${import.meta.env.VITE_TOKEN_API_URL}/v1/public/assets?` + new URLSearchParams(filter).toString(), + { + method: 'GET', + }, + ) + const { data } = await res.json() + const result = data.assets + .filter( + (token: any) => + token.marketCap && token.tokens.find((item: any) => whitelistTokenAddress.includes(item.address)), + ) + .map((token: any) => ({ + ...token, + token: token.tokens.find( + (item: any) => item.chainId === chainId.toString() && whitelistTokenAddress.includes(item.address), + ), + })) + .filter((token: any) => token.token) + .sort((a: any, b: any) => b.marketCap - a.marketCap) + + if (result.length === 1) { + const token = result[0] + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + type: ActionType.HTML, + title: ` +
πŸ“Œ ${ + showAddress ? `Token contract: ${token.token.address}` : token.symbol + }
+
πŸ“ˆ Buy Price: ${ + token.token.priceBuy + ? `${formatDisplayNumber(token.token.priceBuy, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+ `, + }, + KAI_ACTIONS.SWAP_INPUT_SLIPPAGE_TEXT, + { + ...KAI_ACTIONS.SWAP_INPUT_SLIPPAGE, + arg: { + ...arg, + tokenOut: token, + }, + }, + ] + } else if (result.length > 1) { + return [ + KAI_ACTIONS.TOKEN_FOUND, + { + type: ActionType.OPTION, + data: result + .map((item: any) => ({ + title: item.symbol, + space: item.symbol.length <= 10 ? Space.HALF_WIDTH : Space.FULL_WIDTH, + })) + .concat(KAI_ACTIONS.INVALID_BACK_TO_MENU.data), + response: ({ answer: tokenSymbolSelected }: { answer: string }) => { + if (tokenSymbolSelected === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + + const token = result.find((item: any) => item.symbol.toLowerCase() === tokenSymbolSelected) + + if (token) { + const showAddress = answer !== token.token.address.toLowerCase() + + return [ + { + type: ActionType.HTML, + title: ` +
πŸ“Œ ${ + showAddress ? `Token contract: ${token.token.address}` : token.symbol + }
+
πŸ“ˆ Buy Price: ${ + token.token.priceBuy + ? `${formatDisplayNumber(token.token.priceBuy, { + fractionDigits: 2, + significantDigits: 7, + })} ${quoteSymbol}` + : '--' + }
+ `, + }, + KAI_ACTIONS.SWAP_INPUT_SLIPPAGE_TEXT, + { + ...KAI_ACTIONS.SWAP_INPUT_SLIPPAGE, + arg: { + ...arg, + tokenOut: token, + }, + }, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + }, + }, + ] + } + + return [KAI_ACTIONS.TOKEN_NOT_FOUND, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } catch (error) { + return [KAI_ACTIONS.ERROR, KAI_ACTIONS.INVALID_BACK_TO_MENU] + } + }, + }, + SWAP_INPUT_SLIPPAGE_TEXT: { + title: 'πŸ‘‰ Choose max slippage you want to set', + type: ActionType.TEXT, + }, + SWAP_INPUT_SLIPPAGE: { + type: ActionType.OPTION, + data: [0.1, 0.5, 1, 5, 10] + .map(item => ({ title: `${item} %`, space: Space.ONE_THIRD_WIDTH })) + .concat([KAI_OPTIONS.CUSTOM_MAX_SLIPPAGE]), + response: ({ answer, arg, quoteSymbol }: { answer: string; arg: any; quoteSymbol: string }) => { + if (answer === KAI_OPTIONS.CUSTOM_MAX_SLIPPAGE.title.toLowerCase()) + return [{ ...KAI_ACTIONS.CUSTOM_MAX_SLIPPAGE, arg }] + + if (['0.1 %', '0.5 %', '1 %', '5 %', '10 %'].includes(answer)) { + const slippage = answer.replace('%', '') + return [ + { + type: ActionType.HTML, + title: ` +
πŸ”„ You're swapping: ${arg.amountIn} of ${ + arg.tokenIn.symbol + }, est. ${quoteSymbol} value ${arg.tokenIn.token.priceSell * arg.amountIn}
+
πŸ’° For: ${ + (arg.amountIn * arg.tokenIn.token.priceSell) / arg.tokenIn.token.priceBuy + } of ${arg.tokenOut.symbol}, est. ${quoteSymbol} value ${arg.amountIn * arg.tokenIn.token.priceSell}
+
βš–οΈ Slippage tolerance: ${slippage}%
+
πŸ“ Min receive: --
+
πŸ“‰ Price Impact: --
+ `, + }, + KAI_ACTIONS.CONFIRM_SWAP_TOKEN_TEXT, + { + ...KAI_ACTIONS.CONFIRM_SWAP_TOKEN, + arg: { ...arg, slippage }, + }, + ] + } + + return [KAI_ACTIONS.INVALID, KAI_ACTIONS.INVALID_BACK_TO_MENU] + }, + }, + CUSTOM_MAX_SLIPPAGE: { + title: 'πŸ‘‰ Input max slippage you can to set (in percent)', + type: ActionType.TEXT, + placeholder: 'Enter the max slippage', + response: ({ answer, arg, quoteSymbol }: { answer: string; arg: any; quoteSymbol: string }) => { + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + if (!isNumber(answer)) return [KAI_ACTIONS.INVALID, KAI_ACTIONS.INVALID_BACK_TO_MENU] + + return [ + { + type: ActionType.HTML, + title: ` +
πŸ”„ You're awapping: ${arg.amountIn} of ${ + arg.tokenIn.symbol + }, est. ${quoteSymbol} value ${arg.tokenIn.token.priceSell * arg.amountIn}
+
πŸ’° For: ${ + (arg.amountIn * arg.tokenIn.token.priceSell) / arg.tokenOut.token.priceBuy + } of ${arg.tokenOut.symbol}, est. ${quoteSymbol} value ${arg.amountIn * arg.tokenIn.token.priceSell}
+
βš–οΈ Slippage tolerance: ${answer}%
+
πŸ“ Min receive: --
+
πŸ“‰ Price Impact: --
+ `, + }, + KAI_ACTIONS.CONFIRM_SWAP_TOKEN_TEXT, + { + ...KAI_ACTIONS.CONFIRM_SWAP_TOKEN, + arg: { + ...arg, + slippage: answer, + }, + }, + ] + }, + }, + CONFIRM_SWAP_TOKEN_TEXT: { + title: 'Ready to execute the trade❓', + type: ActionType.TEXT, + }, + CONFIRM_SWAP_TOKEN: { + type: ActionType.OPTION, + data: [KAI_OPTIONS.CONFIRM_SWAP, KAI_OPTIONS.BACK_TO_MENU], + response: ({ answer, arg }: { answer: string; arg: any }) => { + console.log(arg) + if (answer === KAI_OPTIONS.BACK_TO_MENU.title.toLowerCase()) return [KAI_ACTIONS.MAIN_MENU] + + return [KAI_ACTIONS.COMING_SOON, KAI_ACTIONS.BACK_TO_MENU] + }, + }, +} diff --git a/src/components/Kai/index.tsx b/src/components/Kai/index.tsx new file mode 100644 index 0000000000..8a9a70dd2f --- /dev/null +++ b/src/components/Kai/index.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' + +import KaiPanel from './KaiPanel' +import { KaiAvatar, Modal, ModalContent, Wrapper } from './styled' + +const kaiAnimate = { + enter: { + opacity: 1, + rotateX: 0, + transition: { + duration: 0.3, + }, + display: 'block', + }, + exit: { + opacity: 0, + rotateX: -15, + transition: { + duration: 0.3, + delay: 0.2, + }, + transitionEnd: { + display: 'none', + }, + }, +} + +const Kai = () => { + const [openKai, setOpenKai] = useState(false) + + const onOpenKai = () => setOpenKai(!openKai) + + return ( + <> + + + + + + + + + + ) +} + +export default Kai diff --git a/src/components/Kai/styled.tsx b/src/components/Kai/styled.tsx new file mode 100644 index 0000000000..0684d580c8 --- /dev/null +++ b/src/components/Kai/styled.tsx @@ -0,0 +1,315 @@ +import { motion } from 'framer-motion' +import { rgba } from 'polished' +import styled, { css, keyframes } from 'styled-components' + +import { ReactComponent as Send } from 'assets/svg/ic_send.svg' +import { ReactComponent as KaiAvatarSvg } from 'assets/svg/kai_avatar.svg' + +export const Wrapper = styled(motion.div)` + position: fixed; + bottom: 1rem; + right: 8rem; + z-index: 1; + height: 36px; + + ${({ theme }) => theme.mediaWidth.upToLarge` + bottom: 120px; + right: 1rem; + `}; +` + +export const KaiAvatar = styled(KaiAvatarSvg)` + cursor: pointer; +` + +export const Modal = styled(motion.div)` + position: fixed; + bottom: 5.2rem; + right: 1rem; + z-index: 10; + font-size: 14px; + width: fit-content; + height: fit-content; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + ${({ theme }) => theme.mediaWidth.upToLarge` + bottom: 174px; + `} +` + +export const ModalContent = styled.div` + background: ${({ theme }) => theme.tableHeader}; + padding: 20px 24px 26px; + border-radius: 12px; + width: 395px; + + ${({ theme }) => theme.mediaWidth.upToExtraSmall` + width: calc(100vw - 2rem); + `} +` + +export const KaiHeaderWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +` + +export const KaiHeaderLeft = styled.div` + display: flex; + gap: 6px; + align-items: center; +` + +export const ChainAnchorWrapper = styled.div` + position: relative; + display: flex; + align-items: center; + padding: 5px; +` + +export const ChainItem = styled.div<{ active: boolean }>` + display: flex; + gap: 12px; + align-items: center; + color: ${({ theme }) => theme.white}; + + ${({ theme, active }) => + active && + css` + color: ${theme.subText}; + `} +` + +export const SelectedChainImg = styled.img` + width: 20px; + height: 20px; + position: relative; + left: 6px; +` + +export const ChainAnchorBackground = styled.div` + position: absolute; + background-color: ${({ theme }) => rgba(theme.white, 0.1)}; + border-radius: 16px; + top: 0; + left: 3px; + width: 190%; + height: 100%; +` + +export const ChainSelectorWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + font-size: 14px; + max-height: 155px; + overflow: auto; + padding: 6px; +` + +export const HeaderTextName = styled.p` + margin: 0; +` + +export const HeaderSubText = styled.p` + margin: 0; + color: ${({ theme }) => theme.subText}; +` + +export const Divider = styled.div` + background-color: #505050; + height: 1px; + width: 100%; + margin: 10px 0 14px; +` + +export const ChatWrapper = styled.div<{ disabled: boolean }>` + position: relative; + height: 36px; + margin-top: 16px; + + ${({ disabled }) => + disabled && + css` + opacity: 0.4; + `} +` + +export const ChatInput = styled.input` + position: absolute; + display: flex; + padding: 10px 30px 13px 16px; + align-items: center; + width: 100%; + white-space: nowrap; + background: none; + border: none; + outline: none; + border-radius: 8px; + color: ${({ theme }) => theme.text}; + border-style: solid; + border: 1px solid ${({ theme }) => theme.background}; + background: ${({ theme }) => theme.background}; + transition: border 100ms; + appearance: none; + -webkit-appearance: none; + + ::placeholder { + color: ${({ theme }) => theme.border}; + font-size: 13.5px; + ${({ theme }) => theme.mediaWidth.upToSmall` + font-size: 12.5px; + `}; + } + + :focus { + border: 1px solid ${({ theme }) => theme.primary}; + outline: none; + } +` + +export const SendIcon = styled(Send)<{ disabled: boolean }>` + position: absolute; + right: 12px; + top: 12px; + color: transparent; + transition: 0.1s ease-in-out; + cursor: pointer; + + :hover { + color: ${({ theme }) => theme.primary}; + } + + ${({ disabled }) => + disabled && + css` + cursor: default; + color: transparent !important; + `} +` + +export const LoadingWrapper = styled.div` + color: ${({ theme }) => theme.subText}; + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; +` + +const loadingKeyFrame = keyframes` + 100%{transform: rotate(.5turn)} +` + +export const Loader = styled.div` + width: 16px; + aspect-ratio: 1; + --c: ${({ theme }) => `no-repeat radial-gradient(farthest-side, ${theme.subText} 92%, #0000)`}; + background: var(--c) 50% 0, var(--c) 50% 100%, var(--c) 100% 50%, var(--c) 0 50%; + background-size: 3px 3px; + animation: ${loadingKeyFrame} 1s infinite; + position: relative; + + ::before { + content: ''; + position: absolute; + inset: 0; + margin: 1px; + background: ${({ theme }) => `repeating-conic-gradient(#0000 0 35deg, ${theme.subText} 0 90deg)`}; + mask: radial-gradient(farthest-side, #0000 calc(100% - 1px), #000 0); + -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 1px), #000 0); + border-radius: 50%; + } +` + +export const ChatPanel = styled.div` + max-height: 415px; + overflow: scroll; + + ${({ theme }) => theme.mediaWidth.upToExtraSmall` + max-height: 50vh; + `} +` + +export const ActionPanel = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + width: 100%; + margin-top: 16px; +` + +export const ActionButton = styled.div<{ width: string; disabled: boolean }>` + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: #fafafa; + height: 36px; + background-color: ${({ theme }) => rgba(theme.white, 0.04)}; + transition: 0.1s ease-in-out; + cursor: pointer; + + :hover { + background-color: ${({ theme }) => rgba(theme.white, 0.08)}; + } + + ${({ width, disabled, theme }) => + css` + width: ${width}; + ${disabled && + ` + background-color: ${rgba(theme.disableText, 0.4)} !important; + cursor: not-allowed; + `} + `} +` + +export const MainActionButton = styled(ActionButton)<{ disabled: boolean }>` + background-color: ${({ theme }) => rgba(theme.primary, 0.1)}; + + :hover { + background-color: ${({ theme }) => rgba(theme.primary, 0.18)}; + } + + ${({ disabled, theme }) => + css` + ${disabled && + ` + background-color: ${rgba(theme.disableText, 0.4)} !important; + cursor: not-allowed; + `} + `} +` + +export const ActionText = styled.div` + margin-top: 16px; +` + +export const UserMessageWrapper = styled.div<{ havePrevious: boolean }>` + margin-top: 16px; + width: 100%; + display: flex; + justify-content: flex-end; + + ${({ havePrevious }) => + havePrevious && + css` + margin-top: 4px; + `} +` + +export const UserMessage = styled.p<{ havePrevious: boolean; haveFollowing: boolean }>` + margin: 0; + background-color: ${({ theme }) => theme.darkerGreen}; + width: fit-content; + border-radius: 16px; + padding: 8px 12px; + max-width: 100%; + word-wrap: break-word; + + ${({ havePrevious, haveFollowing }) => + css` + border-top-right-radius: ${havePrevious ? 4 : 16}px; + border-bottom-right-radius: ${haveFollowing ? 4 : 16}px; + `} +` diff --git a/src/components/Kai/utils.ts b/src/components/Kai/utils.ts new file mode 100644 index 0000000000..44733192a7 --- /dev/null +++ b/src/components/Kai/utils.ts @@ -0,0 +1,3 @@ +export const isNumber = (input: any) => { + return !/\D/.test(input) +} diff --git a/src/constants/networks/type.ts b/src/constants/networks/type.ts index c346bef28f..b0bc023ffe 100644 --- a/src/constants/networks/type.ts +++ b/src/constants/networks/type.ts @@ -4,6 +4,7 @@ import { EnvKeys } from 'constants/env' import { ChainState } from 'hooks/useChainsConfig' export interface NetworkInfo { + readonly displayName?: string readonly chainId: ChainId // route can be used to detect which chain is favored in query param, check out useActiveNetwork.ts diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 5a1c47884a..fdf3fc8bbc 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -12,6 +12,7 @@ import AppHaveUpdate from 'components/AppHaveUpdate' import ErrorBoundary from 'components/ErrorBoundary' import Footer from 'components/Footer/Footer' import Header from 'components/Header' +import Kai from 'components/Kai' import Loader from 'components/LocalLoader' import ModalsGlobal from 'components/ModalsGlobal' import ProtectedRoute from 'components/ProtectedRoute' @@ -218,6 +219,7 @@ export default function App() { {!isPartnerSwap && } +