Setup Solana Boilerplate

This commit is contained in:
Maximilian Schneider 2021-04-25 18:03:30 +03:00
parent 17d5d98428
commit 235874bea8
28 changed files with 1920 additions and 257 deletions

4
@types/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: any
export default content
}

62
@types/types.tsx Normal file
View File

@ -0,0 +1,62 @@
import {
AccountInfo,
Connection,
PublicKey,
Transaction,
} from '@solana/web3.js'
import Wallet from '@project-serum/sol-wallet-adapter'
export interface ConnectionContextValues {
endpoint: string
setEndpoint: (newEndpoint: string) => void
connection: Connection
sendConnection: Connection
availableEndpoints: EndpointInfo[]
setCustomEndpoints: (newCustomEndpoints: EndpointInfo[]) => void
}
export interface EndpointInfo {
name: string
url: string
websocket: string
}
export interface WalletContextValues {
wallet: Wallet
connected: boolean
providerUrl: string
setProviderUrl: (newProviderUrl: string) => void
providerName: string
}
export interface TokenAccount {
pubkey: PublicKey
account: AccountInfo<Buffer> | null
effectiveMint: PublicKey
}
/**
* {tokenMint: preferred token account's base58 encoded public key}
*/
export interface SelectedTokenAccounts {
[tokenMint: string]: string
}
// Token infos
export interface KnownToken {
tokenSymbol: string
tokenName: string
icon?: string
mintAddress: string
}
export interface WalletAdapter {
publicKey: PublicKey
autoApprove: boolean
connected: boolean
signTransaction: (transaction: Transaction) => Promise<Transaction>
signAllTransactions: (transaction: Transaction[]) => Promise<Transaction[]>
connect: () => any
disconnect: () => any
on(event: string, fn: () => void): this
}

View File

@ -0,0 +1,45 @@
import styled from '@emotion/styled'
import useWalletStore from '../stores/useWalletStore'
import { WALLET_PROVIDERS, DEFAULT_PROVIDER } from '../hooks/useWallet'
import useLocalStorageState from '../hooks/useLocalStorageState'
import WalletSelect from './WalletSelect'
import WalletIcon from './WalletIcon'
const StyledWalletTypeLabel = styled.div`
font-size: 0.6rem;
`
const ConnectWalletButton = () => {
const wallet = useWalletStore((s) => s.current)
const [savedProviderUrl] = useLocalStorageState(
'walletProvider',
DEFAULT_PROVIDER.url
)
return (
<div className="flex justify-between border border-th-primary rounded-md h-11 w-48">
<button
onClick={() => wallet.connect()}
disabled={!wallet}
className="text-th-primary hover:text-th-fgd-1 focus:outline-none disabled:text-th-fgd-4 disabled:cursor-wait"
>
<div className="flex flex-row items-center px-2 justify-center h-full rounded-l default-transition hover:bg-th-primary hover:text-th-fgd-1">
<WalletIcon className="w-5 h-5 mr-3 fill-current" />
<div>
<span className="whitespace-nowrap">Connect Wallet</span>
<StyledWalletTypeLabel className="font-normal text-th-fgd-1 text-left leading-3">
{WALLET_PROVIDERS.filter((p) => p.url === savedProviderUrl).map(
({ name }) => name
)}
</StyledWalletTypeLabel>
</div>
</div>
</button>
<div className="relative h-full">
<WalletSelect isPrimary />
</div>
</div>
)
}
export default ConnectWalletButton

