+}
+
+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 && }
+