diff --git a/apps/tangle-cloud/src/app/app.tsx b/apps/tangle-cloud/src/app/app.tsx index dd7ee99878..88564efcbe 100644 --- a/apps/tangle-cloud/src/app/app.tsx +++ b/apps/tangle-cloud/src/app/app.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from 'react-router'; import { lazy, Suspense, type FC, type ReactNode } from 'react'; import Layout from '../components/Layout'; +import ScrollToTop from '../components/ScrollToTop'; import Providers from './providers'; import { PagePath } from '../types'; import { Skeleton } from '@tangle-network/sandbox-ui/primitives'; @@ -70,6 +71,7 @@ const withLayout = (LayoutCmp: FC<{ children: ReactNode }>, Page: FC) => ( const App: FC = () => { return ( + ` is path-aware but + * scroll-naive: navigating from a long /blueprints listing to /rewards + * lands the next page already scrolled wherever the previous page was, + * which makes transitions feel broken (the user has to scroll up to + * read the heading of the new page). + * + * Drop this component anywhere inside the router tree — it renders + * `null` and only runs an effect. + * + * NOTE: pathname-only dependency is deliberate. We do NOT scroll on + * search-param or hash changes, because some pages use those for in-page + * tab state and an unsolicited scroll would yank the viewport away. + */ +const ScrollToTop = () => { + const { pathname } = useLocation(); + + useEffect(() => { + // Use 'instant' (not 'smooth') so the new page is at the top before + // the first paint — smooth-scroll across route transitions looks + // janky because the previous page's content briefly remains visible. + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + }, [pathname]); + + return null; +}; + +export default ScrollToTop; diff --git a/apps/tangle-cloud/src/components/Sidebar.tsx b/apps/tangle-cloud/src/components/Sidebar.tsx index 634d42918f..b81de4ac4a 100644 --- a/apps/tangle-cloud/src/components/Sidebar.tsx +++ b/apps/tangle-cloud/src/components/Sidebar.tsx @@ -66,7 +66,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ Icon: CoinsLineIcon, }, { - name: 'Payments', + name: 'Private Payments', href: PagePath.PAYMENTS_POOL, isInternal: true, Icon: ShieldKeyholeLineIcon, diff --git a/apps/tangle-cloud/src/pages/rewards/page.tsx b/apps/tangle-cloud/src/pages/rewards/page.tsx index ee5da966d6..e0f702f7f8 100644 --- a/apps/tangle-cloud/src/pages/rewards/page.tsx +++ b/apps/tangle-cloud/src/pages/rewards/page.tsx @@ -415,7 +415,18 @@ const RewardsPage: FC = () => { ) : rewardHistoryError ? ( - Could not load claim history. + // Render an empty-state instead of a red error when the + // indexer is simply absent (e.g. `testnet` env with no + // VITE_ENVIO_TESTNET_ENDPOINT configured). The original + // `` here surfaced an alarming + // "Could not load claim history" banner on every page + // load — that's the UX equivalent of a 500, but the + // actual cause is "no historical data source", which is + // a legitimate empty state. + ) : rewardHistory?.length ? ( => { + const contracts = getContractsByChainId(chainId); + if (!contracts || contracts.tangle === '0x0000000000000000000000000000000000000000') { + return []; + } + + const tangleAddress = contracts.tangle as Address; + const { limit = 100, activeOnly = true } = options ?? {}; + + const total = (await publicClient.readContract({ + address: tangleAddress, + abi: TANGLE_ABI, + functionName: 'blueprintCount', + })) as bigint; + + if (total === 0n) { + return []; + } + + const upper = Math.min(Number(total), limit); + const ids = Array.from({ length: upper }, (_, i) => BigInt(i)); + + // Batch both reads per id via Promise.all — viem's PublicClient handles + // request batching automatically when the transport supports it. + const rows = await Promise.all( + ids.map(async (id) => { + try { + const [blueprint, definition] = await Promise.all([ + publicClient.readContract({ + address: tangleAddress, + abi: TANGLE_ABI, + functionName: 'getBlueprint', + args: [id], + }) as Promise<{ + owner: Address; + manager: Address; + createdAt: bigint; + operatorCount: number; + active: boolean; + }>, + publicClient.readContract({ + address: tangleAddress, + abi: TANGLE_ABI, + functionName: 'getBlueprintDefinition', + args: [id], + }) as Promise<{ + metadataUri: string; + metadataHash: `0x${string}`; + }>, + ]); + + const result: Blueprint = { + id: id.toString(), + blueprintId: id, + owner: blueprint.owner, + manager: blueprint.manager, + metadataUri: definition.metadataUri || null, + metadataHash: + definition.metadataHash && + definition.metadataHash !== + '0x0000000000000000000000000000000000000000000000000000000000000000' + ? definition.metadataHash + : null, + active: blueprint.active, + createdAt: BigInt(blueprint.createdAt), + // `updatedAt` is tracked off-chain by the indexer (event log of + // updateBlueprint calls). Without that, the best we can do is + // surface the original createdAt so downstream sorts behave + // predictably; the value is honest about "not seen any + // updates since creation". + updatedAt: BigInt(blueprint.createdAt), + operatorCount: BigInt(blueprint.operatorCount), + }; + return result; + } catch { + // A single failed blueprint shouldn't take down the whole list — + // e.g. a deactivated entry the contract chose to drop from + // getBlueprint. Surface a placeholder so the catalog still + // renders the others. + return null; + } + }), + ); + + const blueprints: Blueprint[] = []; + for (const row of rows) { + if (row === null) continue; + if (activeOnly && !row.active) continue; + blueprints.push(row); + } + return blueprints; +}; diff --git a/libs/tangle-shared-ui/src/data/graphql/useBlueprints.ts b/libs/tangle-shared-ui/src/data/graphql/useBlueprints.ts index 49cd1be777..096936f0ab 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useBlueprints.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useBlueprints.ts @@ -4,12 +4,13 @@ import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; -import { useAccount, useChainId } from 'wagmi'; +import { useAccount, useChainId, usePublicClient } from 'wagmi'; import { executeEnvioGraphQL, EnvioNetwork, getEnvioNetworkFromChainId, } from '../../utils/executeEnvioGraphQL'; +import { fetchBlueprintsOnChain } from '../blueprints/fetchBlueprintsOnChain'; import useNetworkStore from '../../context/useNetworkStore'; import type { Blueprint as AppBlueprint } from '../../types/blueprint'; import { @@ -347,6 +348,7 @@ export const useBlueprints = (options?: { limit = 100, } = options ?? {}; const { activeChainId, resolvedNetwork } = useResolvedEnvioNetwork(network); + const publicClient = usePublicClient({ chainId: activeChainId }); return useQuery({ queryKey: [ @@ -358,13 +360,42 @@ export const useBlueprints = (options?: { limit, ], queryFn: async () => { - const blueprints = await fetchBlueprints( - resolvedNetwork, - activeOnly, + // Primary path: hosted Envio indexer. + try { + const blueprints = await fetchBlueprints( + resolvedNetwork, + activeOnly, + limit, + 0, + ); + // If the indexer is up but lagging (returns 0 while the chain + // has blueprints), fall through to chain-reads so the user + // doesn't see an empty catalog despite there being live data. + if (blueprints.length > 0) { + return blueprints; + } + } catch (err) { + // Indexer endpoint missing or unreachable — log once and + // fall through to the chain-read fallback below. We don't + // re-throw because the on-chain path is the explicit recovery + // strategy, not an error to surface to the user. + console.warn( + `[useBlueprints] Envio indexer fetch failed (${resolvedNetwork}); ` + + 'falling back to direct chain reads.', + err, + ); + } + + // Fallback: read blueprints straight off the Tangle contract. + // Bounded by `limit`, defaults to 100 — fine for the testnet + // scale of a few dozen entries. + if (!publicClient || !activeChainId) { + return []; + } + return fetchBlueprintsOnChain(publicClient, activeChainId, { limit, - 0, - ); - return blueprints; + activeOnly, + }); }, enabled, staleTime: 60_000, @@ -384,6 +415,7 @@ export const useBlueprintsWithMetadata = (options?: { limit = 100, } = options ?? {}; const { activeChainId, resolvedNetwork } = useResolvedEnvioNetwork(network); + const publicClient = usePublicClient({ chainId: activeChainId }); return useQuery({ queryKey: [ @@ -395,12 +427,32 @@ export const useBlueprintsWithMetadata = (options?: { limit, ], queryFn: async () => { - const blueprints = await fetchBlueprints( - resolvedNetwork, - activeOnly, - limit, - 0, - ); + // Mirror the indexer → chain-read fallback strategy from + // `useBlueprints`. Without it, the home page's "Registered + // Blueprints" section renders empty whenever the Envio indexer + // isn't configured for the active network (e.g. testnet today), + // even though the chain has live entries. + let blueprints: Blueprint[] = []; + try { + blueprints = await fetchBlueprints( + resolvedNetwork, + activeOnly, + limit, + 0, + ); + } catch (err) { + console.warn( + `[useBlueprintsWithMetadata] Envio indexer fetch failed (${resolvedNetwork}); ` + + 'falling back to direct chain reads.', + err, + ); + } + if (blueprints.length === 0 && publicClient && activeChainId) { + blueprints = await fetchBlueprintsOnChain(publicClient, activeChainId, { + limit, + activeOnly, + }); + } return Promise.all( blueprints.map(async (bp): Promise => {