113
components/Notification.tsx Normal file
View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import {
CheckCircleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/outline'
import useNotificationStore from '../stores/useNotificationStore'
const NotificationList = () => {
const { notifications, set: setNotificationStore } = useNotificationStore(
(s) => s
)
useEffect(() => {
if (notifications.length > 0) {
const id = setInterval(() => {
setNotificationStore((state) => {
state.notifications = notifications.slice(1, notifications.length)
})
}, 5000)
return () => {
clearInterval(id)
}
}
}, [notifications, setNotificationStore])
const reversedNotifications = [...notifications].reverse()
return (
<div
className={`fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6`}
>
<div className={`flex flex-col w-full`}>
{reversedNotifications.map((n, idx) => (
<Notification
key={`${n.message}${idx}`}
type={n.type}
message={n.message}
description={n.description}
txid={n.txid}
/>
))}
</div>
</div>
)
}
const Notification = ({ type, message, description, txid }) => {
const [showNotification, setShowNotification] = useState(true)
if (!showNotification) return null
return (
<div
className={`max-w-sm w-full bg-th-bkg-3 shadow-lg rounded-md mt-2 pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden`}
>
<div className={`p-4`}>
<div className={`flex items-center`}>
<div className={`flex-shrink-0`}>
{type === 'success' ? (
<CheckCircleIcon className={`text-th-green h-9 w-9 mr-1`} />
) : null}
{type === 'info' && (
<XCircleIcon className={`text-th-primary h-9 w-9 mr-1`} />
)}
{type === 'error' && (
<InformationCircleIcon className={`text-th-red h-9 w-9 mr-1`} />
)}
</div>
<div className={`ml-2 w-0 flex-1`}>
<div className={`text-lg text-th-fgd-1`}>{message}</div>
{description ? (
<p className={`mt-0.5 text-base text-th-fgd-2`}>{description}</p>
) : null}
{txid ? (
<a
href={'https://explorer.solana.com/tx/' + txid}
className="text-th-primary"
>
View transaction {txid.slice(0, 8)}...
{txid.slice(txid.length - 8)}
</a>
) : null}
</div>
<div className={`ml-4 flex-shrink-0 self-start flex`}>
<button
onClick={() => setShowNotification(false)}
className={`bg-th-bkg-3 rounded-md inline-flex text-fgd-3 hover:text-th-fgd-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-th-primary`}
>
<span className={`sr-only`}>Close</span>
<svg
className={`h-5 w-5`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
)
}
export default NotificationList

25
components/WalletIcon.jsx Normal file
View File

@ -0,0 +1,25 @@
const WalletIcon = ({ className }) => {
return (
<svg
className={className}
width="20"
height="17"
viewBox="0 0 20 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.625 8.24561C13.7276 8.24561 13 8.97325 13 9.87061C13 10.768 13.7276 11.4956 14.625 11.4956C15.5224 11.4956 16.25 10.768 16.25 9.87061C16.25 8.97325 15.5224 8.24561 14.625 8.24561ZM14 9.87061C14 9.52554 14.2799 9.24561 14.625 9.24561C14.9701 9.24561 15.25 9.52554 15.25 9.87061C15.25 10.2157 14.9701 10.4956 14.625 10.4956C14.2799 10.4956 14 10.2157 14 9.87061Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.25 0.25C1.59301 0.25 0.25 1.59301 0.25 3.25L0.250676 13.8083C0.250702 15.4652 1.59371 16.8083 3.25068 16.8083H17.2147C18.5735 16.8083 19.7507 15.755 19.7507 14.3708V5.37076C19.7507 4.51126 19.2968 3.77937 18.6275 3.34799C18.6257 2.86554 18.5949 2.24606 18.3863 1.7108C18.2324 1.31604 17.973 0.930835 17.5462 0.652726C17.1244 0.377893 16.6042 0.25 16 0.25H3.25ZM17.6434 4.51627C17.6217 4.50923 17.6004 4.50122 17.5796 4.4923C17.4681 4.45439 17.3457 4.43326 17.2147 4.43326H4.81318C4.39896 4.43326 4.06318 4.09747 4.06318 3.68326C4.06318 3.26904 4.39896 2.93326 4.81318 2.93326H17.1143C17.0993 2.67796 17.0651 2.45157 16.9887 2.2555C16.9238 2.08899 16.8395 1.98262 16.7273 1.90947C16.61 1.83305 16.3958 1.75 16 1.75H3.25C2.42146 1.75 1.75003 2.42141 1.75 3.24995L1.75068 13.8082C1.75068 14.6368 2.42212 15.3083 3.25068 15.3083H17.2147C17.8262 15.3083 18.2507 14.8477 18.2507 14.3708V5.37076C18.2507 5.01586 18.0156 4.67002 17.6434 4.51627Z"
/>
</svg>
)
}
export default WalletIcon

View File

@ -0,0 +1,64 @@
import { Menu } from '@headlessui/react'
import {
ChevronDownIcon,
ChevronUpIcon,
CheckCircleIcon,
} from '@heroicons/react/outline'
import useWalletStore from '../stores/useWalletStore'
import { WALLET_PROVIDERS, DEFAULT_PROVIDER } from '../hooks/useWallet'
import useLocalStorageState from '../hooks/useLocalStorageState'
export default function WalletSelect({ isPrimary = false }) {
const setWalletStore = useWalletStore((s) => s.set)
const [savedProviderUrl] = useLocalStorageState(
'walletProvider',
DEFAULT_PROVIDER.url
)
const handleSelectProvider = (url) => {
setWalletStore((state) => {
state.providerUrl = url
})
}
return (
<Menu>
{({ open }) => (
<>
<Menu.Button
className={`flex justify-center items-center h-full rounded-r rounded-l-none focus:outline-none text-th-primary hover:text-th-fgd-1 ${
isPrimary
? 'px-3 hover:bg-th-primary'
: 'px-2 hover:bg-th-bkg-3 border-l border-th-fgd-4'
} cursor-pointer`}
>
{open ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</Menu.Button>
<Menu.Items className="z-20 p-1 absolute right-0 top-11 bg-th-bkg-1 divide-y divide-th-bkg-3 shadow-lg outline-none rounded-md w-48">
{WALLET_PROVIDERS.map(({ name, url, icon }) => (
<Menu.Item key={name}>
<button
className="flex flex-row items-center justify-between w-full p-2 hover:bg-th-bkg-2 hover:cursor-pointer font-normal focus:outline-none"
onClick={() => handleSelectProvider(url)}
>
<div className="flex">
<img src={icon} className="w-5 h-5 mr-2" />
{name}
</div>
{savedProviderUrl === url ? (
<CheckCircleIcon className="h-4 w-4 text-th-green" />
) : null}{' '}
</button>
</Menu.Item>
))}
</Menu.Items>
</>
)}
</Menu>
)
}

39
hooks/useInterval.tsx Normal file
View File

@ -0,0 +1,39 @@
import { useState, useRef, useEffect } from 'react'
export function useEffectAfterTimeout(effect, timeout) {
useEffect(() => {
const handle = setTimeout(effect, timeout)
return () => clearTimeout(handle)
})
}
export function useListener(emitter, eventName) {
const [, forceUpdate] = useState(0)
useEffect(() => {
const listener = () => forceUpdate((i) => i + 1)
emitter.on(eventName, listener)
return () => emitter.removeListener(eventName, listener)
}, [emitter, eventName])
}
export default function useInterval(callback, delay) {
const savedCallback = useRef<() => void>()
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback
}, [callback])
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current && savedCallback.current()
}
if (delay !== null) {
const id = setInterval(tick, delay)
return () => {
clearInterval(id)
}
}
}, [delay])
}

