diff --git a/apps/demo-wallet/src/App.css b/apps/demo-wallet/src/App.css index 1d8be7bdb..976b7133c 100644 --- a/apps/demo-wallet/src/App.css +++ b/apps/demo-wallet/src/App.css @@ -51,6 +51,38 @@ } } +/* Loading skeleton shimmer. A gray band sweeps left→right across a gray base. + Stays within the app's gray palette (gray-100 base, gray-200 highlight). */ +@keyframes skeleton-shimmer { + 0% { + background-position: 150% 0; + } + 100% { + background-position: -50% 0; + } +} + +.skeleton-shimmer { + background-color: #f3f4f6; /* gray-100 — matches existing animate-pulse skeletons */ + background-image: linear-gradient( + 90deg, + rgba(229, 231, 235, 0) 0%, + rgba(229, 231, 235, 0.9) 50%, + rgba(229, 231, 235, 0) 100% + ); /* gray-200 highlight band */ + background-size: 200% 100%; + background-repeat: no-repeat; + animation: skeleton-shimmer 1.4s ease-in-out infinite; +} + +/* Respect reduced-motion: fall back to a gentle static gray (no sweep). */ +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + background-image: none; + animation: none; + } +} + .card { padding: 2em; } diff --git a/apps/demo-wallet/src/core/components/ui/skeleton/index.ts b/apps/demo-wallet/src/core/components/ui/skeleton/index.ts new file mode 100644 index 000000000..4b5fe132c --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/skeleton/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './skeleton'; diff --git a/apps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsx b/apps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsx new file mode 100644 index 000000000..be3ea7356 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC, HTMLAttributes } from 'react'; + +import { cn } from '@/core/lib/utils'; + +/** + * Reusable animated skeleton placeholder. A gray band sweeps across a gray base + * (see the `.skeleton-shimmer` rule in App.css) — within the app's gray palette. + * Size and shape come from the passed className (width/height/rounding). + * + * @example + */ +export const Skeleton: FC> = ({ className, ...props }) => ( +
+); diff --git a/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx b/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx index a6877f0f5..cb61f9abb 100644 --- a/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx +++ b/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { Skeleton } from '@/core/components/ui/skeleton'; import { useCountUp } from '@/core/hooks/use-count-up'; import { formatLargeValue } from '@/core/utils'; @@ -63,16 +64,17 @@ export const AssetRow: React.FC = ({ icon, fallbackText, name, sym ); }; +/** Loading placeholder mirroring {@link AssetRow}: circular icon, name/amount lines, right-side value. */ export const AssetRowSkeleton: React.FC = () => (
- +
-
-
+ +
-
-
+ +
); diff --git a/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx b/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx index 339b04fa2..79d41f7f9 100644 --- a/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx +++ b/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx @@ -8,6 +8,7 @@ import type { FC } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useJettons } from '@demo/wallet-core'; import { AssetRow, AssetRowSkeleton } from '../asset-row'; import { useAssetRows } from '../../hooks/use-asset-rows'; @@ -15,29 +16,38 @@ import { useAssetRows } from '../../hooks/use-asset-rows'; import { NewLayout } from '@/core/components/shared/new-layout'; import { ScreenHeader } from '@/core/components/shared/screen-header'; -/** Full assets page: every token on the active wallet's balance (TON + all jettons). */ +/** + * Full assets page: Gram + every held jetton, plus (on mainnet) the base tokens at a zero + * balance when not held. Same list the dashboard preview shows the first N of — see + * {@link useAssetRows}. + */ export const AssetsScreen: FC = () => { const navigate = useNavigate(); + const { userJettons, isLoadingJettons, lastJettonsUpdate, error } = useJettons(); const { tonRow, jettonRows, assetsReady } = useAssetRows(); + // A jettons fetch that has never succeeded and failed → short error line (not an endless + // shimmer). Once jettons have loaded once, a transient refresh error won't show the error. + const isError = lastJettonsUpdate === 0 && userJettons.length === 0 && error !== null && !isLoadingJettons; + return ( navigate('/wallet')} />}> -
- {assetsReady && tonRow ? ( - <> - - {jettonRows.map((row) => ( - - ))} - - ) : ( - <> - - - - - )} -
+ {assetsReady && tonRow ? ( +
+ + {jettonRows.map((row) => ( + + ))} +
+ ) : isError ? ( +

Unable to load assets

+ ) : ( +
+ + + +
+ )}
); }; diff --git a/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts b/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts index afb9a461a..dbf550fce 100644 --- a/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts +++ b/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts @@ -6,8 +6,9 @@ * */ -import { useMemo } from 'react'; -import { useJettons, useRates, useWallet } from '@demo/wallet-core'; +import { useEffect, useMemo, useState } from 'react'; +import { useJettons, useRates, useWallet, useWalletKit } from '@demo/wallet-core'; +import type { JettonInfo } from '@ton/walletkit'; import type { AssetRowData } from '../components/asset-row'; @@ -16,6 +17,18 @@ import { findRate, formatRate, toDecimal, tokenImageUrls } from '@/core/utils'; const GRAM_DECIMALS = 9; +/** + * Base tokens always surfaced on mainnet (for discoverability) even at a zero balance, so a + * fresh wallet shows Gram + these below the native row. Held balances always win: if the user + * holds one of these, its live row (with the real balance) replaces the zero-balance base row. + * The hardcoded name/symbol are only a fallback shown until live metadata loads. Addresses are + * the mainnet jetton masters and MUST match the on-chain contracts. + */ +const DEFAULT_JETTONS: { address: string; symbol: string; name: string }[] = [ + { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', symbol: 'USDT', name: 'Tether USD' }, + { address: 'EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k', symbol: 'XAUT', name: 'Tether Gold' }, +]; + /** Candidate icon URLs (best-first), appending the inline base64 image as a last resort. */ export const imageSources = (urls: string[] | undefined, dataBase64?: string): string[] => [ ...(urls ?? []), @@ -24,19 +37,52 @@ export const imageSources = (urls: string[] | undefined, dataBase64?: string): s interface AssetRows { tonRow: AssetRowData | null; - /** All held jettons as rows, sorted by fiat value desc (verified first as a tiebreaker). */ + /** + * Jetton rows sorted by fiat value desc (verified first as a tiebreaker): every held jetton, + * plus — on mainnet — the {@link DEFAULT_JETTONS} base tokens at a zero balance when not held. + */ jettonRows: AssetRowData[]; assetsReady: boolean; } -/** Builds the TON row + a row per held jetton. Shared by the dashboard preview and the full assets page. */ +/** + * Builds the TON row + a row per jetton. Shared by the dashboard preview and the full assets + * page, so both surfaces show the SAME list (the preview renders its first N). On mainnet the + * {@link DEFAULT_JETTONS} base tokens are always included (at a zero balance if not held) for + * discoverability; held jettons are merged in and win on any address collision. + */ export const useAssetRows = (): AssetRows => { - const { balance } = useWallet(); + const { balance, currentWallet, getActiveWallet } = useWallet(); const { userJettons, lastJettonsUpdate } = useJettons(); const { entries: rates, lastUpdated: ratesUpdated } = useRates(); + const walletKit = useWalletKit(); const assetsReady = balance !== undefined && lastJettonsUpdate > 0 && ratesUpdated > 0; + const isMainnet = getActiveWallet()?.network === 'mainnet'; + + // Live metadata (name/icon) for the base tokens, fetched from the API — mainnet only. Until + // it arrives the base rows fall back to the hardcoded def name/symbol. + const [defaultInfos, setDefaultInfos] = useState>({}); + useEffect(() => { + if (!isMainnet || !walletKit || !currentWallet) return; + const network = currentWallet.getNetwork(); + let cancelled = false; + void Promise.all( + DEFAULT_JETTONS.map((def) => walletKit.jettons.getJettonInfo(def.address, network).catch(() => null)), + ).then((infos) => { + if (cancelled) return; + const next: Record = {}; + infos.forEach((info, i) => { + if (info) next[DEFAULT_JETTONS[i].address] = info; + }); + setDefaultInfos(next); + }); + return () => { + cancelled = true; + }; + }, [isMainnet, walletKit, currentWallet]); + const tonRow = useMemo(() => { if (!assetsReady) return null; const rateEntry = rates['GRAM']; @@ -55,29 +101,59 @@ export const useAssetRows = (): AssetRows => { const jettonRows = useMemo(() => { if (!assetsReady) return []; - return userJettons - .map((jetton) => { - const rateEntry = findRate(rates, jetton.address); - const decimals = jetton.decimalsNumber ?? 9; - const amount = toDecimal(jetton.balance, decimals); - const symbol = getJettonsSymbol(jetton) ?? ''; - return { + + const held = userJettons.map((jetton) => { + const rateEntry = findRate(rates, jetton.address); + const decimals = jetton.decimalsNumber ?? 9; + const amount = toDecimal(jetton.balance, decimals); + const symbol = getJettonsSymbol(jetton) ?? ''; + return { + row: { + id: jetton.address, + icon: imageSources(tokenImageUrls(jetton.info?.image), jetton.info?.image?.data), + fallbackText: symbol.slice(0, 2).toUpperCase() || '??', + name: getJettonsName(jetton) ?? symbol, + symbol, + amount, + rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, + fiat: rateEntry ? amount * rateEntry.rate : undefined, + } satisfies AssetRowData, + isVerified: jetton.isVerified, + }; + }); + + // Mainnet: add each base token the wallet doesn't already hold as a zero-balance row + // (dedupe against held jettons by address; held wins so its real balance is kept). + const combined = [...held]; + if (isMainnet) { + const heldByAddress = new Map(userJettons.map((jetton) => [jetton.address, jetton])); + for (const def of DEFAULT_JETTONS) { + if (heldByAddress.has(def.address)) continue; + const info = defaultInfos[def.address]; + const rateEntry = findRate(rates, def.address); + const amount = 0; + combined.push({ row: { - id: jetton.address, - icon: imageSources(tokenImageUrls(jetton.info?.image), jetton.info?.image?.data), - fallbackText: symbol.slice(0, 2).toUpperCase() || '??', - name: getJettonsName(jetton) ?? symbol, - symbol, + id: def.address, + icon: imageSources(info?.image ? [info.image] : undefined, info?.image_data), + fallbackText: def.symbol.slice(0, 2).toUpperCase(), + name: info?.name || def.name, + symbol: info?.symbol || def.symbol, amount, rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, fiat: rateEntry ? amount * rateEntry.rate : undefined, } satisfies AssetRowData, - isVerified: jetton.isVerified, - }; - }) + // Canonical mainnet tokens are verified: keeps them above unverified junk in + // the sort, but below any held token that has a fiat value. + isVerified: true, + }); + } + } + + return combined .sort((a, b) => (b.row.fiat ?? 0) - (a.row.fiat ?? 0) || Number(b.isVerified) - Number(a.isVerified)) .map((entry) => entry.row); - }, [assetsReady, userJettons, rates]); + }, [assetsReady, userJettons, rates, isMainnet, defaultInfos]); return { tonRow, jettonRows, assetsReady }; }; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx index c004f49da..ab0168002 100644 --- a/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx @@ -6,90 +6,39 @@ * */ -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; import { ChevronRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { useJettons, useRates, useWallet, useWalletKit } from '@demo/wallet-core'; -import type { JettonInfo } from '@ton/walletkit'; +import { useJettons } from '@demo/wallet-core'; -import { AssetRow, AssetRowSkeleton, imageSources, useAssetRows } from '@/features/assets'; -import type { AssetRowData } from '@/features/assets'; -import { getJettonsName, getJettonsSymbol } from '@/features/jettons'; -import { findRate, formatRate, toDecimal, tokenImageUrls } from '@/core/utils'; +import { AssetRow, AssetRowSkeleton, useAssetRows } from '@/features/assets'; +// How many jettons the dashboard preview shows below the Gram row. The full Assets page shows +// the whole list; the preview shows the first N of that SAME list (see note below). const JETTON_SLOTS = 2; -// Always-shown fallback jettons (used to pad the preview to 3 assets when the user -// holds fewer). Metadata is used only when the token isn't in the wallet. -const DEFAULT_JETTONS: { address: string; symbol: string; name: string }[] = [ - { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', symbol: 'USDT', name: 'Tether USD' }, - { address: 'EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k', symbol: 'XAUT', name: 'Tether Gold' }, -]; - +/** + * Dashboard "Assets" block. Shows the Gram row plus the first {@link JETTON_SLOTS} jettons from + * {@link useAssetRows} — the first N of the exact same list the full Assets page renders, so the + * two surfaces are consistent. That shared list includes the held jettons plus, on mainnet, the + * base tokens (USDT/XAUt) at a zero balance when not held, so a new mainnet wallet shows Gram + + * those on both surfaces. On a jettons load error the block is hidden rather than shimmering + * forever; while loading it shows shimmer rows. + */ export const DashboardAssets: React.FC = () => { const navigate = useNavigate(); - const { currentWallet, getActiveWallet } = useWallet(); - const { userJettons } = useJettons(); - const { entries: rates } = useRates(); - const walletKit = useWalletKit(); + const { userJettons, isLoadingJettons, lastJettonsUpdate, error } = useJettons(); const { tonRow, jettonRows, assetsReady } = useAssetRows(); - const isMainnet = getActiveWallet()?.network === 'mainnet'; - - // Fetch metadata (name/icon) for the default tokens from the API — mainnet only. - const [defaultInfos, setDefaultInfos] = useState>({}); - useEffect(() => { - if (!isMainnet || !walletKit || !currentWallet) return; - const network = currentWallet.getNetwork(); - let cancelled = false; - void Promise.all( - DEFAULT_JETTONS.map((def) => walletKit.jettons.getJettonInfo(def.address, network).catch(() => null)), - ).then((infos) => { - if (cancelled) return; - const next: Record = {}; - infos.forEach((info, i) => { - if (info) next[DEFAULT_JETTONS[i].address] = info; - }); - setDefaultInfos(next); - }); - return () => { - cancelled = true; - }; - }, [isMainnet, walletKit, currentWallet]); - - // Preview: top JETTON_SLOTS held jettons, padded with default tokens (USDT/XAUT) on mainnet. - const selected = useMemo(() => { - const base = jettonRows.slice(0, JETTON_SLOTS); - if (!isMainnet || base.length >= JETTON_SLOTS) return base; - - const heldByAddress = new Map(userJettons.map((jetton) => [jetton.address, jetton])); - const present = new Set(base.map((row) => row.id)); - const padded = [...base]; - - for (const def of DEFAULT_JETTONS) { - if (padded.length >= JETTON_SLOTS) break; - if (present.has(def.address)) continue; + // Preview: the first JETTON_SLOTS rows of the same list the Assets page renders. + const preview = jettonRows.slice(0, JETTON_SLOTS); - const held = heldByAddress.get(def.address); - const info = defaultInfos[def.address]; - const decimals = held?.decimalsNumber ?? info?.decimals ?? 9; - const amount = held ? toDecimal(held.balance, decimals) : 0; - const rateEntry = findRate(rates, def.address); - padded.push({ - id: def.address, - icon: held - ? imageSources(tokenImageUrls(held.info?.image), held.info?.image?.data) - : imageSources(info?.image ? [info.image] : undefined, info?.image_data), - fallbackText: def.symbol.slice(0, 2).toUpperCase(), - name: (held && getJettonsName(held)) || info?.name || def.name, - symbol: (held && getJettonsSymbol(held)) || info?.symbol || def.symbol, - amount, - rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, - fiat: rateEntry ? amount * rateEntry.rate : undefined, - }); - } - return padded; - }, [jettonRows, isMainnet, userJettons, rates, defaultInfos]); + // A jettons fetch that has never succeeded and failed → hide the whole block (don't shimmer + // forever). Once jettons have loaded once, a transient refresh error won't hide the block. + const isError = lastJettonsUpdate === 0 && userJettons.length === 0 && error !== null && !isLoadingJettons; + if (isError) { + return null; + } return (
@@ -106,7 +55,7 @@ export const DashboardAssets: React.FC = () => {
{tonRow ? : } {assetsReady ? ( - selected.map((row) => ) + preview.map((row) => ) ) : ( <> diff --git a/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx b/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx index eb18bed9c..ce710fcc2 100644 --- a/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx +++ b/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx @@ -10,26 +10,43 @@ import type { FC } from 'react'; import { useNavigate } from 'react-router-dom'; import { useNfts } from '@demo/wallet-core'; -import { NftTile } from '../nft-tile'; +import { NftTile, NftTileSkeleton } from '../nft-tile'; import { NewLayout } from '@/core/components/shared/new-layout'; import { ScreenHeader } from '@/core/components/shared/screen-header'; +const SKELETON_TILES = 4; + /** Full NFTs page: every NFT held by the active wallet, as a grid. */ export const NftsScreen: FC = () => { const navigate = useNavigate(); - const { userNfts, formatNftIndex } = useNfts(); + const { userNfts, formatNftIndex, isLoadingNfts, lastNftsUpdate, error } = useNfts(); + + const hasNfts = userNfts.length > 0; + // "No NFTs yet" is truthful once a load has succeeded and returned nothing (even if a later + // background refresh then errored). "Couldn't load" is only when no load ever succeeded and + // the fetch failed — otherwise the first load is still running (shimmer). + const showEmpty = !hasNfts && lastNftsUpdate > 0; + const isError = !hasNfts && lastNftsUpdate === 0 && error !== null && !isLoadingNfts; return ( navigate('/wallet')} />}> - {userNfts.length === 0 ? ( -

No NFTs yet

- ) : ( + {hasNfts ? (
{userNfts.map((nft) => ( ))}
+ ) : isError ? ( +

Unable to load NFTs

+ ) : showEmpty ? ( +

No NFTs yet

+ ) : ( +
+ {Array.from({ length: SKELETON_TILES }).map((_, index) => ( + + ))} +
)}
); diff --git a/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx b/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx index fc16749e6..b562952af 100644 --- a/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx +++ b/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx @@ -10,6 +10,7 @@ import React from 'react'; import type { NFT } from '@ton/walletkit'; import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { Skeleton } from '@/core/components/ui/skeleton'; import { tokenImageUrls } from '@/core/utils'; const getNftImageSources = (nft: NFT): string[] => { @@ -53,3 +54,14 @@ export const NftTile: React.FC = ({ nft, formatNftIndex }) => { ); }; + +/** Loading placeholder mirroring {@link NftTile}: square image area + name/index lines. */ +export const NftTileSkeleton: React.FC = () => ( +
+ +
+ + +
+
+); diff --git a/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx b/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx index 18fb21fef..839b6fc58 100644 --- a/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx +++ b/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx @@ -11,14 +11,29 @@ import { ChevronRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useNfts } from '@demo/wallet-core'; -import { NftTile } from '../nft-tile'; +import { NftTile, NftTileSkeleton } from '../nft-tile'; -/** Dashboard NFTs preview: a horizontal-scroll strip; renders nothing when the wallet has no NFTs. */ +const SKELETON_TILES = 3; + +/** + * Dashboard NFTs preview: a horizontal-scroll strip. Shows a shimmer while the first load + * is in flight; hides the whole block on a load error (rather than shimmering forever); shows + * a small "No NFTs yet" stub once we've confirmed the wallet genuinely has no NFTs. + */ export const NftsCard: React.FC = () => { const navigate = useNavigate(); - const { userNfts, formatNftIndex } = useNfts(); + const { userNfts, formatNftIndex, isLoadingNfts, lastNftsUpdate, error } = useNfts(); + + // States (mutually exclusive when there are no NFTs): a successful load that returned + // nothing (lastNftsUpdate > 0) is genuinely empty — this holds even if a later background + // refresh then errored, so a transient failure doesn't flip empty→error. isError is only + // the "never loaded AND the fetch failed" case; otherwise the first load is still running. + const hasNfts = userNfts.length > 0; + const showEmpty = !hasNfts && lastNftsUpdate > 0; + const isError = !hasNfts && lastNftsUpdate === 0 && error !== null && !isLoadingNfts; - if (userNfts.length === 0) { + // On a load error, hide the whole block rather than shimmering forever. + if (isError) { return null; } @@ -34,13 +49,26 @@ export const NftsCard: React.FC = () => { -
- {userNfts.map((nft) => ( -
- -
- ))} -
+ {hasNfts ? ( +
+ {userNfts.map((nft) => ( +
+ +
+ ))} +
+ ) : showEmpty ? ( + // Genuinely-empty wallet: a small stub instead of hiding the section. +

No NFTs yet

+ ) : ( +
+ {Array.from({ length: SKELETON_TILES }).map((_, index) => ( +
+ +
+ ))} +
+ )}
); }; diff --git a/apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx b/apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx index c96c4c24a..b40c23f06 100644 --- a/apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx +++ b/apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx @@ -6,40 +6,60 @@ * */ -import { useState } from 'react'; import type { FC } from 'react'; import { useNavigate } from 'react-router-dom'; -import { TransactionRow } from '../transaction-row'; +import { TransactionRow, TransactionRowSkeleton } from '../transaction-row'; import { useTransactionRows } from '../../hooks/use-transaction-rows'; import { Button } from '@/core/components/ui/button'; import { NewLayout } from '@/core/components/shared/new-layout'; import { ScreenHeader } from '@/core/components/shared/screen-header'; -const PAGE_SIZE = 25; +const SKELETON_ROWS = 8; -/** Full transaction history page: all transactions with "load more" pagination. */ +/** + * Full transaction history page: all transactions with offset-based "Load more" pagination + * (each page appends to the list — see use-transaction-rows). On a first-page load error we + * show a short error line instead of an endless shimmer. + */ export const HistoryScreen: FC = () => { const navigate = useNavigate(); - const [limit, setLimit] = useState(PAGE_SIZE); - const { rows, hasMore } = useTransactionRows(limit); + const { rows, hasMore, isLoading, showEmpty, isError, loadMore } = useTransactionRows(); + + // With rows already on screen, `isLoading` means the next offset page is being fetched. + const isLoadingMore = isLoading && rows.length > 0; return ( navigate('/wallet')} />}> - {rows.length === 0 ? ( -

No transactions yet

- ) : ( + {rows.length > 0 ? (
{rows.map((row) => ( ))}
+ ) : isError ? ( +

Unable to load transactions

+ ) : showEmpty ? ( +

No transactions yet

+ ) : ( + // Loading first page: show a shimmer, never a false "empty". +
+ {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( + + ))} +
)} - {hasMore && ( + {hasMore && rows.length > 0 && (
-
diff --git a/apps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsx b/apps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsx index dbbdd3679..209805672 100644 --- a/apps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsx +++ b/apps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsx @@ -10,23 +10,28 @@ import React from 'react'; import { ChevronRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { TransactionRow } from '../transaction-row'; +import { TransactionRow, TransactionRowSkeleton } from '../transaction-row'; import { useTransactionRows } from '../../hooks/use-transaction-rows'; const PREVIEW_COUNT = 6; -// Load a few extra so the preview still fills 6 rows after action-less events are skipped. -const PREVIEW_LOAD = 10; +const SKELETON_ROWS = 4; /** - * Dashboard "History" block: the latest transactions. Like NftsCard, renders nothing - * while loading or when empty; the header navigates to the full history page. + * Dashboard "History" block: the latest transactions (first page fetched at the shared + * page size, only the first {@link PREVIEW_COUNT} shown). Shows shimmer rows while the + * first fetch is in flight; hides the whole block on a load error (so we never shimmer + * forever); shows a small "No transactions yet" stub for a genuinely empty wallet. The + * header navigates to the full history page. */ export const TransactionHistory: React.FC = () => { const navigate = useNavigate(); - const { rows } = useTransactionRows(PREVIEW_LOAD); + // No explicit page size: uses the shared EVENTS_PAGE_SIZE (25) so this collapses onto + // the history page's first-page request. We still render only PREVIEW_COUNT rows. + const { rows, showEmpty, isError } = useTransactionRows(); const preview = rows.slice(0, PREVIEW_COUNT); - if (preview.length === 0) { + // On a load error, hide the whole block rather than shimmering forever. + if (isError) { return null; } @@ -43,9 +48,14 @@ export const TransactionHistory: React.FC = () => {
- {preview.map((row) => ( - - ))} + {preview.length > 0 ? ( + preview.map((row) => ) + ) : showEmpty ? ( + // Genuinely-empty wallet: a small stub instead of hiding the section. +

No transactions yet

+ ) : ( + Array.from({ length: SKELETON_ROWS }).map((_, index) => ) + )}
); diff --git a/apps/demo-wallet/src/features/transactions/components/transaction-row/transaction-row.tsx b/apps/demo-wallet/src/features/transactions/components/transaction-row/transaction-row.tsx index c0304cdd0..c5432338d 100644 --- a/apps/demo-wallet/src/features/transactions/components/transaction-row/transaction-row.tsx +++ b/apps/demo-wallet/src/features/transactions/components/transaction-row/transaction-row.tsx @@ -11,6 +11,8 @@ import { Check, MinusCircle, PlusCircle, X } from 'lucide-react'; import type { TransactionRowModel, TransactionRowStatus } from '../../utils/map-transaction-row'; +import { Skeleton } from '@/core/components/ui/skeleton'; + const StatusBadge: React.FC<{ status: TransactionRowStatus }> = ({ status }) => { const base = 'absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full flex items-center justify-center ring-2 ring-white'; @@ -88,3 +90,18 @@ export const TransactionRow: React.FC = ({ ); }; + +/** Loading placeholder mirroring {@link TransactionRow}: circular icon, title/id lines, amount/date. */ +export const TransactionRowSkeleton: React.FC = () => ( +
+ +
+ + +
+
+ + +
+
+); diff --git a/apps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.ts b/apps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.ts index e41e13a65..459e14eaf 100644 --- a/apps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.ts +++ b/apps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.ts @@ -6,7 +6,7 @@ * */ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useWalletStore } from '@demo/wallet-core'; import { Base64ToHex } from '@ton/walletkit'; @@ -17,17 +17,50 @@ import type { TransactionRowModel } from '../utils/map-transaction-row'; const nowSeconds = (): number => Math.floor(Date.now() / 1000); +/** + * Default page size for /traces. 25 (not the visual preview count) because toncenter returns + * 500 ("timeout: context deadline exceeded") for small limits (1..10) — see loadEvents. Both + * the dashboard preview and the history page fetch this same first page (offset 0), so the + * store coalesces them into one request. + */ +export const EVENTS_PAGE_SIZE = 25; + interface TransactionRows { rows: TransactionRowModel[]; hasMore: boolean; + /** True while the first page of events is still being fetched — show a shimmer. */ + isLoading: boolean; + /** + * True only when it's genuinely correct to show the "No transactions yet" empty state: + * loading has finished, the last fetch did not fail, and there are zero rows. A fresh/uninit + * account (status 'uninitialized' | 'non-existing') qualifies; a timed-out fetch does not. + */ + showEmpty: boolean; + /** True when the first-page fetch failed and no rows are loaded — show an error, not a shimmer. */ + isError: boolean; + /** Fetch the next page by offset and append it to the accumulated events. */ + loadMore: () => void; } /** - * Loads the latest events (first `limit`, newest first), merges pending transactions - * and maps everything to rows. Shared by the dashboard preview and the full history page. + * Loads the latest events (first page, newest first), merges pending transactions and maps + * everything to rows. Shared by the dashboard preview and the full history page. Pagination + * is by offset: the first page is (pageSize, 0); `loadMore` fetches (pageSize, events.length) + * and the store appends it (dedupe by eventId). */ -export const useTransactionRows = (limit: number): TransactionRows => { - const { events, loadEvents, address, pendingTransactions, network, hasMore } = useWalletStore( +export const useTransactionRows = (pageSize: number = EVENTS_PAGE_SIZE): TransactionRows => { + const { + events, + loadEvents, + address, + pendingTransactions, + network, + hasMore, + isLoadingEvents, + eventsLoaded, + eventsError, + accountStatus, + } = useWalletStore( useShallow((state) => { const activeWallet = state.walletManagement.savedWallets.find( (w) => w.id === state.walletManagement.activeWalletId, @@ -39,14 +72,28 @@ export const useTransactionRows = (limit: number): TransactionRows => { pendingTransactions: state.walletManagement.pendingTransactions, network: activeWallet?.network ?? 'testnet', hasMore: state.walletManagement.hasNextEvents, + isLoadingEvents: state.walletManagement.isLoadingEvents, + eventsLoaded: state.walletManagement.eventsLoaded, + eventsError: state.walletManagement.eventsError, + accountStatus: state.walletManagement.accountStatus, }; }), ); + // Fetch the first page (offset 0, which resets the accumulated list) when the wallet or + // page size changes. `loadEvents` is a stable store action; the in-flight guard inside it + // coalesces the StrictMode double-invoke and the dashboard+history duplicate mount. useEffect(() => { if (!address) return; - void loadEvents(limit, 0); - }, [address, loadEvents, limit]); + void loadEvents(pageSize, 0); + }, [address, loadEvents, pageSize]); + + // "Load more": fetch the next offset page and append it. Offset is the count of events + // already loaded, so pages are (pageSize, 0) → (pageSize, pageSize) → (pageSize, 2*pageSize)… + const loadMore = useCallback(() => { + if (!address || isLoadingEvents || !hasMore) return; + void loadEvents(pageSize, events.length); + }, [address, isLoadingEvents, hasMore, loadEvents, pageSize, events]); const rows = useMemo(() => { const eventItems = (events ?? []) as Event[]; @@ -81,5 +128,24 @@ export const useTransactionRows = (limit: number): TransactionRows => { return [...pendingRows, ...eventRows].sort((a, b) => b.timestamp - a.timestamp).map((item) => item.row); }, [events, pendingTransactions, address, network]); - return { rows, hasMore }; + // A brand-new / never-deployed account (fresh wallet) reports 'uninitialized' or + // 'non-existing' from /api/v3/addressInformation — it definitionally has no transactions. + const isFreshAccount = accountStatus === 'uninitialized' || accountStatus === 'non-existing'; + + // Before the first fetch completes, treat as loading (show shimmer, never "empty") — + // unless the account is already known to be fresh (then "no transactions" is immediate). + const isLoading = !!address && (isLoadingEvents || !eventsLoaded) && !isFreshAccount; + + // "No transactions yet" is truthful when there are zero rows AND either the account is a + // fresh/uninit account, or a successful (non-errored) load has completed. A timed-out/failed + // fetch (eventsError) never shows empty — it surfaces as isError (below) instead. + const showEmpty = + !!address && rows.length === 0 && (isFreshAccount || (eventsLoaded && !eventsError && !isLoadingEvents)); + + // The first-page fetch failed and there is nothing to show — surface an error instead of an + // endless shimmer (dashboard hides the block; the page shows a short error line). A failure + // on a later "Load more" page keeps the rows already on screen, so isError stays false there. + const isError = !!address && rows.length === 0 && eventsError && !isLoadingEvents; + + return { rows, hasMore, isLoading, showEmpty, isError, loadMore }; }; diff --git a/demo/wallet-core/src/store/slices/walletManagementSlice.ts b/demo/wallet-core/src/store/slices/walletManagementSlice.ts index 2270fc6ac..444ea6a49 100644 --- a/demo/wallet-core/src/store/slices/walletManagementSlice.ts +++ b/demo/wallet-core/src/store/slices/walletManagementSlice.ts @@ -22,6 +22,26 @@ const log = createComponentLogger('WalletManagementSlice'); let activeStreamingUnwatchers: Array<() => void> = []; +/** + * In-flight guard for loadEvents. The dashboard preview and the history page both mount + * a use-transaction-rows effect, and streaming confirmations also trigger a reload — under + * React StrictMode (dev) these fire concurrently and hammered toncenter with duplicate + * /traces requests (intermittent "timeout: context deadline exceeded"). We coalesce calls + * for the SAME (limit, offset) onto the first request's promise; a different window (e.g. + * the history "Load more" fetching the next offset page) is chained after, never dropped. + * Because dashboard and history now share the same first-page window (limit 25, offset 0), + * they collapse to a single /traces request that serves both. + */ +let inFlightEventsLoad: { key: string; promise: Promise } | null = null; + +/** Toncenter returns this transient error under load; worth one quick retry. */ +const isToncenterTimeout = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error ?? ''); + return /timeout|context deadline exceeded/i.test(message); +}; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + export const createWalletManagementSlice = (walletKitConfig?: WalletKitConfig): WalletManagementSliceCreator => (set: SetState, get) => ({ @@ -33,6 +53,10 @@ export const createWalletManagementSlice = publicKey: undefined, events: [], hasNextEvents: false, + isLoadingEvents: false, + eventsLoaded: false, + eventsError: false, + accountStatus: undefined, pendingTransactions: [], confirmedTraceIds: [], confirmedExternalHashes: [], @@ -337,6 +361,10 @@ export const createWalletManagementSlice = log.warn('Failed to fetch balance during wallet switch (API may be down):', balanceError); } + // Drop any in-flight events load for the previous wallet so the coalescing + // guard doesn't hand this wallet the old wallet's request. + inFlightEventsLoad = null; + set((state) => { state.walletManagement.activeWalletId = walletId; state.walletManagement.address = savedWallet.address; @@ -344,6 +372,9 @@ export const createWalletManagementSlice = state.walletManagement.balance = balance ?? state.walletManagement.balance; state.walletManagement.currentWallet = wallet; state.walletManagement.events = []; + state.walletManagement.eventsLoaded = false; + state.walletManagement.eventsError = false; + state.walletManagement.accountStatus = undefined; }); await get().startWebSocketStreaming(); @@ -388,6 +419,9 @@ export const createWalletManagementSlice = state.walletManagement.balance = undefined; state.walletManagement.currentWallet = undefined; state.walletManagement.events = []; + state.walletManagement.eventsLoaded = false; + state.walletManagement.eventsError = false; + state.walletManagement.accountStatus = undefined; state.walletManagement.pendingTransactions = []; state.walletManagement.confirmedTraceIds = []; state.walletManagement.confirmedExternalHashes = []; @@ -396,6 +430,7 @@ export const createWalletManagementSlice = }); if (isRemovingActiveWallet && isLastWallet) { + inFlightEventsLoad = null; void get().stopWebSocketStreaming(); } @@ -531,6 +566,7 @@ export const createWalletManagementSlice = clearWallet: () => { void get().stopWebSocketStreaming(); + inFlightEventsLoad = null; set((state) => { state.walletManagement.isAuthenticated = false; state.walletManagement.hasWallet = false; @@ -540,6 +576,9 @@ export const createWalletManagementSlice = state.walletManagement.balance = undefined; state.walletManagement.publicKey = undefined; state.walletManagement.events = []; + state.walletManagement.eventsLoaded = false; + state.walletManagement.eventsError = false; + state.walletManagement.accountStatus = undefined; state.walletManagement.pendingTransactions = []; state.walletManagement.confirmedTraceIds = []; state.walletManagement.confirmedExternalHashes = []; @@ -747,7 +786,13 @@ export const createWalletManagementSlice = } }, - loadEvents: async (limit = 10, offset = 0) => { + // Default batch size 25. Fetching >= 25 traces is a workaround for a server-side + // toncenter limitation: GET /api/v3/traces?limit=N returns 500 ("timeout: context + // deadline exceeded") for small N (observed for N in 1..10) but succeeds for 25. + // Not our bug — see the batch constant reused by the UI (use-transaction-rows). + // offset === 0 is treated as a fresh first page (replaces the list); offset > 0 is a + // "Load more" page that is appended to the accumulated events (deduped by eventId). + loadEvents: async (limit = 25, offset = 0) => { const state = get(); if (!state.walletManagement.address) { log.warn('No wallet address available to load events'); @@ -758,57 +803,130 @@ export const createWalletManagementSlice = throw new Error('WalletKit not initialized'); } - try { - log.info( - 'Loading events for address:', - state.walletManagement.address, - 'limit:', - limit, - 'offset:', - offset, - ); + // Coalesce overlapping requests for the SAME window onto the first in-flight load. + // Prevents the duplicate concurrent /traces requests (dashboard preview + history + // page + StrictMode double-mount + streaming refresh all firing at once). A request + // for a different window (e.g. a "Load more" offset page) waits, then runs. + const key = `${limit}:${offset}`; + if (inFlightEventsLoad) { + if (inFlightEventsLoad.key === key) { + return inFlightEventsLoad.promise; + } + const previous = inFlightEventsLoad.promise; + await previous.catch(() => {}); + // Another matching request may have started while we awaited. + if (inFlightEventsLoad?.key === key) { + return inFlightEventsLoad.promise; + } + } + + const run = (async () => { + const startState = get(); + const address = startState.walletManagement.address; + if (!address) return; - const activeWallet = state.walletManagement.savedWallets.find( - (w) => w.id === state.walletManagement.activeWalletId, + const activeWallet = startState.walletManagement.savedWallets.find( + (w) => w.id === startState.walletManagement.activeWalletId, ); const walletNetwork = activeWallet?.network || 'testnet'; + const apiClient = startState.walletCore.walletKit?.getApiClient(getChainNetwork(walletNetwork)); + if (!apiClient) return; - const response = await state.walletCore.walletKit - .getApiClient(getChainNetwork(walletNetwork)) - .getEvents({ - account: state.walletManagement.address, - limit, - offset, - }); + set((s) => { + s.walletManagement.isLoadingEvents = true; + }); - set((state) => { - state.walletManagement.events = response.events; - state.walletManagement.hasNextEvents = response.hasNext; + log.info('Loading events for address:', address, 'limit:', limit, 'offset:', offset); + + // Best-effort account status (fresh/uninit accounts report 'uninitialized'/'non-existing') + // so the UI can tell "genuinely no transactions" from "still loading / failed". + void apiClient + .getAccountState(address) + .then((account) => { + set((s) => { + if (s.walletManagement.address === address) { + s.walletManagement.accountStatus = account.status; + } + }); + }) + .catch((err) => log.warn('Failed to fetch account status:', err)); + + // One quick retry on a transient toncenter timeout. + let response: Awaited>; + try { + response = await apiClient.getEvents({ account: address, limit, offset }); + } catch (error) { + if (isToncenterTimeout(error)) { + log.warn('getEvents timed out; retrying once'); + await delay(600); + response = await apiClient.getEvents({ account: address, limit, offset }); + } else { + throw error; + } + } + + set((s) => { + // offset 0 = fresh first page (replace). offset > 0 = "Load more" page: + // append to what's already loaded and dedupe by eventId so a page overlap + // (e.g. a new tx shifting the window) can't produce duplicate rows. + if (offset > 0) { + const existing = s.walletManagement.events as Array<{ eventId?: string }>; + const seenEventIds = new Set( + existing.map((ev) => ev.eventId).filter((id): id is string => !!id), + ); + const appended = (response.events as Array<{ eventId?: string }>).filter( + (ev) => !ev.eventId || !seenEventIds.has(ev.eventId), + ); + s.walletManagement.events = [...existing, ...appended]; + } else { + s.walletManagement.events = response.events; + } + s.walletManagement.hasNextEvents = response.hasNext; const eventTraceIds = new Set(); const eventExtHashes = new Set(); for (const ev of response.events as Array<{ eventId?: string; traceExternalHash?: string }>) { if (ev.eventId) eventTraceIds.add(ev.eventId); if (ev.traceExternalHash) eventExtHashes.add(Base64ToHex(ev.traceExternalHash)); } - state.walletManagement.confirmedTraceIds = [ - ...state.walletManagement.confirmedTraceIds, + s.walletManagement.confirmedTraceIds = [ + ...s.walletManagement.confirmedTraceIds, ...eventTraceIds, ].slice(-50); - state.walletManagement.confirmedExternalHashes = [ - ...state.walletManagement.confirmedExternalHashes, + s.walletManagement.confirmedExternalHashes = [ + ...s.walletManagement.confirmedExternalHashes, ...eventExtHashes, ].slice(-50); - state.walletManagement.pendingTransactions = state.walletManagement.pendingTransactions.filter( + s.walletManagement.pendingTransactions = s.walletManagement.pendingTransactions.filter( (p) => !(p.traceId && eventTraceIds.has(p.traceId)) && !(p.externalHash && eventExtHashes.has(p.externalHash)), ); + s.walletManagement.eventsError = false; }); log.info(`Loaded ${response.events.length} events`); - } catch (error) { - log.error('Error loading events:', error); - } + })(); + + const promise = run + .catch((error) => { + log.error('Error loading events:', error); + set((s) => { + s.walletManagement.eventsError = true; + }); + }) + .finally(() => { + set((s) => { + s.walletManagement.isLoadingEvents = false; + s.walletManagement.eventsLoaded = true; + }); + // Only clear if we're still the current load (a newer one may have replaced us). + if (inFlightEventsLoad?.promise === promise) { + inFlightEventsLoad = null; + } + }); + + inFlightEventsLoad = { key, promise }; + return promise; }, getAvailableWallets: (): Wallet[] => { diff --git a/demo/wallet-core/src/types/store.ts b/demo/wallet-core/src/types/store.ts index 33ad4e8d3..a58dd15a0 100644 --- a/demo/wallet-core/src/types/store.ts +++ b/demo/wallet-core/src/types/store.ts @@ -32,6 +32,7 @@ import type { GaslessQuote, GaslessSupportedAsset, SendTransactionResponse, + AccountStatus, } from '@ton/walletkit'; import type { PendingTransaction } from './streaming'; @@ -84,6 +85,18 @@ export interface WalletManagementSlice { // Event history for active wallet events: unknown[]; hasNextEvents: boolean; + /** True while a getEvents (traces) request is in flight. Drives the history shimmer. */ + isLoadingEvents: boolean; + /** True once at least one getEvents request has completed (success or error) for the active wallet. */ + eventsLoaded: boolean; + /** True when the last getEvents request failed (e.g. toncenter timeout) — used to keep showing a shimmer instead of a false "no transactions". */ + eventsError: boolean; + /** + * On-chain status of the active account ('active' | 'uninitialized' | 'frozen' | 'non-existing'), + * from /api/v3/addressInformation. A brand-new/uninit account (no on-chain transactions) reports + * 'uninitialized' or 'non-existing' — used to decide when "No transactions yet" is truthful. + */ + accountStatus?: AccountStatus; /** Pending transactions from WebSocket streaming */ pendingTransactions: PendingTransaction[];