diff --git a/components/UI/changeWallet.tsx b/components/UI/changeWallet.tsx index f8367aa8..f6eab20b 100644 --- a/components/UI/changeWallet.tsx +++ b/components/UI/changeWallet.tsx @@ -1,6 +1,6 @@ import React from "react"; import styles from "../../styles/components/wallets.module.css"; -import { Connector, useConnectors } from "@starknet-react/core"; +import { Connector, useConnect } from "@starknet-react/core"; import Button from "./button"; import { FunctionComponent } from "react"; import { Modal } from "@mui/material"; @@ -17,13 +17,13 @@ const ChangeWallet: FunctionComponent = ({ closeWallet, hasWallet, }) => { - const { connect, connectors } = useConnectors(); + const { connect, connectors } = useConnect(); const downloadLinks = useGetDiscoveryWallets( getDiscoveryWallets.getDiscoveryWallets() ); function connectWallet(connector: Connector): void { - connect(connector); + connect({ connector }); closeWallet(); } @@ -59,7 +59,9 @@ const ChangeWallet: FunctionComponent = ({ +

Notifications

+
+
+ {notifications.length > 0 ? ( + notifications.map((notification, index) => { + return ( + + ); + }) + ) : ( +

You don't have any notifications yet. Start some quests!

+ )} +
+
+ + ); +}; +export default ModalNotifications; diff --git a/components/UI/notifications/notificationDetail.tsx b/components/UI/notifications/notificationDetail.tsx new file mode 100644 index 00000000..a84a79e6 --- /dev/null +++ b/components/UI/notifications/notificationDetail.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from "react"; +import styles from "../../../styles/components/notifications.module.css"; +import { FunctionComponent } from "react"; +import DoneIcon from "../iconsComponents/icons/doneIcon"; +import theme from "../../../styles/theme"; +import CloseCircleIcon from "../iconsComponents/icons/closeCircleIcon"; +import { timeElapsed } from "../../../utils/timeService"; +import { + NotificationType, + TransactionType, + notificationLinkText, + notificationTitle, +} from "../../../constants/notifications"; +import { CircularProgress } from "@mui/material"; + +type NotificationDetailProps = { + notification: SQNotification; + isLastItem: boolean; +}; + +const NotificationDetail: FunctionComponent = ({ + notification, + isLastItem, +}) => { + const statusIcon = useMemo(() => { + if (notification.type === NotificationType.TRANSACTION) { + if (notification.data.status === "pending") { + return ; + } else if (notification.data.status === "error") { + return ; + } else { + return ; + } + } + }, [notification, notification.data?.status]); + + const externalUrl = useMemo(() => { + if (notification.type === NotificationType.TRANSACTION) { + return `https://${ + process.env.NEXT_PUBLIC_IS_TESTNET === "true" ? "testnet." : "" + }starkscan.co/tx/${notification.data.hash}`; + } + }, [notification]); + + const title = useMemo(() => { + if (notification.type === NotificationType.TRANSACTION) { + return notificationTitle[notification.data.type as TransactionType][ + notification.data.status + ]; + } + }, [notification, notification.data?.status]); + + return ( +
+
+ {statusIcon} +
{title}
+
+
{notification.subtext}
+
+
+ {timeElapsed(notification.timestamp)} +
+
window.open(externalUrl)} + > + {notificationLinkText[notification.type as NotificationType]} +
+
+ {!isLastItem ?
: null} +
+ ); +}; +export default NotificationDetail; diff --git a/components/UI/wallets.tsx b/components/UI/wallets.tsx index 46b8f63e..3fc026f3 100644 --- a/components/UI/wallets.tsx +++ b/components/UI/wallets.tsx @@ -1,6 +1,6 @@ import React from "react"; import styles from "../../styles/components/wallets.module.css"; -import { Connector, useAccount, useConnectors } from "@starknet-react/core"; +import { Connector, useAccount, useConnect } from "@starknet-react/core"; import Button from "./button"; import { FunctionComponent, useEffect } from "react"; import { Modal } from "@mui/material"; @@ -17,7 +17,7 @@ const Wallets: FunctionComponent = ({ closeWallet, hasWallet, }) => { - const { connect, connectors } = useConnectors(); + const { connect, connectors } = useConnect(); const { account } = useAccount(); const downloadLinks = useGetDiscoveryWallets( getDiscoveryWallets.getDiscoveryWallets() @@ -30,7 +30,7 @@ const Wallets: FunctionComponent = ({ }, [account, closeWallet]); function connectWallet(connector: Connector): void { - connect(connector); + connect({ connector }); closeWallet(); } @@ -66,7 +66,9 @@ const Wallets: FunctionComponent = ({
diff --git a/constants/notifications.ts b/constants/notifications.ts new file mode 100644 index 00000000..0ffe693c --- /dev/null +++ b/constants/notifications.ts @@ -0,0 +1,22 @@ +export enum NotificationType { + TRANSACTION = "TRANSACTION", +} + +export enum TransactionType { + MINT_NFT = "MINT_NFT", +} + +export const notificationTitle: Record< + TransactionType, + Record<"pending" | "success" | "error", string> +> = { + [TransactionType.MINT_NFT]: { + pending: "Transaction pending...", + success: "NFT received", + error: "Transaction failed", + }, +}; + +export const notificationLinkText: Record = { + [NotificationType.TRANSACTION]: "See transaction", +}; diff --git a/hooks/useNotificationManager.ts b/hooks/useNotificationManager.ts new file mode 100644 index 00000000..74910a60 --- /dev/null +++ b/hooks/useNotificationManager.ts @@ -0,0 +1,85 @@ +import { useAccount, useProvider } from "@starknet-react/core"; +import { useAtom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { useEffect } from "react"; +import { hexToDecimal } from "../utils/feltService"; +import { NotificationType } from "../constants/notifications"; + +const notificationsAtom = atomWithStorage[]>( + "userNotifications_SQ", + [] +); +const readStatusAtom = atomWithStorage( + "unreadNotifications_SQ", + false +); + +export function useNotificationManager() { + const { provider } = useProvider(); + const { address } = useAccount(); + const [notifications, setNotifications] = useAtom(notificationsAtom); + const [unreadNotifications, setUnread] = useAtom(readStatusAtom); + + useEffect(() => { + const intervalId = setInterval(() => { + notifications.forEach(checkTransactionStatus); + }, 5000); + + return () => clearInterval(intervalId); // Cleanup on component unmount + }, [notifications]); + + const checkTransactionStatus = async ( + notification: SQNotification, + index: number + ) => { + if (notification.type !== NotificationType.TRANSACTION) return; + if (notification.address !== hexToDecimal(address)) return; + if (notification.data.status === "pending") { + const transaction = notification.data; + const data = await provider.getTransactionReceipt(transaction.hash); + const updatedTransactions = [...notifications]; + + if (data?.status === "REJECTED" || data?.status === "REVERTED") { + updatedTransactions[index].data.status = "error"; + updatedTransactions[index].data.txStatus = "REJECTED"; + setNotifications(updatedTransactions); + setUnread(true); + } else if ( + data?.finality_status !== "NOT_RECEIVED" && + data?.finality_status !== "RECEIVED" + ) { + updatedTransactions[index].data.txStatus = data.finality_status; + updatedTransactions[index].data.status = "success"; + setNotifications(updatedTransactions); + setUnread(true); + } + } + }; + + const filteredNotifications = address + ? notifications.filter( + (notification) => notification.address === hexToDecimal(address) + ) + : []; + + const addTransaction = (notification: SQNotification) => { + setNotifications((prev) => [ + { ...notification, address: hexToDecimal(address) }, + ...prev, + ]); + setUnread(true); + }; + + const updateReadStatus = () => { + if (unreadNotifications) { + setUnread(false); + } + }; + + return { + notifications: filteredNotifications, + addTransaction, + unreadNotifications, + updateReadStatus, + }; +} diff --git a/package.json b/package.json index 5c3f36be..715800a3 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,15 @@ "@nimiq/style": "^0.8.5", "@react-three/drei": "^9.80.3", "@react-three/fiber": "^8.13.6", - "@starknet-react/core": "^1.0.3", + "@starknet-react/chains": "^0.1.0-next.0", + "@starknet-react/core": "^2.0.0-next.0", "@use-gesture/react": "^10.2.27", "@vercel/analytics": "^0.1.5", "big.js": "^6.2.1", "bn.js": "^5.2.1", "chart.js": "^4.3.0", - "get-starknet-core": "^3.1.0", + "get-starknet-core": "^3.2.0", + "jotai": "^2.5.0", "lottie-react": "^2.4.0", "maath": "^0.7.0", "mongodb": "^4.12.1", @@ -38,7 +40,7 @@ "react-intersection-observer": "^9.5.2", "react-loader-spinner": "^5.2.0", "react-use": "^17.4.0", - "starknet": "5.14.1", + "starknet": "^5.19.5", "starknetid.js": "^1.5.2", "three": "^0.155.0", "twitter-api-sdk": "^1.2.1" diff --git a/pages/[addressOrDomain].tsx b/pages/[addressOrDomain].tsx index 3b2bdedf..5ab363d6 100644 --- a/pages/[addressOrDomain].tsx +++ b/pages/[addressOrDomain].tsx @@ -1,9 +1,9 @@ -import React, { useContext, useEffect, useLayoutEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import type { NextPage } from "next"; import styles from "../styles/profile.module.css"; import { useRouter } from "next/router"; import { isHexString } from "../utils/stringService"; -import { Connector, useAccount, useConnectors } from "@starknet-react/core"; +import { useAccount } from "@starknet-react/core"; import { StarknetIdJsContext } from "../context/StarknetIdJsProvider"; import { hexToDecimal } from "../utils/feltService"; import { minifyAddress } from "../utils/stringService"; @@ -18,7 +18,6 @@ const AddressOrDomain: NextPage = () => { const router = useRouter(); const { addressOrDomain } = router.query; const { address } = useAccount(); - const { connectors, connect } = useConnectors(); const { starknetIdNavigator } = useContext(StarknetIdJsContext); const [initProfile, setInitProfile] = useState(false); const [identity, setIdentity] = useState(); @@ -32,38 +31,6 @@ const AddressOrDomain: NextPage = () => { useEffect(() => setNotFound(false), [dynamicRoute]); - useLayoutEffect(() => { - async function tryAutoConnect(connectors: Connector[]) { - const lastConnectedConnectorId = - localStorage.getItem("lastUsedConnector"); - if (lastConnectedConnectorId === null) { - return; - } - - const lastConnectedConnector = connectors.find( - (connector) => connector.id === lastConnectedConnectorId - ); - if (lastConnectedConnector === undefined) { - return; - } - - try { - if (!(await lastConnectedConnector.ready())) { - // Not authorized anymore. - return; - } - - await connect(lastConnectedConnector); - } catch { - // no-op - } - } - - if (!address) { - tryAutoConnect(connectors); - } - }, []); - useEffect(() => { setInitProfile(false); setAchievements([]); diff --git a/pages/_app.tsx b/pages/_app.tsx index 94131e33..f98d826b 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -4,19 +4,33 @@ import type { AppProps } from "next/app"; import Navbar from "../components/UI/navbar"; import Head from "next/head"; import { ThemeProvider } from "@mui/material"; -import { InjectedConnector, StarknetConfig } from "@starknet-react/core"; +import { + StarknetConfig, + alchemyProvider, + argent, + braavos, +} from "@starknet-react/core"; import { Analytics } from "@vercel/analytics/react"; import { StarknetIdJsProvider } from "../context/StarknetIdJsProvider"; import { createTheme } from "@mui/material/styles"; import Footer from "../components/UI/footer"; import { QuestsContextProvider } from "../context/QuestsProvider"; import { WebWalletConnector } from "@argent/starknet-react-webwallet-connector"; +import { goerli, mainnet } from "@starknet-react/chains"; function MyApp({ Component, pageProps }: AppProps) { + const chains = [ + process.env.NEXT_PUBLIC_IS_TESTNET === "true" ? goerli : mainnet, + ]; + const providers = [ + alchemyProvider({ + apiKey: process.env.NEXT_PUBLIC_ALCHEMY_KEY as string, + }), + ]; const connectors = useMemo( () => [ - new InjectedConnector({ options: { id: "braavos" } }), - new InjectedConnector({ options: { id: "argentX" } }), + braavos(), + argent(), new WebWalletConnector({ url: process.env.NEXT_PUBLIC_IS_TESTNET === "true" @@ -44,7 +58,12 @@ function MyApp({ Component, pageProps }: AppProps) { }); return ( - + diff --git a/pages/not-connected.tsx b/pages/not-connected.tsx index 543a21f9..cfb12464 100644 --- a/pages/not-connected.tsx +++ b/pages/not-connected.tsx @@ -1,5 +1,5 @@ import { NextPage } from "next"; -import { useAccount, useConnectors } from "@starknet-react/core"; +import { useAccount } from "@starknet-react/core"; import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; import Wallets from "../components/UI/wallets"; @@ -7,7 +7,6 @@ import ErrorScreen from "../components/UI/screens/errorScreen"; const NotConnected: NextPage = () => { const { address } = useAccount(); - const { available, connect } = useConnectors(); const { push } = useRouter(); const [hasWallet, setHasWallet] = useState(true); @@ -20,11 +19,7 @@ const NotConnected: NextPage = () => { connect(available[0]) - : () => setHasWallet(true) - } + onClick={() => setHasWallet(true)} /> setHasWallet(false)} hasWallet={hasWallet} /> diff --git a/styles/components/navbar.module.css b/styles/components/navbar.module.css index 8cd601a1..bac06786 100644 --- a/styles/components/navbar.module.css +++ b/styles/components/navbar.module.css @@ -59,12 +59,20 @@ color: var(--background); } +.buttonTextSection { + display: flex; + flex-direction: row; + gap: 6px; + align-items: center; + justify-content: space-around; + min-width: 124px; +} + .buttonText { margin-right: 32px; text-transform: capitalize; font-weight: bold; font-family: Sora-ExtraBold; - min-width: 124px; /* Body/middle/bold */ font-size: 18px; font-weight: 700; diff --git a/styles/components/notifications.module.css b/styles/components/notifications.module.css new file mode 100644 index 00000000..a79d0a81 --- /dev/null +++ b/styles/components/notifications.module.css @@ -0,0 +1,136 @@ +.menu { + position: absolute; + background-color: var(--background600); + border: 1px solid var(--secondary500); + align-self: center; + left: 50%; + top: 12vh; + transform: translate(-50%, 0); + border-radius: 8px; + padding: 24px 0; + width: 550px; + display: flex; + flex-direction: column; + z-index: 1000; + max-height: 490px; +} + +.blackFilter { + width: 100%; + height: 100%; + position: absolute; + background-color: rgba(0, 0, 0, 0.192); + z-index: auto; +} + +.menu_close { + position: absolute; + top: 12px; + right: 12px; + width: 22px; + stroke: rgb(101, 101, 101); + + cursor: pointer; + transition: 0.3s; + background-color: transparent; + border: none; +} + +.menu_close:hover { + stroke: white; +} + +.menu_title { + color: var(--secondary); + font-size: 21px; + font-weight: bold; + text-align: left; + padding-left: 24px; +} + +.menu_line { + height: 1px; + align-self: stretch; + background: var(--content-grey-medium, #AAB1B6); + margin-top: 24px; +} + +.notif_section { + padding: 24px 24px 0 24px; + overflow-y: scroll; +} + +.notif_detail { + display: flex; + flex-direction: column; + align-items: left; + gap: 6px; +} + +.notif_title { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 20px; + letter-spacing: 0.16px; +} + +.quest_name { + color: #FFF; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; +} + +.notif_info { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.notif_time { + color: #CDCCCC; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; +} + +.notif_line { + height: 1px; + align-self: stretch; + background: var(--content-grey-medium, #AAB1B6); + margin: 10px 0; +} + +.notif_link { + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 24px; + background: linear-gradient(90deg, var(--primary) -0.12%, var(--tertiary) 99.88%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + cursor: pointer; +} + +@media (max-width: 820px) { + .menu { + width: 90%; + padding: 30px; + } + + .menu_title { + font-size: 19px; + margin: -7px -2px 2px; + } +} diff --git a/tests/utils/timeService.test.js b/tests/utils/timeService.test.js new file mode 100644 index 00000000..16521e54 --- /dev/null +++ b/tests/utils/timeService.test.js @@ -0,0 +1,50 @@ +import { timeElapsed } from "../../utils/timeService"; + +describe("timeElapsed function", () => { + const seconds = 1000; + const minutes = 60 * seconds; + const hours = 60 * minutes; + const days = 24 * hours; + const weeks = 7 * days; + const months = 4.35 * weeks; // Average weeks in a month + const years = 12 * months; + + it("should return time in seconds format", () => { + const now = Date.now(); + expect(timeElapsed(now - 10 * seconds)).toBe("10 s ago"); + }); + + it("should return time in minutes format", () => { + const now = Date.now(); + expect(timeElapsed(now - 10 * minutes)).toBe("10 min. ago"); + }); + + it("should return time in hours format", () => { + const now = Date.now(); + expect(timeElapsed(now - 3 * hours)).toBe("3 hr. ago"); + }); + + it("should return 'yesterday' or days format", () => { + const now = Date.now(); + expect(timeElapsed(now - 1 * days)).toBe("yesterday"); + expect(timeElapsed(now - 3 * days)).toBe("3 days ago"); + }); + + it("should return 'last week' or weeks format", () => { + const now = Date.now(); + expect(timeElapsed(now - 1 * weeks)).toBe("last week"); + expect(timeElapsed(now - 3 * weeks)).toBe("3 weeks ago"); + }); + + it("should return 'last month' or months format", () => { + const now = Date.now(); + expect(timeElapsed(now - 1 * months)).toBe("last month"); + expect(timeElapsed(now - 3 * months)).toBe("3 months ago"); + }); + + it("should return 'last year' or years format", () => { + const now = Date.now(); + expect(timeElapsed(now - 1 * years)).toBe("last year"); + expect(timeElapsed(now - 3 * years)).toBe("3 years ago"); + }); + }); \ No newline at end of file diff --git a/types/frontTypes.d.ts b/types/frontTypes.d.ts index ea23156c..1ced3d1b 100644 --- a/types/frontTypes.d.ts +++ b/types/frontTypes.d.ts @@ -1,4 +1,4 @@ -type IconProps = { width: string; color?: string }; +type IconProps = { width: string; color?: string; secondColor?: string }; type Issuer = { name: string; @@ -184,3 +184,27 @@ type QuestCategory = { }; type LandTabs = "achievements" | "nfts"; + +// Here we can add more types of notifications +type NotificationData = TransactionData; + +type SQNotification = { + address?: string; // decimal address + timestamp: number; + subtext: string; + type: NotificationType; + data: T; +}; + +type TransactionData = { + type: TransactionType; + hash: string; + status: "pending" | "success" | "error"; + txStatus?: + | "NOT_RECEIVED" + | "RECEIVED" + | "ACCEPTED_ON_L2" + | "ACCEPTED_ON_L1" + | "REJECTED" + | "REVERTED"; +}; diff --git a/utils/timeService.ts b/utils/timeService.ts new file mode 100644 index 00000000..e5623a64 --- /dev/null +++ b/utils/timeService.ts @@ -0,0 +1,88 @@ +export function timeElapsed(timestamp: number) { + const now = Date.now(); + const differenceInSeconds = (now - timestamp) / 1000; + + const secondsInAMinute = 60; + const minutesInAnHour = 60; + const hoursInADay = 24; + const daysInAWeek = 7; + const weeksInAMonth = 4.35; // Average weeks in a month + const monthsInAYear = 12; + + if (differenceInSeconds < 60) { + return `${Math.round(differenceInSeconds)} s ago`; + } else if (differenceInSeconds < secondsInAMinute * minutesInAnHour) { + return `${Math.round(differenceInSeconds / secondsInAMinute)} min. ago`; + } else if ( + differenceInSeconds < + secondsInAMinute * minutesInAnHour * hoursInADay + ) { + const hoursElapsed = Math.round( + differenceInSeconds / (secondsInAMinute * minutesInAnHour) + ); + return `${hoursElapsed} hr. ago`; + } else if ( + differenceInSeconds < + secondsInAMinute * minutesInAnHour * hoursInADay * daysInAWeek + ) { + const daysElapsed = Math.round( + differenceInSeconds / (secondsInAMinute * minutesInAnHour * hoursInADay) + ); + if (daysElapsed === 1) return "yesterday"; + else return `${daysElapsed} days ago`; + } else if ( + differenceInSeconds < + secondsInAMinute * + minutesInAnHour * + hoursInADay * + daysInAWeek * + weeksInAMonth + ) { + const weeksElapsed = Math.round( + differenceInSeconds / + (secondsInAMinute * minutesInAnHour * hoursInADay * daysInAWeek) + ); + if (weeksElapsed === 1) return "last week"; + else return `${weeksElapsed} weeks ago`; + } else if ( + differenceInSeconds < + secondsInAMinute * + minutesInAnHour * + hoursInADay * + daysInAWeek * + weeksInAMonth * + monthsInAYear + ) { + const monthsElapsed = Math.round( + differenceInSeconds / + (secondsInAMinute * + minutesInAnHour * + hoursInADay * + daysInAWeek * + weeksInAMonth) + ); + if (monthsElapsed === 1) return "last month"; + else return `${monthsElapsed} months ago`; + } else { + const yearsElapsed = Math.round( + differenceInSeconds / + (secondsInAMinute * + minutesInAnHour * + hoursInADay * + daysInAWeek * + weeksInAMonth * + monthsInAYear) + ); + if (yearsElapsed === 1) return "last year"; + else + return `${Math.round( + differenceInSeconds / + (secondsInAMinute * + minutesInAnHour * + hoursInADay * + daysInAWeek * + weeksInAMonth * + monthsInAYear) + )} years ago`; + } +}