View File

@ -0,0 +1,69 @@
import { useMemo, useState, useEffect, useCallback } from 'react'
const localStorageListeners = {}
export function useLocalStorageStringState(
key: string,
defaultState: string | null = null
): [string | null, (newState: string | null) => void] {
const state =
typeof window !== 'undefined'
? localStorage.getItem(key) || defaultState
: ''
const [, notify] = useState(key + '\n' + state)
useEffect(() => {
if (!localStorageListeners[key]) {
localStorageListeners[key] = []
}
localStorageListeners[key].push(notify)
return () => {
localStorageListeners[key] = localStorageListeners[key].filter(
(listener) => listener !== notify
)
if (localStorageListeners[key].length === 0) {
delete localStorageListeners[key]
}
}
}, [key])
const setState = useCallback<(newState: string | null) => void>(
(newState) => {
if (!localStorageListeners[key]) {
localStorageListeners[key] = []
}
const changed = state !== newState
if (!changed) {
return
}
if (newState === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, newState)
}
localStorageListeners[key].forEach((listener) =>
listener(key + '\n' + newState)
)
},
[state, key]
)
return [state, setState]
}
export default function useLocalStorageState<T = any>(
key: string,
defaultState: T | null = null
): [T, (newState: T) => void] {
const [stringState, setStringState] = useLocalStorageStringState(
key,
JSON.stringify(defaultState)
)
return [
useMemo(() => stringState && JSON.parse(stringState), [stringState]),
(newState) => setStringState(JSON.stringify(newState)),
]
}

134
hooks/useWallet.tsx Normal file
View File

@ -0,0 +1,134 @@
import { useEffect, useMemo } from 'react'
import Wallet from '@project-serum/sol-wallet-adapter'
import { WalletAdapter } from '../@types/types'
import useWalletStore from '../stores/useWalletStore'
import { notify } from '../utils/notifications'
import {
PhantomWalletAdapter,
SolletExtensionAdapter,
} from '../utils/wallet-adapters'
import useInterval from './useInterval'
import useLocalStorageState from './useLocalStorageState'
const SECONDS = 1000
const ASSET_URL =
'https://cdn.jsdelivr.net/gh/solana-labs/oyster@main/assets/wallets'
export const WALLET_PROVIDERS = [
{
name: 'Sollet.io',
url: 'https://www.sollet.io',
icon: `${ASSET_URL}/sollet.svg`,
},
{
name: 'Sollet Extension',
url: 'https://www.sollet.io/extension',
icon: `${ASSET_URL}/sollet.svg`,
adapter: SolletExtensionAdapter as any,
},
{
name: 'Phantom',
url: 'https://www.phantom.app',
icon: `https://www.phantom.app/img/logo.png`,
adapter: PhantomWalletAdapter,
},
]
export const DEFAULT_PROVIDER = WALLET_PROVIDERS[0]
export default function useWallet() {
const {
connected,
connection: { endpoint },
current: wallet,
providerUrl: selectedProviderUrl,
set: setWalletStore,
actions,
} = useWalletStore((state) => state)
const [savedProviderUrl, setSavedProviderUrl] = useLocalStorageState(
'walletProvider',
DEFAULT_PROVIDER.url
)
const provider = useMemo(
() => WALLET_PROVIDERS.find(({ url }) => url === savedProviderUrl),
[savedProviderUrl]
)
useEffect(() => {
console.log('provider url changed', selectedProviderUrl)
if (selectedProviderUrl) {
setSavedProviderUrl(selectedProviderUrl)
}
}, [selectedProviderUrl])
useEffect(() => {
if (provider) {
const updateWallet = () => {
// hack to also update wallet synchronously in case it disconnects
// eslint-disable-next-line react-hooks/exhaustive-deps
const wallet = new (provider.adapter || Wallet)(
savedProviderUrl,
endpoint
) as WalletAdapter
setWalletStore((state) => {
state.current = wallet
})
}
if (document.readyState !== 'complete') {
// wait to ensure that browser extensions are loaded
const listener = () => {
updateWallet()
window.removeEventListener('load', listener)
}
window.addEventListener('load', listener)
return () => window.removeEventListener('load', listener)
} else {
updateWallet()
}
}
}, [provider, savedProviderUrl, endpoint])
useEffect(() => {
if (!wallet) return
wallet.on('connect', async () => {
setWalletStore((state) => {
state.connected = true
})
notify({
message: 'Wallet connected',
description:
'Connected to wallet ' +
wallet.publicKey.toString().substr(0, 5) +
'...' +
wallet.publicKey.toString().substr(-5),
})
actions.fetchWalletBalances()
})
wallet.on('disconnect', () => {
setWalletStore((state) => {
state.connected = false
state.balances = []
})
notify({
type: 'info',
message: 'Disconnected from wallet',
})
})
return () => {
if (wallet && wallet.connected) {
wallet.disconnect()
}
setWalletStore((state) => {
state.connected = false
})
}
}, [wallet, setWalletStore])
useInterval(() => {
actions.fetchWalletBalances()
}, 20 * SECONDS)
return { connected, wallet }
}

