Skip to content
Merged
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
80 changes: 47 additions & 33 deletions webapp/src/components/account/account-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -151,15 +159,18 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) {
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
iconOnly
iconLeft={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />}
aria-label="Refresh wallet balances"
onClick={handleRefresh}
disabled={isRefreshing}
/>
<div className="flex items-center gap-2">
<TokenPicker />
<Button
variant="secondary"
size="sm"
iconOnly
iconLeft={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />}
aria-label="Refresh wallet balances"
onClick={handleRefresh}
disabled={isRefreshing}
/>
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
Expand Down Expand Up @@ -190,11 +201,11 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) {
<CardHeader className="relative pb-2">
<CardTitle className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-sand-1100">USDC Balance</span>
{usdcMint && (
<span className="text-sm font-medium text-sand-1100">{symbol} Balance</span>
{selectedMint && (
<p className="flex items-center gap-1 text-[10px] font-mono text-sand-900 mt-0.5">
{usdcMint.slice(0, 8)}...{usdcMint.slice(-4)}
<CopyButton value={usdcMint} />
{selectedMint.slice(0, 8)}...{selectedMint.slice(-4)}
<CopyButton value={selectedMint} />
</p>
)}
</div>
Expand All @@ -213,7 +224,7 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) {
<div className="grid grid-cols-2 gap-4">
<div className="min-w-0">
<div className="text-lg sm:text-2xl lg:text-[36px] leading-tight font-semibold tracking-tight text-foreground">
{usdcBalance.toLocaleString(undefined, {
{tokenBalance.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
Expand All @@ -222,10 +233,13 @@ export function WalletBalanceCards({ address: addr }: { address: Address }) {
</div>
<div className="min-w-0">
<div className="text-lg sm:text-2xl lg:text-[36px] leading-tight font-semibold tracking-tight text-foreground">
{(usdcBalance - reservedAmount + incomingAmount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
{(tokenBalance - reservedAmount + incomingAmount).toLocaleString(
undefined,
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}
</div>
<div className="text-sm font-medium text-sand-1000 tracking-wide">
Spendable
Expand Down
36 changes: 26 additions & 10 deletions webapp/src/components/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { CURRENT_PROGRAM_VERSION } from '@solana/subscriptions';
import solanaLogo from '@/assets/solana-logo.svg';
import { clearCustomCluster, isValidRpcUrl, readCustomCluster, saveCustomCluster } from '@/lib/custom-rpc';
import { clearCustomRpc, detectNetwork, isValidRpcUrl, readCustomRpc, saveCustomRpc } from '@/lib/custom-rpc';
import { cn } from '@/lib/utils';

import { NAV_ITEMS, type NavItem } from './nav-items';
Expand All @@ -35,26 +35,39 @@ function ClusterButton() {
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = useState(false);
const [url, setUrl] = useState('');
const [saving, setSaving] = useState(false);

const hasCustom = readCustomCluster() !== null;
const hasCustom = readCustomRpc() !== null;

function openDialog() {
setUrl(readCustomCluster()?.url ?? '');
setUrl(readCustomRpc()?.url ?? '');
setDialogOpen(true);
}

function handleSave() {
async function handleSave() {
const trimmed = url.trim();
if (!isValidRpcUrl(trimmed)) {
toast.error('Enter a valid http(s) RPC URL');
return;
}
saveCustomCluster(trimmed);
window.location.reload();
setSaving(true);
try {
const network = await detectNetwork(trimmed);
if (!network) {
toast.error('Could not detect mainnet, devnet, or testnet from this RPC');
return;
}
saveCustomRpc(trimmed, network);
window.location.reload();
} catch {
toast.error('Could not reach RPC URL');
} finally {
setSaving(false);
}
}

function handleRemove() {
clearCustomCluster();
clearCustomRpc();
window.location.reload();
}

Expand Down Expand Up @@ -119,7 +132,8 @@ function ClusterButton() {
<DialogHeader>
<DialogTitle>Custom RPC endpoint</DialogTitle>
<DialogDescription>
Point the app at your own Solana RPC URL. Saving reloads the page and selects it.
Point the app at your own Solana RPC URL. The network is detected from the endpoint; saving
reloads the page and selects it.
</DialogDescription>
</DialogHeader>
<TextInput
Expand All @@ -129,10 +143,12 @@ function ClusterButton() {
inputClassName="font-mono"
/>
<DialogFooter>
<Button variant="secondary" onClick={() => setDialogOpen(false)}>
<Button variant="secondary" disabled={saving} onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
<Button loading={saving} onClick={() => void handleSave()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
6 changes: 5 additions & 1 deletion webapp/src/components/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -47,7 +49,9 @@ export function AppProviders({ children }: Readonly<{ children: React.ReactNode
return (
<ReactQueryProvider>
<ErrorBoundary FallbackComponent={WalletErrorFallback}>
<SolanaProvider>{children}</SolanaProvider>
<SolanaProvider>
<SelectedTokenProvider>{children}</SelectedTokenProvider>
</SolanaProvider>
</ErrorBoundary>
</ReactQueryProvider>
);
Expand Down
25 changes: 13 additions & 12 deletions webapp/src/components/dashboard/summary-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -88,12 +94,7 @@ export function SummaryCards() {
<div className="flex justify-between items-center text-sm">
<span className="text-sand-1100">Amount</span>
<span className="font-bold text-foreground text-sm sm:text-base truncate">
$
{subsCounts.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
USDC
{formatTokenAmount(subsCounts.totalAmount, decimals)} {symbol}
</span>
</div>
</div>
Expand Down
Loading
Loading