diff --git a/webapp/src/components/account/account-ui.tsx b/webapp/src/components/account/account-ui.tsx index d48065d..ab0d4d4 100644 --- a/webapp/src/components/account/account-ui.tsx +++ b/webapp/src/components/account/account-ui.tsx @@ -13,9 +13,10 @@ import { toast } from 'sonner'; import type { TokenAccountEntry } from '@/lib/types'; import { useGetBalanceQuery, useGetTokenAccountsQuery, useAirdropSol, useAirdropUsdc } from './account-data-access'; import { useDelegations, useIncomingDelegations } from '@/hooks/use-delegations'; -import { useUsdcMint, useUsdcMintRaw } from '@/hooks/use-token-config'; import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status'; -import { USDC_MULTIPLIER, cn, recurringAvailable } from '@/lib/utils'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { TokenPicker } from '@/components/token/token-picker'; +import { cn, recurringAvailable } from '@/lib/utils'; import { getBlockTimestamp } from '@/hooks/use-time-travel'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { useProgramAddress } from '@/hooks/use-token-config'; @@ -67,7 +68,10 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { const solQuery = useGetBalanceQuery({ address: addr }); const tokenQuery = useGetTokenAccountsQuery({ address: addr }); const { url: rpcUrl } = useClusterConfig(); - const usdcMint = useUsdcMint(); + const { selectedMint, selectedToken } = useSelectedToken(); + const decimals = selectedToken?.decimals ?? 0; + const symbol = selectedToken?.symbol ?? ''; + const divisor = 10 ** decimals; const progAddr = useProgramAddress(); const outgoing = useDelegations(); const incoming = useIncomingDelegations(); @@ -83,16 +87,21 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { const reservedAmount = useMemo(() => { let total = 0; - for (const d of outgoing.fixed) total += Number(d.data.amount) / USDC_MULTIPLIER; - for (const d of outgoing.recurring) total += Number(d.data.amountPerPeriod) / USDC_MULTIPLIER; + for (const d of outgoing.fixed) { + if (d.data.mint === selectedMint) total += Number(d.data.amount) / divisor; + } + for (const d of outgoing.recurring) { + if (d.data.mint === selectedMint) total += Number(d.data.amountPerPeriod) / divisor; + } return total; - }, [outgoing.fixed, outgoing.recurring]); + }, [outgoing.fixed, outgoing.recurring, selectedMint, divisor]); const incomingAmount = useMemo(() => { let total = 0; for (const d of incoming.all) { + if (d.data.mint !== selectedMint) continue; if (d.type === 'Fixed') { - total += Number(d.data.amount) / USDC_MULTIPLIER; + total += Number(d.data.amount) / divisor; } else { total += Number( @@ -103,22 +112,21 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { d.data.periodLengthS, blockTime, ), - ) / USDC_MULTIPLIER; + ) / divisor; } } return total; - }, [incoming.all, blockTime]); + }, [incoming.all, blockTime, selectedMint, divisor]); - const usdcAccount = useMemo(() => { + const tokenAccount = useMemo(() => { return (tokenQuery.data as TokenAccountEntry[] | undefined)?.find(entry => { - return entry.account?.data?.parsed?.info?.mint === usdcMint; + return entry.account?.data?.parsed?.info?.mint === selectedMint; }); - }, [tokenQuery.data, usdcMint]); + }, [tokenQuery.data, selectedMint]); - const usdcBalance = usdcAccount?.account?.data?.parsed?.info?.tokenAmount?.uiAmount ?? 0; + const tokenBalance = tokenAccount?.account?.data?.parsed?.info?.tokenAmount?.uiAmount ?? 0; - const { mint: usdcMintRaw } = useUsdcMintRaw(); - const { data: statusData } = useSubscriptionAuthorityStatus(usdcMintRaw); + const { data: statusData } = useSubscriptionAuthorityStatus(selectedMint); const delegationId = statusData?.data?.initId ?? null; const [spinning, setSpinning] = useState(false); @@ -151,15 +159,18 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) { )} - - + diff --git a/webapp/src/components/app-providers.tsx b/webapp/src/components/app-providers.tsx index d693a21..a830354 100644 --- a/webapp/src/components/app-providers.tsx +++ b/webapp/src/components/app-providers.tsx @@ -3,6 +3,8 @@ import { SolanaProvider } from './solana/solana-provider'; import { ErrorBoundary } from 'react-error-boundary'; import React from 'react'; +import { SelectedTokenProvider } from '@/hooks/use-selected-token'; + function WalletErrorFallback({ error }: { error: unknown }) { const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; if (error instanceof Error) { @@ -47,7 +49,9 @@ export function AppProviders({ children }: Readonly<{ children: React.ReactNode return ( - {children} + + {children} + ); diff --git a/webapp/src/components/dashboard/summary-cards.tsx b/webapp/src/components/dashboard/summary-cards.tsx index f287500..81afaaa 100644 --- a/webapp/src/components/dashboard/summary-cards.tsx +++ b/webapp/src/components/dashboard/summary-cards.tsx @@ -4,30 +4,36 @@ import { useDelegations, useIncomingDelegations } from '@/hooks/use-delegations' import { useMySubscriptions, useSubscriberCounts } from '@/hooks/use-subscriptions'; import { useMyPlans } from '@/hooks/use-plans'; import { useMemo } from 'react'; -import { USDC_MULTIPLIER } from '@/lib/utils'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { formatTokenAmount } from '@/lib/token-display'; export function SummaryCards() { const outgoing = useDelegations(); const incoming = useIncomingDelegations(); const { data: subscriptions } = useMySubscriptions(); const { data: plans } = useMyPlans(); + const { selectedMint, selectedToken } = useSelectedToken(); + const decimals = selectedToken?.decimals ?? 0; + const symbol = selectedToken?.symbol ?? ''; const outgoingCount = outgoing.all.length; const incomingCount = incoming.all.length; const subsCounts = useMemo(() => { - if (!subscriptions || subscriptions.length === 0) return { active: 0, totalAmount: 0 }; - const active = subscriptions.filter(s => Number(s.subscription.expiresAtTs) === 0); + if (!subscriptions || subscriptions.length === 0) return { active: 0, totalAmount: 0n }; + const active = subscriptions.filter( + s => Number(s.subscription.expiresAtTs) === 0 && s.plan?.data.mint === selectedMint, + ); - let totalAmount = 0; + let totalAmount = 0n; for (const sub of active) { if (sub.plan) { - totalAmount += Number(sub.plan.data.terms.amount) / USDC_MULTIPLIER; + totalAmount += sub.plan.data.terms.amount; } } return { active: active.length, totalAmount }; - }, [subscriptions]); + }, [subscriptions, selectedMint]); const planAddresses = useMemo(() => (plans ?? []).map(p => p.address), [plans]); const { data: subscriberCounts } = useSubscriberCounts(planAddresses); @@ -88,12 +94,7 @@ export function SummaryCards() {
Amount - $ - {subsCounts.totalAmount.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{' '} - USDC + {formatTokenAmount(subsCounts.totalAmount, decimals)} {symbol}
diff --git a/webapp/src/components/delegation/active-delegations.tsx b/webapp/src/components/delegation/active-delegations.tsx index 95e5f5e..c6156ab 100644 --- a/webapp/src/components/delegation/active-delegations.tsx +++ b/webapp/src/components/delegation/active-delegations.tsx @@ -26,7 +26,6 @@ import { useWallet } from '@solana/connector/react'; import { address } from '@solana/kit'; import { useMemo, useState } from 'react'; import { - USDC_MULTIPLIER, isExpired, isStillCollectibleSubscription, invalidateWithDelay, @@ -42,6 +41,8 @@ import { getBlockTimestamp } from '@/hooks/use-time-travel'; import { useMySubscriptions } from '@/hooks/use-subscriptions'; import { getDelegationApprovalState } from '@/lib/delegation-approval-state'; import { groupDelegationsByMint } from '@/lib/delegation-filters'; +import { useTokenConfig } from '@/hooks/use-token-config'; +import { formatTokenAmount, parseTokenAmount, resolvePlanTokenDisplay } from '@/lib/token-display'; interface ActiveDelegationsProps { tokenMint: string; @@ -87,12 +88,8 @@ function formatTimeRemaining(expiryTs: bigint, blockTime?: number): string | nul return `${Math.floor(remaining / 60)}m left`; } -function formatAmount(amount: bigint | number): string { - const num = Number(amount) / USDC_MULTIPLIER; - return num.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }); +function formatAmount(amount: bigint | number, decimals: number): string { + return formatTokenAmount(BigInt(amount), decimals); } interface RevokeDelegationButtonProps { @@ -154,11 +151,20 @@ function RevokeDelegationButton({ delegation }: RevokeDelegationButtonProps) { interface TransferDelegationButtonProps { delegation: DelegationItem; tokenMint: string; + decimals: number; + symbol: string; disabled?: boolean; blockTime?: number; } -function TransferDelegationButton({ delegation, tokenMint, disabled, blockTime }: TransferDelegationButtonProps) { +function TransferDelegationButton({ + delegation, + tokenMint, + decimals, + symbol, + disabled, + blockTime, +}: TransferDelegationButtonProps) { const [open, setOpen] = useState(false); const [amount, setAmount] = useState(''); const { transferFixed, transferRecurring } = useSubscriptionsMutations(); @@ -173,11 +179,11 @@ function TransferDelegationButton({ delegation, tokenMint, disabled, blockTime } delegation.data.periodLengthS, blockTime, ); - const availableAmount = formatAmount(availableRaw); + const availableAmount = formatAmount(availableRaw, decimals); const handleTransfer = async () => { try { - const amountBigInt = BigInt(Math.floor(parseFloat(amount) * USDC_MULTIPLIER)); + const amountBigInt = parseTokenAmount(amount, decimals); const mutation = isFixed ? transferFixed : transferRecurring; await mutation.mutateAsync({ @@ -222,14 +228,16 @@ function TransferDelegationButton({ delegation, tokenMint, disabled, blockTime }
- + setAmount(e.target.value)} placeholder="0.00" /> -

Available: {availableAmount} USDC

+

+ Available: {availableAmount} {symbol} +

@@ -274,6 +282,8 @@ interface DelegationTableProps { mode: TabType; showExpired?: boolean; tokenMint: string; + decimals: number; + symbol: string; blockTime?: number; subscriptionAuthorityInitId?: bigint | null; } @@ -283,6 +293,8 @@ function FixedDelegationTable({ mode, showExpired, tokenMint, + decimals, + symbol, blockTime, subscriptionAuthorityInitId, }: DelegationTableProps) { @@ -367,7 +379,7 @@ function FixedDelegationTable({

- {formatAmount(d.data.amount)} USDC + {formatAmount(d.data.amount, decimals)} {symbol} @@ -393,6 +405,8 @@ function FixedDelegationTable({ @@ -413,6 +427,8 @@ function RecurringDelegationTable({ mode, showExpired, tokenMint, + decimals, + symbol, blockTime, subscriptionAuthorityInitId, }: DelegationTableProps) { @@ -504,7 +520,7 @@ function RecurringDelegationTable({
- {formatAmount(available)} USDC + {formatAmount(available, decimals)} {symbol} / {formatDuration(d.data.periodLengthS)} @@ -539,6 +555,8 @@ function RecurringDelegationTable({ @@ -632,6 +650,8 @@ function InitPrompt({ const { account } = useWallet(); const { initSubscriptionAuthority } = useSubscriptionsMutations(); const queryClient = useQueryClient(); + const { data: tokens } = useTokenConfig(); + const symbol = resolvePlanTokenDisplay(tokenMint, tokens).symbol; const walletAddress = account; const { data: tokenAccounts, isLoading: tokenAccountsLoading } = useGetTokenAccountsQuery({ @@ -695,7 +715,7 @@ function InitPrompt({ {!hasAta && !tokenAccountsLoading && ( -

No token account found. Get some USDC first.

+

No token account found. Get some {symbol} first.

)} groupDelegationsByMint(outgoing.all, tokenMint), [outgoing.all, tokenMint]); const incomingForMint = useMemo(() => groupDelegationsByMint(incoming.all, tokenMint), [incoming.all, tokenMint]); @@ -917,6 +941,8 @@ export function ActiveDelegations({ mode="outgoing" showExpired={showExpired} tokenMint={tokenMint} + decimals={decimals} + symbol={symbol} blockTime={blockTime} subscriptionAuthorityInitId={subscriptionAuthorityInitId} /> @@ -925,6 +951,8 @@ export function ActiveDelegations({ mode="outgoing" showExpired={showExpired} tokenMint={tokenMint} + decimals={decimals} + symbol={symbol} blockTime={blockTime} subscriptionAuthorityInitId={subscriptionAuthorityInitId} /> @@ -941,12 +969,16 @@ export function ActiveDelegations({ delegations={incomingForMint.fixed} mode="incoming" tokenMint={tokenMint} + decimals={decimals} + symbol={symbol} blockTime={blockTime} /> diff --git a/webapp/src/components/delegation/delegation-management-panel.tsx b/webapp/src/components/delegation/delegation-management-panel.tsx index ccd73a4..22e0a5a 100644 --- a/webapp/src/components/delegation/delegation-management-panel.tsx +++ b/webapp/src/components/delegation/delegation-management-panel.tsx @@ -1,7 +1,8 @@ import { AlertCircle } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { ActiveDelegations } from './active-delegations'; -import { useUsdcMintRaw } from '@/hooks/use-token-config'; +import { TokenPicker } from '@/components/token/token-picker'; +import { useSelectedToken } from '@/hooks/use-selected-token'; import { useSubscriptionAuthorityStatus } from '@/hooks/use-subscription-authority-status'; function LoadingState() { @@ -19,9 +20,7 @@ function TokenConfigError() {

Token Configuration Error

-

- USDC token is not configured. Please ensure the API server is running. -

+

No tokens are configured for this network.

@@ -53,7 +52,7 @@ function StatusError({ onRetry }: { onRetry: () => void }) { } export function DelegationManagementPanel() { - const { mint: usdcMint, isLoading: isMintLoading } = useUsdcMintRaw(); + const { selectedMint, tokens } = useSelectedToken(); const { isLoading: statusLoading, isError, @@ -61,9 +60,9 @@ export function DelegationManagementPanel() { isApproved, data: statusData, refetch: refetchStatus, - } = useSubscriptionAuthorityStatus(usdcMint); + } = useSubscriptionAuthorityStatus(selectedMint); - if (isMintLoading || statusLoading) { + if (tokens === undefined || statusLoading) { return ; } @@ -71,7 +70,7 @@ export function DelegationManagementPanel() { return ; } - if (!usdcMint) { + if (!selectedMint) { return ; } @@ -80,6 +79,9 @@ export function DelegationManagementPanel() { return (
+
+ +
{subscriptionAuthorityInitId != null && (
@@ -89,7 +91,7 @@ export function DelegationManagementPanel() {
)} @@ -41,7 +51,8 @@ export function HistoryEntry({ record }: { record: CollectionRecord }) { )} {fmtDateTime(record.timestamp)} - ${totalAmount.toFixed(2)} total from {record.subscribersCollected}/{record.subscribersTotal} subs + {totalAmount.toFixed(2)} {symbol} total from {record.subscribersCollected}/{record.subscribersTotal}{' '} + subs {isSuccess && record.signatures[0] && ( @@ -72,10 +83,13 @@ function CollectPlanCard({ const { url: rpcUrl } = useClusterConfig(); const { collectSubscriptionPayments } = useSubscriptionsMutations(); + const { data: tokens } = useTokenConfig(); + const token = resolvePlanTokenDisplay(plan.data.mint, tokens); + const decimals = token.decimals ?? 0; + const meta = useMemo(() => parsePlanMeta(plan.data.metadataUri), [plan.data.metadataUri]); const planName = meta.n || `Plan ${ellipsify(plan.address)}`; const PlanIcon = (meta.i && ICON_MAP[meta.i]) || Star; - const amountUsd = Number(plan.data.terms.amount) / USDC_MULTIPLIER; // eslint-disable-next-line react-hooks/exhaustive-deps const history = useMemo(() => getCollectionHistory(plan.address), [plan.address, historyVersion]); @@ -151,8 +165,8 @@ function CollectPlanCard({

{planName}

- ${amountUsd.toFixed(2)} / period - {subscriberCount} subscriber - {subscriberCount !== 1 ? 's' : ''} + {formatTokenAmount(plan.data.terms.amount, decimals)} {token.symbol} / period -{' '} + {subscriberCount} subscriber{subscriberCount !== 1 ? 's' : ''}

@@ -177,7 +191,7 @@ function CollectPlanCard({

Collection History

{history.slice(0, 10).map(record => ( - + ))}
)} diff --git a/webapp/src/components/plan/enhanced-collect-payments.tsx b/webapp/src/components/plan/enhanced-collect-payments.tsx index 0818cab..2a32691 100644 --- a/webapp/src/components/plan/enhanced-collect-payments.tsx +++ b/webapp/src/components/plan/enhanced-collect-payments.tsx @@ -36,27 +36,71 @@ import { clearCollectionHistory, } from '@/lib/collection-history'; import { parsePlanMeta, ICON_MAP } from '@/lib/plan-constants'; -import { USDC_MULTIPLIER, ellipsify, fmtDateShort } from '@/lib/utils'; +import { ellipsify, fmtDateShort } from '@/lib/utils'; +import { useTokenConfig } from '@/hooks/use-token-config'; +import { resolvePlanTokenDisplay } from '@/lib/token-display'; +import type { TokenConfig } from '@/config/networks'; + +interface PendingTokenTotal { + symbol: string; + amount: number; +} + +function sumPendingByToken(plans: PlanSubscriberData[], tokens: TokenConfig[] | undefined): PendingTokenTotal[] { + const byMint = new Map(); + for (const p of plans) { + if (p.totalPending <= 0n) continue; + const t = resolvePlanTokenDisplay(p.plan.data.mint, tokens); + const existing = byMint.get(p.plan.data.mint); + if (existing) existing.raw += p.totalPending; + else byMint.set(p.plan.data.mint, { symbol: t.symbol, decimals: t.decimals ?? 0, raw: p.totalPending }); + } + return [...byMint.values()].map(v => ({ symbol: v.symbol, amount: Number(v.raw) / 10 ** v.decimals })); +} + +function formatPendingTotals(totals: PendingTokenTotal[]): string { + if (totals.length === 0) return '0'; + return totals + .map( + t => + `${t.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${t.symbol}`, + ) + .join(' ยท '); +} function CollectSummaryCards({ - totalPending, + pendingByToken, activeSubscribers, cancelledCount, plansWithPending, totalPlans, }: { - totalPending: number; + pendingByToken: PendingTokenTotal[]; activeSubscribers: number; cancelledCount: number; plansWithPending: number; totalPlans: number; }) { + const pendingValue = + pendingByToken.length === 0 ? ( + 0 + ) : ( +
+ {pendingByToken.map(t => ( + + {t.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '} + {t.symbol} + + ))} +
+ ); + const cards = [ { icon: DollarSign, title: 'Total Pending', row1Label: 'Amount', - row1Value: `$${totalPending.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USDC`, + row1Value: pendingValue, row2Label: 'Across', row2Value: `${activeSubscribers + cancelledCount} subscribers`, }, @@ -93,7 +137,7 @@ function CollectSummaryCards({
{card.row1Label} - {card.row1Value} +
{card.row1Value}
@@ -110,11 +154,11 @@ function CollectSummaryCards({ function CollectAllButton({ plansData, - totalPendingUsd, + pendingByToken, onComplete, }: { plansData: PlanSubscriberData[]; - totalPendingUsd: number; + pendingByToken: PendingTokenTotal[]; onComplete?: () => void; }) { const [collecting, setCollecting] = useState(false); @@ -218,9 +262,7 @@ function CollectAllButton({ loading={collecting} onClick={handleCollectAll} > - {collecting - ? progress || 'Collecting...' - : `Collect All Pending ($${totalPendingUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`} + {collecting ? progress || 'Collecting...' : `Collect All Pending (${formatPendingTotals(pendingByToken)})`} ); } @@ -235,11 +277,14 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData; const { collectSubscriptionPayments } = useSubscriptionsMutations(); const { plan, subscribers, currentSubscribers, staleAuthoritySubscribers, staleSubscribers, eligible } = planData; + const { data: tokens } = useTokenConfig(); + const token = resolvePlanTokenDisplay(plan.data.mint, tokens); + const decimals = token.decimals ?? 0; const meta = useMemo(() => parsePlanMeta(plan.data.metadataUri), [plan.data.metadataUri]); const planName = meta.n || `Plan ${ellipsify(plan.address)}`; const PlanIcon = (meta.i && ICON_MAP[meta.i]) || Star; - const amountUsd = Number(plan.data.terms.amount) / USDC_MULTIPLIER; - const pendingUsd = Number(planData.totalPending) / USDC_MULTIPLIER; + const amountUsd = Number(plan.data.terms.amount) / 10 ** decimals; + const pendingUsd = Number(planData.totalPending) / 10 ** decimals; const staleSubscriberAddresses = useMemo( () => new Set(staleSubscribers.map(sub => sub.subscriptionAddress)), [staleSubscribers], @@ -336,8 +381,8 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;

{planName}

- ${amountUsd.toFixed(2)}/period · {eligible.length}/{currentSubscribers.length}{' '} - eligible + {amountUsd.toFixed(2)} {token.symbol}/period · {eligible.length}/ + {currentSubscribers.length} eligible {staleSubscribers.length + staleAuthoritySubscribers.length > 0 && ` / ${staleSubscribers.length + staleAuthoritySubscribers.length} stale`}

@@ -345,7 +390,9 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData;
{pendingUsd > 0 && ( - ${pendingUsd.toFixed(2)} pending + + {pendingUsd.toFixed(2)} {token.symbol} pending + )} - Collect ${pendingUsd.toFixed(2)} + Collect {pendingUsd.toFixed(2)} {token.symbol}
@@ -403,7 +450,7 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData; sub.subscriptionAddress, ); const collectibleUsd = eligEntry - ? Number(eligEntry.collectAmount) / USDC_MULTIPLIER + ? Number(eligEntry.collectAmount) / 10 ** decimals : null; return ( @@ -437,7 +484,7 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData; Excluded ) : collectibleUsd !== null ? ( - ${collectibleUsd.toFixed(2)} + {collectibleUsd.toFixed(2)} {token.symbol} ) : ( Collected @@ -454,7 +501,12 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData; ) : (
{history.slice(0, 10).map(record => ( - + ))}
)} @@ -466,6 +518,10 @@ function EnhancedPlanCard({ planData, blockTs }: { planData: PlanSubscriberData; } function RecentCollections({ version, onClear }: { version: number; onClear: () => void }) { + const { data: tokens } = useTokenConfig(); + const primary = tokens?.[0]; + const decimals = primary?.decimals ?? 0; + const symbol = primary?.symbol ?? ''; // eslint-disable-next-line react-hooks/exhaustive-deps const history = useMemo(() => getCollectionHistory(), [version]); @@ -514,7 +570,7 @@ function RecentCollections({ version, onClear }: { version: number; onClear: () {record.planName}
- +
))} @@ -525,6 +581,7 @@ function RecentCollections({ version, onClear }: { version: number; onClear: () export function EnhancedCollectPayments() { const { data, isLoading, allPlans, plansWithSubs, refetch } = useAllPlanSubscribers(); + const { data: tokens } = useTokenConfig(); const queryClient = useQueryClient(); const [spinning, setSpinning] = useState(false); const [historyVersion, setHistoryVersion] = useState(0); @@ -567,7 +624,7 @@ export function EnhancedCollectPayments() { ); } - const totalPendingUsd = data ? Number(data.totalPendingAmount) / USDC_MULTIPLIER : 0; + const pendingByToken = sumPendingByToken(data?.plans ?? [], tokens); const totalActive = data?.totalActiveSubscribers ?? 0; const totalCancelled = data?.plans.reduce((sum, p) => sum + p.cancelledCount, 0) ?? 0; const plansWithPending = data?.plansWithPending ?? 0; @@ -576,7 +633,7 @@ export function EnhancedCollectPayments() { return (
setHistoryVersion(v => v + 1)} />
diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx index 33d0820..909151d 100644 --- a/webapp/src/components/solana/solana-provider.tsx +++ b/webapp/src/components/solana/solana-provider.tsx @@ -8,6 +8,7 @@ import { useWallet, useWalletConnectors, useWalletInfo, + type SolanaCluster, type SolanaClusterId, } from '@solana/connector/react'; import { Button } from '@solana/design-system'; @@ -22,14 +23,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { CUSTOM_CLUSTER_ID, readCustomCluster } from '@/lib/custom-rpc'; +import { readCustomRpc } from '@/lib/custom-rpc'; import { ellipsify } from '@/lib/utils'; function defaultClusterId(): SolanaClusterId { const stored = localStorage.getItem('setup-cluster'); const configured = import.meta.env.VITE_DEFAULT_CLUSTER; const id = stored || configured || (import.meta.env.DEV ? 'solana:localnet' : 'solana:devnet'); - if (id === CUSTOM_CLUSTER_ID && readCustomCluster()) return CUSTOM_CLUSTER_ID; return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' || id === 'solana:mainnet' ? (id as SolanaClusterId) : 'solana:devnet'; @@ -38,23 +38,32 @@ function defaultClusterId(): SolanaClusterId { function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'mainnet' | 'testnet' { if (clusterId === 'solana:devnet') return 'devnet'; if (clusterId === 'solana:testnet') return 'testnet'; - if (clusterId === 'solana:mainnet' || clusterId === CUSTOM_CLUSTER_ID) return 'mainnet'; + if (clusterId === 'solana:mainnet') return 'mainnet'; return 'localnet'; } -function buildClusters() { - const custom = readCustomCluster(); - return [ +function buildClusters(): SolanaCluster[] { + const clusters: SolanaCluster[] = [ ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []), - { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' }, - { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' }, + { id: 'solana:devnet', label: 'Devnet', url: 'https://api.devnet.solana.com' }, + { id: 'solana:testnet', label: 'Testnet', url: 'https://api.testnet.solana.com' }, { - id: 'solana:mainnet' as const, + id: 'solana:mainnet', label: 'Mainnet', url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com', }, - ...(custom ? [custom] : []), ]; + + const custom = readCustomRpc(); + if (custom) { + const target = clusters.find(c => c.id === `solana:${custom.network}`); + if (target) { + target.url = custom.url; + target.label = `${target.label} (${custom.label})`; + } + } + + return clusters; } export function WalletButton() { diff --git a/webapp/src/components/token/add-token-dialog.tsx b/webapp/src/components/token/add-token-dialog.tsx new file mode 100644 index 0000000..3dfe115 --- /dev/null +++ b/webapp/src/components/token/add-token-dialog.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { Button, TextInput } from '@solana/design-system'; +import { address, createSolanaRpc } from '@solana/kit'; +import { useQueryClient } from '@tanstack/react-query'; +import { Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useClusterConfig } from '@/hooks/use-cluster-config'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { clusterIdToNetwork } from '@/lib/cluster'; +import { addCustomToken, readCustomTokens, removeCustomToken } from '@/lib/custom-tokens'; +import { ellipsify } from '@/lib/utils'; + +async function fetchMintDecimals(rpcUrl: string, mint: string): Promise { + const rpc = createSolanaRpc(rpcUrl); + const res = await rpc.getAccountInfo(address(mint), { commitment: 'confirmed', encoding: 'jsonParsed' }).send(); + const data = res.value?.data as { parsed?: { info?: { decimals?: number }; type?: string } } | undefined; + if (!data || data.parsed?.type !== 'mint') throw new Error('Address is not an SPL token mint'); + const decimals = data.parsed.info?.decimals; + if (typeof decimals !== 'number') throw new Error('Could not read mint decimals'); + return decimals; +} + +export function AddTokenDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const { id, url } = useClusterConfig(); + const network = clusterIdToNetwork(id); + const queryClient = useQueryClient(); + const { setSelectedMint } = useSelectedToken(); + const [mint, setMint] = useState(''); + const [symbol, setSymbol] = useState(''); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + const [listVersion, setListVersion] = useState(0); + + void listVersion; + const customTokens = readCustomTokens(network); + + const refresh = async () => { + await queryClient.invalidateQueries({ queryKey: ['network-config'] }); + setListVersion(v => v + 1); + }; + + async function handleAdd() { + const trimmedMint = mint.trim(); + const trimmedSymbol = symbol.trim(); + if (!trimmedMint || !trimmedSymbol) { + toast.error('Enter a mint address and a symbol'); + return; + } + setSaving(true); + try { + const decimals = await fetchMintDecimals(url, trimmedMint); + addCustomToken(network, { + decimals, + mint: trimmedMint, + name: name.trim() || trimmedSymbol, + symbol: trimmedSymbol, + }); + await refresh(); + setSelectedMint(trimmedMint); + toast.success(`Added ${trimmedSymbol}`); + setMint(''); + setSymbol(''); + setName(''); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to add token'); + } finally { + setSaving(false); + } + } + + async function handleRemove(tokenMint: string) { + removeCustomToken(network, tokenMint); + await refresh(); + } + + return ( + + + + Add token + + Track an SPL token on {network}. Decimals are read from the mint; symbol and name are yours. + + + +
+ setMint(e.currentTarget.value)} + placeholder="Mint address" + inputClassName="font-mono" + /> +
+ setSymbol(e.currentTarget.value)} + placeholder="Symbol" + /> + setName(e.currentTarget.value)} + placeholder="Name (optional)" + /> +
+
+ + {customTokens.length > 0 && ( +
+

Custom tokens

+ {customTokens.map(token => ( +
+ + {token.symbol}{' '} + {ellipsify(token.mint)} + +
+ ))} +
+ )} + + + + + +
+
+ ); +} diff --git a/webapp/src/components/token/token-picker.tsx b/webapp/src/components/token/token-picker.tsx new file mode 100644 index 0000000..628a6cc --- /dev/null +++ b/webapp/src/components/token/token-picker.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Button } from '@solana/design-system'; +import { ChevronDown, Plus } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { TokenConfig } from '@/config/networks'; +import { useSelectedToken } from '@/hooks/use-selected-token'; +import { ellipsify } from '@/lib/utils'; +import { AddTokenDialog } from './add-token-dialog'; + +function tokenLabel(token: TokenConfig): string { + return token.symbol || token.name || ellipsify(token.mint); +} + +export function TokenPicker() { + const { tokens, selectedMint, selectedToken, setSelectedMint } = useSelectedToken(); + const [addOpen, setAddOpen] = useState(false); + + return ( +
+ {tokens && tokens.length > 1 && ( + + + + + + {tokens.map(token => ( + setSelectedMint(token.mint)} + className={token.mint === selectedMint ? 'font-medium' : undefined} + > + {tokenLabel(token)} + + ))} + + + )} +
+ ); +} diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts index 3654ce3..2031018 100644 --- a/webapp/src/config/networks.ts +++ b/webapp/src/config/networks.ts @@ -18,10 +18,6 @@ const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; export const STATIC_NETWORKS: Record = { - custom: { - programAddress: PROGRAM_ID, - tokens: [{ decimals: 6, mint: MAINNET_USDC, name: 'USD Coin', symbol: 'USDC' }], - }, devnet: { programAddress: PROGRAM_ID, tokens: [{ decimals: 6, mint: DEVNET_USDC, name: 'USD Coin', symbol: 'USDC' }], diff --git a/webapp/src/hooks/use-selected-token.tsx b/webapp/src/hooks/use-selected-token.tsx new file mode 100644 index 0000000..7a7ea32 --- /dev/null +++ b/webapp/src/hooks/use-selected-token.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; + +import type { TokenConfig } from '@/config/networks'; +import { useTokenConfig } from '@/hooks/use-token-config'; + +const STORAGE_KEY = 'selected-token-mint'; + +interface SelectedTokenValue { + selectedMint: string | null; + selectedToken: TokenConfig | null; + setSelectedMint: (mint: string) => void; + tokens: TokenConfig[] | undefined; +} + +const SelectedTokenContext = createContext(null); + +export function SelectedTokenProvider({ children }: { children: ReactNode }) { + const { data: tokens } = useTokenConfig(); + const [override, setOverride] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + }); + + const setSelectedMint = useCallback((mint: string) => { + try { + localStorage.setItem(STORAGE_KEY, mint); + } catch { + /* empty */ + } + setOverride(mint); + }, []); + + const value = useMemo(() => { + const overrideValid = override != null && (tokens?.some(t => t.mint === override) ?? false); + const selectedMint = (overrideValid ? override : tokens?.[0]?.mint) ?? null; + const selectedToken = tokens?.find(t => t.mint === selectedMint) ?? null; + return { selectedMint, selectedToken, setSelectedMint, tokens }; + }, [tokens, override, setSelectedMint]); + + return {children}; +} + +export function useSelectedToken(): SelectedTokenValue { + const ctx = useContext(SelectedTokenContext); + if (!ctx) throw new Error('useSelectedToken must be used within SelectedTokenProvider'); + return ctx; +} diff --git a/webapp/src/hooks/use-token-config.ts b/webapp/src/hooks/use-token-config.ts index f26370a..e74154b 100644 --- a/webapp/src/hooks/use-token-config.ts +++ b/webapp/src/hooks/use-token-config.ts @@ -4,6 +4,13 @@ import { type NetworkConfig, STATIC_NETWORKS } from '@/config/networks'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { api } from '@/lib/api-client'; import { clusterIdToNetwork } from '@/lib/cluster'; +import { readCustomTokens } from '@/lib/custom-tokens'; + +function withCustomTokens(network: string, config: NetworkConfig): NetworkConfig { + const custom = readCustomTokens(network).filter(c => !config.tokens.some(t => t.mint === c.mint)); + if (custom.length === 0) return config; + return { ...config, tokens: [...config.tokens, ...custom] }; +} export function useNetworkConfig() { const { id } = useClusterConfig(); @@ -11,14 +18,15 @@ export function useNetworkConfig() { return useQuery({ queryFn: async () => { + let base = STATIC_NETWORKS[network]; if (import.meta.env.DEV) { try { - return await api.config.getNetworkConfig(network); + base = await api.config.getNetworkConfig(network); } catch { - return STATIC_NETWORKS[network]; + base = STATIC_NETWORKS[network]; } } - return STATIC_NETWORKS[network]; + return withCustomTokens(network, base); }, queryKey: ['network-config', network, import.meta.env.DEV], retry: 2, diff --git a/webapp/src/lib/cluster.ts b/webapp/src/lib/cluster.ts index 191788d..44c1f3f 100644 --- a/webapp/src/lib/cluster.ts +++ b/webapp/src/lib/cluster.ts @@ -1,7 +1,6 @@ -export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet' | 'custom'; +export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet'; export function clusterIdToNetwork(id: string): Network { - if (id === 'solana:custom') return 'custom'; if (id.includes('devnet')) return 'devnet'; if (id.includes('testnet')) return 'testnet'; if (id.includes('mainnet')) return 'mainnet'; diff --git a/webapp/src/lib/custom-rpc.ts b/webapp/src/lib/custom-rpc.ts index 7e3e659..77f55ec 100644 --- a/webapp/src/lib/custom-rpc.ts +++ b/webapp/src/lib/custom-rpc.ts @@ -1,32 +1,48 @@ -import type { SolanaCluster } from '@solana/connector/react'; +import { createSolanaRpc } from '@solana/kit'; -export const CUSTOM_CLUSTER_ID = 'solana:custom' as const; +export type CustomNetwork = 'mainnet' | 'devnet' | 'testnet'; const URL_KEY = 'custom-rpc-url'; const LABEL_KEY = 'custom-rpc-label'; +const NETWORK_KEY = 'custom-rpc-network'; const SETUP_CLUSTER_KEY = 'setup-cluster'; -const SETUP_COMPLETE_KEY = 'setup-complete-custom'; -export function readCustomCluster(): SolanaCluster | null { +const GENESIS_TO_NETWORK: Record = { + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d': 'mainnet', + EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG: 'devnet', + '4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY': 'testnet', +}; + +export interface CustomRpc { + label: string; + network: CustomNetwork; + url: string; +} + +export function readCustomRpc(): CustomRpc | null { const url = localStorage.getItem(URL_KEY); - if (!url) return null; - return { id: CUSTOM_CLUSTER_ID, label: localStorage.getItem(LABEL_KEY) || 'Custom', url }; + const network = localStorage.getItem(NETWORK_KEY) as CustomNetwork | null; + if (!url || !network) return null; + return { label: localStorage.getItem(LABEL_KEY) || 'Custom', network, url }; } -export function saveCustomCluster(url: string, label?: string): void { +export async function detectNetwork(url: string): Promise { + const genesisHash = await createSolanaRpc(url).getGenesisHash().send(); + return GENESIS_TO_NETWORK[genesisHash] ?? null; +} + +export function saveCustomRpc(url: string, network: CustomNetwork, label?: string): void { localStorage.setItem(URL_KEY, url); + localStorage.setItem(NETWORK_KEY, network); localStorage.setItem(LABEL_KEY, label?.trim() || 'Custom'); - localStorage.setItem(SETUP_CLUSTER_KEY, CUSTOM_CLUSTER_ID); - localStorage.setItem(SETUP_COMPLETE_KEY, 'true'); + localStorage.setItem(SETUP_CLUSTER_KEY, `solana:${network}`); + localStorage.setItem(`setup-complete-${network}`, 'true'); } -export function clearCustomCluster(): void { +export function clearCustomRpc(): void { localStorage.removeItem(URL_KEY); + localStorage.removeItem(NETWORK_KEY); localStorage.removeItem(LABEL_KEY); - localStorage.removeItem(SETUP_COMPLETE_KEY); - if (localStorage.getItem(SETUP_CLUSTER_KEY) === CUSTOM_CLUSTER_ID) { - localStorage.removeItem(SETUP_CLUSTER_KEY); - } } export function isValidRpcUrl(value: string): boolean { diff --git a/webapp/src/lib/custom-tokens.ts b/webapp/src/lib/custom-tokens.ts new file mode 100644 index 0000000..6c7c863 --- /dev/null +++ b/webapp/src/lib/custom-tokens.ts @@ -0,0 +1,24 @@ +import type { TokenConfig } from '@/config/networks'; + +const KEY_PREFIX = 'custom-tokens:'; + +export function readCustomTokens(network: string): TokenConfig[] { + try { + const raw = localStorage.getItem(KEY_PREFIX + network); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as TokenConfig[]) : []; + } catch { + return []; + } +} + +export function addCustomToken(network: string, token: TokenConfig): void { + const existing = readCustomTokens(network).filter(t => t.mint !== token.mint); + localStorage.setItem(KEY_PREFIX + network, JSON.stringify([...existing, token])); +} + +export function removeCustomToken(network: string, mint: string): void { + const existing = readCustomTokens(network).filter(t => t.mint !== mint); + localStorage.setItem(KEY_PREFIX + network, JSON.stringify(existing)); +} diff --git a/webapp/src/lib/utils.ts b/webapp/src/lib/utils.ts index ed2eab9..481b5d3 100644 --- a/webapp/src/lib/utils.ts +++ b/webapp/src/lib/utils.ts @@ -3,8 +3,6 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { QueryClient } from '@tanstack/react-query'; -export const USDC_DECIMALS = 6; -export const USDC_MULTIPLIER = 10 ** USDC_DECIMALS; export const SECONDS_PER_DAY = 86400; const TIME_DRIFT_ALLOWED_SECS = 120;