7
netlify.toml Normal file
View File

@ -0,0 +1,7 @@
[build]
command = "npm run build"
publish = "out"
[[plugins]]
package = "@netlify/plugin-nextjs"

View File

@ -26,9 +26,19 @@
]
},
"dependencies": {
"@emotion/react": "^11.1.5",
"@emotion/styled": "^11.3.0",
"@headlessui/react": "^1.0.0",
"@heroicons/react": "^1.0.1",
"@project-serum/sol-wallet-adapter": "^0.2.0",
"@solana/spl-token": "^0.1.3",
"@solana/web3.js": "^1.5.0",
"immer": "^9.0.1",
"next": "latest",
"next-themes": "^0.0.14",
"react": "^17.0.1",
"react-dom": "^17.0.1"
"react-dom": "^17.0.1",
"zustand": "^3.4.1"
},
"devDependencies": {
"@testing-library/react": "^11.2.5",
@ -41,12 +51,14 @@
"eslint": "^7.19.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react-hooks": "^4.2.0",
"husky": "^4.2.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"jest-watch-typeahead": "^0.6.1",
"lint-staged": "^10.0.10",
"prettier": "^2.0.2",
"tailwindcss": "^2.1.2",
"typescript": "^4.1.3"
}
}

45
pages/_app.tsx Normal file
View File

@ -0,0 +1,45 @@
import Head from 'next/head'
import { ThemeProvider } from 'next-themes'
import '../styles/index.css'
import useWallet from '../hooks/useWallet'
function App({ Component, pageProps }) {
useWallet()
const title = 'Mango Markets'
const description =
'Mango Markets - Decentralised, cross-margin trading up to 5x leverage with lightning speed and near-zero fees powered by Serum.'
const keywords =
'Mango Markets, Serum, SRM, Serum DEX, DEFI, Decentralized Finance, Decentralised Finance, Crypto, ERC20, Ethereum, Decentralize, Solana, SOL, SPL, Cross-Chain, Trading, Fastest, Fast, SerumBTC, SerumUSD, SRM Tokens, SPL Tokens'
return (
<>
<Head>
<title>{title}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="keywords" content={keywords} />
<meta name="description" content={description} />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Mango Markets" />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="/twitter-image.png" />
<link rel="manifest" href="/manifest.json"></link>
</Head>
<ThemeProvider defaultTheme="Mango">
<Component {...pageProps} />
</ThemeProvider>
</>
)
}
export default App

View File

@ -1,211 +1,13 @@
import Head from 'next/head'
import Image from 'next/image'
import Notifications from '../components/Notification'
import ConnectWalletButton from '../components/ConnectWalletButton'
export const Home = (): JSX.Element => (
<div className="container">
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
const Index = () => {
return (
<div className={`bg-th-bkg-1 text-th-fgd-1 transition-all `}>
<ConnectWalletButton />
<Notifications />
</div>
)
}
<main>
<h1 className="title">
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className="description">
Get started by editing <code>pages/index.tsx</code>
</p>
<button
onClick={() => {
window.alert('With typescript and Jest')
}}
>
Test Button
</button>
<div className="grid">
<a href="https://nextjs.org/docs" className="card">
<h3>Documentation &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className="card">
<h3>Learn &rarr;</h3>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/master/examples"
className="card"
>
<h3>Examples &rarr;</h3>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className="card"
>
<h3>Deploy &rarr;</h3>
<p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
</a>
</div>
</main>
<footer>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
</a>
</footer>
<style jsx>{`
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
footer img {
margin-left: 0.5rem;
}
footer a {
display: flex;
justify-content: center;
align-items: center;
}
a {
color: inherit;
text-decoration: none;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}
`}</style>
<style jsx global>{`
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
* {
box-sizing: border-box;
}
`}</style>
</div>
)
export default Home
export default Index

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

