From 0aef71c7072cad52a58a0bc1dd9bb61c98543060 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Mon, 5 Jan 2026 15:20:35 +0530 Subject: [PATCH 1/2] added gas tank page and home tile --- src/apps/gas-tank/assets/gas-tank-icon.svg | 7 + src/apps/gas-tank/assets/history.svg | 5 + .../gas-tank/components/App/AppWrapper.tsx | 136 ++++++++++ .../gas-tank/components/App/HomeScreen.tsx | 140 ++++++++++ .../components/Balance/GasTankBalanceCard.tsx | 100 +++++++ .../components/History/GasTankHistoryCard.tsx | 252 ++++++++++++++++++ .../components/History/GasTankSkeleton.tsx | 85 ++++++ .../components/History/TransactionRow.tsx | 95 +++++++ src/apps/gas-tank/hooks/useGasTankHistory.ts | 145 ++++++++++ src/apps/gas-tank/index.tsx | 7 + src/apps/gas-tank/manifest.json | 5 + src/apps/gas-tank/types/gasTank.ts | 72 +++++ .../components/GasTankTile/GasTankTile.tsx | 166 ++++++++++++ src/apps/pillarx-app/index.tsx | 14 + src/apps/pillarx-app/utils/configComponent.ts | 2 + .../components/Onboarding/TopUpScreen.tsx | 2 + src/apps/pulse/components/Search/Search.tsx | 5 +- src/apps/pulse/hooks/useGasTankBalance.ts | 15 ++ src/components/AppIcon.tsx | 8 + src/providers/AllowedAppsProvider.tsx | 39 ++- src/types/api.ts | 1 + 21 files changed, 1298 insertions(+), 3 deletions(-) create mode 100755 src/apps/gas-tank/assets/gas-tank-icon.svg create mode 100644 src/apps/gas-tank/assets/history.svg create mode 100755 src/apps/gas-tank/components/App/AppWrapper.tsx create mode 100755 src/apps/gas-tank/components/App/HomeScreen.tsx create mode 100755 src/apps/gas-tank/components/Balance/GasTankBalanceCard.tsx create mode 100755 src/apps/gas-tank/components/History/GasTankHistoryCard.tsx create mode 100755 src/apps/gas-tank/components/History/GasTankSkeleton.tsx create mode 100755 src/apps/gas-tank/components/History/TransactionRow.tsx create mode 100755 src/apps/gas-tank/hooks/useGasTankHistory.ts create mode 100755 src/apps/gas-tank/index.tsx create mode 100755 src/apps/gas-tank/manifest.json create mode 100755 src/apps/gas-tank/types/gasTank.ts create mode 100644 src/apps/pillarx-app/components/GasTankTile/GasTankTile.tsx diff --git a/src/apps/gas-tank/assets/gas-tank-icon.svg b/src/apps/gas-tank/assets/gas-tank-icon.svg new file mode 100755 index 00000000..4b6e2dfc --- /dev/null +++ b/src/apps/gas-tank/assets/gas-tank-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/gas-tank/assets/history.svg b/src/apps/gas-tank/assets/history.svg new file mode 100644 index 00000000..61fe2e2e --- /dev/null +++ b/src/apps/gas-tank/assets/history.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/gas-tank/components/App/AppWrapper.tsx b/src/apps/gas-tank/components/App/AppWrapper.tsx new file mode 100755 index 00000000..4618c01d --- /dev/null +++ b/src/apps/gas-tank/components/App/AppWrapper.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState, useEffect } from 'react'; +import { HomeScreen } from './HomeScreen'; +import { useGasTankBalance } from '../../../pulse/hooks/useGasTankBalance'; +import Search from '../../../pulse/components/Search/Search'; +import { + useGetWalletPortfolioQuery, + convertPortfolioAPIResponseToToken, +} from '../../../../services/pillarXApiWalletPortfolio'; +import { SelectedToken } from '../../../pulse/types/tokens'; +import { MobulaChainNames } from '../../../pulse/utils/constants'; +import useTransactionKit from '../../../../hooks/useTransactionKit'; + +/** + * AppWrapper component + * Main wrapper for the Gas Tank app + * Follows the same pattern as Pulse AppWrapper + */ +export const AppWrapper: React.FC = () => { + const { walletAddress: accountAddress } = useTransactionKit(); + const { + totalBalance, + isLoading: isBalanceLoading, + refetch: refetchBalance, + } = useGasTankBalance(accountAddress || null); + + // Smart loading state to prevent flickering on refetch + const [hasInitialLoad, setHasInitialLoad] = useState(false); + + useEffect(() => { + if (!isBalanceLoading) { + setHasInitialLoad(true); + } + }, [isBalanceLoading]); + + // Show loading only on initial fetch + const displayLoading = isBalanceLoading && !hasInitialLoad; + + // State management + const [searching, setSearching] = useState(false); + const [isBuy, setIsBuy] = useState(true); + const [chains, setChains] = useState(MobulaChainNames.All); + const [buyToken, setBuyToken] = useState(null); + const [sellToken, setSellToken] = useState(null); + const [topupToken, setTopupToken] = useState(null); + const [isSearchingFromTopup, setIsSearchingFromTopup] = useState(false); + const [onboardingScreen, setOnboardingScreen] = useState< + 'welcome' | 'topup' | null + >(null); + + // Fetch wallet portfolio + const { + data: walletPortfolioData, + isLoading: walletPortfolioLoading, + isFetching: walletPortfolioFetching, + error: walletPortfolioError, + refetch: refetchWalletPortfolio, + } = useGetWalletPortfolioQuery( + { wallet: accountAddress || '', isPnl: false }, + { + skip: !accountAddress, + refetchOnFocus: false, + } + ); + + // Convert portfolio data to tokens format + const portfolioTokens = useMemo(() => { + if (!walletPortfolioData?.result?.data) return []; + return convertPortfolioAPIResponseToToken(walletPortfolioData.result.data); + }, [walletPortfolioData]); + + // Smart loading state for portfolio to prevent flickering on refetch + const [hasPortfolioLoaded, setHasPortfolioLoaded] = useState(false); + + useEffect(() => { + if (portfolioTokens.length > 0 || (!walletPortfolioLoading && walletPortfolioData)) { + setHasPortfolioLoaded(true); + } + }, [portfolioTokens, walletPortfolioLoading, walletPortfolioData]); + + // Show loading only on initial fetch + const displayPortfolioLoading = walletPortfolioLoading && !hasPortfolioLoaded; + + // Sync sellToken to topupToken when coming from search in topup mode + useEffect(() => { + if (isSearchingFromTopup && sellToken) { + setTopupToken(sellToken); + setIsSearchingFromTopup(false); + } + }, [isSearchingFromTopup, sellToken]); + + // Render Search if active + if (searching) { + return ( + + ); + } + + // Render HomeScreen - core Gas Tank functionality + return ( + + ); +}; diff --git a/src/apps/gas-tank/components/App/HomeScreen.tsx b/src/apps/gas-tank/components/App/HomeScreen.tsx new file mode 100755 index 00000000..ff966303 --- /dev/null +++ b/src/apps/gas-tank/components/App/HomeScreen.tsx @@ -0,0 +1,140 @@ +import { Dispatch, SetStateAction } from 'react'; +import { useGasTankHistory } from '../../hooks/useGasTankHistory'; +import { GasTankBalanceCard } from '../Balance/GasTankBalanceCard'; +import { GasTankHistoryCard } from '../History/GasTankHistoryCard'; +import { SkeletonBalanceCard, SkeletonHistoryCard } from '../History/GasTankSkeleton'; +import TopUpScreen from '../../../pulse/components/Onboarding/TopUpScreen'; +import { SelectedToken } from '../../../pulse/types/tokens'; +import { PortfolioToken } from '../../../../services/tokensData'; +import PillarXLogo from '../../../../assets/images/pillarX_full_white.png'; + +interface HomeScreenProps { + accountAddress: string | null; + setSearching: Dispatch>; + buyToken: SelectedToken | null; + setBuyToken: Dispatch>; + sellToken: SelectedToken | null; + setSellToken: Dispatch>; + isBuy: boolean; + setIsBuy: Dispatch>; + refetchWalletPortfolio: () => void; + refetchGasTankBalance: () => void; + setIsSearchingFromTopup: Dispatch>; + portfolioTokens: PortfolioToken[]; + isPortfolioLoading: boolean; + topupToken: SelectedToken | null; + setTopupToken: Dispatch>; + onboardingScreen: 'welcome' | 'topup' | null; + setOnboardingScreen: Dispatch>; + totalBalance: number; + isBalanceLoading: boolean; + hasPortfolioLoaded: boolean; +} + +/** + * Main gas tank home screen + * Displays balance card and transaction history card + * Manages top-up flow + */ +export const HomeScreen: React.FC = ({ + accountAddress, + setSearching, + buyToken, + setBuyToken, + sellToken, + setSellToken, + isBuy, + setIsBuy, + refetchWalletPortfolio, + refetchGasTankBalance, + setIsSearchingFromTopup, + portfolioTokens, + isPortfolioLoading, + topupToken, + setTopupToken, + onboardingScreen, + setOnboardingScreen, + totalBalance, + isBalanceLoading, + hasPortfolioLoaded, +}) => { + + + + const { + transactions, + isLoading: isHistoryLoading, + error: historyError, + refetch: refetchHistory, + } = useGasTankHistory(accountAddress); + + // Show TopUpScreen if in top-up mode + if (onboardingScreen === 'topup') { + return ( +
+ { + setOnboardingScreen(null); + refetchGasTankBalance(); + }} + initialBalance={totalBalance} + setSearching={() => { + setIsSearchingFromTopup(true); + setSearching(true); + }} + selectedToken={topupToken} + portfolioTokens={portfolioTokens} + setOnboardingScreen={setOnboardingScreen} + markOnboardingComplete={() => {}} + isPortfolioLoading={isPortfolioLoading} + hasPortfolioData={hasPortfolioLoaded} + showCloseButton={true} + /> +
+ ); + } + + +// ... existing code ... + + // Main Gas Tank display + return ( +
+ + {/* PillarX Logo */} +
+ PillarX +
+ + {/* Container with specific gradient background */} +
+ {/* Main content - Flex column on mobile, Row on larger screens */} +
+ {/* Left card - Balance */} + {isBalanceLoading ? ( + + ) : ( + setOnboardingScreen('topup')} + /> + )} + + {/* Right card - History */} + {isHistoryLoading && transactions.length === 0 ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/src/apps/gas-tank/components/Balance/GasTankBalanceCard.tsx b/src/apps/gas-tank/components/Balance/GasTankBalanceCard.tsx new file mode 100755 index 00000000..c9cbc5dd --- /dev/null +++ b/src/apps/gas-tank/components/Balance/GasTankBalanceCard.tsx @@ -0,0 +1,100 @@ +import { ProcessedGasTankTransaction } from '../../types/gasTank'; +import { TailSpin } from 'react-loader-spinner'; +import GasTankIcon from '../../../pulse/assets/gas-tank-icon.svg'; + +interface GasTankBalanceCardProps { + balance: number; + isLoading: boolean; + transactions: ProcessedGasTankTransaction[]; + onTopUpClick: () => void; +} + +/** + * Gas tank balance card component + * Displays: balance, "On All Networks" subtitle, total spend, and top-up button + */ +export const GasTankBalanceCard: React.FC = ({ + balance, + isLoading, + transactions, + onTopUpClick, +}) => { + // Calculate total spend from transactions (sum of all Spend type transactions) + const totalSpend = transactions + .filter((tx) => tx.type === 'Spend') + .reduce((sum, tx) => sum + parseFloat(tx.usdcAmount), 0); + + return ( +
+ {/* Background Vectors Removed */} + + {/* Header with icon */} +
+ {/* Group 1171278651 */} +
+ Gas Tank +
+ + Universal Gas Tank + +
+ + {/* Balance display */} +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ + ${balance.toFixed(2)} + + + On All Networks + +
+ )} +
+ + {/* Top up button */} +
+ +
+ + {/* Subtitle */} +
+ + Top up your Gas Tank so you pay for network fees on every chain. + +
+ + {/* Total spend badge - If user meant "balance needs to be on bottom", maybe they meant "Total Spend"? Positioned here. */} + {totalSpend > 0 && ( +
+ + Total Spend: ${totalSpend.toFixed(2)} + +
+ )} + + {/* Description */} +
+

