From 98d0859cd50c58257e03883286bcec8a3c911fef Mon Sep 17 00:00:00 2001 From: Pavel Lesyuk Date: Sat, 4 Jul 2026 23:30:41 +0800 Subject: [PATCH 1/2] fix(demo-wallet): loading skeletons, error states, offset pagination, empty-state stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sections (History, Assets, NFTs) previously shimmered forever on a failed list fetch and could flash a false "empty" while still loading. Add explicit loading / error / empty handling backed by real load state. Store (wallet-core): - Add isLoadingEvents / eventsLoaded / eventsError / accountStatus to walletManagement, driven from loadEvents. - Coalesce concurrent loadEvents calls for the same (limit, offset) onto one in-flight request (removes the duplicate concurrent /traces requests that the StrictMode double-invoke amplified); a different window (Load more) chains after and is never dropped. - Best-effort single retry on a transient toncenter timeout. - Fetch account status (addressInformation) so the UI can distinguish a fresh/uninitialized account (truly no transactions) from a still-loading or failed fetch. - Reset the new flags + in-flight guard on wallet switch / remove / clear. UI (demo-wallet): - Add a reusable animated Skeleton primitive (gray shimmer within the app palette, respects prefers-reduced-motion) with layout-matched variants for the Asset row, NFT tile and History row. - Show shimmers while loading on both the dashboard preview cards and the full Assets / NFTs / History pages. - Empty-state stubs ("No NFTs yet" / "No transactions yet") show only after a successful load returned nothing (or a confirmed fresh account) — never while loading or after a failed fetch. On the dashboard a genuinely-empty wallet shows the stub instead of the section being hidden. - Error states: the dashboard preview hides the section on a load error; the full page shows a short "Unable to load ..." line instead of an endless shimmer. - History "Load more" pages by offset -- (25,0) -> (25,25) -> (25,50) -- appending each page to the accumulated list (deduped by eventId) instead of refetching a growing limit; shows a spinner while the next page loads. Work around a server-side toncenter limitation where GET /api/v3/traces?limit=N returns 500 ("timeout: context deadline exceeded") for small N (observed 1..10) but succeeds for 25: default the events batch to 25 everywhere. Because the dashboard preview and History now request the same first page (limit 25, offset 0), the store coalesces them into one /traces request. Assets: surface the mainnet base tokens (native Gram + USDT + XAUt) at a zero balance when not held, on BOTH the dashboard preview and the full Assets page, via the shared useAssetRows hook so both render the exact same list (the preview shows its first N); held jettons are merged in and win on any address collision. Mainnet-only; testnet and held-token display unchanged. --- apps/demo-wallet/src/App.css | 32 ++++ .../src/core/components/ui/skeleton/index.ts | 9 + .../core/components/ui/skeleton/skeleton.tsx | 22 +++ .../assets/components/asset-row/asset-row.tsx | 12 +- .../assets-screen/assets-screen.tsx | 44 +++-- .../features/assets/hooks/use-asset-rows.ts | 118 +++++++++--- .../dashboard-assets/dashboard-assets.tsx | 97 +++------- .../nft/components/nft-screen/nft-screen.tsx | 27 ++- .../nft/components/nft-tile/nft-tile.tsx | 12 ++ .../nft/components/nfts-card/nfts-card.tsx | 50 +++-- .../history-screen/history-screen.tsx | 42 +++-- .../transaction-history.tsx | 32 +++- .../transaction-row/transaction-row.tsx | 17 ++ .../hooks/use-transaction-rows.ts | 84 ++++++++- .../src/store/slices/walletManagementSlice.ts | 178 +++++++++++++++--- demo/wallet-core/src/types/store.ts | 13 ++ 16 files changed, 597 insertions(+), 192 deletions(-) create mode 100644 apps/demo-wallet/src/core/components/ui/skeleton/index.ts create mode 100644 apps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsx 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..16afe2b6d 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,16 @@ 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..25745a535 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,26 @@ 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[]; From 38739b5d9a2bce63c7f8b94995f0de7988baba72 Mon Sep 17 00:00:00 2001 From: Pavel Lesyuk Date: Sun, 5 Jul 2026 00:28:40 +0800 Subject: [PATCH 2/2] style(demo-wallet): apply prettier formatting prettier/prettier fixes flagged by CI lint (formatting only, no behavior change): transaction-history skeleton map and use-transaction-rows early-return. --- .../components/transaction-history/transaction-history.tsx | 4 +--- .../src/features/transactions/hooks/use-transaction-rows.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) 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 16afe2b6d..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 @@ -54,9 +54,7 @@ export const TransactionHistory: React.FC = () => { // Genuinely-empty wallet: a small stub instead of hiding the section.

No transactions yet

) : ( - Array.from({ length: SKELETON_ROWS }).map((_, index) => ( - - )) + Array.from({ length: SKELETON_ROWS }).map((_, index) => ) )}
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 25745a535..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 @@ -140,9 +140,7 @@ export const useTransactionRows = (pageSize: number = EVENTS_PAGE_SIZE): Transac // 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)); + !!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