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
10 changes: 9 additions & 1 deletion src/apps/pulse/components/Buy/Buy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ interface BuyProps {
usdcPrice?: number; // For Relay Buy: USDC price from portfolio (passed from HomeScreen)
}

/**
* Render the Buy UI and manage the token purchase flow using either Relay Buy or the Intent SDK.
*
* This component handles user input for USD amounts, debounces and validates the amount, computes dispensable assets and permitted chains, fetches Relay buy offers or generates Express Intent responses, and surfaces preview and error states. It also integrates token selection (including token-atlas search), manages module installation for the Intent SDK, and exposes refresh and preview callbacks to parent components.
*
* @param props - Component props that provide token and portfolio data, balance information, callbacks for preview/selection/refresh, and configuration such as custom amounts and optional USDC price.
* @returns A React element rendering the buy interface and its associated controls and status.
*/
export default function Buy(props: BuyProps) {
const {
setExpressIntentResponse: setExInResp,
Expand Down Expand Up @@ -842,4 +850,4 @@ export default function Buy(props: BuyProps) {
</div>
</div>
);
}
}
17 changes: 16 additions & 1 deletion src/apps/pulse/components/Buy/BuyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ export interface BuyButtonProps {
useRelayBuy: boolean;
}

/**
* Render the Buy button with state-aware label, styling, and enable/disable logic.
*
* The button text adapts to loading/installing/fetching states, missing modules (prompts to enable trading),
* selected token and USD amount (shows estimated token and USD values), and express intent/offer responses.
*
* @param props - Component props that control rendering and behavior. Key behaviors:
* - isDisabled when installing, fetching, notEnoughLiquidity, loading, no token selected, USD amount ≤ 0,
* or when the express intent/offer response is missing, contains an `error`, or contains an empty `bids` array.
* - Exception: when not using Relay Buy and modules are not installed but a paying token exists, the button
* is enabled to allow enabling trading.
* - onClick is forwarded to `handleBuySubmit`.
*
* @returns The rendered button element for initiating a buy or enabling trading.
*/
export default function BuyButton(props: BuyButtonProps) {
const {
areModulesInstalled,
Expand Down Expand Up @@ -190,4 +205,4 @@ export default function BuyButton(props: BuyButtonProps) {
)}
</button>
);
}
}
9 changes: 8 additions & 1 deletion src/apps/pulse/components/Price/TokenPrice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ export interface TokenPriceProps {
value: number;
}

/**
* Renders a token price in USD using different formats depending on its magnitude and where the first non-zero decimal appears.
*
* @param props - Component props.
* @param props.value - The token price in USD.
* @returns A JSX element displaying the formatted price: "$0.00" when all decimals are zero, "$X.XX" for values >= 0.01 or when the first non-zero decimal is within the first two places, and a compact "$0.0<sub>N</sub>dddd" form for very small values where `N` is the number of leading zeros before the first significant digits and `dddd` are up to four significant decimal digits.
*/
export default function TokenPrice(props: TokenPriceProps): JSX.Element {
const { value } = props;
const fixed = value.toFixed(10);
Expand Down Expand Up @@ -47,4 +54,4 @@ export default function TokenPrice(props: TokenPriceProps): JSX.Element {
{significantDigits}
</p>
);
}
}
15 changes: 14 additions & 1 deletion src/apps/pulse/components/Search/MarketList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export interface MarketListProps {
minLiquidity?: number;
}

/**
* Render a vertical list of market rows as interactive buttons.
*
* Each row shows token0 artwork (or initials fallback), an overlaid chain logo,
* pair name, exchange, liquidity and 24h volume, USD liquidity on the right,
* and an optional 24h price change badge. Clicking a row invokes the provided
* selection handler with the corresponding market.
*
* @param props - Component props containing markets and selection handler.
* - `markets`: array of market objects to render; when missing or empty nothing is rendered.
* - `handleMarketSelect`: callback invoked with the selected `Market` when a row is clicked.
* @returns The rendered list of market rows, or `null` when `markets` is falsy or empty.
*/
export default function MarketList(props: MarketListProps) {
const { markets, handleMarketSelect } = props;

Expand Down Expand Up @@ -157,4 +170,4 @@ export default function MarketList(props: MarketListProps) {
})}
</>
);
}
}
20 changes: 19 additions & 1 deletion src/apps/pulse/components/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@
position: 'fixed' as const,
};