+ The PillarX Gas Tank is your universal balance for covering transaction + fees across all networks. When you top up your Tank, you’re allocating + tokens specifically for paying gas. You can increase your balance + anytime, and the tokens in your Tank can be used to pay network fees on + any supported chain. +

+
+
+ ); +}; diff --git a/src/apps/gas-tank/components/History/GasTankHistoryCard.tsx b/src/apps/gas-tank/components/History/GasTankHistoryCard.tsx new file mode 100755 index 00000000..595d5664 --- /dev/null +++ b/src/apps/gas-tank/components/History/GasTankHistoryCard.tsx @@ -0,0 +1,252 @@ +import { useMemo, useState, useEffect, useRef } from 'react'; +import { ProcessedGasTankTransaction } from '../../types/gasTank'; +import { TransactionRow } from './TransactionRow'; +import { SkeletonTransactionRow } from './GasTankSkeleton'; +import HistoryIcon from '../../assets/history.svg'; + +interface GasTankHistoryCardProps { + transactions: ProcessedGasTankTransaction[]; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +} + +type SortKey = 'date' | 'type' | 'amount' | 'token'; +type SortDirection = 'asc' | 'desc'; + +interface SortConfig { + key: SortKey; + direction: SortDirection; +} + +/** + * Gas tank history card with sortable columns + * Displays transactions with Date, Type, Amount, and Token columns + */ +export const GasTankHistoryCard: React.FC = ({ + transactions, + isLoading, + error, + onRetry, +}) => { + const [sortConfig, setSortConfig] = useState({ + key: 'date', + direction: 'desc', + }); + + // Infinite scroll state + const [displayedLimit, setDisplayedLimit] = useState(10); + + // Reset displayed limit when transactions change + useMemo(() => { + setDisplayedLimit(10); + }, [transactions]); + + // Sort transactions based on current sort config + const sortedTransactions = useMemo(() => { + const sorted = [...transactions]; + + sorted.sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + + switch (sortConfig.key) { + case 'date': + aValue = a.timestamp; + bValue = b.timestamp; + break; + case 'type': + aValue = a.type; + bValue = b.type; + break; + case 'amount': + // Sort algebraically: Top-up is positive, Spend is negative + const aAmount = parseFloat(a.usdcAmount); + const bAmount = parseFloat(b.usdcAmount); + aValue = a.type === 'Top-up' ? aAmount : -aAmount; + bValue = b.type === 'Top-up' ? bAmount : -bAmount; + break; + case 'token': + aValue = 'USDC'; // All transactions are in USDC + bValue = 'USDC'; + break; + default: + return 0; + } + + if (typeof aValue === 'string') { + if (sortConfig.direction === 'asc') { + return aValue.localeCompare(bValue as string); + } else { + return (bValue as string).localeCompare(aValue); + } + } else { + if (sortConfig.direction === 'asc') { + return aValue - (bValue as number); + } else { + return (bValue as number) - aValue; + } + } + }); + + return sorted; + }, [transactions, sortConfig]); + + const handleSort = (key: SortKey) => { + setSortConfig((current) => ({ + key, + direction: + current.key === key && current.direction === 'asc' ? 'desc' : 'asc', + })); + }; + + const renderSortIndicator = (key: SortKey) => { + const isActive = sortConfig.key === key; + const isAsc = sortConfig.direction === 'asc'; + + return ( +
+ {/* Up arrow */} + + + + {/* Down arrow */} + + + +
+ ); + }; + + const visibleTransactions = sortedTransactions.slice(0, displayedLimit); + const loaderRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && displayedLimit < sortedTransactions.length) { + setDisplayedLimit((prev) => prev + 10); + } + }, + { threshold: 0.1 } + ); + + if (loaderRef.current) { + observer.observe(loaderRef.current); + } + + return () => observer.disconnect(); + }, [displayedLimit, sortedTransactions.length]); + + return ( +
+ {/* Background Vectors Removed */} + + {/* Header */} +
+
+ Gas Tank History +
+

+ Gas Tank History +

+
+ + {/* Column Headers */} +
+ + + + +
+ + {/* Content */} +
+ {error ? ( +
+

+ Failed to load history +

+ +
+ ) : isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ) : sortedTransactions.length === 0 ? ( +
+

No transactions yet

+
+ ) : ( + <> + {visibleTransactions.map((tx) => ( + + ))} + + {/* Infinite Scroll Loader Trigger */} + {displayedLimit < sortedTransactions.length && ( +
+ )} + + )} +
+
+ ); +}; diff --git a/src/apps/gas-tank/components/History/GasTankSkeleton.tsx b/src/apps/gas-tank/components/History/GasTankSkeleton.tsx new file mode 100755 index 00000000..e92d513d --- /dev/null +++ b/src/apps/gas-tank/components/History/GasTankSkeleton.tsx @@ -0,0 +1,85 @@ +/** + * Skeleton loading components for gas tank + */ + +/** + * Skeleton row for transaction history loading state + */ +export const SkeletonTransactionRow: React.FC = () => ( +
+ {/* Date skeleton */} +
+
+
+
+ + {/* Type skeleton */} +
+
+
+ + {/* Amount skeleton */} +
+
+
+ + {/* Token skeleton */} +
+
+
+
+
+); + +/** + * Skeleton balance card + */ +export const SkeletonBalanceCard: React.FC = () => ( +
+ {/* Header skeleton */} +
+
+
+
+ + {/* Balance skeleton */} +
+
+
+
+ + {/* Button skeleton */} +
+ + {/* Total spend skeleton */} +
+
+); + +/** + * Skeleton history card + */ +export const SkeletonHistoryCard: React.FC = () => ( +
+ {/* Header skeleton */} +
+
+
+
+ + {/* Column headers skeleton */} +
+
+
+
+
+
+ + {/* Transaction rows skeleton */} +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+); diff --git a/src/apps/gas-tank/components/History/TransactionRow.tsx b/src/apps/gas-tank/components/History/TransactionRow.tsx new file mode 100755 index 00000000..5239926c --- /dev/null +++ b/src/apps/gas-tank/components/History/TransactionRow.tsx @@ -0,0 +1,95 @@ +import { ProcessedGasTankTransaction } from '../../types/gasTank'; +import { getLogoForChainId, getBlockScan } from '../../../../utils/blockchain'; +import moment from 'moment'; + +interface TransactionRowProps { + transaction: ProcessedGasTankTransaction; +} + +/** + * Individual transaction row component + * Displays: Date | Type | Amount | Chain Icon + * Clickable to open block explorer if transaction hash is available + */ +export const TransactionRow: React.FC = ({ + transaction, +}) => { + const chainLogo = getLogoForChainId(transaction.chainId); + const formattedDate = moment.unix(transaction.timestamp).format('MMM DD'); + const formattedTime = moment.unix(transaction.timestamp).format('HH:mm'); + + // Determine color based on transaction type + const isTopUp = transaction.type === 'Top-up'; + const amountColor = isTopUp ? 'text-[#5CFF93]' : 'text-[#FF366C]'; + + // Handle click to open block explorer + const handleClick = () => { + if (transaction.transactionHash) { + const explorerUrl = getBlockScan(transaction.chainId, false); + if (explorerUrl) { + window.open(`${explorerUrl}${transaction.transactionHash}`, '_blank', 'noopener,noreferrer'); + } + } + }; + + const isClickable = !!transaction.transactionHash; + + return ( +
+ {/* Date Column */} +
+ + {formattedDate}, + + + {formattedTime} + +
+ + {/* Type Column */} +
+ + {transaction.type} + +
+ + {/* Amount Column */} +
+ + {transaction.displayAmount} + +
+ + {/* Token with Chain Icon Column */} +
+
+ {/* Main Token Logo - using provided logo or default generic icon if missing */} + Token + + {/* Chain Badge - Bottom Right */} + {chainLogo && ( +
+ Chain +
+ )} +
+ + {transaction.usdcAmount} {transaction.tokenSymbol} + +
+
+ ); +}; diff --git a/src/apps/gas-tank/hooks/useGasTankHistory.ts b/src/apps/gas-tank/hooks/useGasTankHistory.ts new file mode 100755 index 00000000..cf599462 --- /dev/null +++ b/src/apps/gas-tank/hooks/useGasTankHistory.ts @@ -0,0 +1,145 @@ +import { useState, useEffect } from 'react'; +import { utils } from 'ethers'; +import { + GasTankHistoryTransaction, + GasTankHistoryResponse, + ProcessedGasTankTransaction, + UseGasTankHistoryReturn, +} from '../types/gasTank'; + +/** + * Hook for fetching and processing gas tank transaction history + * Handles API calls, data transformation, and USDC amount conversion + */ +export const useGasTankHistory = ( + walletAddress: string | null +): UseGasTankHistoryReturn => { + const [transactions, setTransactions] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchHistory = async () => { + if (!walletAddress) { + setTransactions([]); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; + const response = await fetch( + `${paymasterUrl}/getGasTankHistory?sender=${walletAddress}` + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch gas tank history: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as GasTankHistoryResponse; + const processed = processTransactions(data.history || []); + + // Sort by timestamp, newest first + processed.sort((a, b) => b.timestamp - a.timestamp); + + setTransactions(processed); + } catch (err) { + console.error('Error fetching gas tank history:', err); + const errorMessage = + err instanceof Error ? err.message : 'Unknown error occurred'; + setError(new Error(errorMessage)); + setTransactions([]); + } finally { + setIsLoading(false); + } + }; + + /** + * Transform raw API transactions to display format + * Handles both Deposit and TransactionRepayment types + */ + const processTransactions = ( + rawTransactions: GasTankHistoryTransaction[] + ): ProcessedGasTankTransaction[] => { + return rawTransactions.map((tx) => { + const timestamp = typeof tx.timestamp === 'string' + ? parseInt(tx.timestamp, 10) + : tx.timestamp; + + // Default values + let type: 'Top-up' | 'Spend' = 'Spend'; + let usdcAmount = '0'; + let displayAmount = '$0.00'; + let tokenSymbol = 'USDC'; + let tokenLogo = tx.tokenLogo; + + // Extract details based on types and swap data + if (tx.transactionType === 'Deposit') { + type = 'Top-up'; + const amount = typeof tx.amount === 'string' ? parseFloat(tx.amount) : tx.amount; + usdcAmount = amount.toFixed(2); + displayAmount = `+$${amount.toFixed(2)}`; + } else { + type = 'Spend'; + const usdcDecimals = tx.chainId === 56 ? 18 : 6; + const amountInWei = typeof tx.amount === 'string' ? tx.amount : tx.amount.toString(); + try { + const rawAmount = utils.formatUnits(amountInWei, usdcDecimals); + const amount = parseFloat(rawAmount); + usdcAmount = amount.toFixed(2); + displayAmount = amount > 0 ? `-$${amount.toFixed(2)}` : '-$0.00'; + } catch (err) { + console.error(`Failed to convert amount for transaction ${tx.id}`, err); + } + } + + // Override with swap data if present + if (tx.swap && tx.swap.length > 0) { + const swapData = tx.swap[0]; + tokenSymbol = swapData.asset.symbol; + if (swapData.asset.logo) { + tokenLogo = swapData.asset.logo; + } + + // Use swap amounts + // amount is token amount + usdcAmount = swapData.amount.toFixed(4); // Show more decimals for tokens which might be small + + // amount_usd is the dollar value + const usdVal = swapData.amount_usd; + const sign = type === 'Top-up' ? '+' : '-'; + displayAmount = `${sign}$${usdVal.toFixed(2)}`; + } + + return { + id: tx.id, + timestamp, + chainId: tx.chainId, + type, + usdcAmount, + displayAmount, + transactionHash: tx.transactionHash, + tokenLogo, + tokenSymbol, + }; + }); + }; + + // Fetch history on mount or when wallet address changes + useEffect(() => { + fetchHistory(); + }, [walletAddress]); + + return { + transactions, + isLoading, + error, + refetch: fetchHistory, + }; +}; diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx new file mode 100755 index 00000000..263b94f9 --- /dev/null +++ b/src/apps/gas-tank/index.tsx @@ -0,0 +1,7 @@ +import { AppWrapper } from './components/App/AppWrapper'; + +/** + * Gas Tank App Entry Point + * Main component exported for app loader + */ +export default AppWrapper; diff --git a/src/apps/gas-tank/manifest.json b/src/apps/gas-tank/manifest.json new file mode 100755 index 00000000..18b41265 --- /dev/null +++ b/src/apps/gas-tank/manifest.json @@ -0,0 +1,5 @@ +{ + "title": "Gas Tank", + "description": "Universal Gas Tank - Manage your gas balance across all networks", + "translations": {} +} diff --git a/src/apps/gas-tank/types/gasTank.ts b/src/apps/gas-tank/types/gasTank.ts new file mode 100755 index 00000000..2d2259a8 --- /dev/null +++ b/src/apps/gas-tank/types/gasTank.ts @@ -0,0 +1,72 @@ +/** + * Gas Tank transaction types and interfaces + */ + +export type GasTankTransactionType = 'Deposit' | 'TransactionRepayment'; + +/** + * Swap data interfaces + */ +export interface SwapAsset { + id: number; + symbol: string; + decimals: number; + logo?: string; + name?: string; +} + +export interface SwapTransactionData { + id: string; + amount_usd: number; + amount: number; + asset: SwapAsset; +} + +/** + * Raw transaction data from API + */ +export interface GasTankHistoryTransaction { + id: string; + timestamp: number | string; + chainId: number; + amount: string | number; // In USDC smallest units (wei-like format) + transactionType: GasTankTransactionType; + transactionHash?: string; // Transaction hash for block explorer link + amountUsd?: string; // Only for Deposit transactions + sender?: string; + blockHash?: string; + swap?: SwapTransactionData[]; + tokenLogo?: string; +} + +/** + * API response from getGasTankHistory endpoint + */ +export interface GasTankHistoryResponse { + history: GasTankHistoryTransaction[]; +} + +/** + * Processed transaction ready for display + */ +export interface ProcessedGasTankTransaction { + id: string; + timestamp: number; + chainId: number; + type: 'Top-up' | 'Spend'; + usdcAmount: string; // Formatted USDC amount (e.g., \"0.21\") + displayAmount: string; // Display string (e.g., \"+0.09 USDC\" or \"-0.21 USDC (gas)\") + transactionHash?: string; // Transaction hash for block explorer link + tokenLogo?: string; + tokenSymbol: string; +} + +/** + * Hook return type for useGasTankHistory + */ +export interface UseGasTankHistoryReturn { + transactions: ProcessedGasTankTransaction[]; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} diff --git a/src/apps/pillarx-app/components/GasTankTile/GasTankTile.tsx b/src/apps/pillarx-app/components/GasTankTile/GasTankTile.tsx new file mode 100644 index 00000000..a9b4817e --- /dev/null +++ b/src/apps/pillarx-app/components/GasTankTile/GasTankTile.tsx @@ -0,0 +1,166 @@ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +// hooks +import { useGasTankBalance } from '../../../pulse/hooks/useGasTankBalance'; +import { useGasTankHistory } from '../../../gas-tank/hooks/useGasTankHistory'; +import useTransactionKit from '../../../../hooks/useTransactionKit'; + +// assets +import GasTankIcon from '../../../pulse/assets/gas-tank-icon.svg'; + +// components +import { TransactionRow } from '../../../gas-tank/components/History/TransactionRow'; +import { SkeletonTransactionRow } from '../../../gas-tank/components/History/GasTankSkeleton'; +import PillarXTile from '../../components/TileContainer/TileContainer'; + +const GasTankTile = () => { + const navigate = useNavigate(); + const { walletAddress } = useTransactionKit(); + + const { totalBalance, isLoading: isBalanceLoading } = useGasTankBalance(walletAddress || null); + const { transactions, isLoading: isHistoryLoading } = useGasTankHistory(walletAddress || null); + + // Take first 5 transactions + const formatTransactions = transactions.slice(0, 5); + + const handleClick = () => { + navigate('/gas-tank'); + }; + + return ( + + {/* Left Panel: Detailed Balance Card */} +
+ {/* Header with icon */} +
+
+ Gas Tank +
+ + Universal Gas Tank + +
+ + {/* Balance */} +
+ {isBalanceLoading ? ( + + ) : ( +
+ + ${totalBalance.toFixed(2)} + + + On All Networks + +
+ )} +
+ + {/* Description */} +
+

+ The PillarX Gas Tank is your universal balance for covering transaction + fees across all networks. +

+
+
+ + {/* Right Panel: History */} +
+ {/* Column Headers */} +
+
+ Date +
+
+ Type +
+
+ Amount +
+
+ Token +
+
+ + {/* List */} +
+ {isHistoryLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : formatTransactions.length === 0 ? ( + No recent transactions + ) : ( +
+ {formatTransactions.map((tx) => ( + + ))} +
+ )} +
+
+
+ ); +}; + +// Reuse styles similar to GasTankBalanceCard but adapted for Tile +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + background: #1E1D24; + border: 1px solid #25232D; + border-radius: 20px; + padding: 24px; + cursor: pointer; + transition: border-color 0.2s; + min-height: 350px; + gap: 24px; + + @media (min-width: 768px) { + flex-direction: row; + align-items: stretch; + } + + &:hover { + border-color: #353340; + } +`; + +const EmptyState = styled.div` + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + text-align: center; + padding: 20px 0; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +`; + +const SkeletonBalance = styled.div` + width: 120px; + height: 40px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: .5; + } + } +`; + +export default GasTankTile; diff --git a/src/apps/pillarx-app/index.tsx b/src/apps/pillarx-app/index.tsx index 1d31f168..71fe1c70 100644 --- a/src/apps/pillarx-app/index.tsx +++ b/src/apps/pillarx-app/index.tsx @@ -127,6 +127,16 @@ const App = () => { } }); + // Inject Gas Tank Tile + const gasTankTile: Projection = { + id: 'gas-tank-tile', + layout: ApiLayout.GAS_TANK, + meta: { + display: { title: 'Gas Tank' }, + }, + data: {} as any, + }; + // Inject Algo Insights Tile (Mock Data) const algoTile: Projection = { id: 'algo-insights-mock', @@ -220,6 +230,10 @@ const App = () => { }; // Add to the beginning of the feed if not already present + // Add GasTank first, then Algo, so Algo ends up on top (index 0) and GasTank at index 1 + if (!newApiData.some((item) => item.id === gasTankTile.id)) { + newApiData.unshift(gasTankTile); + } if (!newApiData.some((item) => item.id === algoTile.id)) { newApiData.unshift(algoTile); } diff --git a/src/apps/pillarx-app/utils/configComponent.ts b/src/apps/pillarx-app/utils/configComponent.ts index c42caff1..19a7de0f 100644 --- a/src/apps/pillarx-app/utils/configComponent.ts +++ b/src/apps/pillarx-app/utils/configComponent.ts @@ -11,6 +11,7 @@ import TokensHorizontalTile from '../components/TokensHorizontalTile/TokensHoriz import TokensVerticalTile from '../components/TokensVerticalTile/TokensVerticalTile'; import TokensWithMarketDataTile from '../components/TokensWithMarketDataTile/TokensWithMarketDataTile'; import AlgoInsightsTile from '../components/AlgoInsightsTile/AlgoInsightsTile'; +import GasTankTile from '../components/GasTankTile/GasTankTile'; type TileComponentType = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -26,4 +27,5 @@ export const componentMap: TileComponentType = { [ApiLayout.PXPOINTS]: PointsTile, [ApiLayout.TOKENS_WITH_MARKET_DATA]: TokensWithMarketDataTile, [ApiLayout.ALGO_INSIGHTS]: AlgoInsightsTile, + [ApiLayout.GAS_TANK]: GasTankTile, }; diff --git a/src/apps/pulse/components/Onboarding/TopUpScreen.tsx b/src/apps/pulse/components/Onboarding/TopUpScreen.tsx index 0f69f679..79f295be 100644 --- a/src/apps/pulse/components/Onboarding/TopUpScreen.tsx +++ b/src/apps/pulse/components/Onboarding/TopUpScreen.tsx @@ -61,6 +61,7 @@ interface TopUpScreenProps { setOnboardingScreen: Dispatch>; markOnboardingComplete: () => void; isPortfolioLoading?: boolean; + hasPortfolioData?: boolean; // True if portfolio has been loaded at least once showCloseButton?: boolean; // Show ESC button instead of back arrow } @@ -74,6 +75,7 @@ export default function TopUpScreen(props: TopUpScreenProps) { setOnboardingScreen, markOnboardingComplete, isPortfolioLoading = false, + hasPortfolioData = false, showCloseButton = false, } = props; const [amount, setAmount] = useState(''); diff --git a/src/apps/pulse/components/Search/Search.tsx b/src/apps/pulse/components/Search/Search.tsx index 3d1aab76..f1426fb4 100644 --- a/src/apps/pulse/components/Search/Search.tsx +++ b/src/apps/pulse/components/Search/Search.tsx @@ -466,7 +466,10 @@ export default function Search({ limitDigitsNumber(item.price || 0) ), dailyPriceChange: - 'price_change_24h' in item ? item.price_change_24h || 0.0 : 0.0, + 'price_change_24h' in item && + typeof item.price_change_24h === 'number' + ? item.price_change_24h + : 0.0, chainId: selectedChainId, decimals: selectedDecimals, address: selectedContract, diff --git a/src/apps/pulse/hooks/useGasTankBalance.ts b/src/apps/pulse/hooks/useGasTankBalance.ts index 8a121c95..9ee6c7b9 100644 --- a/src/apps/pulse/hooks/useGasTankBalance.ts +++ b/src/apps/pulse/hooks/useGasTankBalance.ts @@ -84,11 +84,26 @@ export function useGasTankBalance( } }; + // Initial fetch when wallet address or paymaster URL changes useEffect(() => { fetchGasTankBalance(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletAddress, paymasterUrl]); + // Polling: Refetch balance every 30 seconds + useEffect(() => { + if (!walletAddress || !paymasterUrl) return; + + const POLL_INTERVAL = 30000; // 30 seconds + const intervalId = setInterval(() => { + fetchGasTankBalance(); + }, POLL_INTERVAL); + + // Cleanup interval on unmount + return () => clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletAddress, paymasterUrl]); + return { totalBalance, chainBalances, diff --git a/src/components/AppIcon.tsx b/src/components/AppIcon.tsx index cf636fa9..64f0cc1c 100644 --- a/src/components/AppIcon.tsx +++ b/src/components/AppIcon.tsx @@ -7,6 +7,9 @@ import { animation } from '../theme'; import { ApiAllowedApp } from '../providers/AllowedAppsProvider'; import { AppManifest } from '../types'; +// assets +import GasTankIcon from '../apps/pulse/assets/gas-tank-icon.svg'; + const AppIcon = ({ app, appId, @@ -20,6 +23,11 @@ const AppIcon = ({ useEffect(() => { const loadIconSrc = async () => { + if (appId === 'gas-tank') { + setIconSrc(GasTankIcon); + return; + } + if ((app as ApiAllowedApp).type === 'app-external') { setIconSrc((app as ApiAllowedApp).logo); } else { diff --git a/src/providers/AllowedAppsProvider.tsx b/src/providers/AllowedAppsProvider.tsx index 6b1c49be..e329a009 100644 --- a/src/providers/AllowedAppsProvider.tsx +++ b/src/providers/AllowedAppsProvider.tsx @@ -81,13 +81,48 @@ const AllowedAppsProvider = ({ children }: { children: React.ReactNode }) => { paramsSerializer: () => finalQueryString, } ); - if (expired || !data?.length) { + if (expired) { setIsLoading(false); return; } - setAllowed(data?.map((app: ApiAllowedApp) => app)); + + // Default apps that are always available + const defaultApps: ApiAllowedApp[] = [ + { + id: 'gas-tank', + type: 'app', + appId: 'gas-tank', + title: 'Gas Tank', + name: 'Gas Tank', + shortDescription: 'Universal Gas Tank', + longDescription: + 'Manage your gas balance across all networks with the PillarX Gas Tank', + logo: '../apps/gas-tank/assets/gas-tank-icon.svg', + }, + ]; + + // Combine default apps with API-fetched apps + const allApps = data?.length ? [...defaultApps, ...data] : defaultApps; + setAllowed(allApps.map((app: ApiAllowedApp) => app)); } catch (e) { console.warn('Error calling PillarX apps API', e); + // Still provide default apps even if API fails + if (!expired) { + const defaultApps: ApiAllowedApp[] = [ + { + id: 'gas-tank', + type: 'app', + appId: 'gas-tank', + title: 'Gas Tank', + name: 'Gas Tank', + shortDescription: 'Universal Gas Tank', + longDescription: + 'Manage your gas balance across all networks with the PillarX Gas Tank', + logo: '../apps/gas-tank/assets/gas-tank-icon.svg', + }, + ]; + setAllowed(defaultApps); + } } setIsLoading(false); })(); diff --git a/src/types/api.ts b/src/types/api.ts index 541bb0c2..0b929080 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -8,6 +8,7 @@ export enum ApiLayout { PXPOINTS = 'PXPOINTS', TOKENS_WITH_MARKET_DATA = 'TOKENS_WITH_MARKET_DATA', ALGO_INSIGHTS = 'ALGO_INSIGHTS', + GAS_TANK = 'GAS_TANK', } export enum LeaderboardRankChange { From 08891858dd099aa5ad6d088efac7b72c7deb997a Mon Sep 17 00:00:00 2001 From: Vignesh Date: Mon, 5 Jan 2026 20:24:02 +0530 Subject: [PATCH 2/2] styling fix --- src/apps/gas-tank/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx index 263b94f9..cc322ffe 100755 --- a/src/apps/gas-tank/index.tsx +++ b/src/apps/gas-tank/index.tsx @@ -1,4 +1,5 @@ import { AppWrapper } from './components/App/AppWrapper'; +import '../pulse/styles/tailwindPulse.css'; /** * Gas Tank App Entry Point