944 lines
33 KiB
TypeScript
944 lines
33 KiB
TypeScript
import { MangoAccountLayout } from '@blockworks-foundation/mango-client'
|
|
import {
|
|
ChevronRightIcon,
|
|
UserGroupIcon,
|
|
ArrowSmDownIcon,
|
|
ArrowSmUpIcon,
|
|
LinkIcon,
|
|
ExclamationCircleIcon,
|
|
UserCircleIcon,
|
|
PencilIcon,
|
|
} from '@heroicons/react/outline'
|
|
import { useWallet } from '@solana/wallet-adapter-react'
|
|
import { PublicKey } from '@solana/web3.js'
|
|
import { ElementTitle } from 'components'
|
|
import Button from 'components/Button'
|
|
import MangoAccountCard, {
|
|
numberCurrencyCompacter,
|
|
} from 'components/MangoAccountCard'
|
|
import Modal from 'components/Modal'
|
|
import Input from 'components/Input'
|
|
import ProfileImage from 'components/ProfileImage'
|
|
import ProfileImageButton from 'components/ProfileImageButton'
|
|
import SelectMangoAccount from 'components/SelectMangoAccount'
|
|
import Tabs from 'components/Tabs'
|
|
import dayjs from 'dayjs'
|
|
import { useTranslation } from 'next-i18next'
|
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
|
import { useRouter } from 'next/router'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import useMangoStore from 'stores/useMangoStore'
|
|
import { abbreviateAddress, formatUsdValue } from 'utils'
|
|
import { notify } from 'utils/notifications'
|
|
import { Label } from 'components'
|
|
import Select from 'components/Select'
|
|
import EmptyState from 'components/EmptyState'
|
|
import { handleWalletConnect } from 'components/ConnectWalletButton'
|
|
import { sign } from 'tweetnacl'
|
|
import bs58 from 'bs58'
|
|
import Loading from 'components/Loading'
|
|
import InlineNotification from 'components/InlineNotification'
|
|
import { startCase } from 'lodash'
|
|
import useMangoAccount from 'hooks/useMangoAccount'
|
|
|
|
export async function getStaticProps({ locale }) {
|
|
return {
|
|
props: {
|
|
...(await serverSideTranslations(locale, ['common', 'profile'])),
|
|
// Will be passed to the page component as props
|
|
},
|
|
}
|
|
}
|
|
|
|
const TABS = ['following', 'followers']
|
|
|
|
export default function Profile() {
|
|
const { t } = useTranslation(['common', 'profile'])
|
|
const [profileData, setProfileData] = useState<any>(null)
|
|
const [walletMangoAccounts, setWalletMangoAccounts] = useState<any[]>([])
|
|
const [loadMangoAccounts, setLoadMangoAccounts] = useState(false)
|
|
const { initialLoad } = useMangoAccount()
|
|
const [walletMangoAccountsStats, setWalletMangoAccountsStats] = useState<
|
|
any[]
|
|
>([])
|
|
const [loadMangoAccountsStats, setLoadMangoAccountsStats] = useState(false)
|
|
const [following, setFollowing] = useState<any[]>([])
|
|
const [followers, setFollowers] = useState<any[]>([])
|
|
const [activeTab, setActiveTab] = useState('following')
|
|
const [loadFollowing, setLoadFollowing] = useState(false)
|
|
const [loadFollowers, setLoadFollowers] = useState(false)
|
|
const [showEditProfile, setShowEditProfile] = useState(false)
|
|
const [initialFollowingLoad, setInitialFollowingLoad] = useState(false)
|
|
const [loadProfileDetails, setLoadProfileDetails] = useState(false)
|
|
const [profilePk, setProfilePk] = useState<string | string[]>('')
|
|
const mangoGroup = useMangoStore((s) => s.selectedMangoGroup.current)
|
|
const mangoClient = useMangoStore((s) => s.connection.client)
|
|
const mangoCache = useMangoStore((s) => s.selectedMangoGroup.cache)
|
|
const mangoAccounts = useMangoStore((s) => s.mangoAccounts)
|
|
const { publicKey, connected, wallet } = useWallet()
|
|
const router = useRouter()
|
|
const { name } = router.query
|
|
const ownedProfile = useMangoStore((s) => s.profile.details)
|
|
const loadOwnedProfile = useMangoStore((s) => s.profile.loadDetails)
|
|
|
|
const handleConnect = useCallback(() => {
|
|
if (wallet) {
|
|
handleWalletConnect(wallet)
|
|
}
|
|
}, [wallet])
|
|
|
|
useEffect(() => {
|
|
if (profileData) {
|
|
setProfilePk(profileData.wallet_pk)
|
|
}
|
|
setInitialFollowingLoad(false)
|
|
setActiveTab('following')
|
|
}, [profileData])
|
|
|
|
useEffect(() => {
|
|
const fetchProfileDetails = async () => {
|
|
setLoadProfileDetails(true)
|
|
try {
|
|
const response = await fetch(
|
|
`https://mango-transaction-log.herokuapp.com/v3/user-data/profile-details?profile-name=${name
|
|
?.toString()
|
|
.replace(/-/g, ' ')}`
|
|
)
|
|
const data = await response.json()
|
|
setProfileData(data)
|
|
setLoadProfileDetails(false)
|
|
} catch (e) {
|
|
notify({ type: 'error', title: t('profile:profile-fetch-fail') })
|
|
console.log(e)
|
|
setLoadProfileDetails(false)
|
|
}
|
|
}
|
|
if (name) {
|
|
fetchProfileDetails()
|
|
} else {
|
|
if (loadOwnedProfile) {
|
|
setLoadProfileDetails(true)
|
|
} else {
|
|
setProfileData(ownedProfile)
|
|
setLoadProfileDetails(false)
|
|
}
|
|
}
|
|
}, [name, loadOwnedProfile])
|
|
|
|
useEffect(() => {
|
|
if (mangoGroup && profilePk && !initialFollowingLoad) {
|
|
fetchFollowing()
|
|
}
|
|
}, [mangoGroup, profilePk])
|
|
|
|
useEffect(() => {
|
|
if (profilePk) {
|
|
fetchFollowers()
|
|
}
|
|
}, [profilePk])
|
|
|
|
useEffect(() => {
|
|
if (mangoGroup) {
|
|
const getProfileAccounts = async (unownedPk: string) => {
|
|
setLoadMangoAccounts(true)
|
|
const accounts = await fetchAllMangoAccounts(new PublicKey(unownedPk))
|
|
if (accounts) {
|
|
setWalletMangoAccounts(accounts)
|
|
}
|
|
setLoadMangoAccounts(false)
|
|
}
|
|
if (profilePk) {
|
|
getProfileAccounts(profilePk.toString())
|
|
} else {
|
|
setWalletMangoAccounts(mangoAccounts)
|
|
}
|
|
}
|
|
}, [profilePk, mangoAccounts, mangoGroup])
|
|
|
|
useEffect(() => {
|
|
const getAccountsStats = async () => {
|
|
setLoadMangoAccountsStats(true)
|
|
const accountsStats: any[] = []
|
|
for (const acc of walletMangoAccounts) {
|
|
const stats = await fetchAccountStats(acc.publicKey.toString())
|
|
accountsStats.push({
|
|
mangoAccount: acc.publicKey.toString(),
|
|
stats,
|
|
})
|
|
}
|
|
setWalletMangoAccountsStats(accountsStats)
|
|
setLoadMangoAccountsStats(false)
|
|
}
|
|
if (walletMangoAccounts.length) {
|
|
getAccountsStats()
|
|
}
|
|
}, [walletMangoAccounts])
|
|
|
|
const fetchFollowers = async () => {
|
|
if (!publicKey && !profilePk) return
|
|
const pubkey = profilePk
|
|
? profilePk
|
|
: publicKey
|
|
? publicKey?.toString()
|
|
: null
|
|
setLoadFollowers(true)
|
|
try {
|
|
const followerRes = await fetch(
|
|
`https://mango-transaction-log.herokuapp.com/v3/user-data/followers?wallet-pk=${pubkey}`
|
|
)
|
|
const parsedResponse = await followerRes.json()
|
|
if (parsedResponse.length > 0) {
|
|
setFollowers(parsedResponse)
|
|
} else {
|
|
setFollowers([])
|
|
}
|
|
setLoadFollowers(false)
|
|
} catch {
|
|
setLoadFollowers(false)
|
|
notify({
|
|
type: 'error',
|
|
title: 'Unable to load followers',
|
|
})
|
|
}
|
|
}
|
|
|
|
const fetchFollowing = async () => {
|
|
if (!mangoGroup || !profilePk) return
|
|
setLoadFollowing(true)
|
|
try {
|
|
const followingInfo: any[] = []
|
|
const followingRes = await fetch(
|
|
`https://mango-transaction-log.herokuapp.com/v3/user-data/following?wallet-pk=${profilePk}`
|
|
)
|
|
const parsedResponse = await followingRes.json()
|
|
for (let i = 0; i < parsedResponse.length; i++) {
|
|
const pk = new PublicKey(parsedResponse[i].mango_account)
|
|
const mangoAccount = await mangoClient.getMangoAccount(
|
|
pk,
|
|
mangoGroup?.dexProgramId
|
|
)
|
|
const stats = await fetchAccountStats(parsedResponse[i].mango_account)
|
|
followingInfo.push({
|
|
...parsedResponse[i],
|
|
mango_account: mangoAccount,
|
|
stats,
|
|
})
|
|
}
|
|
setFollowing(followingInfo)
|
|
setLoadFollowing(false)
|
|
setInitialFollowingLoad(true)
|
|
} catch {
|
|
notify({
|
|
type: 'error',
|
|
title: 'Unable to load following',
|
|
})
|
|
setLoadFollowing(false)
|
|
}
|
|
}
|
|
|
|
const fetchAccountStats = async (pk) => {
|
|
const response = await fetch(
|
|
`https://mango-transaction-log.herokuapp.com/v3/stats/account-performance-detailed?mango-account=${pk}&start-date=${dayjs()
|
|
.subtract(1, 'day')
|
|
.format('YYYY-MM-DD')}`
|
|
)
|
|
const parsedResponse = await response.json()
|
|
const entries: any = Object.entries(parsedResponse).sort((a, b) =>
|
|
b[0].localeCompare(a[0])
|
|
)
|
|
|
|
const stats = entries
|
|
.map(([key, value]) => {
|
|
return { ...value, time: key }
|
|
})
|
|
.filter((x) => x)
|
|
|
|
return stats
|
|
}
|
|
|
|
const fetchAllMangoAccounts = async (walletPk) => {
|
|
if (!walletPk || !mangoGroup) return
|
|
|
|
const delegateFilter = [
|
|
{
|
|
memcmp: {
|
|
offset: MangoAccountLayout.offsetOf('delegate'),
|
|
bytes: walletPk.toBase58(),
|
|
},
|
|
},
|
|
]
|
|
const accountSorter = (a, b) =>
|
|
a.publicKey.toBase58() > b.publicKey.toBase58() ? 1 : -1
|
|
|
|
return Promise.all([
|
|
mangoClient.getMangoAccountsForOwner(mangoGroup, walletPk, true),
|
|
mangoClient.getAllMangoAccounts(mangoGroup, delegateFilter, false),
|
|
])
|
|
.then((values) => {
|
|
const [mangoAccounts, delegatedAccounts] = values
|
|
if (mangoAccounts.length + delegatedAccounts.length > 0) {
|
|
const sortedAccounts = mangoAccounts
|
|
.slice()
|
|
.sort(accountSorter)
|
|
.concat(delegatedAccounts.sort(accountSorter))
|
|
return sortedAccounts
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
notify({
|
|
type: 'error',
|
|
title: 'Unable to load mango account',
|
|
description: err.message,
|
|
})
|
|
console.log('Could not get margin accounts for wallet', err)
|
|
})
|
|
}
|
|
|
|
const handleTabChange = (tabName) => {
|
|
setActiveTab(tabName)
|
|
}
|
|
|
|
const canEdit = useMemo(() => {
|
|
if (publicKey) {
|
|
return publicKey.toString() === profileData?.wallet_pk
|
|
}
|
|
return false
|
|
}, [publicKey, profileData])
|
|
|
|
const totalValue = useMemo(() => {
|
|
return walletMangoAccounts.reduce((a, c) => {
|
|
const value = c.computeValue(mangoGroup, mangoCache)
|
|
return a + Number(value)
|
|
}, 0)
|
|
}, [walletMangoAccounts])
|
|
|
|
const totalPnl = useMemo(() => {
|
|
return walletMangoAccountsStats.reduce((a, c) => {
|
|
const value = c.stats.length > 0 ? c.stats[0].pnl : 0
|
|
return a + value
|
|
}, 0)
|
|
}, [walletMangoAccountsStats])
|
|
|
|
const accountsLoaded = publicKey
|
|
? !loadMangoAccounts && !initialLoad
|
|
: !loadMangoAccounts
|
|
const accountsStatsLoaded = publicKey
|
|
? !loadMangoAccounts && !initialLoad && !loadMangoAccountsStats
|
|
: !loadMangoAccounts && !loadMangoAccountsStats
|
|
|
|
return name && !profileData && !loadProfileDetails ? (
|
|
<div className="mt-6 rounded-lg border border-th-bkg-3 p-6">
|
|
<EmptyState
|
|
icon={<div className="mb-4 text-2xl">🙃</div>}
|
|
title={t('profile:no-profile-exists')}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="pt-6">
|
|
<div className="flex flex-col pb-6 md:flex-row md:items-end md:justify-between md:pb-4">
|
|
{connected || name ? (
|
|
<div className="flex w-full items-start justify-between md:items-end">
|
|
<div className="flex items-start justify-between rounded-lg">
|
|
<div className="flex flex-col sm:flex-row sm:items-center">
|
|
<ProfileImageButton
|
|
imageSize="80"
|
|
placeholderSize="40"
|
|
disabled={
|
|
!connected ||
|
|
profileData?.wallet_pk !== publicKey?.toString()
|
|
}
|
|
publicKey={profilePk ? profilePk.toString() : undefined}
|
|
/>
|
|
<div>
|
|
{!loadProfileDetails ? (
|
|
<>
|
|
<div className="mb-1.5 flex items-center space-x-3">
|
|
<h1 className="capitalize">
|
|
{profileData?.profile_name}
|
|
</h1>
|
|
<div className="w-max rounded-full px-2 py-1 text-xs text-th-fgd-4 ring-1 ring-inset ring-th-fgd-4">
|
|
{t(
|
|
`profile:${profileData?.trader_category.toLowerCase()}`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="mb-1.5 flex space-x-3">
|
|
<div className="h-7 w-40 animate-pulse rounded bg-th-bkg-3" />
|
|
<div className="h-7 w-12 animate-pulse rounded bg-th-bkg-3" />
|
|
</div>
|
|
)}
|
|
<div className="flex space-x-4">
|
|
{!loadFollowing ? (
|
|
<p className="mb-0 font-bold text-th-fgd-1">
|
|
{following.length}{' '}
|
|
<span className="font-normal text-th-fgd-4">
|
|
{t('following')}
|
|
</span>
|
|
</p>
|
|
) : (
|
|
<div className="h-5 w-20 animate-pulse rounded bg-th-bkg-3" />
|
|
)}
|
|
{!loadFollowers ? (
|
|
<p className="mb-0 font-bold text-th-fgd-1">
|
|
{followers.length}{' '}
|
|
<span className="font-normal text-th-fgd-4">
|
|
{t('followers')}
|
|
</span>
|
|
</p>
|
|
) : (
|
|
<div className="h-5 w-20 animate-pulse rounded bg-th-bkg-3" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
{canEdit ? (
|
|
<Button
|
|
className="flex h-8 w-full items-center justify-center rounded-full px-3 py-0 text-xs"
|
|
onClick={() => setShowEditProfile(true)}
|
|
>
|
|
<PencilIcon className="mr-2 h-5 w-5" />
|
|
{t('profile:edit-profile')}
|
|
</Button>
|
|
) : null}
|
|
{!canEdit && publicKey ? (
|
|
<Button
|
|
className="flex h-8 w-full items-center justify-center rounded-full px-3 py-0 text-xs"
|
|
onClick={() => router.push('/profile')}
|
|
>
|
|
<UserCircleIcon className="mr-2 h-5 w-5" />
|
|
{t('profile:your-profile')}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{connected || name ? (
|
|
<div className="grid grid-cols-12 gap-6">
|
|
<div className="col-span-12 lg:col-span-8">
|
|
<div className="mb-8 grid grid-flow-col grid-cols-1 grid-rows-2 md:grid-cols-2 md:grid-rows-1 md:gap-4">
|
|
<div className="border-t border-th-bkg-3 p-3 sm:p-4 md:border-b">
|
|
{accountsLoaded ? (
|
|
<>
|
|
<div className="pb-0.5 text-th-fgd-3">
|
|
{t('profile:total-value')}
|
|
</div>
|
|
<div className="text-2xl font-bold text-th-fgd-1 md:text-3xl">
|
|
{formatUsdValue(totalValue)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="h-14 w-40 animate-pulse rounded-md bg-th-bkg-3" />
|
|
)}
|
|
</div>
|
|
<div className="border-y border-th-bkg-3 p-3 sm:p-4">
|
|
{accountsStatsLoaded ? (
|
|
<>
|
|
<div className="pb-0.5 text-th-fgd-3">
|
|
{t('profile:total-pnl')}
|
|
</div>
|
|
<div className="text-2xl font-bold text-th-fgd-1 md:text-3xl">
|
|
{formatUsdValue(totalPnl)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="h-14 w-40 animate-pulse rounded-md bg-th-bkg-3" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="pb-8 lg:hidden">
|
|
<Accounts
|
|
accounts={walletMangoAccounts}
|
|
accountsStats={walletMangoAccountsStats}
|
|
canEdit={canEdit}
|
|
fetchFollowers={fetchFollowers}
|
|
loaded={accountsLoaded}
|
|
/>
|
|
</div>
|
|
<Tabs
|
|
activeTab={activeTab}
|
|
onChange={handleTabChange}
|
|
tabs={TABS}
|
|
/>
|
|
<div className="space-y-2">
|
|
{activeTab === 'following' ? (
|
|
loadFollowing ? (
|
|
<div className="space-y-2">
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
</div>
|
|
) : following.length > 0 ? (
|
|
following.map((user) => {
|
|
const accountEquity = user.mango_account.computeValue(
|
|
mangoGroup,
|
|
mangoCache
|
|
)
|
|
const pnl: number =
|
|
user.stats.length > 0 ? user.stats[0].pnl : 0
|
|
return (
|
|
<div
|
|
className="relative"
|
|
key={user.mango_account.publicKey.toString()}
|
|
>
|
|
<button
|
|
className="default-transition absolute bottom-4 left-20 flex items-center space-x-2 rounded-full border border-th-fgd-4 px-2 py-1 hover:border-th-fgd-2"
|
|
onClick={() =>
|
|
router.push(
|
|
`/profile?name=${user.profile_name.replace(
|
|
/\s/g,
|
|
'-'
|
|
)}`,
|
|
undefined,
|
|
{ shallow: true }
|
|
)
|
|
}
|
|
>
|
|
<p className="mb-0 text-xs capitalize text-th-fgd-3">
|
|
{user.profile_name}
|
|
</p>
|
|
</button>
|
|
<a
|
|
className="default-transition block flex h-[104px] w-full rounded-md border border-th-bkg-4 p-4 hover:border-th-fgd-4 sm:h-[84px] sm:justify-between sm:pb-4"
|
|
href={`/account?pubkey=${user.mango_account.publicKey.toString()}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="my-auto">
|
|
<ProfileImage
|
|
imageSize="56"
|
|
placeholderSize="32"
|
|
publicKey={user.wallet_pk}
|
|
/>
|
|
</div>
|
|
<div className="ml-3 flex flex-col sm:flex-grow sm:flex-row sm:justify-between">
|
|
<p className="mb-0 font-bold text-th-fgd-1">
|
|
{user.mango_account.name
|
|
? user.mango_account.name
|
|
: abbreviateAddress(
|
|
user.mango_account.publicKey
|
|
)}
|
|
</p>
|
|
<div className="flex items-center">
|
|
<p className="mb-0">
|
|
{formatUsdValue(accountEquity.toNumber())}
|
|
</p>
|
|
<span className="pl-2 pr-1 text-th-fgd-4">|</span>
|
|
<span
|
|
className={`flex items-center ${
|
|
pnl < 0 ? 'text-th-red' : 'text-th-green'
|
|
}`}
|
|
>
|
|
{pnl < 0 ? (
|
|
<ArrowSmDownIcon className="mr-0.5 h-5 w-5" />
|
|
) : (
|
|
<ArrowSmUpIcon className="mr-0.5 h-5 w-5" />
|
|
)}
|
|
{numberCurrencyCompacter.format(pnl)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="my-auto ml-auto">
|
|
<ChevronRightIcon className="ml-2 mt-0.5 h-5 w-5 text-th-fgd-4" />
|
|
</div>
|
|
</a>
|
|
</div>
|
|
)
|
|
})
|
|
) : (
|
|
<div className="rounded-lg border border-th-bkg-3 p-4 md:p-6">
|
|
<EmptyState
|
|
desc={t('profile:no-following-desc')}
|
|
icon={<UserGroupIcon />}
|
|
title={t('profile:no-following')}
|
|
/>
|
|
</div>
|
|
)
|
|
) : null}
|
|
{activeTab === 'followers' ? (
|
|
loadFollowers ? (
|
|
<div className="space-y-2">
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-24 animate-pulse rounded-md bg-th-bkg-3" />
|
|
</div>
|
|
) : followers.length > 0 ? (
|
|
followers.map((user) => {
|
|
return (
|
|
<button
|
|
className="default-transition block flex w-full items-center justify-between rounded-md border border-th-bkg-4 p-4 hover:border-th-fgd-4"
|
|
onClick={() =>
|
|
router.push(
|
|
`/profile?name=${user.profile_name.replace(
|
|
/\s/g,
|
|
'-'
|
|
)}`,
|
|
undefined,
|
|
{ shallow: true }
|
|
)
|
|
}
|
|
key={user.wallet_pk}
|
|
>
|
|
<ProfileImage
|
|
imageSize="56"
|
|
placeholderSize="32"
|
|
publicKey={user.wallet_pk}
|
|
/>
|
|
<p className="mb-0 ml-3 font-bold capitalize text-th-fgd-1">
|
|
{user.profile_name}
|
|
</p>
|
|
<div className="my-auto ml-auto">
|
|
<ChevronRightIcon className="ml-2 mt-0.5 h-5 w-5 text-th-fgd-4" />
|
|
</div>
|
|
</button>
|
|
)
|
|
})
|
|
) : (
|
|
<div className="rounded-lg border border-th-bkg-3 p-4 md:p-6">
|
|
<EmptyState
|
|
desc={t('profile:no-followers-desc')}
|
|
icon={<UserGroupIcon />}
|
|
title={t('profile:no-followers')}
|
|
/>
|
|
</div>
|
|
)
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="col-span-4 hidden lg:block">
|
|
<Accounts
|
|
accounts={walletMangoAccounts}
|
|
accountsStats={walletMangoAccountsStats}
|
|
canEdit={canEdit}
|
|
fetchFollowers={fetchFollowers}
|
|
loaded={accountsLoaded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="-mt-4 rounded-lg border border-th-bkg-3 p-4 md:p-6">
|
|
<EmptyState
|
|
buttonText={t('connect')}
|
|
desc={t('profile:connect-view-profile')}
|
|
disabled={!wallet || !mangoGroup}
|
|
icon={<LinkIcon />}
|
|
onClickButton={handleConnect}
|
|
title={t('connect-wallet')}
|
|
/>
|
|
</div>
|
|
)}
|
|
{showEditProfile ? (
|
|
<EditProfileModal
|
|
isOpen={showEditProfile}
|
|
onClose={() => setShowEditProfile(false)}
|
|
profile={profileData}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const TRADER_CATEGORIES = [
|
|
'day-trader',
|
|
'degen',
|
|
'discretionary',
|
|
'market-maker',
|
|
'swing-trader',
|
|
'trader',
|
|
'yolo',
|
|
]
|
|
|
|
const EditProfileModal = ({
|
|
isOpen,
|
|
onClose,
|
|
profile,
|
|
}: {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
profile?: any
|
|
}) => {
|
|
const { t } = useTranslation(['common', 'profile'])
|
|
const { publicKey, signMessage } = useWallet()
|
|
const [profileName, setProfileName] = useState(
|
|
startCase(profile?.profile_name) || ''
|
|
)
|
|
const [traderCategory, setTraderCategory] = useState(
|
|
profile?.trader_category || TRADER_CATEGORIES[5]
|
|
)
|
|
const [inputErrors, setInputErrors] = useState({})
|
|
const [loadUniquenessCheck, setLoadUniquenessCheck] = useState(false)
|
|
const [loadUpdateProfile, setLoadUpdateProfile] = useState(false)
|
|
const [updateError, setUpdateError] = useState('')
|
|
const actions = useMangoStore((s) => s.actions)
|
|
|
|
const validateProfileName = async (name) => {
|
|
const re = /^([a-zA-Z0-9]+\s)*[a-zA-Z0-9]+$/
|
|
if (!re.test(name)) {
|
|
setInputErrors({
|
|
...inputErrors,
|
|
regex: t('profile:invalid-characters'),
|
|
})
|
|
}
|
|
if (name.length > 29) {
|
|
setInputErrors({
|
|
...inputErrors,
|
|
length: t('profile:length-error'),
|
|
})
|
|
}
|
|
try {
|
|
setLoadUniquenessCheck(true)
|
|
const response = await fetch(
|
|
`https://mango-transaction-log.herokuapp.com/v3/user-data/check-profile-name-unique?profile-name=${name.toLowerCase()}`
|
|
)
|
|
const uniquenessCheck = await response.json()
|
|
setLoadUniquenessCheck(false)
|
|
|
|
if (response.status === 200 && !uniquenessCheck) {
|
|
setInputErrors({
|
|
...inputErrors,
|
|
uniqueness: t('profile:uniqueness-fail'),
|
|
})
|
|
}
|
|
} catch {
|
|
setInputErrors({
|
|
...inputErrors,
|
|
api: t('profile:uniqueness-api-fail'),
|
|
})
|
|
setLoadUniquenessCheck(false)
|
|
}
|
|
}
|
|
|
|
const onChangeNameInput = (name) => {
|
|
setProfileName(name)
|
|
setInputErrors({})
|
|
}
|
|
|
|
const saveProfile = async () => {
|
|
setUpdateError('')
|
|
const name = profileName.toLowerCase()
|
|
await validateProfileName(name)
|
|
if (!Object.keys(inputErrors).length) {
|
|
setLoadUpdateProfile(true)
|
|
try {
|
|
if (!publicKey) throw new Error('Wallet not connected!')
|
|
if (!signMessage)
|
|
throw new Error('Wallet does not support message signing!')
|
|
|
|
const messageString = JSON.stringify({
|
|
profile_name: name,
|
|
trader_category: traderCategory,
|
|
})
|
|
const message = new TextEncoder().encode(messageString)
|
|
const signature = await signMessage(message)
|
|
if (!sign.detached.verify(message, signature, publicKey.toBytes()))
|
|
throw new Error('Invalid signature!')
|
|
|
|
const requestOptions = {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
wallet_pk: publicKey.toString(),
|
|
message: messageString,
|
|
signature: bs58.encode(signature),
|
|
}),
|
|
}
|
|
const response = await fetch(
|
|
'https://mango-transaction-log.herokuapp.com/v3/user-data/profile-details',
|
|
requestOptions
|
|
)
|
|
if (response.status === 200) {
|
|
setLoadUpdateProfile(false)
|
|
await actions.fetchProfileDetails(publicKey.toString())
|
|
onClose()
|
|
notify({
|
|
type: 'success',
|
|
title: t('profile:profile-update-success'),
|
|
})
|
|
}
|
|
} catch {
|
|
setLoadUpdateProfile(false)
|
|
setUpdateError(t('profile:profile-update-fail'))
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} onClose={onClose}>
|
|
<Modal.Header>
|
|
<ElementTitle noMarginBottom>{t('profile:edit-profile')}</ElementTitle>
|
|
</Modal.Header>
|
|
{updateError ? (
|
|
<div className="pb-3">
|
|
<InlineNotification type="error" desc={updateError} />
|
|
</div>
|
|
) : null}
|
|
<div className="pb-4">
|
|
<Label>{t('profile:profile-name')}</Label>
|
|
<Input
|
|
type="text"
|
|
error={Object.keys(inputErrors).length}
|
|
value={profileName}
|
|
onChange={(e) => onChangeNameInput(e.target.value)}
|
|
/>
|
|
{Object.keys(inputErrors).length ? (
|
|
<div className="mt-1.5 flex items-center space-x-1">
|
|
<ExclamationCircleIcon className="h-4 w-4 text-th-red" />
|
|
<p className="mb-0 text-xs text-th-red">
|
|
{Object.values(inputErrors).toString()}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="pb-6">
|
|
<Label>{t('profile:trader-category')}</Label>
|
|
<Select
|
|
value={t(`profile:${traderCategory}`)}
|
|
onChange={(cat) => setTraderCategory(cat)}
|
|
className="w-full"
|
|
>
|
|
{TRADER_CATEGORIES.map((cat) => (
|
|
<Select.Option key={cat} value={cat}>
|
|
<div className="flex w-full items-center justify-between">
|
|
{t(`profile:${cat}`)}
|
|
</div>
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
className="flex w-full justify-center"
|
|
disabled={
|
|
!!Object.keys(inputErrors).length ||
|
|
loadUniquenessCheck ||
|
|
!profileName
|
|
}
|
|
onClick={() => saveProfile()}
|
|
>
|
|
{loadUniquenessCheck || loadUpdateProfile ? (
|
|
<Loading />
|
|
) : (
|
|
t('profile:save-profile')
|
|
)}
|
|
</Button>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const Accounts = ({
|
|
accounts,
|
|
accountsStats,
|
|
canEdit,
|
|
fetchFollowers,
|
|
loaded,
|
|
}: {
|
|
accounts: any[]
|
|
accountsStats: any[]
|
|
canEdit: boolean
|
|
fetchFollowers: () => void
|
|
loaded: boolean
|
|
}) => {
|
|
const { t } = useTranslation(['common', 'profile'])
|
|
const { publicKey, signMessage } = useWallet()
|
|
const actions = useMangoStore((s) => s.actions)
|
|
const following = useMangoStore((s) => s.profile.following)
|
|
|
|
useEffect(() => {
|
|
if (publicKey && !canEdit) {
|
|
actions.fetchProfileFollowing(publicKey?.toString())
|
|
}
|
|
}, [publicKey, canEdit])
|
|
|
|
const handleToggleFollow = async (
|
|
isFollowed: boolean,
|
|
mangoAccountPk: string
|
|
) => {
|
|
if (publicKey) {
|
|
isFollowed
|
|
? await actions.unfollowAccount(mangoAccountPk, publicKey, signMessage)
|
|
: await actions.followAccount(mangoAccountPk, publicKey, signMessage)
|
|
fetchFollowers()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-th-bkg-3 p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="mb-0">{t('accounts')}</h3>
|
|
</div>
|
|
{canEdit ? (
|
|
loaded ? (
|
|
accounts.length ? (
|
|
<SelectMangoAccount />
|
|
) : (
|
|
<p className="mb-0">{t('no-account-found')}</p>
|
|
)
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="h-16 w-full animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-16 w-full animate-pulse rounded-md bg-th-bkg-3" />
|
|
</div>
|
|
)
|
|
) : loaded ? (
|
|
accounts.length ? (
|
|
<div className="space-y-2">
|
|
{accounts.map((acc) => {
|
|
const statsAccount = accountsStats.find(
|
|
(a) => a.mangoAccount === acc.publicKey.toString()
|
|
)
|
|
const pnl: number =
|
|
statsAccount && statsAccount.stats.length > 0
|
|
? statsAccount.stats[0].pnl
|
|
: 0
|
|
|
|
const isFollowed = following.find(
|
|
(a) => a.mango_account === acc.publicKey.toString()
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col rounded-md border border-th-bkg-4 p-4 md:flex-row md:items-center md:justify-between lg:flex-col lg:items-start xl:flex-row xl:items-center xl:justify-between"
|
|
key={acc.publicKey.toString()}
|
|
>
|
|
<MangoAccountCard mangoAccount={acc} pnl={pnl} />
|
|
{isFollowed ? (
|
|
<Button
|
|
className={`mt-4 w-24 bg-transparent pl-4 pr-4 text-xs ring-1 ring-inset ring-th-fgd-3 before:content-[attr(data-before)] hover:ring-th-red before:hover:block before:hover:text-th-red before:hover:content-[attr(data-before-hover)] md:mt-0 lg:mt-4 xl:mt-0 xl:mt-0`}
|
|
disabled={!publicKey}
|
|
onClick={() =>
|
|
handleToggleFollow(isFollowed, acc.publicKey.toString())
|
|
}
|
|
data-before={t('profile:following')}
|
|
data-before-hover={t('profile:unfollow')}
|
|
/>
|
|
) : (
|
|
<Button
|
|
className="mt-4 w-24 pl-4 pr-4 text-xs md:mt-0 lg:mt-4 xl:mt-0 xl:mt-0"
|
|
disabled={!publicKey}
|
|
onClick={() =>
|
|
handleToggleFollow(isFollowed, acc.publicKey.toString())
|
|
}
|
|
>
|
|
{t('profile:follow')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="mb-0">{t('no-account-found')}</p>
|
|
)
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="h-32 w-full animate-pulse rounded-md bg-th-bkg-3" />
|
|
<div className="h-32 w-full animate-pulse rounded-md bg-th-bkg-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|