Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/demo-wallet/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions apps/demo-wallet/src/core/components/ui/skeleton/index.ts
Original file line number Diff line number Diff line change
@@ -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';
22 changes: 22 additions & 0 deletions apps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 <Skeleton className="h-4 w-24 rounded" />
*/
export const Skeleton: FC<HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div aria-hidden className={cn('skeleton-shimmer rounded-md', className)} {...props} />
);
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,16 +64,17 @@ export const AssetRow: React.FC<AssetRowData> = ({ icon, fallbackText, name, sym
);
};

/** Loading placeholder mirroring {@link AssetRow}: circular icon, name/amount lines, right-side value. */
export const AssetRowSkeleton: React.FC = () => (
<div className="flex items-center gap-3 py-2">
<span className="w-10 h-10 rounded-full bg-gray-100 animate-pulse flex-shrink-0" />
<Skeleton className="w-10 h-10 rounded-full flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-32 rounded bg-gray-100 animate-pulse" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<div className="text-right space-y-1.5">
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse ml-auto" />
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse ml-auto" />
<Skeleton className="h-4 w-16 ml-auto" />
<Skeleton className="h-3 w-12 ml-auto" />
</div>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,46 @@

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';

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 (
<NewLayout header={<ScreenHeader title="Assets" onBack={() => navigate('/wallet')} />}>
<div className="space-y-1">
{assetsReady && tonRow ? (
<>
<AssetRow {...tonRow} />
{jettonRows.map((row) => (
<AssetRow key={row.id} {...row} />
))}
</>
) : (
<>
<AssetRowSkeleton />
<AssetRowSkeleton />
<AssetRowSkeleton />
</>
)}
</div>
{assetsReady && tonRow ? (
<div className="space-y-1">
<AssetRow {...tonRow} />
{jettonRows.map((row) => (
<AssetRow key={row.id} {...row} />
))}
</div>
) : isError ? (
<p className="py-12 text-center text-sm text-gray-400">Unable to load assets</p>
) : (
<div className="space-y-1">
<AssetRowSkeleton />
<AssetRowSkeleton />
<AssetRowSkeleton />
</div>
)}
</NewLayout>
);
};
118 changes: 97 additions & 21 deletions apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 ?? []),
Expand All @@ -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<Record<string, JettonInfo>>({});
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<string, JettonInfo> = {};
infos.forEach((info, i) => {
if (info) next[DEFAULT_JETTONS[i].address] = info;
});
setDefaultInfos(next);
});
return () => {
cancelled = true;
};
}, [isMainnet, walletKit, currentWallet]);

const tonRow = useMemo<AssetRowData | null>(() => {
if (!assetsReady) return null;
const rateEntry = rates['GRAM'];
Expand All @@ -55,29 +101,59 @@ export const useAssetRows = (): AssetRows => {

const jettonRows = useMemo<AssetRowData[]>(() => {
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 };
};
Loading
Loading