diff --git a/apis/notifications.ts b/apis/notifications.ts new file mode 100644 index 00000000..e8a6e633 --- /dev/null +++ b/apis/notifications.ts @@ -0,0 +1,23 @@ +import { NOTIFICATION_API } from 'utils/constants' + +export type Notification = { + content: string + createdAt: string + seen: boolean + title: string + id: number +} + +export const fetchNotifications = async (wallet: string, token: string) => { + const data = await fetch(`${NOTIFICATION_API}notifications`, { + headers: { + authorization: token, + publickey: wallet, + }, + }) + const body = await data.json() + if (body.error) { + throw { error: body.error, status: data.status } + } + return body as Notification[] +} diff --git a/components/ThemeSwitcher.tsx b/components/ThemeSwitcher.tsx index 03d5bdad..5994d6d8 100644 --- a/components/ThemeSwitcher.tsx +++ b/components/ThemeSwitcher.tsx @@ -15,7 +15,7 @@ const ThemeSwitcher = () => { > {THEMES.map((value) => ( setTheme(t(value))} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index ec5f8286..5a145b9a 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -25,6 +25,7 @@ import { useViewport } from 'hooks/useViewport' import { breakpoints } from 'utils/theme' import AccountsButton from './AccountsButton' import useUnownedAccount from 'hooks/useUnownedAccount' +import NotificationsButton from './notifications/NotificationsButton' const TopBar = () => { const { t } = useTranslation('common') @@ -118,7 +119,8 @@ const TopBar = () => { >{`${t('deposit')} / ${t('withdraw')}`} )} {connected ? ( -
+
+
diff --git a/components/account/AccountPage.tsx b/components/account/AccountPage.tsx index 125a7870..016a677a 100644 --- a/components/account/AccountPage.tsx +++ b/components/account/AccountPage.tsx @@ -522,7 +522,7 @@ const AccountPage = () => {

-
+
{ return ( diff --git a/components/chat/Chat.tsx b/components/chat/Chat.tsx index 415dc5d9..74bc3f95 100644 --- a/components/chat/Chat.tsx +++ b/components/chat/Chat.tsx @@ -85,7 +85,7 @@ const Chat = () => { )}
- + Content Policy
diff --git a/components/modals/UserSetupModal.tsx b/components/modals/UserSetupModal.tsx index daf3173b..25ceebd3 100644 --- a/components/modals/UserSetupModal.tsx +++ b/components/modals/UserSetupModal.tsx @@ -368,9 +368,7 @@ const UserSetupModal = ({ )} - - {t('onboarding:skip')} - + {t('onboarding:skip')}
@@ -513,9 +511,7 @@ const UserSetupModal = ({ )} - - {t('onboarding:skip')} - + {t('onboarding:skip')} @@ -547,40 +543,6 @@ const UserSetupModal = ({
) : null} - {/* - {showSetupStep === 4 ? ( -
-

- {t('onboarding:your-profile')} -

-

{t('onboarding:profile-desc')}

- {!showEditProfilePic ? ( -
- setShowEditProfilePic(true)} - onboarding - /> - - - {t('onboarding:skip-finish')} - - -
- ) : null} - -
- setShowEditProfilePic(false)} - /> -
-
-
- ) : null} -
*/}
diff --git a/components/notifications/NotificationsButton.tsx b/components/notifications/NotificationsButton.tsx new file mode 100644 index 00000000..aa45f570 --- /dev/null +++ b/components/notifications/NotificationsButton.tsx @@ -0,0 +1,52 @@ +import { useCookies } from 'hooks/notifications/useCookies' +import { useNotifications } from 'hooks/notifications/useNotifications' +import { useMemo, useState } from 'react' +import NotificationsDrawer from './NotificationsDrawer' +import { useToaster } from 'hooks/notifications/useToaster' +import { BellIcon } from '@heroicons/react/20/solid' +import { useIsAuthorized } from 'hooks/notifications/useIsAuthorized' + +const NotificationsButton = () => { + useCookies() + useToaster() + const { data, isFetching } = useNotifications() + const isAuth = useIsAuthorized() + const [showDraw, setShowDraw] = useState(false) + + const notificationCount = useMemo(() => { + if (!isAuth && !isFetching) { + return 1 + } else if (!data || !data.length) { + return 0 + } else return data.filter((x) => !x.seen).length + }, [data, isAuth, isFetching]) + + const toggleModal = () => { + setShowDraw(!showDraw) + } + + return ( + <> + + + + ) +} + +export default NotificationsButton diff --git a/components/notifications/NotificationsDrawer.tsx b/components/notifications/NotificationsDrawer.tsx new file mode 100644 index 00000000..57c467ab --- /dev/null +++ b/components/notifications/NotificationsDrawer.tsx @@ -0,0 +1,282 @@ +import Button, { IconButton, LinkButton } from '@components/shared/Button' +import { Dialog, Transition } from '@headlessui/react' +import { + CalendarIcon, + FaceSmileIcon, + InboxIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/20/solid' +import { bs58 } from '@project-serum/anchor/dist/cjs/utils/bytes' +import { useWallet } from '@solana/wallet-adapter-react' +import { Payload, SIWS } from '@web3auth/sign-in-with-solana' +import { useHeaders } from 'hooks/notifications/useHeaders' +import { useIsAuthorized } from 'hooks/notifications/useIsAuthorized' +import { useNotifications } from 'hooks/notifications/useNotifications' +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import { NOTIFICATION_API } from 'utils/constants' +import NotificationCookieStore from '@store/notificationCookieStore' +import dayjs from 'dayjs' +import { useTranslation } from 'next-i18next' +import { notify } from 'utils/notifications' + +const NotificationsDrawer = ({ + isOpen, + onClose, +}: { + isOpen: boolean + onClose: () => void +}) => { + const { t } = useTranslation('notifications') + const { data, refetch } = useNotifications() + const wallet = useWallet() + const isAuth = useIsAuthorized() + const headers = useHeaders() + const setCookie = NotificationCookieStore((s) => s.setCookie) + const [isRemoving, setIsRemoving] = useState(false) + + const unseenNotifications = useMemo(() => { + if (!data || !data.length) return [] + return data.filter((x) => !x.seen) + }, [data]) + + const markAsSeen = useCallback( + async (ids: number[]) => { + try { + const resp = await fetch(`${NOTIFICATION_API}notifications/seen`, { + method: 'POST', + headers: headers.headers, + body: JSON.stringify({ + ids: ids, + seen: true, + }), + }) + const body = await resp.json() + const error = body.error + if (error) { + notify({ + type: 'error', + title: 'Error', + description: error, + }) + return + } + refetch() + } catch (e) { + notify({ + type: 'error', + title: 'Error', + description: JSON.stringify(e), + }) + } + }, + [NOTIFICATION_API, headers] + ) + + const remove = useCallback( + async (ids: number[]) => { + setIsRemoving(true) + try { + const resp = await fetch( + `${NOTIFICATION_API}notifications/removeForUser`, + { + method: 'POST', + headers: headers.headers, + body: JSON.stringify({ + ids: ids, + }), + } + ) + const body = await resp.json() + const error = body.error + if (error) { + notify({ + type: 'error', + title: 'Error', + description: error, + }) + return + } + refetch() + } catch (e) { + notify({ + type: 'error', + title: 'Error', + description: JSON.stringify(e), + }) + } + setIsRemoving(false) + }, + [NOTIFICATION_API, headers] + ) + + const createSolanaMessage = useCallback(() => { + const payload = new Payload() + payload.domain = window.location.host + payload.address = wallet.publicKey!.toString() + payload.uri = window.location.origin + payload.statement = 'Login to Mango Notifications Admin App' + payload.version = '1' + payload.chainId = 1 + + const message = new SIWS({ payload }) + + const messageText = message.prepareMessage() + const messageEncoded = new TextEncoder().encode(messageText) + + wallet.signMessage!(messageEncoded) + .then(async (resp) => { + const tokenResp = await fetch(`${NOTIFICATION_API}auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...payload, + signatureString: bs58.encode(resp), + }), + }) + const body = await tokenResp.json() + const token = body.token + const error = body.error + if (error) { + notify({ + type: 'error', + title: 'Error', + description: error, + }) + return + } + setCookie(payload.address, token) + }) + .catch((e) => { + notify({ + type: 'error', + title: 'Error', + description: e.message ? e.message : `${e}`, + }) + }) + }, [window.location, wallet, NOTIFICATION_API]) + + // Mark all notifications as seen when the inbox is opened + useEffect(() => { + if (isOpen && unseenNotifications?.length) { + markAsSeen([...unseenNotifications.map((x) => x.id)]) + } + }, [isOpen, unseenNotifications]) + + return ( + + + +
+ + + +
+

{t('notifications')}

+
+ {data?.length ? ( + remove(data.map((n) => n.id))} + > + + {t('clear-all')} + + ) : null} + +
+
+ {isAuth ? ( + <> + {data?.length ? ( + <> +
+ {data?.map((notification) => ( +
+
+
+

{notification.title}

+ remove([notification.id])} + className="mt-1 text-th-fgd-3" + hideBg + > + + +
+
+ +

+ {dayjs(notification.createdAt).format( + 'DD MMM YYYY, h:mma' + )} +

+
+

{notification.content}

+
+
+ ))} +
+ + ) : ( +
+
+ +

+ {t('empty-state-title')} +

+

{t('empty-state-desc')}

+
+
+ )} + + ) : ( +
+
+ +

{t('unauth-title')}

+

{t('unauth-desc')}

+ +
+
+ )} +
+
+
+
+ ) +} + +export default NotificationsDrawer diff --git a/components/shared/Notification.tsx b/components/notifications/TransactionNotification.tsx similarity index 87% rename from components/shared/Notification.tsx rename to components/notifications/TransactionNotification.tsx index e9980105..401f6a33 100644 --- a/components/shared/Notification.tsx +++ b/components/notifications/TransactionNotification.tsx @@ -7,8 +7,8 @@ import { XMarkIcon, } from '@heroicons/react/20/solid' import mangoStore, { CLUSTER } from '@store/mangoStore' -import { Notification, notify } from '../../utils/notifications' -import Loading from './Loading' +import { TransactionNotification, notify } from '../../utils/notifications' +import Loading from '@components/shared/Loading' import { Transition } from '@headlessui/react' import { CLIENT_TX_TIMEOUT, @@ -22,9 +22,9 @@ import { EXPLORERS } from '@components/settings/PreferredExplorerSettings' const setMangoStore = mangoStore.getState().set -const NotificationList = () => { +const TransactionNotificationList = () => { const { t } = useTranslation() - const notifications = mangoStore((s) => s.notifications) + const transactionNotifications = mangoStore((s) => s.transactionNotifications) const walletTokens = mangoStore((s) => s.wallet.tokens) const notEnoughSoLMessage = t('deposit-more-sol') const [notificationPosition] = useLocalStorageState( @@ -37,11 +37,11 @@ const NotificationList = () => { // if a notification is shown with {"InstructionError":[0,{"Custom":1}]} then // add a notification letting the user know they may not have enough SOL useEffect(() => { - if (notifications.length) { - const customErrorNotification = notifications.find( + if (transactionNotifications.length) { + const customErrorNotification = transactionNotifications.find( (n) => n.description && n.description.includes('"Custom":1') ) - const notEnoughSolNotification = notifications.find( + const notEnoughSolNotification = transactionNotifications.find( (n) => n.title && n.title.includes(notEnoughSoLMessage) ) @@ -56,19 +56,19 @@ const NotificationList = () => { }) } } - }, [notifications, walletTokens, maxSolDeposit]) + }, [transactionNotifications, walletTokens, maxSolDeposit]) const clearAll = useCallback(() => { setMangoStore((s) => { - const newNotifications = s.notifications.map((n) => ({ + const newNotifications = s.transactionNotifications.map((n) => ({ ...n, show: false, })) - s.notifications = newNotifications + s.transactionNotifications = newNotifications }) - }, [notifications]) + }, [transactionNotifications]) - const reversedNotifications = [...notifications].reverse() + const reversedNotifications = [...transactionNotifications].reverse() const getPosition = (position: string) => { const sharedClasses = @@ -92,7 +92,7 @@ const NotificationList = () => { return (
- {notifications.filter((n) => n.show).length > 1 ? ( + {transactionNotifications.filter((n) => n.show).length > 1 ? ( ) : null} {reversedNotifications.map((n) => ( - + ))}
) } -const Notification = ({ notification }: { notification: Notification }) => { +const TransactionNotification = ({ + notification, +}: { + notification: TransactionNotification +}) => { const [notificationPosition] = useLocalStorageState( NOTIFICATION_POSITION_KEY, 'Bottom-Left' @@ -141,20 +145,20 @@ const Notification = ({ notification }: { notification: Notification }) => { useEffect(() => { if ((type === 'error' || type === 'success') && txid) { setMangoStore((s) => { - const newNotifications = s.notifications.map((n) => + const newNotifications = s.transactionNotifications.map((n) => n.txid === txid && n.type === 'confirm' ? { ...n, show: false } : n ) - s.notifications = newNotifications + s.transactionNotifications = newNotifications }) } }, [type, txid]) const hideNotification = () => { setMangoStore((s) => { - const newNotifications = s.notifications.map((n) => + const newNotifications = s.transactionNotifications.map((n) => n.id === id ? { ...n, show: false } : n ) - s.notifications = newNotifications + s.transactionNotifications = newNotifications }) } @@ -302,4 +306,4 @@ const Notification = ({ notification }: { notification: Notification }) => { ) } -export default NotificationList +export default TransactionNotificationList diff --git a/components/profile/EditNftProfilePic.tsx b/components/profile/EditNftProfilePic.tsx index 562c3a21..784e658f 100644 --- a/components/profile/EditNftProfilePic.tsx +++ b/components/profile/EditNftProfilePic.tsx @@ -153,7 +153,7 @@ const EditNftProfilePic = ({ onClose }: { onClose: () => void }) => { {t('save')} {profile?.profile_image_url ? ( - + {t('profile:remove')} ) : null} diff --git a/components/shared/BalancesTable.tsx b/components/shared/BalancesTable.tsx index ea429edc..daa39dfe 100644 --- a/components/shared/BalancesTable.tsx +++ b/components/shared/BalancesTable.tsx @@ -324,7 +324,7 @@ const Balance = ({ bank }: { bank: BankWithBalance }) => { {!isUnownedAccount ? ( asPath.includes('/trade') && isBaseOrQuote ? ( handleTradeFormBalanceClick(Math.abs(balance), isBaseOrQuote) } @@ -336,7 +336,7 @@ const Balance = ({ bank }: { bank: BankWithBalance }) => { ) : asPath.includes('/swap') ? ( handleSwapFormBalanceClick( Number(formatNumericValue(balance, tokenBank.mintDecimals)) diff --git a/components/shared/Button.tsx b/components/shared/Button.tsx index f32056d6..541167c3 100644 --- a/components/shared/Button.tsx +++ b/components/shared/Button.tsx @@ -114,7 +114,7 @@ export const LinkButton: FunctionComponent = ({ disabled={disabled} className={`flex items-center border-0 font-bold ${ secondary ? 'text-th-active' : 'text-th-fgd-2' - } rounded-sm underline focus-visible:text-th-active focus-visible:no-underline disabled:cursor-not-allowed disabled:opacity-50 md:hover:text-th-fgd-3 md:hover:no-underline ${className}`} + } rounded-sm focus-visible:text-th-active focus-visible:underline disabled:cursor-not-allowed disabled:opacity-50 ${className} md:hover:text-th-fgd-3`} {...props} type="button" > diff --git a/components/shared/MaxAmountButton.tsx b/components/shared/MaxAmountButton.tsx index 7a881040..258a5405 100644 --- a/components/shared/MaxAmountButton.tsx +++ b/components/shared/MaxAmountButton.tsx @@ -19,7 +19,7 @@ const MaxAmountButton = ({ }) => { return ( diff --git a/components/stats/MangoPerpStatsCharts.tsx b/components/stats/MangoPerpStatsCharts.tsx index 79fae1ce..13dc5448 100644 --- a/components/stats/MangoPerpStatsCharts.tsx +++ b/components/stats/MangoPerpStatsCharts.tsx @@ -41,14 +41,16 @@ const MangoPerpStatsCharts = () => { if (!hasDate) { a.push({ date: c.date_hour, - feeValue: c.fees_accrued, + feeValue: c.total_fees, }) } else { - hasDate.feeValue = hasDate.feeValue + c.fees_accrued + hasDate.feeValue = hasDate.feeValue + c.total_fees } - return a + return a.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ) }, []) - return values.reverse() + return values }, [perpStats]) const totalOpenInterestValues = useMemo(() => { @@ -64,9 +66,11 @@ const MangoPerpStatsCharts = () => { hasDate.openInterest = hasDate.openInterest + Math.floor(c.open_interest * c.price) } - return a + return a.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ) }, []) - return values.reverse() + return values }, [perpStats]) return ( diff --git a/components/stats/TokenStats.tsx b/components/stats/TokenStats.tsx index 5baa4465..6345ea1a 100644 --- a/components/stats/TokenStats.tsx +++ b/components/stats/TokenStats.tsx @@ -6,7 +6,6 @@ import { } from '@heroicons/react/20/solid' import { useTranslation } from 'next-i18next' import Image from 'next/legacy/image' -import { useEffect } from 'react' import { useViewport } from '../../hooks/useViewport' import { breakpoints } from '../../utils/theme' import { LinkButton } from '../shared/Button' @@ -17,7 +16,6 @@ import { NextRouter, useRouter } from 'next/router' import useJupiterMints from 'hooks/useJupiterMints' import { Table, Td, Th, TrBody, TrHead } from '@components/shared/TableElements' import useMangoGroup from 'hooks/useMangoGroup' -import mangoStore from '@store/mangoStore' import FormatNumericValue from '@components/shared/FormatNumericValue' import BankAmountWithValue from '@components/shared/BankAmountWithValue' import useBanksWithBalances from 'hooks/useBanksWithBalances' @@ -25,8 +23,6 @@ import Decimal from 'decimal.js' const TokenStats = () => { const { t } = useTranslation(['common', 'token']) - const actions = mangoStore.getState().actions - const initialStatsLoad = mangoStore((s) => s.tokenStats.initialLoad) const { group } = useMangoGroup() const { mangoTokens } = useJupiterMints() const { width } = useViewport() @@ -34,18 +30,6 @@ const TokenStats = () => { const router = useRouter() const banks = useBanksWithBalances() - useEffect(() => { - if (group && !initialStatsLoad) { - actions.fetchTokenStats() - } - }, [group]) - - // const goToTokenPage = (bank: Bank) => { - // router.push(`/token/${bank.name.split(' ')[0].toUpperCase()}`, undefined, { - // shallow: true, - // }) - // } - const goToTokenPage = (token: string, router: NextRouter) => { const query = { ...router.query, ['token']: token } router.push({ pathname: router.pathname, query }) diff --git a/components/stats/TotalDepositBorrowCharts.tsx b/components/stats/TotalDepositBorrowCharts.tsx index a558671e..553e7ad8 100644 --- a/components/stats/TotalDepositBorrowCharts.tsx +++ b/components/stats/TotalDepositBorrowCharts.tsx @@ -1,11 +1,12 @@ import mangoStore from '@store/mangoStore' import { useTranslation } from 'next-i18next' import dynamic from 'next/dynamic' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import dayjs from 'dayjs' import { formatYAxis } from 'utils/formatting' import useBanksWithBalances from 'hooks/useBanksWithBalances' import { TokenStatsItem } from 'types' +import useMangoGroup from 'hooks/useMangoGroup' const DetailedAreaChart = dynamic( () => import('@components/shared/DetailedAreaChart'), { ssr: false } @@ -19,12 +20,21 @@ interface TotalValueItem { const TotalDepositBorrowCharts = () => { const { t } = useTranslation(['common', 'token', 'trade']) + const { group } = useMangoGroup() const tokenStats = mangoStore((s) => s.tokenStats.data) + const initialStatsLoad = mangoStore((s) => s.tokenStats.initialLoad) const loadingStats = mangoStore((s) => s.tokenStats.loading) const [borrowDaysToShow, setBorrowDaysToShow] = useState('30') const [depositDaysToShow, setDepositDaysToShow] = useState('30') const banks = useBanksWithBalances() + useEffect(() => { + if (group && !initialStatsLoad) { + const actions = mangoStore.getState().actions + actions.fetchTokenStats() + } + }, [group, initialStatsLoad]) + const totalDepositBorrowValues = useMemo(() => { if (!tokenStats) return [] const values: TotalValueItem[] = tokenStats.reduce( @@ -42,11 +52,13 @@ const TotalDepositBorrowCharts = () => { hasDate.borrowValue = hasDate.borrowValue + Math.floor(c.total_borrows * c.price) } - return a + return a.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ) }, [] ) - return values.reverse() + return values }, [tokenStats]) const [currentTotalDepositValue, currentTotalBorrowValue] = useMemo(() => { diff --git a/components/trade/AdvancedMarketHeader.tsx b/components/trade/AdvancedMarketHeader.tsx index 5a0ab0f7..e8729ebb 100644 --- a/components/trade/AdvancedMarketHeader.tsx +++ b/components/trade/AdvancedMarketHeader.tsx @@ -204,7 +204,7 @@ const AdvancedMarketHeader = ({
{selectedMarket instanceof PerpMarket ? ( setShowMarketDetails(true)} > diff --git a/components/trade/GroupSize.tsx b/components/trade/GroupSize.tsx index 6585490f..3fcd0df6 100644 --- a/components/trade/GroupSize.tsx +++ b/components/trade/GroupSize.tsx @@ -1,6 +1,7 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { Listbox } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' +import Decimal from 'decimal.js' const GroupSize = ({ tickSize, @@ -13,13 +14,20 @@ const GroupSize = ({ onChange: (x: number) => void className?: string }) => { + const formatSize = useCallback( + (multiplier: number) => { + return new Decimal(tickSize).mul(multiplier).toNumber() + }, + [tickSize] + ) + const sizes = useMemo( () => [ tickSize, - tickSize * 5, - tickSize * 10, - tickSize * 50, - tickSize * 100, + formatSize(5), + formatSize(10), + formatSize(50), + formatSize(100), ], [tickSize] ) diff --git a/components/trade/MarketCloseModal.tsx b/components/trade/MarketCloseModal.tsx index 877b5efa..e525717c 100644 --- a/components/trade/MarketCloseModal.tsx +++ b/components/trade/MarketCloseModal.tsx @@ -204,7 +204,7 @@ const MarketCloseModal: FunctionComponent = ({ {submitting ? : {t('trade:close-position')}} {t('cancel')} diff --git a/components/trade/PerpPositions.tsx b/components/trade/PerpPositions.tsx index edc12a0d..880e2abe 100644 --- a/components/trade/PerpPositions.tsx +++ b/components/trade/PerpPositions.tsx @@ -149,6 +149,7 @@ const PerpPositions = () => {

{isSelectedMarket ? ( handlePositionClick(floorBasePosition, market) } @@ -294,7 +295,7 @@ const PerpPositions = () => { {isSelectedMarket && asPath === '/trade' ? ( handlePositionClick(floorBasePosition, market) } diff --git a/components/wallet/ConnectedMenu.tsx b/components/wallet/ConnectedMenu.tsx index 421e505a..7e891282 100644 --- a/components/wallet/ConnectedMenu.tsx +++ b/components/wallet/ConnectedMenu.tsx @@ -76,11 +76,14 @@ const ConnectedMenu = () => {

-
+
s.updateCookie) + const removeCookie = NotificationCookieStore((s) => s.removeCookie) + const token = NotificationCookieStore((s) => s.currentToken) + const { error } = useNotifications() + const errorResp = error as Error + + useEffect(() => { + updateCookie(wallet.publicKey?.toBase58()) + }, [wallet.publicKey?.toBase58()]) + + useEffect(() => { + if (errorResp?.status === 401 && wallet.publicKey && token) { + removeCookie(wallet.publicKey?.toBase58()) + notify({ + title: errorResp.error, + type: 'error', + }) + } + }, [errorResp, wallet.publicKey?.toBase58()]) +} diff --git a/hooks/notifications/useHeaders.ts b/hooks/notifications/useHeaders.ts new file mode 100644 index 00000000..6908a318 --- /dev/null +++ b/hooks/notifications/useHeaders.ts @@ -0,0 +1,15 @@ +import { useWallet } from '@solana/wallet-adapter-react' +import NotificationCookieStore from '@store/notificationCookieStore' + +export function useHeaders() { + const { publicKey } = useWallet() + const token = NotificationCookieStore((s) => s.currentToken) + + return { + headers: { + authorization: token, + publickey: publicKey?.toBase58() || '', + 'Content-Type': 'application/json', + }, + } +} diff --git a/hooks/notifications/useIsAuthorized.ts b/hooks/notifications/useIsAuthorized.ts new file mode 100644 index 00000000..00d0bc28 --- /dev/null +++ b/hooks/notifications/useIsAuthorized.ts @@ -0,0 +1,15 @@ +import { useWallet } from '@solana/wallet-adapter-react' +import { useNotifications } from './useNotifications' +import NotificationCookieStore from '@store/notificationCookieStore' + +export function useIsAuthorized() { + const wallet = useWallet() + const { error, isFetched, isLoading } = useNotifications() + const walletPubKey = wallet.publicKey?.toBase58() + const token = NotificationCookieStore((s) => s.currentToken) + + const isAuthorized = + walletPubKey && token && !error && isFetched && !isLoading + + return isAuthorized +} diff --git a/hooks/notifications/useNotifications.ts b/hooks/notifications/useNotifications.ts new file mode 100644 index 00000000..53bd5185 --- /dev/null +++ b/hooks/notifications/useNotifications.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchNotifications } from 'apis/notifications' +import NotificationCookieStore from '@store/notificationCookieStore' +import { useWallet } from '@solana/wallet-adapter-react' + +//10min +const refetchMs = 600000 + +export function useNotifications() { + const wallet = useWallet() + const walletPubKey = wallet.publicKey?.toBase58() + const token = NotificationCookieStore((s) => s.currentToken) + const criteria = `${walletPubKey}${token}` + + return useQuery( + ['notifications', criteria], + () => fetchNotifications(walletPubKey!, token!), + { + enabled: !!(walletPubKey && token), + staleTime: refetchMs, + retry: 1, + refetchInterval: refetchMs, + } + ) +} diff --git a/hooks/notifications/useToaster.ts b/hooks/notifications/useToaster.ts new file mode 100644 index 00000000..cc386e83 --- /dev/null +++ b/hooks/notifications/useToaster.ts @@ -0,0 +1,28 @@ +import usePrevious from '@components/shared/usePrevious' +import { useNotifications } from './useNotifications' +import { useEffect } from 'react' +import { Notification } from '../../apis/notifications' +import { notify } from 'utils/notifications' + +export function useToaster() { + const { data } = useNotifications() + const previousData = usePrevious(data) + + useEffect(() => { + if (data && data.length && previousData) { + const oldIds = previousData.map((item: Notification) => item.id) + const newObjects = data.filter( + (item: Notification) => !oldIds.includes(item.id) + ) + if (newObjects.length) { + newObjects.map((x) => + notify({ + title: 'New message', + description: x.title, + type: 'info', + }) + ) + } + } + }, [data, previousData]) +} diff --git a/package.json b/package.json index 595b5990..615a2d7e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@tippyjs/react": "4.2.6", "@types/howler": "2.2.7", "@types/lodash": "4.14.185", + "@web3auth/sign-in-with-solana": "1.0.0", "assert": "2.0.0", "big.js": "6.2.1", "clsx": "1.2.1", @@ -46,6 +47,7 @@ "html2canvas": "1.4.1", "http-proxy-middleware": "2.0.6", "immer": "9.0.12", + "js-cookie": "3.0.1", "klinecharts": "8.6.3", "lodash": "4.17.21", "next": "13.1.0", @@ -76,6 +78,7 @@ "@types/big.js": "6.1.6", "@types/node": "17.0.23", "@types/react": "18.0.3", + "@types/js-cookie": "3.0.3", "@types/react-dom": "18.0.0", "@types/react-grid-layout": "1.3.2", "@types/react-window": "1.8.5", diff --git a/pages/404.tsx b/pages/404.tsx index 1a27d181..3ce80831 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -7,6 +7,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'profile', 'search', 'settings', diff --git a/pages/_app.tsx b/pages/_app.tsx index a0f69581..f0311af7 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -19,7 +19,7 @@ import { GlowWalletAdapter, } from '@solana/wallet-adapter-wallets' import { clusterApiUrl } from '@solana/web3.js' -import Notifications from '../components/shared/Notification' +import TransactionNotification from '@components/notifications/TransactionNotification' import { ThemeProvider } from 'next-themes' import { appWithTranslation } from 'next-i18next' import Layout from '../components/Layout' @@ -118,7 +118,7 @@ function MyApp({ Component, pageProps }: AppProps) { - + diff --git a/pages/borrow.tsx b/pages/borrow.tsx index 32cd170d..f3e16cc1 100644 --- a/pages/borrow.tsx +++ b/pages/borrow.tsx @@ -8,6 +8,7 @@ export async function getStaticProps({ locale }: { locale: string }) { ...(await serverSideTranslations(locale, [ 'borrow', 'common', + 'notifications', 'onboarding', 'profile', 'search', diff --git a/pages/dashboard/index.tsx b/pages/dashboard/index.tsx index c38740d9..a6a50a0f 100644 --- a/pages/dashboard/index.tsx +++ b/pages/dashboard/index.tsx @@ -27,6 +27,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'onboarding', 'profile', 'search', diff --git a/pages/governance/listToken.tsx b/pages/governance/listToken.tsx index 62365e6c..aa7b876b 100644 --- a/pages/governance/listToken.tsx +++ b/pages/governance/listToken.tsx @@ -10,11 +10,12 @@ export async function getStaticProps({ locale }: { locale: string }) { return { props: { ...(await serverSideTranslations(locale, [ - 'governance', - 'search', 'common', + 'governance', + 'notifications', 'onboarding', 'profile', + 'search', ])), }, } diff --git a/pages/governance/vote.tsx b/pages/governance/vote.tsx index 32fd7030..6d7cd9a9 100644 --- a/pages/governance/vote.tsx +++ b/pages/governance/vote.tsx @@ -8,11 +8,12 @@ export async function getStaticProps({ locale }: { locale: string }) { return { props: { ...(await serverSideTranslations(locale, [ - 'governance', - 'search', 'common', + 'governance', + 'notifications', 'onboarding', 'profile', + 'search', ])), }, } diff --git a/pages/index.tsx b/pages/index.tsx index 898a26f8..4d2ea855 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -9,6 +9,7 @@ export async function getStaticProps({ locale }: { locale: string }) { 'account', 'activity', 'common', + 'notifications', 'onboarding', 'onboarding-tours', 'profile', diff --git a/pages/leaderboard.tsx b/pages/leaderboard.tsx index de69d1cd..9aa46b92 100644 --- a/pages/leaderboard.tsx +++ b/pages/leaderboard.tsx @@ -8,6 +8,7 @@ export async function getStaticProps({ locale }: { locale: string }) { ...(await serverSideTranslations(locale, [ 'common', 'leaderboard', + 'notifications', 'profile', 'search', ])), diff --git a/pages/search.tsx b/pages/search.tsx index 7bdfdcfd..685eaa04 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -7,6 +7,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'profile', 'search', ])), diff --git a/pages/settings.tsx b/pages/settings.tsx index 71f795ce..b0f4eaea 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -12,6 +12,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'onboarding', 'profile', 'search', diff --git a/pages/stats.tsx b/pages/stats.tsx index 72f2bc62..ae981abb 100644 --- a/pages/stats.tsx +++ b/pages/stats.tsx @@ -7,6 +7,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'onboarding', 'profile', 'search', diff --git a/pages/swap.tsx b/pages/swap.tsx index 8a2d22bf..e82ebb30 100644 --- a/pages/swap.tsx +++ b/pages/swap.tsx @@ -7,6 +7,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'onboarding', 'onboarding-tours', 'profile', diff --git a/pages/trade.tsx b/pages/trade.tsx index c3382cbf..0a67d37e 100644 --- a/pages/trade.tsx +++ b/pages/trade.tsx @@ -16,6 +16,7 @@ export async function getStaticProps({ locale }: { locale: string }) { props: { ...(await serverSideTranslations(locale, [ 'common', + 'notifications', 'onboarding', 'onboarding-tours', 'profile', diff --git a/public/locales/en/notifications.json b/public/locales/en/notifications.json new file mode 100644 index 00000000..71bfce22 --- /dev/null +++ b/public/locales/en/notifications.json @@ -0,0 +1,9 @@ +{ + "clear-all": "Clear All", + "empty-state-desc": "You're all up-to-date", + "empty-state-title": "Nothing to see here", + "notifications": "Notifications", + "sign-message": "Sign Message", + "unauth-desc": "You need to verify your wallet to start receiving notifications.", + "unauth-title": "Notifications Inbox" +} \ No newline at end of file diff --git a/public/locales/es/notifications.json b/public/locales/es/notifications.json new file mode 100644 index 00000000..71bfce22 --- /dev/null +++ b/public/locales/es/notifications.json @@ -0,0 +1,9 @@ +{ + "clear-all": "Clear All", + "empty-state-desc": "You're all up-to-date", + "empty-state-title": "Nothing to see here", + "notifications": "Notifications", + "sign-message": "Sign Message", + "unauth-desc": "You need to verify your wallet to start receiving notifications.", + "unauth-title": "Notifications Inbox" +} \ No newline at end of file diff --git a/public/locales/ru/notifications.json b/public/locales/ru/notifications.json new file mode 100644 index 00000000..71bfce22 --- /dev/null +++ b/public/locales/ru/notifications.json @@ -0,0 +1,9 @@ +{ + "clear-all": "Clear All", + "empty-state-desc": "You're all up-to-date", + "empty-state-title": "Nothing to see here", + "notifications": "Notifications", + "sign-message": "Sign Message", + "unauth-desc": "You need to verify your wallet to start receiving notifications.", + "unauth-title": "Notifications Inbox" +} \ No newline at end of file diff --git a/public/locales/zh/notifications.json b/public/locales/zh/notifications.json new file mode 100644 index 00000000..71bfce22 --- /dev/null +++ b/public/locales/zh/notifications.json @@ -0,0 +1,9 @@ +{ + "clear-all": "Clear All", + "empty-state-desc": "You're all up-to-date", + "empty-state-title": "Nothing to see here", + "notifications": "Notifications", + "sign-message": "Sign Message", + "unauth-desc": "You need to verify your wallet to start receiving notifications.", + "unauth-title": "Notifications Inbox" +} \ No newline at end of file diff --git a/public/locales/zh_tw/notifications.json b/public/locales/zh_tw/notifications.json new file mode 100644 index 00000000..71bfce22 --- /dev/null +++ b/public/locales/zh_tw/notifications.json @@ -0,0 +1,9 @@ +{ + "clear-all": "Clear All", + "empty-state-desc": "You're all up-to-date", + "empty-state-title": "Nothing to see here", + "notifications": "Notifications", + "sign-message": "Sign Message", + "unauth-desc": "You need to verify your wallet to start receiving notifications.", + "unauth-title": "Notifications Inbox" +} \ No newline at end of file diff --git a/public/logos/logo-mark.svg b/public/logos/logo-mark.svg index 767b3f89..4c03f566 100644 --- a/public/logos/logo-mark.svg +++ b/public/logos/logo-mark.svg @@ -1,45 +1,51 @@ - - - - - - - - - + + + + + + + + + - - - + + + + - + - + - + - + - + - + - - - + + + + + + + + diff --git a/store/mangoStore.ts b/store/mangoStore.ts index 5b8ab734..7c738f0a 100644 --- a/store/mangoStore.ts +++ b/store/mangoStore.ts @@ -21,7 +21,7 @@ import { } from '@blockworks-foundation/mango-v4' import EmptyWallet from '../utils/wallet' -import { Notification, notify } from '../utils/notifications' +import { TransactionNotification, notify } from '../utils/notifications' import { getNFTsByOwner, getTokenAccountsByOwnerWithWrappedSol, @@ -159,8 +159,8 @@ export type MangoStore = { } mangoAccounts: MangoAccount[] markets: Serum3Market[] | undefined - notificationIdCounter: number - notifications: Array + transactionNotificationIdCounter: number + transactionNotifications: Array perpMarkets: PerpMarket[] perpStats: { loading: boolean @@ -305,8 +305,8 @@ const mangoStore = create()( }, mangoAccounts: [], markets: undefined, - notificationIdCounter: 0, - notifications: [], + transactionNotificationIdCounter: 0, + transactionNotifications: [], perpMarkets: [], perpStats: { loading: false, diff --git a/store/notificationCookieStore.ts b/store/notificationCookieStore.ts new file mode 100644 index 00000000..a6eb96f6 --- /dev/null +++ b/store/notificationCookieStore.ts @@ -0,0 +1,60 @@ +import produce from 'immer' +import Cookies from 'js-cookie' +import create from 'zustand' + +type ICookieStore = { + currentToken: string + set: (x: (x: ICookieStore) => void) => void + updateCookie: (wallet?: string) => void + removeCookie: (wallet: string) => void + setCookie: (wallet: string, token: string) => void +} + +const CookieStore = create((set, get) => ({ + currentToken: '', + set: (fn) => set(produce(fn)), + updateCookie: async (wallet?: string) => { + const set = get().set + const token = wallet ? getWalletToken(wallet) : '' + set((state) => { + state.currentToken = token + }) + }, + removeCookie: async (wallet: string) => { + const set = get().set + if (getWalletToken(wallet)) { + removeWalletToken(wallet) + set((state) => { + state.currentToken = '' + }) + } + }, + setCookie: async (wallet: string, token: string) => { + const set = get().set + setWalletToken(wallet, token) + set((state) => { + state.currentToken = token + }) + }, +})) + +export default CookieStore + +const cookieName = 'authToken-' + +const getWalletToken = (wallet: string) => { + const token = Cookies.get(`${cookieName}${wallet}`) + return token || '' +} + +const removeWalletToken = (wallet: string) => { + Cookies.remove(`${cookieName}${wallet}`) +} + +const setWalletToken = (wallet: string, token: string) => { + Cookies.set(`${cookieName}${wallet}`, token, { + secure: true, + sameSite: 'strict', + expires: 360, + }) +} diff --git a/styles/globals.css b/styles/globals.css index c40ff192..6abc37fa 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -416,7 +416,8 @@ svg { h1, h2, -h3 { +h3, +h4 { @apply font-bold text-th-fgd-1; } diff --git a/types/index.ts b/types/index.ts index e3fd66e0..3f4dcd30 100644 --- a/types/index.ts +++ b/types/index.ts @@ -288,6 +288,7 @@ export interface NFT { export interface PerpStatsItem { date_hour: string fees_accrued: number + fees_settled: number funding_rate_hourly: number instantaneous_funding_rate: number mango_group: string @@ -296,6 +297,7 @@ export interface PerpStatsItem { perp_market: string price: number stable_price: number + total_fees: number } export type ActivityFeed = { diff --git a/utils/constants.ts b/utils/constants.ts index 44fce971..46dfca4d 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -87,4 +87,7 @@ export const TRADE_VOLUME_ALERT_KEY = 'tradeVolumeAlert-0.1' export const PAGINATION_PAGE_LENGTH = 250 export const JUPITER_API_MAINNET = 'https://token.jup.ag/strict' + export const JUPITER_API_DEVNET = 'https://api.jup.ag/api/tokens/devnet' + +export const NOTIFICATION_API = 'https://notifications-api.herokuapp.com/' diff --git a/utils/notifications.ts b/utils/notifications.ts index add22ae3..ac2a086c 100644 --- a/utils/notifications.ts +++ b/utils/notifications.ts @@ -3,7 +3,7 @@ import mangoStore from '@store/mangoStore' import { Howl } from 'howler' import { SOUND_SETTINGS_KEY } from './constants' -export type Notification = { +export type TransactionNotification = { type: 'success' | 'info' | 'error' | 'confirm' title: string description?: null | string @@ -29,8 +29,8 @@ export function notify(newNotification: { noSound?: boolean }) { const setMangoStore = mangoStore.getState().set - const notifications = mangoStore.getState().notifications - const lastId = mangoStore.getState().notificationIdCounter + const notifications = mangoStore.getState().transactionNotifications + const lastId = mangoStore.getState().transactionNotificationIdCounter const newId = lastId + 1 const savedSoundSettings = localStorage.getItem(SOUND_SETTINGS_KEY) const soundSettings = savedSoundSettings @@ -53,7 +53,7 @@ export function notify(newNotification: { } } - const newNotif: Notification = { + const newNotif: TransactionNotification = { id: newId, type: 'success', show: true, @@ -62,7 +62,7 @@ export function notify(newNotification: { } setMangoStore((state) => { - state.notificationIdCounter = newId - state.notifications = [...notifications, newNotif] + state.transactionNotificationIdCounter = newId + state.transactionNotifications = [...notifications, newNotif] }) } diff --git a/yarn.lock b/yarn.lock index 4087b28a..e530f411 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2157,6 +2157,11 @@ dependencies: "@types/node" "*" +"@types/js-cookie@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.3.tgz#d6bfbbdd0c187354ca555213d1962f6d0691ff4e" + integrity sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww== + "@types/json-schema@^7.0.9": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" @@ -2669,6 +2674,16 @@ "@walletconnect/window-getters" "^1.0.1" tslib "1.14.1" +"@web3auth/sign-in-with-solana@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@web3auth/sign-in-with-solana/-/sign-in-with-solana-1.0.0.tgz#f7fedff74bc47782fb84fc176dc942095e81eb1f" + integrity sha512-YwgROXLDfmaqk9lh6hLO/VSKR32FxftOd01aLUMic4yM1IQ00/hx0/12phzE5gn3uiuBZVoHv6yWEAFmnfxh2Q== + dependencies: + "@stablelib/random" "^1.0.1" + bs58 "^5.0.0" + tweetnacl "^1.0.3" + valid-url "^1.0.9" + JSONStream@^1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -5754,6 +5769,11 @@ js-base64@^3.7.2: resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" integrity sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA== +js-cookie@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" + integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== + js-sha256@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" @@ -8340,6 +8360,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== +tweetnacl@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" + integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -8483,6 +8508,11 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +valid-url@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" + integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA== + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"