diff --git a/components/front-page/HeroBanner.tsx b/components/front-page/HeroBanner.tsx index 8551f0811..08ea506a0 100644 --- a/components/front-page/HeroBanner.tsx +++ b/components/front-page/HeroBanner.tsx @@ -52,12 +52,12 @@ export const HeroBanner = ({ className="py-3 px-4 w-full rounded-md flex gap-2" style={{ backgroundColor: "rgba(28, 100, 242, 0.2)" }} > -
+
- +
-
Zeitgeist
+
Zeitgeist
{chainProperties.tokenSymbol.toString()}
diff --git a/components/top-bar/Alerts.tsx b/components/top-bar/Alerts.tsx new file mode 100644 index 000000000..1646e2e71 --- /dev/null +++ b/components/top-bar/Alerts.tsx @@ -0,0 +1,232 @@ +import { Menu, Transition, Portal } from "@headlessui/react"; +import Modal from "components/ui/Modal"; +import { + ReadyToReportMarketAlertData, + RedeemableMarketsAlertData, + RelevantMarketDisputeAlertData, + useAlerts, +} from "lib/state/alerts"; +import { useWallet } from "lib/state/wallet"; +import { useRouter } from "next/router"; +import { Fragment, PropsWithChildren, useEffect, useState } from "react"; +import { AiOutlineFileAdd } from "react-icons/ai"; +import { BiMoneyWithdraw } from "react-icons/bi"; +import { IoMdNotificationsOutline } from "react-icons/io"; + +export const Alerts = () => { + const wallet = useWallet(); + const { alerts, setAsRead } = useAlerts(wallet.realAddress); + + const hasNotifications = alerts.length > 0; + + const [hoveringMenu, setHoveringMenu] = useState(false); + + const mouseEnterMenuHandler = () => { + setHoveringMenu(true); + }; + const mouseLeaveMenuHandler = () => { + setHoveringMenu(false); + }; + + return ( + + {({ open, close }) => { + return ( + <> +
+ +
+ + {hasNotifications && ( +
+ )} +
+
+
+ + +
+ ); +}; + +const AlertCard: React.FC void }> = ({ + children, + onClick, +}) => ( +
+
+ {children} +
+
+); + +const ReadyToReportMarketAlertItem = ({ + alert, +}: { + alert: ReadyToReportMarketAlertData; +}) => { + const router = useRouter(); + + useEffect(() => { + router.prefetch(`/markets/${alert.market.marketId}`); + }, [alert]); + + return ( + { + router.push(`/markets/${alert.market.marketId}`); + }} + > +
+
+ + Submit Report +
+
+
+

{alert.market.question}

+
+
+ ); +}; + +const RedeemableMarketAlertItem = ({ + alert, +}: { + alert: RedeemableMarketsAlertData; +}) => { + const router = useRouter(); + const wallet = useWallet(); + + useEffect(() => { + router.prefetch(`/portfolio/${wallet.realAddress}`); + }, [alert, wallet.realAddress]); + + return ( + { + router.push(`/portfolio/${wallet.realAddress}`); + }} + > +
+
+ + Redeemable Tokens +
+
+
+

+ You have {alert.markets.length} redeemable markets. +

+
+
+ ); +}; + +const RelevantMarketDisputeItem = ({ + alert, +}: { + alert: RelevantMarketDisputeAlertData; +}) => { + return ; +}; + +/** + * @note Since the param here is `never` it prevents us from forgetting to add a case for a new alert type + * If a case for a alert type is missing in the rendering of the list, the compiler will complain. + */ +const UnknownAlertItem = ({ alert }: { alert: never }) => { + return <>; +}; diff --git a/components/menu/MenuItem.tsx b/components/top-bar/MenuItem.tsx similarity index 100% rename from components/menu/MenuItem.tsx rename to components/top-bar/MenuItem.tsx diff --git a/components/menu/MenuLogo.tsx b/components/top-bar/MenuLogo.tsx similarity index 100% rename from components/menu/MenuLogo.tsx rename to components/top-bar/MenuLogo.tsx diff --git a/components/menu/Navigation.tsx b/components/top-bar/Navigation.tsx similarity index 100% rename from components/menu/Navigation.tsx rename to components/top-bar/Navigation.tsx diff --git a/components/menu/index.tsx b/components/top-bar/index.tsx similarity index 98% rename from components/menu/index.tsx rename to components/top-bar/index.tsx index 290fbc9ff..6e5d05e8d 100644 --- a/components/menu/index.tsx +++ b/components/top-bar/index.tsx @@ -2,7 +2,7 @@ import { Fragment, useState } from "react"; import { Menu, Transition } from "@headlessui/react"; import { CATEGORIES } from "components/front-page/PopularCategories"; -import MenuLogo from "components/menu/MenuLogo"; +import MenuLogo from "components/top-bar/MenuLogo"; import dynamic from "next/dynamic"; import Image from "next/image"; import Link from "next/link"; @@ -17,6 +17,7 @@ import { FiList, } from "react-icons/fi"; import { useCategoryCounts } from "lib/hooks/queries/useCategoryCounts"; +import { Alerts } from "./Alerts"; const AccountButton = dynamic(() => import("../account/AccountButton"), { ssr: false, @@ -158,7 +159,10 @@ const TopBar = () => {
Leaderboard
- +
+ + +
); diff --git a/components/menu/navigation-items.ts b/components/top-bar/navigation-items.ts similarity index 100% rename from components/menu/navigation-items.ts rename to components/top-bar/navigation-items.ts diff --git a/layouts/DefaultLayout.tsx b/layouts/DefaultLayout.tsx index 341cbec87..a1170d63d 100644 --- a/layouts/DefaultLayout.tsx +++ b/layouts/DefaultLayout.tsx @@ -3,7 +3,7 @@ import { useResizeDetector } from "react-resize-detector"; import Image from "next/image"; import dynamic from "next/dynamic"; -import TopBar from "components/menu"; +import TopBar from "components/top-bar"; import Footer from "components/ui/Footer"; import NotificationCenter from "components/ui/NotificationCenter"; import GrillChat from "components/grillchat"; diff --git a/lib/hooks/queries/useReadyToReportMarkets.ts b/lib/hooks/queries/useReadyToReportMarkets.ts new file mode 100644 index 000000000..01228fa97 --- /dev/null +++ b/lib/hooks/queries/useReadyToReportMarkets.ts @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { MarketStatus } from "@zeitgeistpm/indexer"; +import { isFullSdk } from "@zeitgeistpm/sdk-next"; +import { isNotNull } from "@zeitgeistpm/utility/dist/null"; +import { useSdkv2 } from "../useSdkv2"; +import { useChainTime } from "lib/state/chaintime"; + +export const useReadyToReportMarkets = (account?: string) => { + const [sdk, id] = useSdkv2(); + const chainTime = useChainTime(); + + const enabled = sdk && isFullSdk(sdk) && account && chainTime; + + return useQuery( + [id, "ready-to-report-markets", account], + async () => { + if (enabled) { + const closedMarketsForAccount = await sdk.indexer.markets({ + where: { + oracle_eq: account, + status_eq: MarketStatus.Closed, + }, + }); + + let readyToReportMarkets = ( + await Promise.all( + closedMarketsForAccount.markets.map(async (market) => { + const stage = await sdk.model.markets.getStage(market, chainTime); + if ( + stage.type === "OracleReportingPeriod" || + stage.type === "OpenReportingPeriod" + ) { + return market; + } + return null; + }), + ) + ).filter(isNotNull); + + return readyToReportMarkets; + } + }, + { + enabled: Boolean(enabled), + refetchInterval: 1000 * 60, + }, + ); +}; diff --git a/lib/hooks/queries/useRedeemableMarkets.ts b/lib/hooks/queries/useRedeemableMarkets.ts new file mode 100644 index 000000000..1f053da72 --- /dev/null +++ b/lib/hooks/queries/useRedeemableMarkets.ts @@ -0,0 +1,81 @@ +import { + IOMarketOutcomeAssetId, + MarketOutcomeAssetId, + getIndexOf, + getMarketIdOf, + isIndexedSdk, + parseAssetId, +} from "@zeitgeistpm/sdk-next"; +import { MarketStatus } from "@zeitgeistpm/indexer"; +import { useSdkv2 } from "../useSdkv2"; +import { useAccountTokenPositions } from "./useAccountTokenPositions"; +import { useQuery } from "@tanstack/react-query"; + +export const redeemableMarketsRootKey = "redeemable-markets"; + +export const useRedeemableMarkets = (account?: string) => { + const [sdk, id] = useSdkv2(); + const { data: tokenPositions } = useAccountTokenPositions(account); + + const enabled = sdk && account && tokenPositions && isIndexedSdk(sdk); + + return useQuery( + [ + id, + redeemableMarketsRootKey, + account, + tokenPositions + ?.map((p) => p.id) + .sort() + .join("|"), + ], + async () => { + if (enabled) { + if (!tokenPositions.length) return []; + + const outcomeAssetIds = tokenPositions + .map((tokenPosition) => + parseAssetId(tokenPosition.assetId).unwrapOr(null), + ) + .filter((assetId): assetId is MarketOutcomeAssetId => + IOMarketOutcomeAssetId.is(assetId), + ); + + const marketIds = outcomeAssetIds.map((assetId) => + getMarketIdOf(assetId), + ); + + const marketsResponse = await sdk.indexer.markets({ + where: { + marketId_in: marketIds, + status_eq: MarketStatus.Resolved, + }, + }); + + const redeemableMarkets = marketsResponse.markets.filter((market) => { + if (market.marketType.scalar) return true; + + const hasWinningPosition = + tokenPositions.filter((tokenPosition) => { + const assetId = parseAssetId(tokenPosition.assetId).unwrapOr( + null, + ); + if (assetId && IOMarketOutcomeAssetId.is(assetId)) { + return ( + market.marketId === getMarketIdOf(assetId) && + getIndexOf(assetId) === market.report?.outcome.categorical + ); + } + }).length > 0; + + return hasWinningPosition; + }); + + return redeemableMarkets; + } + }, + { + enabled: Boolean(enabled), + }, + ); +}; diff --git a/lib/state/alerts/index.ts b/lib/state/alerts/index.ts new file mode 100644 index 000000000..3d6637677 --- /dev/null +++ b/lib/state/alerts/index.ts @@ -0,0 +1,2 @@ +export * from "./useAlerts"; +export * from "./types"; diff --git a/lib/state/alerts/types.ts b/lib/state/alerts/types.ts new file mode 100644 index 000000000..5b7e56f9f --- /dev/null +++ b/lib/state/alerts/types.ts @@ -0,0 +1,70 @@ +import { FullMarketFragment } from "@zeitgeistpm/indexer"; +import Opaque, { create } from "ts-opaque"; + +/** + * Top level alert type. + */ +export type Alert = IAlert & AlertData; + +export type IAlert = { + /** + * Unique identifier for the alert. + * @note generated client side by `withId` function. + */ + id: AlertId; +}; + +/** + * Opaque type to ensure that the `id` field is a AlertId and only ever set by the `withId` function. + */ +export type AlertId = Opaque; + +/** + * Union type of all possible alert types. + */ +export type AlertData = { account: string; dismissible?: true } & ( + | ReadyToReportMarketAlertData + | RelevantMarketDisputeAlertData + | RedeemableMarketsAlertData +); + +export type ReadyToReportMarketAlertData = { + type: "ready-to-report-market"; + market: FullMarketFragment; +}; + +export type RelevantMarketDisputeAlertData = { + type: "market-dispute"; + market: FullMarketFragment; +}; + +export type RedeemableMarketsAlertData = { + type: "redeemable-markets"; + markets: FullMarketFragment[]; +}; + +/** + * Attach an id to an alert. + * + * @param alert AlertData + * @returns Alert + */ +export const withId = (alert: AlertData): Alert => { + switch (alert.type) { + case "ready-to-report-market": + return { + id: create(`${alert.account}-${alert.type}-${alert.market.marketId}`), + ...alert, + }; + case "market-dispute": + return { + id: create(`${alert.account}-${alert.type}-${alert.market.marketId}`), + ...alert, + }; + case "redeemable-markets": + return { + id: create(`${alert.account}-${alert.type}`), + ...alert, + }; + } +}; diff --git a/lib/state/alerts/useAlerts.ts b/lib/state/alerts/useAlerts.ts new file mode 100644 index 000000000..12802f9fe --- /dev/null +++ b/lib/state/alerts/useAlerts.ts @@ -0,0 +1,87 @@ +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { useReadyToReportMarkets } from "../../hooks/queries/useReadyToReportMarkets"; +import { useRedeemableMarkets } from "../../hooks/queries/useRedeemableMarkets"; +import { persistentAtom } from "../util/persistent-atom"; +import { Alert, AlertId, withId } from "./types"; + +export type UseAlerts = { + /** + * All alerts for the current account. + */ + alerts: Alert[]; + /** + * Set an alert as read. + * + * @note + * This is only possible for alerts that are marked as dismissible = true. + * Alerts should only be marked as dismissible if they are not time sensitive and has not user action that will result in them being + * automatically dismissed. Example: "You have markets ready to report" is not dismissible since reporting will remove them. + * Whereas something like a reminder, news or add should be dismissible. + * + * @param alert - the alert to set as read. + */ + setAsRead: (alert: Alert & { dismissible: true }) => void; +}; + +const readAlertsAtom = persistentAtom<{ read: AlertId[] }>({ + key: "read-alerts", + defaultValue: { read: [] }, + migrations: [], +}); + +export const useAlerts = (account?: string): UseAlerts => { + const { data: marketsReadyToReport } = useReadyToReportMarkets(account); + const { data: redeemableMarkets } = useRedeemableMarkets(account); + + const [readAlerts, setRead] = useAtom(readAlertsAtom); + + const alerts: Alert[] = useMemo(() => { + const alerts: Alert[] = []; + + if (!account) return alerts; + + const add = (alert: Alert) => { + if (!readAlerts.read.includes(alert.id)) { + alerts.push(alert); + } + }; + + if (redeemableMarkets && redeemableMarkets.length > 0) { + add( + withId({ + account, + markets: redeemableMarkets, + type: "redeemable-markets", + }), + ); + } + + if (marketsReadyToReport) { + marketsReadyToReport.forEach((market) => { + add( + withId({ + account, + market, + type: "ready-to-report-market", + }), + ); + }); + } + + return alerts; + }, [readAlerts, account, marketsReadyToReport, redeemableMarkets]); + + const setAsRead = (alert: Alert & { dismissible: true }) => { + if (!alert.dismissible) + return console.warn( + "Attempted to set a non-dismissible alert as read. Should not be reachable in the type system.", + ); + + setRead((prev) => ({ + read: [...prev.read, alert.id], + })); + }; + + return { alerts, setAsRead }; +}; diff --git a/package.json b/package.json index c6489e8a4..2a1b2dfa3 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "sharp": "^0.31.2", "styled-components": "^5.3.3", "toformat": "^2.0.0", + "ts-opaque": "^3.0.1", "uri-js": "^4.4.1", "use-debounce": "^7.0.1", "validatorjs": "^3.22.1", diff --git a/pages/_document.tsx b/pages/_document.tsx index 2bc9fc873..4fb8a1fe4 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -61,7 +61,7 @@ export default function Document() { content="@ZeitgeistPM" /> - +
diff --git a/styles/index.css b/styles/index.css index c967cda87..10f3848fe 100644 --- a/styles/index.css +++ b/styles/index.css @@ -293,6 +293,38 @@ tr td:first-child { height: 0; } +.subtle-scroll-bar::-webkit-scrollbar { + background-color: transparent; + width: 8px; + +} + +/* background of the scrollbar except button or resizer */ +.subtle-scroll-bar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 4px; +} + +/* scrollbar itself */ +.subtle-scroll-bar::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0, 0.7); + border-radius: 16px; + border: 4px solid inherit; +} + +/* set button(top and bottom of the scrollbar) */ +.subtle-scroll-bar::-webkit-scrollbar-button { + display:none; +} + +.subtle-scroll-bar.subtle-scroll-bar-on-hover::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0, 0.001); +} + +.subtle-scroll-bar.subtle-scroll-bar-on-hover:hover::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0, 0.7); +} + /* Skeleton */ .dark > * .MuiSkeleton-root { height: 1.2em; diff --git a/tailwind.config.js b/tailwind.config.js index dbdc46f18..6cfc26208 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -33,10 +33,15 @@ module.exports = { "0%": { transform: "scale(0.82)", opacity: "0" }, "100%": { transform: "scale(1)", opacity: "1" }, }, + "pulse-scale": { + "0%, 100%": { transform: "scale(1)", opacity: "0.8" }, + "50%": { transform: "scale(1.1)", opacity: "1" }, + }, ...defaultTheme.keyframes, }, animation: { "pop-in": "pop-in 0.4s cubic-bezier(.38,.39,.2,1.45) forwards", + "pulse-scale": "pulse-scale 1s ease-in-out infinite", ...defaultTheme.animation, }, fontFamily: { @@ -192,6 +197,7 @@ module.exports = { success: "#E8FFE4", info: "#E4F5FF", error: "#FFE6E4", + "ztg-gray": "#CFD7E4", "ztg-pink": "#DC056C", "tooltip-bg": "#FDF5DB", "maroon-base": "#3C0C0C", diff --git a/yarn.lock b/yarn.lock index 5e63c4da4..68b8b5ab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3273,6 +3273,7 @@ __metadata: tailwindcss: ^3.1.8 toformat: ^2.0.0 ts-node: ^10.9.1 + ts-opaque: ^3.0.1 ts-prune: ^0.10.3 typescript: ^5.0.4 uri-js: ^4.4.1 @@ -11701,6 +11702,13 @@ __metadata: languageName: node linkType: hard +"ts-opaque@npm:^3.0.1": + version: 3.0.1 + resolution: "ts-opaque@npm:3.0.1" + checksum: 146f71d08d5915ead4551dce310719c53ead762a3ca6219373beacf273c84a7988b7716e5f8b9d0e9013d87ed84bc9f1e794ecdc4ff14083723ceacd9899040c + languageName: node + linkType: hard + "ts-prune@npm:^0.10.3": version: 0.10.3 resolution: "ts-prune@npm:0.10.3"