51
public/mango.svg Normal file
View File

@ -0,0 +1,51 @@
<svg width="985" height="1006" viewBox="0 0 985 1006" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M472.746 272.289C472.751 272.283 472.755 272.276 472.76 272.27C509.05 290.89 548.76 297.27 586.76 297.16C608.84 317.44 627.21 341.26 644.47 365.72C651.627 375.937 658.082 386.628 663.79 397.72C676.989 423.136 686.696 450.028 696.518 477.237C699.398 485.217 702.289 493.225 705.28 501.23C706.217 504.656 707.196 508.084 708.217 511.514L708.27 511.5C723.44 563.53 745.59 619.11 775.27 665L775.241 665.012C785.058 680.087 796.055 694.361 808.13 707.7C810.506 710.27 812.936 712.816 815.369 715.365L815.37 715.366L815.371 715.367L815.374 715.37C826.742 727.28 838.19 739.274 844.68 754.28C852.76 772.98 852.14 794.49 847.28 814.28C824.35 907.56 734.52 932.07 648.67 934.33L648.711 934.223C616.522 934.864 584.556 932.465 556.21 929.56C556.21 929.56 419.5 915.41 303.62 830.69L299.88 827.91C299.88 827.91 299.88 827.91 299.881 827.909C286.355 817.83 273.451 806.941 261.24 795.3C228.76 764.3 199.86 729.14 177.76 690.54C177.908 690.392 178.055 690.243 178.203 690.095C175.587 685.388 173.079 680.633 170.68 675.83C149.3 633.04 136.27 586.46 135.68 536.95C134.674 453.873 163.095 368.795 217.118 307.113C217.098 307.062 217.079 307.011 217.06 306.96C246.31 274.92 282.87 249.75 326.15 235.42C353.283 226.354 381.768 222.001 410.37 222.55C427.775 242.987 448.954 259.874 472.746 272.289ZM406.153 815.85C425.711 808.711 444.24 799.11 461.518 787.279C444.131 799.029 425.575 808.637 406.153 815.85Z" fill="url(#paint0_linear)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M756.498 204.628C756.512 204.628 756.526 204.628 756.54 204.627L757.72 203.867C605.3 -56.133 406 148.567 406 148.567L406.285 149.068C406.273 149.071 406.262 149.074 406.25 149.077C520.856 350.01 738.664 216.087 756.498 204.628Z" fill="url(#paint1_linear)"/>
<path d="M567.56 652.44C525.56 752.84 444.92 819.32 347.22 829.39C345.12 829.67 318.38 831.78 303.62 830.69C419.5 915.41 556.21 929.56 556.21 929.56C585.48 932.56 618.61 935.02 651.86 934.15C663.57 903.58 670.16 868.58 668.27 828.87C663.88 736.64 717.36 689.29 775.27 665C745.59 619.11 723.44 563.53 708.27 511.5C663.05 523.53 605.62 561.36 567.56 652.44Z" fill="url(#paint2_linear)"/>
<path d="M666.44 828.22C668.34 867.93 660.38 903.76 648.67 934.33C734.52 932.07 824.35 907.56 847.28 814.28C852.14 794.49 852.76 772.98 844.68 754.28C836.8 736.06 821.61 722.28 808.13 707.7C795.519 693.769 784.084 678.818 773.94 663C716.08 687.3 662.05 736 666.44 828.22Z" fill="url(#paint3_linear)"/>
<path d="M705.28 501.23C692.09 465.93 680.86 430.59 663.79 397.72C658.082 386.628 651.627 375.937 644.47 365.72C627.21 341.26 608.84 317.44 586.76 297.16C548.76 297.27 509.05 290.89 472.76 272.27C436 324.41 393.94 412.86 432.44 513.82C489.29 662.92 373.92 764.82 299.88 827.91L303.62 830.69C317.502 831.773 331.455 831.573 345.3 830.09C442.99 820.01 528.3 751.93 570.3 651.54C608.37 560.46 663.75 525.86 708.91 513.82C707.637 509.62 706.427 505.423 705.28 501.23Z" fill="url(#paint4_linear)"/>
<path d="M221.09 302.67C164.49 364.67 134.65 451.86 135.68 536.95C136.27 586.46 149.3 633.04 170.68 675.83C173.887 682.25 177.287 688.583 180.88 694.83C299.87 575.39 256.88 397.42 221.09 302.67Z" fill="url(#paint5_linear)"/>
<path d="M434.44 513.82C395.94 412.82 437.06 324.95 473.77 272.82C449.561 260.357 428.024 243.28 410.37 222.55C381.768 222.001 353.283 226.354 326.15 235.42C282.87 249.75 246.31 274.92 217.06 306.96C252.06 399.63 294.12 573.72 177.76 690.54C199.86 729.14 228.76 764.3 261.24 795.3C274.051 807.513 287.625 818.899 301.88 829.39C375.92 766.33 491.29 662.92 434.44 513.82Z" fill="url(#paint6_linear)"/>
<path d="M578 165.13C658.57 196.92 715 205.53 755.91 204.3L757.09 203.54C604.67 -56.4601 405.37 148.24 405.37 148.24L405.66 148.75C448.65 141.13 511.13 138.76 578 165.13Z" fill="url(#paint7_linear)"/>
<path d="M579 163.33C512.17 137 449.33 138 405.62 148.75C520.23 349.69 738.05 215.75 755.87 204.3C714.93 205.53 659.57 195.12 579 163.33Z" fill="url(#paint8_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="46.5" y1="344.5" x2="978.5" y2="903" gradientUnits="userSpaceOnUse">
<stop stop-color="#E54033"/>
<stop offset="0.489583" stop-color="#FECA1A"/>
<stop offset="1" stop-color="#AFD803"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="263767" y1="31225.5" x2="205421" y2="-28791.6" gradientUnits="userSpaceOnUse">
<stop offset="0.15" stop-color="#6CBF00"/>
<stop offset="1" stop-color="#AFD803"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="207.43" y1="837.73" x2="791.43" y2="695.73" gradientUnits="userSpaceOnUse">
<stop offset="0.21" stop-color="#E54033"/>
<stop offset="0.84" stop-color="#FECA1A"/>
</linearGradient>
<linearGradient id="paint3_linear" x1="667.54" y1="798.34" x2="847.74" y2="799.69" gradientUnits="userSpaceOnUse">
<stop stop-color="#FECA1A"/>
<stop offset="0.4" stop-color="#FECA1A"/>
<stop offset="1" stop-color="#AFD803"/>
</linearGradient>
<linearGradient id="paint4_linear" x1="259.65" y1="841.37" x2="629.1" y2="341.2" gradientUnits="userSpaceOnUse">
<stop offset="0.16" stop-color="#E54033"/>
<stop offset="0.84" stop-color="#FECA1A"/>
</linearGradient>
<linearGradient id="paint5_linear" x1="205.85" y1="344.39" x2="189.49" y2="667.45" gradientUnits="userSpaceOnUse">
<stop stop-color="#FECA1A"/>
<stop offset="0.76" stop-color="#E54033"/>
</linearGradient>
<linearGradient id="paint6_linear" x1="386.58" y1="260.5" x2="287.91" y2="635.17" gradientUnits="userSpaceOnUse">
<stop offset="0.16" stop-color="#FECA1A"/>
<stop offset="1" stop-color="#E54033"/>
</linearGradient>
<linearGradient id="paint7_linear" x1="424.8" y1="81.1199" x2="790.13" y2="215.78" gradientUnits="userSpaceOnUse">
<stop offset="0.15" stop-color="#6CBF00"/>
<stop offset="1" stop-color="#AFD803"/>
</linearGradient>
<linearGradient id="paint8_linear" x1="263766" y1="31225.2" x2="205420" y2="-28791.9" gradientUnits="userSpaceOnUse">
<stop offset="0.15" stop-color="#6CBF00"/>
<stop offset="1" stop-color="#AFD803"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -1,4 +0,0 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,19 @@
import create, { State } from 'zustand'
import produce from 'immer'
interface NotificationStore extends State {
notifications: Array<{
type: string
message: string
description?: string
txid?: string
}>
set: (x: any) => void
}
const useNotificationStore = create<NotificationStore>((set, get) => ({
notifications: [],
set: (fn) => set(produce(fn)),
}))
export default useNotificationStore

81
stores/useWalletStore.tsx Normal file
View File

@ -0,0 +1,81 @@
import create, { State } from 'zustand'
import produce from 'immer'
import { Connection, PublicKey } from '@solana/web3.js'
import { EndpointInfo, WalletAdapter } from '../@types/types'
import { getOwnedTokenAccounts } from '../utils/tokens'
export const ENDPOINTS: EndpointInfo[] = [
{
name: 'mainnet-beta',
url: 'https://solana-api.projectserum.com/',
websocket: 'https://api.mainnet-beta.solana.com/',
},
{
name: 'devnet',
url: 'https://devnet.solana.com',
websocket: 'https://devnet.solana.com',
},
]
const CLUSTER = 'mainnet-beta'
const ENDPOINT = ENDPOINTS.find((e) => e.name === CLUSTER)
const DEFAULT_CONNECTION = new Connection(ENDPOINT.url, 'recent')
const WEBSOCKET_CONNECTION = new Connection(ENDPOINT.websocket, 'recent')
interface WalletStore extends State {
connected: boolean
connection: {
cluster: string
current: Connection
websocket: Connection
endpoint: string
}
current: WalletAdapter | undefined
providerUrl: string
balances: Array<{ account: any; publicKey: PublicKey }>
set: (x: any) => void
actions: any
}
const useWalletStore = create<WalletStore>((set, get) => ({
connected: false,
connection: {
cluster: CLUSTER,
current: DEFAULT_CONNECTION,
websocket: WEBSOCKET_CONNECTION,
endpoint: ENDPOINT.url,
},
current: null,
providerUrl: null,
balances: [],
actions: {
async fetchWalletBalances() {
const connection = get().connection.current
const connected = get().connected
const wallet = get().current
const walletOwner = wallet?.publicKey
const set = get().set
if (connected && walletOwner) {
const ownedTokenAccounts = await getOwnedTokenAccounts(
connection,
walletOwner
)
console.log('fetched wallet balances', ownedTokenAccounts)
set((state) => {
state.balances = ownedTokenAccounts
})
} else {
set((state) => {
state.balances = []
})
}
},
},
set: (fn) => set(produce(fn)),
}))
export default useWalletStore

3
styles/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

87
tailwind.config.js Normal file
View File

@ -0,0 +1,87 @@
// const colors = require('tailwindcss/colors')
// const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
mode: 'jit',
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
future: {
removeDeprecatedGapUtilities: true,
purgeLayersByDefault: true,
},
darkMode: false,
theme: {
fontFamily: {
display: ['Lato, sans-serif'],
body: ['Lato, sans-serif'],
},
extend: {
cursor: {
help: 'help',
},
colors: {
'mango-orange': {
DEFAULT: '#DFAB01',
dark: '#CB9C01',
},
'mango-yellow': '#F2C94C',
'mango-red': '#E54033',
'mango-green': '#AFD803',
'mango-dark': {
lighter: '#332F46',
light: '#262337',
DEFAULT: '#141026',
},
'mango-med': {
light: '#C2BDD9',
DEFAULT: '#9490A6',
dark: '#706C81',
},
'mango-light': {
light: '#FCFCFF',
DEFAULT: '#F0EDFF',
dark: '#B9B5CE',
},
'mango-grey': {
lighter: '#f7f7f7',
light: '#e6e6e6',
dark: '#092e34',
darker: '#072428',
darkest: '#061f23',
},
'mango-theme': {
yellow: '#F2C94C',
red: { DEFAULT: '#E54033', dark: '#C7251A' },
green: { DEFAULT: '#AFD803', dark: '#91B503' },
'bkg-1': '#141026',
'bkg-2': '#1D1832',
'bkg-3': '#322E47',
'fgd-1': '#F0EDFF',
'fgd-2': '#FCFCFF',
'fgd-3': '#B9B5CE',
'fgd-4': '#706C81',
},
'th-bkg-1': 'var(--bkg-1)',
'th-bkg-2': 'var(--bkg-2)',
'th-bkg-3': 'var(--bkg-3)',
'th-fgd-1': 'var(--fgd-1)',
'th-fgd-2': 'var(--fgd-2)',
'th-fgd-3': 'var(--fgd-3)',
'th-fgd-4': 'var(--fgd-4)',
'th-primary': 'var(--primary)',
'th-red': 'var(--red)',
'th-red-dark': 'var(--red-dark)',
'th-green': 'var(--green)',
'th-green-dark': 'var(--green-dark)',
},
},
},
variants: {
extend: {
cursor: ['hover', 'focus', 'disabled'],
opacity: ['disabled'],
backgroundColor: ['disabled'],
textColor: ['disabled'],
},
},
plugins: [],
}

