add token select modal; fix content widths;

This commit is contained in:
tjs 2022-07-11 23:00:22 -04:00
parent 771ee63ea5
commit 375d369573
13 changed files with 307 additions and 86 deletions

View File

@ -1,3 +1,6 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": 0
}
}

View File

@ -15,7 +15,7 @@ const Home = () => {
<div className="mt-8">
<div className="flex-col space-y-4">
<div className="mx-auto flex max-w-7xl justify-center space-x-4">
<div className="w-full space-y-6">
<div className="flex-grow space-y-6">
<SwapTokenChart
inputTokenId={inputTokenInfo?.extensions?.coingeckoId}
outputTokenId={outputTokenInfo?.extensions?.coingeckoId}

View File

@ -1,40 +1,50 @@
import { ChevronDownIcon } from '@heroicons/react/solid'
import Image from 'next/image'
import { useState } from 'react'
import mangoStore from '../store/state'
import SelectTokenModal from './swap/SelectTokenModal'
type TokenSelectProps = {
token: string
onChange: (x?: any) => void
onChange: (x: string) => void
}
const TokenSelect = ({ token, onChange }: TokenSelectProps) => {
const [showTokenSelectModal, setShowTokenSelectModal] = useState(false)
const group = mangoStore((s) => s.group)
const handleTokenSelect = (sym: string) => {
setShowTokenSelectModal(false)
onChange(sym)
}
if (!group) return null
return (
<div className="flex items-center">
<div className="flex min-w-[24px] items-center">
<Image
alt=""
width="30"
height="30"
src={`/icons/${token.toLowerCase()}.svg`}
/>
</div>
<label htmlFor="tokenIn" className="sr-only">
Token
</label>
<select
id="tokenIn"
name="tokenIn"
autoComplete="token"
className="text-mango-200 h-full rounded-md border-transparent bg-transparent pr-10 text-lg font-bold focus:ring-0"
onChange={onChange}
value={token}
<>
<div
onClick={() => setShowTokenSelectModal(true)}
className="-ml-3 flex h-full items-center rounded-full py-2 px-4 hover:cursor-pointer hover:shadow-lg hover:drop-shadow-lg hover:backdrop-brightness-125"
>
{Array.from(group.banksMap.keys()).map((symbol) => {
return <option key={symbol}>{symbol}</option>
})}
</select>
</div>
<div className="mr-3 flex min-w-[24px] items-center">
<Image
alt=""
width="30"
height="30"
src={`/icons/${token.toLowerCase()}.svg`}
/>
</div>
<div className="text-xl text-th-fgd-2">{token}</div>
<ChevronDownIcon className="ml-1.5 h-5 w-5 text-th-fgd-3" />
</div>
{showTokenSelectModal ? (
<SelectTokenModal
isOpen={showTokenSelectModal}
onClose={() => setShowTokenSelectModal(false)}
onTokenSelect={handleTokenSelect}
/>
) : null}
</>
)
}

View File

@ -48,7 +48,7 @@ const NotificationList = () => {
return (
<div
className={`text-th-fgd-1 pointer-events-none fixed inset-0 z-50 flex items-end px-4 py-6 sm:p-6`}
className={`pointer-events-none fixed bottom-0 left-0 z-50 flex w-full items-end px-4 py-6 text-th-fgd-1 sm:p-6`}
>
<div className={`flex w-full flex-col`}>
{reversedNotifications.map((n) => (
@ -127,31 +127,31 @@ const Notification = ({ notification }: { notification: Notification }) => {
leaveTo="-translate-y-2 sm:translate-y-0 sm:-translate-x-48"
>
<div
className={`border-th-bkg-4 bg-th-bkg-3 pointer-events-auto mt-2 w-full max-w-sm overflow-hidden rounded-md border shadow-lg ring-1 ring-black ring-opacity-5`}
className={`pointer-events-auto mt-2 w-full max-w-sm overflow-hidden rounded-md border border-th-bkg-4 bg-th-bkg-3 shadow-lg ring-1 ring-black ring-opacity-5`}
>
<div className={`relative flex items-center px-2 py-2.5`}>
<div className={`flex-shrink-0`}>
{type === 'success' ? (
<CheckCircleIcon className={`text-th-green mr-1 h-7 w-7`} />
<CheckCircleIcon className={`mr-1 h-7 w-7 text-th-green`} />
) : null}
{type === 'info' && (
<InformationCircleIcon
className={`text-th-primary mr-1 h-7 w-7`}
className={`mr-1 h-7 w-7 text-th-primary`}
/>
)}
{type === 'error' && (
<XCircleIcon className={`text-th-red mr-1 h-7 w-7`} />
<XCircleIcon className={`mr-1 h-7 w-7 text-th-red`} />
)}
{type === 'confirm' && (
<Loading className="text-th-fgd-3 mr-1 h-7 w-7" />
<Loading className="mr-1 h-7 w-7 text-th-fgd-3" />
)}
</div>
<div className={`ml-2 flex-1`}>
<div className={`text-normal text-th-fgd-1 font-bold`}>
<div className={`text-normal font-bold text-th-fgd-1`}>
{parsedTitle || title}
</div>
{description ? (
<p className={`text-th-fgd-3 mb-0 mt-0.5 leading-tight`}>
<p className={`mb-0 mt-0.5 leading-tight text-th-fgd-3`}>
{description}
</p>
) : null}
@ -179,7 +179,7 @@ const Notification = ({ notification }: { notification: Notification }) => {
<div className={`absolute right-2 top-2 flex-shrink-0`}>
<button
onClick={hideNotification}
className={`text-th-fgd-4 md:hover:text-th-primary focus:outline-none`}
className={`text-th-fgd-4 focus:outline-none md:hover:text-th-primary`}
>
<span className={`sr-only`}>Close</span>
<svg

View File

@ -0,0 +1,201 @@
import {
memo,
useMemo,
useState,
PureComponent,
useEffect,
ChangeEvent,
} from 'react'
import { SearchIcon } from '@heroicons/react/outline'
import Image from 'next/image'
import { FixedSizeList } from 'react-window'
import Modal from '../shared/Modal'
import { Token } from '../../types/jupiter'
import mangoStore from '../../store/state'
const generateSearchTerm = (item: Token, searchValue: string) => {
const normalizedSearchValue = searchValue.toLowerCase()
const values = `${item.symbol} ${item.name}`.toLowerCase()
const isMatchingWithSymbol =
item.symbol.toLowerCase().indexOf(normalizedSearchValue) >= 0
const matchingSymbolPercent = isMatchingWithSymbol
? normalizedSearchValue.length / item.symbol.length
: 0
return {
token: item,
matchingIdx: values.indexOf(normalizedSearchValue),
matchingSymbolPercent,
}
}
const startSearch = (items: Token[], searchValue: string) => {
return items
.map((item) => generateSearchTerm(item, searchValue))
.filter((item) => item.matchingIdx >= 0)
.sort((i1, i2) => i1.matchingIdx - i2.matchingIdx)
.sort((i1, i2) => i2.matchingSymbolPercent - i1.matchingSymbolPercent)
.map((item) => item.token)
}
type ItemRendererProps = {
data: any
index: number
style: any
}
class ItemRenderer extends PureComponent<ItemRendererProps> {
render() {
// Access the items array using the "data" prop:
const tokenInfo: Token = this.props.data.items[this.props.index]
return (
<div style={this.props.style}>
<button
key={tokenInfo?.address}
className="flex w-full cursor-pointer items-center justify-between rounded-none py-4 px-2 font-normal focus:bg-th-bkg-3 focus:outline-none md:hover:bg-th-bkg-4"
onClick={() => this.props.data.onSubmit(tokenInfo.symbol)}
>
<div className="flex items-center">
<picture>
<source srcSet={tokenInfo?.logoURI} type="image/webp" />
<img
src={tokenInfo?.logoURI}
width="24"
height="24"
alt={tokenInfo?.symbol}
/>
</picture>
<div className="ml-4">
<div className="text-left text-th-fgd-2">
{tokenInfo?.symbol || 'unknown'}
</div>
<div className="text-left text-th-fgd-4">
{tokenInfo?.name || 'unknown'}
</div>
</div>
</div>
</button>
</div>
)
}
}
const popularTokenSymbols = ['USDC', 'SOL', 'USDT', 'MNGO', 'BTC', 'ETH']
const SelectTokenModal = ({
isOpen,
onClose,
onTokenSelect,
}: {
isOpen: boolean
onClose: (x?: any) => void
onTokenSelect: (x: string) => void
}) => {
const [search, setSearch] = useState('')
const tokens = mangoStore.getState().jupiterTokens
const walletTokens = mangoStore((s) => s.wallet.tokens)
const popularTokens = useMemo(() => {
return walletTokens?.length
? tokens.filter((token) => {
const walletMints = walletTokens.map((tok) => tok.mint.toString())
return !token?.name || !token?.symbol
? false
: popularTokenSymbols.includes(token.symbol) &&
walletMints.includes(token.address)
})
: tokens.filter((token) => {
return !token?.name || !token?.symbol
? false
: popularTokenSymbols.includes(token.symbol)
})
}, [walletTokens, tokens])
useEffect(() => {
function onEscape(e: any) {
if (e.keyCode === 27) {
onClose?.()
}
}
window.addEventListener('keydown', onEscape)
return () => window.removeEventListener('keydown', onEscape)
}, [onClose])
const tokenInfos = useMemo(() => {
if (tokens?.length) {
const filteredTokens = tokens.filter((token) => {
return !token?.name || !token?.symbol ? false : true
})
if (walletTokens?.length) {
const walletMints = walletTokens.map((tok) => tok.mint.toString())
return filteredTokens.sort(
(a, b) =>
walletMints.indexOf(b.address) - walletMints.indexOf(a.address)
)
} else {
return filteredTokens
}
} else {
return []
}
}, [tokens, walletTokens])
const handleUpdateSearch = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
}
const sortedTokens = search ? startSearch(tokenInfos, search) : tokenInfos
return (
<Modal isOpen={isOpen} onClose={onClose}>
<div className="flex flex-col pb-2 md:h-2/3">
<div className="flex items-center text-lg text-th-fgd-4">
<SearchIcon className="h-8 w-8" />
<input
type="text"
className="ml-4 flex-1 bg-transparent focus:outline-none"
placeholder="Search by token or paste address"
autoFocus
value={search}
onChange={handleUpdateSearch}
/>
</div>
{popularTokens.length && onTokenSelect ? (
<div className="mt-8 flex flex-wrap">
{popularTokens.map((token) => (
<button
className="mx-1 mb-2 flex items-center rounded-md border border-th-bkg-4 py-1 px-3 hover:border-th-fgd-3 focus:border-th-fgd-2"
onClick={() => onTokenSelect(token.symbol)}
key={token.address}
>
<Image
alt=""
width="16"
height="16"
src={`/icons/${token.symbol.toLowerCase()}.svg`}
/>
<span className="ml-1.5 text-th-fgd-1">{token.symbol}</span>
</button>
))}
</div>
) : null}
<div className="-ml-6 mt-2 w-[600px] border-t border-th-bkg-4"></div>
<FixedSizeList
width="100%"
height={403}
itemData={{ items: sortedTokens, onSubmit: onTokenSelect }}
itemCount={sortedTokens.length}
itemSize={72}
className="thin-scroll"
>
{ItemRenderer}
</FixedSizeList>
</div>
</Modal>
)
}
export default memo(SelectTokenModal)

View File

@ -25,23 +25,29 @@ const Swap = () => {
setAmountIn(e.target.value)
}
const handleTokenInSelect = (e: ChangeEvent<HTMLSelectElement>) => {
const inputTokenInfo = tokens.find((t: any) => t.symbol === e.target.value)
const handleTokenInSelect = (symbol: string) => {
const inputTokenInfo = tokens.find((t: any) => t.symbol === symbol)
set((s) => {
s.inputTokenInfo = inputTokenInfo
})
setInputToken(e.target.value)
setInputToken(symbol)
}
const handleTokenOutSelect = (e: ChangeEvent<HTMLSelectElement>) => {
const outputTokenInfo = tokens.find((t: any) => t.symbol === e.target.value)
const handleTokenOutSelect = (symbol: string) => {
const outputTokenInfo = tokens.find((t: any) => t.symbol === symbol)
set((s) => {
s.outputTokenInfo = outputTokenInfo
})
setOutputToken(e.target.value)
setOutputToken(symbol)
}
const handleSwitchTokens = () => {
const inputTokenInfo = tokens.find((t: any) => t.symbol === inputToken)
const outputTokenInfo = tokens.find((t: any) => t.symbol === outputToken)
set((s) => {
s.inputTokenInfo = outputTokenInfo
s.outputTokenInfo = inputTokenInfo
})
setInputToken(outputToken)
setOutputToken(inputToken)
}
@ -90,21 +96,21 @@ const Swap = () => {
<ContentBox className="max-w-md">
<div className="">
<div className="mt-1 flex-col rounded-md bg-th-bkg-1 py-2 px-6">
<div className="flex justify-between">
<div className="flex items-center justify-between">
<TokenSelect token={inputToken} onChange={handleTokenInSelect} />
<div>
<input
type="text"
name="amountIn"
id="amountIn"
className="tex-th-fgd-2 w-full rounded-lg border-none bg-transparent text-right text-2xl focus:ring-0"
className="w-full rounded-lg border-none bg-transparent text-right text-2xl text-th-fgd-3 focus:outline-none"
placeholder="0.00"
value={amountIn}
onChange={handleAmountInChange}
/>
</div>
</div>
<div>
<div className="mb-1">
<label
htmlFor="default-range"
className="block text-sm font-medium text-gray-900 dark:text-gray-300"
@ -122,9 +128,9 @@ const Swap = () => {
{/* <SwitchVerticalIcon className="default-transition h-10 w-10 rounded-full border-4 border-th-bkg-1 bg-th-bkg-2 p-1.5 text-th-fgd-3 md:hover:text-th-primary" /> */}
</button>
</div>
<div className="mt-4 flex items-center justify-between rounded-md py-2 px-6">
<div className="mt-4 flex items-center justify-between py-2 px-6">
<TokenSelect token={outputToken} onChange={handleTokenOutSelect} />
<div className="tex-th-fgd-2 w-full text-right text-2xl">
<div className="w-full cursor-context-menu text-right text-2xl text-th-fgd-3">
{amountOut ? numberFormat.format(amountOut) : null}
</div>
</div>

View File

@ -97,40 +97,6 @@ const SwapTokenChart: FunctionComponent<SwapTokenChartProps> = ({
setChartData(formattedData.filter((d: any) => d.price))
}
// Alternative chart data. Needs a timestamp tolerance to get data points for each asset
// const getChartData = async () => {
// const now = Date.now() / 1000
// const inputResponse = await fetch(
// `https://api.coingecko.com/api/v3/coins/${inputTokenId}/market_chart/range?vs_currency=usd&from=${
// now - 1 * 86400
// }&to=${now}`
// )
// const outputResponse = await fetch(
// `https://api.coingecko.com/api/v3/coins/${outputTokenId}/market_chart/range?vs_currency=usd&from=${
// now - 1 * 86400
// }&to=${now}`
// )
// const inputData = await inputResponse.json()
// const outputData = await outputResponse.json()
// const data = inputData?.prices.concat(outputData?.prices)
// const formattedData = data.reduce((a, c) => {
// const found = a.find(
// (price) => c[0] >= price.time - 120000 && c[0] <= price.time + 120000
// )
// if (found) {
// found.price = found.inputPrice / c[1]
// } else {
// a.push({ time: c[0], inputPrice: c[1] })
// }
// return a
// }, [])
// setChartData(formattedData.filter((d) => d.price))
// }
const getInputTokenInfo = async () => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${inputTokenId}?localization=false&tickers=false&developer_data=false&sparkline=false
@ -247,7 +213,7 @@ const SwapTokenChart: FunctionComponent<SwapTokenChartProps> = ({
</div>
</div>
{!hideChart ? (
<div className="mt-6 h-36 w-full" ref={observe}>
<div className="mt-6 h-36" ref={observe}>
<AreaChart
width={width}
height={height}

View File

@ -1,9 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
env: {
BROWSER: true,
},
images: {
domains: ['raw.githubusercontent.com'],
},
reactStrictMode: true,
webpack: (config, { isServer }) => {
if (!isServer) {
// don't resolve 'fs' module on the client to prevent this error on build --> Error: Can't resolve 'fs'

View File

@ -41,6 +41,7 @@
"@types/node": "17.0.23",
"@types/react": "18.0.3",
"@types/react-dom": "18.0.0",
"@types/react-window": "^1.8.5",
"autoprefixer": "^10.4.4",
"eslint": "8.13.0",
"eslint-config-next": "12.1.5",

View File

@ -17,6 +17,7 @@ import {
getTokenAccountsByOwnerWithWrappedSol,
TokenAccount,
} from '../utils/tokens'
import { Token } from '../types/jupiter'
const DEVNET_GROUP = new PublicKey(
'A9XhGqUUjV992cD36qWDY8wDiZnGuCaUWtSE3NGXjDCb'
@ -41,7 +42,7 @@ export type MangoStore = {
connection: Connection
group: Group | undefined
client: MangoClient
jupiterTokens: any[]
jupiterTokens: Token[]
mangoAccount: MangoAccount | undefined
markets: Serum3Market[] | undefined
notificationIdCounter: number

View File

@ -124,5 +124,5 @@ module.exports = {
// textColor: ['disabled'],
// },
// },
plugins: [require('@tailwindcss/forms')],
plugins: [],
}

View File

@ -9,3 +9,13 @@ export type Routes = {
routesInfos: RouteInfo[]
cached: boolean
}
export interface Token {
chainId: number // 101,
address: string // 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
symbol: string // 'USDC',
name: string // 'Wrapped USDC',
decimals: number // 6,
logoURI: string // 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW/logo.png',
tags: string[] // [ 'stablecoin' ]
}

View File

@ -10,7 +10,7 @@
core-js-pure "^3.20.2"
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.2":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.2":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
@ -1143,6 +1143,13 @@
dependencies:
"@types/react" "*"
"@types/react-window@^1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.0.3":
version "18.0.3"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.3.tgz#baefa397561372015b9f8ba5bc83bc3f84ae8fcb"
@ -3231,6 +3238,11 @@ md5.js@^1.3.4:
inherits "^2.0.1"
safe-buffer "^5.1.2"
"memoize-one@>=3.1.1 <6":
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -3787,6 +3799,14 @@ react-transition-group@2.9.0:
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
react-window@1.8.6:
version "1.8.6"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@18.0.0:
version "18.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96"