/**
* Render a modal search interface for discovering tokens, markets, and wallet holdings with support for buy/sell modes, chain selection, sorting, and refresh.
*
* The component manages search state, fetches trending/fresh/top-gainers data when applicable, renders parsed token and market lists, applies liquidity filtering for markets, and handles token/market selection (including multi-chain token resolution using wallet USDC balances). It also supports keyboard and outside-click dismissal and preserves a relayBuy query parameter when closing.
*
* @param setSearching - Callback to toggle the parent search open/closed state
* @param isBuy - If true, the component operates in buy mode (shows Trending/Fresh/Top Gainers filters and chain selector); otherwise it operates in sell/holdings mode
* @param setBuyToken - Setter invoked with the selected buy token payload
* @param setSellToken - Setter invoked with the selected sell token payload
* @param chains - Array of selected chain identifiers used to scope search requests
* @param setChains - Setter to update the selected chains
* @param walletPortfolioData - Optional wallet portfolio data used for "My Holdings" and multi-chain heuristics
* @param walletPortfolioLoading - Boolean indicating initial wallet portfolio loading state
* @param walletPortfolioFetching - Boolean indicating ongoing wallet portfolio refetching
* @param walletPortfolioError - Optional error object for wallet portfolio fetch failures
* @param refetchWalletPortfolio - Optional function to trigger a wallet portfolio refetch
* @returns The rendered Search component JSX element
*/
export default function Search({
setSearching,
isBuy,
Expand Down Expand Up @@ -104,7 +122,7 @@

let list: { assets: Asset[]; markets: Market[] } | undefined;
if (searchData?.result.data) {
list = parseSearchData(searchData?.result.data!, chains, searchText);

Check failure on line 125 in src/apps/pulse/components/Search/Search.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/apps/pulse/components/App/tests/AppWrapper.test.tsx > <AppWrapper /> > renders Search component > with multiple query parameters

TypeError: (0 , parseSearchData) is not a function ❯ Search src/apps/pulse/components/Search/Search.tsx:125:12 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7 ❯ recoverFromConcurrentError node_modules/react-dom/cjs/react-dom.development.js:25889:20 ❯ performConcurrentWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:25789:22

Check failure on line 125 in src/apps/pulse/components/Search/Search.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/apps/pulse/components/App/tests/AppWrapper.test.tsx > <AppWrapper /> > renders Search component > when valid asset parameter is present

TypeError: (0 , parseSearchData) is not a function ❯ Search src/apps/pulse/components/Search/Search.tsx:125:12 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7 ❯ recoverFromConcurrentError node_modules/react-dom/cjs/react-dom.development.js:25889:20 ❯ performConcurrentWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:25789:22
}

// Update sorted assets when search results change
Expand Down Expand Up @@ -795,4 +813,4 @@
}
</div>
);
}
}
11 changes: 10 additions & 1 deletion src/apps/pulse/components/Search/SearchSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ interface SearchSkeletonProps {
showSections?: boolean;
}

/**
* Renders a pulsing loading skeleton for a search/results list.
*
* Shows optional section header placeholders and a fixed number of skeleton rows
* consisting of an avatar, two text lines, and two right-aligned value bars.
*
* @param showSections - When `true`, render the two header placeholder lines; when `false`, omit them.
* @returns A React element containing the loading skeleton UI.
*/
export default function SearchSkeleton({ showSections = true }: SearchSkeletonProps) {
return (
<div className="w-full px-2.5 animate-pulse">
Expand Down Expand Up @@ -42,4 +51,4 @@ export default function SearchSkeleton({ showSections = true }: SearchSkeletonPr
))}
</div>
);
}
}
15 changes: 14 additions & 1 deletion src/apps/pulse/components/Search/TokenList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ export interface TokenListProps {
hideHeaders?: boolean;
}