View File

@ -1,17 +1,10 @@
import React from 'react'
import { render, fireEvent } from '../testUtils'
import { Home } from '../../pages/index'
import Home from '../../pages/index'
describe('Home page', () => {
it('matches snapshot', () => {
it('renders', () => {
const { asFragment } = render(<Home />, {})
expect(asFragment()).toMatchSnapshot()
})
it('clicking button triggers alert', () => {
const { getByText } = render(<Home />, {})
window.alert = jest.fn()
fireEvent.click(getByText('Test Button'))
expect(window.alert).toHaveBeenCalledWith('With typescript and Jest')
expect(true).toBe(true)
})
})

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

20
utils/notifications.tsx Normal file
View File

@ -0,0 +1,20 @@
import useNotificationStore from '../stores/useNotificationStore'
export function notify(newNotification: {
type?: string
message: string
description?: string
txid?: string
}) {
const {
notifications,
set: setNotificationStore,
} = useNotificationStore.getState()
setNotificationStore((state) => {
state.notifications = [
...notifications,
{ type: 'success', ...newNotification },
]
})
}

70
utils/tokens.ts Normal file
View File

@ -0,0 +1,70 @@
import { Connection, PublicKey } from '@solana/web3.js'
import * as bs58 from 'bs58'
import {
AccountLayout as TokenLayout,
AccountInfo as TokenAccount,
} from '@solana/spl-token'
export const TOKEN_PROGRAM_ID = new PublicKey(
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
)
export type ProgramAccount<T> = {
publicKey: PublicKey
account: T
}
export function parseTokenAccountData(
data: Buffer
): { mint: PublicKey; owner: PublicKey; amount: number } {
const { mint, owner, amount } = TokenLayout.decode(data)
return {
mint: new PublicKey(mint),
owner: new PublicKey(owner),
amount,
}
}
export async function getOwnedTokenAccounts(
connection: Connection,
publicKey: PublicKey
): Promise<ProgramAccount<TokenAccount>[]> {
const filters = getOwnedAccountsFilters(publicKey)
// @ts-ignore
const resp = await connection._rpcRequest('getProgramAccounts', [
TOKEN_PROGRAM_ID.toBase58(),
{
commitment: connection.commitment,
filters,
},
])
if (resp.error) {
throw new Error(
'failed to get token accounts owned by ' +
publicKey.toBase58() +
': ' +
resp.error.message
)
}
return resp.result.map(({ pubkey, account: { data } }) => {
data = bs58.decode(data)
return {
publicKey: new PublicKey(pubkey),
account: parseTokenAccountData(data),
}
})
}
export function getOwnedAccountsFilters(publicKey: PublicKey) {
return [
{
memcmp: {
offset: TokenLayout.offsetOf('owner'),
bytes: publicKey.toBase58(),
},
},
{
dataSize: TokenLayout.span,
},
]
}

View File

@ -0,0 +1,2 @@
export * from './phantom'
export * from './sollet-extension'

View File

@ -0,0 +1,102 @@
import EventEmitter from 'eventemitter3'
import { PublicKey, Transaction } from '@solana/web3.js'
import { notify } from '../../utils/notifications'
import { WalletAdapter } from '../../@types/types'
type PhantomEvent = 'disconnect' | 'connect'
type PhantomRequestMethod =
| 'connect'
| 'disconnect'
| 'signTransaction'
| 'signAllTransactions'
interface PhantomProvider {
publicKey?: PublicKey
isConnected?: boolean
autoApprove?: boolean
signTransaction: (transaction: Transaction) => Promise<Transaction>
signAllTransactions: (transactions: Transaction[]) => Promise<Transaction[]>
connect: () => Promise<void>
disconnect: () => Promise<void>
on: (event: PhantomEvent, handler: (args: any) => void) => void
request: (method: PhantomRequestMethod, params: any) => Promise<any>
listeners: (event: PhantomEvent) => (() => void)[]
}
export class PhantomWalletAdapter
extends EventEmitter
implements WalletAdapter {
constructor() {
super()
this.connect = this.connect.bind(this)
}
private get _provider(): PhantomProvider | undefined {
if ((window as any)?.solana?.isPhantom) {
return (window as any).solana
}
return undefined
}
private _handleConnect = (...args) => {
this.emit('connect', ...args)
}
private _handleDisconnect = (...args) => {
this.emit('disconnect', ...args)
}
get connected() {
return this._provider?.isConnected || false
}
get autoApprove() {
return this._provider?.autoApprove || false
}
async signAllTransactions(
transactions: Transaction[]
): Promise<Transaction[]> {
if (!this._provider) {
return transactions
}
return this._provider.signAllTransactions(transactions)
}
get publicKey() {
return this._provider?.publicKey
}
async signTransaction(transaction: Transaction) {
if (!this._provider) {
return transaction
}
return this._provider.signTransaction(transaction)
}
connect() {
if (!this._provider) {
window.open('https://phantom.app/', '_blank')
notify({
message: 'Connection Error',
description: 'Please install Phantom wallet and then reload this page.',
})
return
}
if (!this._provider.listeners('connect').length) {
this._provider?.on('connect', this._handleConnect)
}
if (!this._provider.listeners('disconnect').length) {
this._provider?.on('disconnect', this._handleDisconnect)
}
return this._provider?.connect()
}
disconnect() {
if (this._provider) {
this._provider.disconnect()
}
}
}

View File

@ -0,0 +1,20 @@
import Wallet from '@project-serum/sol-wallet-adapter'
import { notify } from '../../utils/notifications'
export function SolletExtensionAdapter(_, network) {
const sollet = (window as any).sollet
if (sollet) {
return new Wallet(sollet, network)
}
return {
on: () => {},
connect: () => {
notify({
message: 'Sollet Extension Error',
description:
'Please install the Sollet Extension for Chrome and then reload this page.',
})
},
}
}

862
yarn.lock

File diff suppressed because it is too large Load Diff