diff --git a/src/common/balances/BalanceProvider.tsx b/src/common/balances/BalanceProvider.tsx index 6a3137ad..934a332f 100644 --- a/src/common/balances/BalanceProvider.tsx +++ b/src/common/balances/BalanceProvider.tsx @@ -1,7 +1,14 @@ import { createContext, PropsWithChildren, useContext, useRef } from 'react'; import { useChainRegistry } from '@common/chainRegistry'; import { IAssetBalance } from '@common/balances/types'; -import { chainAssetAccountIdToString, ChainAssetAccount } from '@common/types'; +import { + chainAssetAccountIdToString, + ChainAssetAccount, + ChainId, + Gift, + PersistentGift, + GiftStatus, +} from '@common/types'; import { useNumId } from '@common/utils/NumId'; import { SubscriptionState } from '@common/subscription/types'; import { createBalanceService } from '@common/balances/BalanceService'; @@ -14,6 +21,7 @@ type UpdaterCallbackStore = Record; type BalanceProviderContextProps = { subscribeBalance: (account: ChainAssetAccount, onUpdate: UpdateCallback) => number; unsubscribeBalance: (unsubscribeId: number) => void; + getGiftsState: (accounts: Gift[], chainId: ChainId) => Promise<[Gift[], Gift[]]>; }; const BalanceProviderContext = createContext({} as BalanceProviderContextProps); @@ -152,8 +160,29 @@ export const BalanceProvider = ({ children }: PropsWithChildren) => { } } + async function getGiftsState(accounts: PersistentGift[], chainId: ChainId): Promise<[Gift[], Gift[]]> { + const connection = await getConnection(chainId); + const balances = await connection.api.query.system.account.multi(accounts.map((i) => i.address)); + const chain = await getChain(chainId); + + const unclaimed = [] as Gift[]; + const claimed = [] as Gift[]; + + balances.forEach((d, idx) => + d.data.free.isEmpty + ? claimed.push({ + ...accounts[idx], + chainAsset: chain?.assets[0], + status: GiftStatus.CLAIMED, + }) + : unclaimed.push({ ...accounts[idx], chainAsset: chain?.assets[0], status: GiftStatus.UNCLAIMED }), + ); + + return [unclaimed, claimed]; + } + return ( - + {children} ); diff --git a/src/common/routing/paths.ts b/src/common/routing/paths.ts index 9fd0a772..6f756183 100644 --- a/src/common/routing/paths.ts +++ b/src/common/routing/paths.ts @@ -8,6 +8,9 @@ export const Paths = { DASHBOARD: '/dashboard', + GIFTS: '/gifts', + GIFT_DETAILS: '/gifts/gift-details', + TRANSFER: '/transfer', TRANSFER_SELECT_TOKEN: '/transfer/select-token', TRANSFER_ADDRESS: '/transfer/address', diff --git a/src/common/types/index.ts b/src/common/types/index.ts index a39746d8..b1cee702 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -1,3 +1,4 @@ +import { Asset } from './../chainRegistry/types/index'; export type HexString = `0x${string}`; import { u8aToHex, hexToU8a } from '@polkadot/util'; @@ -42,6 +43,24 @@ export type TrasferAsset = AssetAccount & { }; export type StateResolution = { resolve: (value: T) => void; reject: () => void }; +export const enum GiftStatus { + CLAIMED = 'Claimed', + UNCLAIMED = 'Unclaimed', +} + +export type PersistentGift = { + timestamp: number; + address: string; + secret: string; + balance: string; + chainId: ChainId; + status: GiftStatus; +}; + +export type Gift = PersistentGift & { + chainAsset?: Asset; +}; + export function chainAssetIdToString(value: ChainAssetId): string { return `${value.chainId} - ${value.assetId}`; } diff --git a/src/common/utils/gift.ts b/src/common/utils/gift.ts new file mode 100644 index 00000000..6d7a696e --- /dev/null +++ b/src/common/utils/gift.ts @@ -0,0 +1,22 @@ +import secureLocalStorage from 'react-secure-storage'; +import { ChainId, PersistentGift } from '../types'; + +export const backupGifts = (address: string, secret: string, chainId: ChainId, balance: string): void => { + const gift = { timestamp: Date.now(), address, secret, chainId, balance }; + const storedGifts = secureLocalStorage.getItem('GIFT_STORE') as string; + const backup = storedGifts ? [...JSON.parse(storedGifts), gift] : [gift]; + + secureLocalStorage.setItem('GIFT_STORE', JSON.stringify(backup)); +}; + +export const getGifts = (): Map | null => { + const gifts = JSON.parse(secureLocalStorage.getItem('GIFT_STORE') as string); + if (!gifts) return null; + + const map = new Map(); + gifts.forEach((gift: PersistentGift) => { + map.set(gift.chainId, [...(map.get(gift.chainId) || []), gift]); + }); + + return map; +}; diff --git a/src/components/GiftPlate/GiftPlate.tsx b/src/components/GiftPlate/GiftPlate.tsx new file mode 100644 index 00000000..c05dca6c --- /dev/null +++ b/src/components/GiftPlate/GiftPlate.tsx @@ -0,0 +1,26 @@ +import { CaptionText, HelpText, Icon, Plate } from '@/components'; +import { Gift, GiftStatus } from '@/common/types'; + +const GiftPlate = ({ gift }: { gift: Gift }) => { + const date = new Date(gift.timestamp).toLocaleString(); + + return ( + + +
+ + {gift.balance} {gift.chainAsset?.symbol} + + + Created: {date} + + + {gift.status} + +
+ {gift.status === GiftStatus.UNCLAIMED && } +
+ ); +}; + +export default GiftPlate; diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 1b003a53..60104c92 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -1,12 +1,15 @@ import { PropsWithChildren } from 'react'; -import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; import { ChainRegistry } from '@/common/chainRegistry'; +import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { BalanceProvider } from '@/common/balances/BalanceProvider'; export default function Layout({ children }: PropsWithChildren) { return ( -
{children}
+ +
{children}
+
); diff --git a/src/components/index.ts b/src/components/index.ts index 18a06434..02aff5a5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -18,6 +18,7 @@ import PasswordForm from './PasswordForm/PasswordForm'; import Shimmering from './Shimmering/Shimmering'; import Identicon from './Identicon/Identicon'; import Layout from './Layout/Layout'; +import GiftPlate from './GiftPlate/GiftPlate'; export { TextBase, @@ -39,4 +40,5 @@ export { Shimmering, Identicon, Layout, + GiftPlate, }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index b5c9534e..96ba2731 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,10 +1,10 @@ import { AppProps } from 'next/app'; +import { NextPage } from 'next'; import { ReactElement, ReactNode, useEffect, useState } from 'react'; import { NextUIProvider } from '@nextui-org/react'; import { TelegramProvider } from '@common/providers/telegramProvider'; import { GlobalStateProvider } from '@/common/providers/contextProvider'; import './globals.css'; -import { NextPage } from 'next'; export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode; diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 71ff3bfb..f966d548 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -1,16 +1,11 @@ -import { BalanceProvider } from '@/common/balances/BalanceProvider'; -import { ChainRegistry } from '@/common/chainRegistry'; -import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { ReactElement } from 'react'; +import { Layout } from '@/components'; import { DashboardMain } from '@/screens/dashboard'; export default function DashboardMainPage() { - return ( - - - - - - - - ); + return ; } + +DashboardMainPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/src/pages/gifts/gift-details.tsx b/src/pages/gifts/gift-details.tsx new file mode 100644 index 00000000..6ce2bd23 --- /dev/null +++ b/src/pages/gifts/gift-details.tsx @@ -0,0 +1,42 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { WebApp } from '@twa-dev/types'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { Paths } from '@/common/routing'; +import { createTgLink } from '@/common/telegram'; +import GiftDetails from '@/screens/gifts/GiftDetails'; +import { TgLink } from '@/common/telegram/types'; +import Image from 'next/image'; + +export default function GiftPage() { + const router = useRouter(); + const { BackButton, MainButton, webApp } = useTelegram(); + const [link, setLink] = useState(null); + const searchParams = router.query; + + useEffect(() => { + BackButton?.show(); + MainButton?.hide(); + + const callback = async () => { + router.push(Paths.GIFTS); + }; + BackButton?.onClick(callback); + setLink(createTgLink(searchParams.seed as string, searchParams.symbol as string)); + + return () => { + BackButton?.hide(); + BackButton?.offClick(callback); + }; + }, []); + + // TODO change image + return ( +

+ gift + +
+ ); +} diff --git a/src/pages/gifts/index.tsx b/src/pages/gifts/index.tsx new file mode 100644 index 00000000..28f19e9c --- /dev/null +++ b/src/pages/gifts/index.tsx @@ -0,0 +1,78 @@ +'use client'; +import { ReactElement, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { Paths } from '@/common/routing'; +import { BodyText, GiftPlate, Layout, Shimmering, TitleText } from '@/components'; +import { useBalances } from '@/common/balances/BalanceProvider'; +import { getGifts } from '@/common/utils/gift'; +import { Gift } from '@/common/types'; + +// TODO improve loading state for unclaimed and claimed +export default function GiftPage() { + const router = useRouter(); + const { BackButton, MainButton } = useTelegram(); + const { getGiftsState } = useBalances(); + const [unclaimedGifts, setUnclaimedGifts] = useState([]); + const [claimedGifts, setClaimedGifts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + BackButton?.show(); + MainButton?.hide(); + + const callback = async () => { + router.push(Paths.DASHBOARD); + }; + BackButton?.onClick(callback); + + const mapGifts = getGifts(); + if (!mapGifts) return; + + mapGifts.forEach((value, key) => { + (async function () { + const [unclaimed, claimed] = await getGiftsState(value, key); + setUnclaimedGifts((prev) => [...prev, ...unclaimed]); + setClaimedGifts((prev) => [...prev, ...claimed]); + setLoading(false); + })(); + }); + + return () => { + BackButton?.hide(); + BackButton?.offClick(callback); + }; + }, []); + + return ( + <> + + Gifts + + + Unclaimed + + {loading && } + {!!unclaimedGifts.length && + unclaimedGifts.map((gift) => ( + + + + ))} + + Claimed + + {loading && } + {!!claimedGifts.length && claimedGifts.map((gift) => )} + + ); +} + +GiftPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4b948bdb..87ca30d8 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,8 +1,9 @@ import React, { useEffect } from 'react'; +import { useGlobalContext } from '@/common/providers/contextProvider'; import { getWallet } from '@common/wallet'; +import { Layout } from '@/components'; import OnboardingStartPage from './onboarding'; import DashboardMainPage from './dashboard'; -import { useGlobalContext } from '@/common/providers/contextProvider'; export default function App() { const { setPublicKey } = useGlobalContext(); @@ -12,5 +13,11 @@ export default function App() { setPublicKey(wallet?.publicKey); }, []); - return wallet ? : ; + return wallet ? ( + + + + ) : ( + + ); } diff --git a/src/pages/transfer/amount.tsx b/src/pages/transfer/amount.tsx index 103e249a..5927612d 100644 --- a/src/pages/transfer/amount.tsx +++ b/src/pages/transfer/amount.tsx @@ -66,12 +66,12 @@ export default function AmountPage() { }, [BackButton, MainButton]); useEffect(() => { + if (!isAmountValid || !Number(amount)) return; + const callback = () => { setSelectedAsset((prev) => ({ ...prev!, transferAll, amount })); router.push(selectedAsset?.isGift ? Paths.TRANSFER_CREATE_GIFT : Paths.TRANSFER_CONFIRMATION); }; - - if (!isAmountValid || !Number(amount)) return; MainButton?.enable(); MainButton?.onClick(callback); diff --git a/src/pages/transfer/create-gift.tsx b/src/pages/transfer/create-gift.tsx index 32b9b550..d7329791 100644 --- a/src/pages/transfer/create-gift.tsx +++ b/src/pages/transfer/create-gift.tsx @@ -2,19 +2,20 @@ import { ReactElement, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { Player } from '@lottiefiles/react-lottie-player'; -import { Button } from '@nextui-org/react'; import { WebApp } from '@twa-dev/types'; import { useTelegram } from '@common/providers/telegramProvider'; import { useGlobalContext } from '@/common/providers/contextProvider'; import { Paths } from '@/common/routing'; -import { BodyText, HeadlineText, Layout } from '@/components'; +import { HeadlineText, Layout } from '@/components'; +import GiftDetails from '@/screens/gifts/GiftDetails'; import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; import { handleSend } from '@/common/utils/balance'; -import { TrasferAsset } from '@/common/types'; +import { ChainId, TrasferAsset } from '@/common/types'; import { createGiftWallet } from '@/common/wallet'; -import { createTgLink, navigateTranferById } from '@/common/telegram'; +import { createTgLink } from '@/common/telegram'; import { TgLink } from '@/common/telegram/types'; +import { backupGifts } from '@/common/utils/gift'; export default function CreateGiftPage() { const router = useRouter(); @@ -38,6 +39,7 @@ export default function CreateGiftPage() { const wallet = createGiftWallet(selectedAsset.addressPrefix as number); (async function () { await handleSend(submitExtrinsic, selectedAsset as TrasferAsset, wallet.address).then(() => { + backupGifts(wallet.address, wallet.secret, selectedAsset.chainId as ChainId, selectedAsset.amount as string); setLink(createTgLink(wallet.secret, selectedAsset.symbol as string)); setLoading(false); MainButton?.show(); @@ -59,18 +61,7 @@ export default function CreateGiftPage() { Creating your gift.. ) : ( - <> - - - Now you can send this link anyone who you needed to claim funds. When they will open it, the gift will - marked as claimed - - - + )} ); diff --git a/src/screens/dashboard/main/DashboardMain.tsx b/src/screens/dashboard/main/DashboardMain.tsx index 4f1d3e59..249bfefa 100644 --- a/src/screens/dashboard/main/DashboardMain.tsx +++ b/src/screens/dashboard/main/DashboardMain.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useRouter } from 'next/router'; import { encodeAddress } from '@polkadot/util-crypto'; -import { Avatar } from '@nextui-org/react'; +import { Avatar, Button } from '@nextui-org/react'; import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; import { useGlobalContext } from '@/common/providers/contextProvider'; @@ -29,18 +29,19 @@ export const DashboardMain = () => { return; } const [seed, symbol] = webApp.initDataUnsafe.start_param.split('_'); - (async () => { const chain = await getAssetBySymbol(symbol); const address = encodeAddress(publicKey, chain.chain.addressPrefix); claimGift(seed, address, chain.chain.chainId, submitExtrinsic) .then(() => { - setIsGiftClaimed(true); alert('Gift claimed successfully!'); }) .catch(() => { alert('Failed to claim gift'); + }) + .finally(() => { + setIsGiftClaimed(true); }); })(); }, [webApp, publicKey, isGiftClaimed]); @@ -73,7 +74,6 @@ export const DashboardMain = () => { subscribeBalance(account, (balance: IAssetBalance) => { console.info(`${address} ${chain.name} => balance: ${balance.total().toString()}`); - setAssets((prevAssets) => updateAssetsBalance(prevAssets, chain, balance)); }); } @@ -88,7 +88,7 @@ export const DashboardMain = () => { } return ( -
+
Hello, {user?.first_name || 'friend'} @@ -103,7 +103,15 @@ export const DashboardMain = () => { router.push(Paths.TRANSFER)} />
- + + Assets diff --git a/src/screens/gifts/GiftDetails.tsx b/src/screens/gifts/GiftDetails.tsx new file mode 100644 index 00000000..8fbd17b2 --- /dev/null +++ b/src/screens/gifts/GiftDetails.tsx @@ -0,0 +1,30 @@ +import { Button, Snippet } from '@nextui-org/react'; +import { WebApp } from '@twa-dev/types'; + +import { BodyText } from '@/components'; +import { navigateTranferById } from '@/common/telegram'; +import { TgLink } from '@/common/telegram/types'; + +type GiftDetailsProps = { + link: TgLink | null; + webApp: WebApp; +}; + +export default function GiftDetails({ link, webApp }: GiftDetailsProps) { + if (!link) return; + + return ( + <> + + {link.url} + + + Now you can send this link anyone who you needed to claim funds. When they will open it, the gift will marked as + claimed + + + + ); +} diff --git a/tsconfig.json b/tsconfig.json index a48fc7e8..fe99d7e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ESNext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,