/**
* Render a sortable, clickable list of tokens with visuals, metrics, and price information.
*
* Renders optional sort headers (when `hideHeaders` is false and `searchType` is Trending or Fresh),
* a list of token rows that call `handleTokenSelect` when clicked, and an empty fragment when `assets` is falsy.
*
* @param props - Component props
* @param props.assets - Array of token assets to display; if falsy, the component renders nothing
* @param props.handleTokenSelect - Callback invoked with the selected asset when a row is clicked
* @param props.searchType - Controls header visibility and whether fresh timestamps are shown
* @param props.hideHeaders - When true, suppresses the sort header row even for Trending/Fresh search types
* @returns A JSX element containing the token list or an empty fragment when no assets are provided
*/
export default function TokenList(props: TokenListProps) {
const { assets, handleTokenSelect, searchType, hideHeaders } = props;

Expand Down Expand Up @@ -225,4 +238,4 @@ export default function TokenList(props: TokenListProps) {
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
}
85 changes: 77 additions & 8 deletions src/apps/pulse/utils/parseSearchData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ export type Market = {
price: number | null;
};

/**
* Build a single Asset entry for a token using its first supported chain as the primary chain and attach multi-chain metadata.
*
* Filters out unsupported chains and wrapped native token deployments; when at least one valid chain remains, returns an array containing one Asset populated from the primary chain and including `allChains`, `allContracts`, and `allDecimals` for every valid chain. If no valid chains remain, returns an empty array.
*
* @param asset - The token response object to convert into an Asset
* @param chains - Which chain(s) to consider (specific chain name or `MobulaChainNames.All`)
* @returns An array containing one Asset with primary-chain fields and multi-chain metadata, or an empty array if no valid chains are found
*/
export function parseAssetData(
asset: TokenAssetResponse,
chains: MobulaChainNames
Expand Down Expand Up @@ -107,6 +116,12 @@ export function parseAssetData(
return result;
}

/**
* Convert a TokenAssetResponse into a single Asset entry that consolidates multi-chain information when supported.
*
* @param asset - TokenAssetResponse from the API representing a token across chains; wrapped native-token contract entries are ignored.
* @returns An array containing one Asset built from the first valid chain as the primary chain and populated `allChains`, `allContracts`, and `allDecimals`; returns an empty array if no valid chains are available.
*/
export function parseTokenData(asset: TokenAssetResponse): Asset[] {
const result: Asset[] = [];
const { blockchains, decimals, contracts } = asset;
Expand Down Expand Up @@ -156,7 +171,12 @@ export function parseTokenData(asset: TokenAssetResponse): Asset[] {
}

/**
* Parse market pairs from TokenAssetResponse, ensuring the searched token appears first
* Extracts market pairs from a token response where either token matches the search term, ordering each pair so the matched token is first.
*
* @param asset - Token asset response containing pair data
* @param searchTerm - Term used to match token symbol or name (case-insensitive)
* @param chains - Chain filter; use MobulaChainNames.All to include all chains
* @returns An array of Market entries matching the search term with the matched token positioned as `token0`; empty if none
*/
export function parseMarketPairs(
asset: TokenAssetResponse,
Expand Down Expand Up @@ -225,7 +245,16 @@ export function parseMarketPairs(
}

/**
* Parse market pairs from PairResponse
* Construct the pair representation including tokens, liquidity, volume, and pricing information.
*
* @returns An object representing the market pair with these fields:
* - `pairName`: string in the form `"token0.symbol/token1.symbol"`
* - `token0`, `token1`: token objects from the pair
* - `liquidity`: pair liquidity
* - `volume24h`: 24-hour volume (prefers `pair.volume_24h` if present)
* - `blockchain`, `address`, `exchange`: source identifiers
* - `priceChange24h`: 24-hour price change if provided, `null` otherwise
* - `price`: current pair price
*/
export function parsePairResponse(pair: PairResponse): Market {
return {
Expand All @@ -243,7 +272,13 @@ export function parsePairResponse(pair: PairResponse): Market {
}

/**
* Sort assets by relevance to search term, then by market cap
* Order assets by relevance to the provided search term, then by market capitalization.
*
* The search term is compared to asset symbols case-insensitively after trimming; assets whose symbol exactly matches the search term are placed before others. Assets with equal relevance are ordered by `mCap` descending (treating missing `mCap` as zero).
*
* @param assets - Array of assets to sort
* @param searchTerm - Term used to prioritize exact symbol matches
* @returns The same assets array sorted with exact symbol matches first, then by market cap (highest first)
*/
export function sortAssets(assets: Asset[], searchTerm: string): Asset[] {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
Expand All @@ -264,7 +299,11 @@ export function sortAssets(assets: Asset[], searchTerm: string): Asset[] {
}

/**
* Sort markets by relevance to search term, then by liquidity
* Order markets by relevance to the search term, placing markets whose token0 symbol exactly matches the term first, then by descending liquidity.
*
* @param markets - Array of market entries to sort
* @param searchTerm - Search string used to determine relevance (matched against token0 symbol, case-insensitive)
* @returns Markets sorted so exact `token0` symbol matches to `searchTerm` come first, ties broken by higher `liquidity`
*/
export function sortMarkets(markets: Market[], searchTerm: string): Market[] {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
Expand All @@ -286,7 +325,11 @@ export function sortMarkets(markets: Market[], searchTerm: string): Market[] {
}

/**
* Filter markets by minimum liquidity threshold
* Selects markets with liquidity greater than or equal to a minimum threshold.
*
* @param markets - Array of market entries to filter
* @param minLiquidity - Minimum liquidity threshold; markets with liquidity >= `minLiquidity` are kept
* @returns The filtered array of markets whose `liquidity` is greater than or equal to `minLiquidity`
*/
export function filterMarketsByLiquidity(
markets: Market[],
Expand All @@ -295,6 +338,13 @@ export function filterMarketsByLiquidity(
return markets.filter((market) => (market.liquidity || 0) >= minLiquidity);
}

/**
* Parse mixed API search results into deduplicated, filtered, and optionally sorted asset and market lists.
*
* @param searchData - Array of API responses which may be token/asset entries or pair records.
* @param chains - Chain filter controlling which chains to consider (e.g., all chains or a specific Mobula chain).
* @param searchTerm - Optional search term used to prioritize and sort results; also enables debug logging for certain terms.
* @returns An object containing `assets` (deduplicated and filtered Asset[] with merged multi-chain metadata) and `markets` (deduplicated Market[]), optionally sorted by relevance to `searchTerm`.
export function parseSearchData(
searchData: TokenAssetResponse[] | PairResponse[],
chains: MobulaChainNames,
Expand Down Expand Up @@ -389,8 +439,12 @@ export function parseSearchData(
}

/**
* Deduplicate assets by Mobula ID and symbol
* This handles cases where the API returns both 'asset' and 'token' types for the same asset
* Produce a deduplicated list of assets, preferring entries that include a Mobula `id`.
*
* Removes duplicate token entries by symbol when an asset with an `id` exists, and merges multi-chain metadata for entries that represent the same asset across chains.
*
* @param assets - Array of Asset entries (may include both asset-type entries with `id` and token-type entries without `id`)
* @returns An array of unique Asset objects where duplicates are removed and multi-chain fields (`allChains`, `allContracts`, `allDecimals`) are merged into the retained entry
*/
function deduplicateAssetsBySymbol(assets: Asset[]): Asset[] {
const assetMap = new Map<string, Asset>();
Expand Down Expand Up @@ -438,6 +492,13 @@ function deduplicateAssetsBySymbol(assets: Asset[]): Asset[] {
return Array.from(assetMap.values());
}

/**
* Builds Asset entries from projection data representing fresh and trending tokens across chains.
*
* Parses each projection's market rows to produce assets keyed by symbol (or name if symbol is empty), merging entries that appear on multiple chains into a single Asset with aggregated `volume` and `mCap`, and populated `allChains`, `allContracts`, and `allDecimals`.
*
* @param projections - Array of projection objects containing tokens market data across chains.
* @returns An array of Assets with non-zero volume and market cap, where multi-chain occurrences are merged and per-asset fields (price, priceChange24h, liquidity, timestamp, decimals, logo) are populated.
export function parseFreshAndTrendingTokens(
projections: Projection[]
): Asset[] {
Expand Down Expand Up @@ -529,6 +590,14 @@ export function parseFreshAndTrendingTokens(
return filteredAssets;
}

/**
* Merge multi-chain arrays (`allChains`, `allContracts`, `allDecimals`) from `source` into `target`, mutating `target`.
*
* If `source` lacks the multi-chain arrays the function does nothing. If `target` lacks those arrays they are copied from `source`. Otherwise, each chain present in `source` that is not already in `target.allChains` is appended along with its corresponding contract and decimals.
*
* @param target - Asset to receive merged multi-chain data (mutated).
* @param source - Asset providing multi-chain data to merge.
*/
function mergeMultiChainData(target: Asset, source: Asset) {
if (!source.allChains || !source.allContracts || !source.allDecimals) return;

Expand All @@ -551,4 +620,4 @@ function mergeMultiChainData(target: Asset, source: Asset) {
target.allDecimals.push(source.allDecimals[i]);
}
}
}
}
Loading