diff --git a/apps/payments/web/src/App.tsx b/apps/payments/web/src/App.tsx index 65c008a21..3c3a5b68b 100644 --- a/apps/payments/web/src/App.tsx +++ b/apps/payments/web/src/App.tsx @@ -13,19 +13,23 @@ import { DashboardView } from './views/DashboardView'; import { EmissionsView } from './views/EmissionsView'; import { DiemRewardsView } from './views/DiemRewardsView'; import { ChannelsView } from './components/ChannelsView'; +import { SettingsView } from './views/SettingsView'; import { AuthorizedWalletProvider } from './context/AuthorizedWalletContext'; +import { useAuthorizedWallet } from './context/AuthorizedWalletContext'; import { AuthorizeWalletAlert } from './layout/AuthorizeWalletAlert'; export type OverlayPhase = 'deposit' | 'success' | null; -const VALID_TABS = new Set(['dashboard', 'channels', 'emissions', 'diem-rewards']); +const VALID_TABS = new Set(['overview', 'rewards', 'diem-rewards', 'activity', 'settings']); function parseTabFromUrl(): TabId { const raw = new URLSearchParams(window.location.search).get('tab'); - if (!raw) return 'dashboard'; + if (!raw) return 'overview'; // Legacy compat: the old deposits tab no longer exists; fall through to dashboard. - if (raw === 'deposit' || raw === 'deposits') return 'dashboard'; - return VALID_TABS.has(raw as TabId) ? (raw as TabId) : 'dashboard'; + if (raw === 'deposit' || raw === 'deposits' || raw === 'dashboard') return 'overview'; + if (raw === 'channels') return 'activity'; + if (raw === 'emissions') return 'rewards'; + return VALID_TABS.has(raw as TabId) ? (raw as TabId) : 'overview'; } function shouldOpenDepositFromUrl(): boolean { @@ -187,6 +191,7 @@ function AppShell({ }: AppShellProps) { const [justDeposited, setJustDeposited] = useState(false); const [depositPromptDismissed, setDepositPromptDismissed] = useState(false); + const authorizedWallet = useAuthorizedWallet(); const isLoading = !balanceLoaded; const isEmptyBuyer = @@ -216,34 +221,48 @@ function AppShell({
0} + isDark={isDark} + onToggleTheme={onToggleTheme} onOpenWallet={onOpenWalletDrawer} - onOpenDeposit={onOpenDeposit} />
- {activeTab === 'dashboard' && } - {activeTab === 'channels' && } - {activeTab === 'emissions' && } + {activeTab === 'overview' && ( + onSelectTab('rewards')} + onOpenDiemRewards={() => onSelectTab('diem-rewards')} + onOpenActivity={() => onSelectTab('activity')} + /> + )} + {activeTab === 'rewards' && } {activeTab === 'diem-rewards' && } + {activeTab === 'activity' && } + {activeTab === 'settings' && ( + + )}
- + (null); @@ -91,20 +92,20 @@ export function AuthorizeWalletModal({ document.body, )} - {!isConnected ? ( + {!walletConnected ? (
Step 1 — Connect a wallet
- - {({ openConnectModal, mounted }) => ( + + {({ openConnectModal, ready, connected }) => connected ? null : ( )} - +
) : (
@@ -117,7 +118,7 @@ export function AuthorizeWalletModal({ diff --git a/apps/payments/web/src/components/ChannelsView.scss b/apps/payments/web/src/components/ChannelsView.scss index 22ea6a755..0644d5cac 100644 --- a/apps/payments/web/src/components/ChannelsView.scss +++ b/apps/payments/web/src/components/ChannelsView.scss @@ -1,32 +1,3 @@ -.channels-section-head-row { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 16px; - - .dashboard-section-head { - flex: 1 1 auto; - min-width: 0; - } -} - -.dashboard-kpi-sub { - margin-top: 4px; - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - letter-spacing: 0.02em; - font-variant-numeric: tabular-nums; -} - -.channels-table-caption { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; -} - .channels-table-wrap { overflow-x: auto; margin: 0 -24px; @@ -73,10 +44,6 @@ border-bottom: none; } - tbody tr:hover td { - background: var(--card-hover, rgba(148, 163, 184, 0.05)); - } - .channels-table-num { text-align: right; color: var(--text-primary); @@ -86,7 +53,7 @@ .channels-table-cell-seller { color: var(--text-primary); font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .channels-table-cell-id { @@ -194,22 +161,6 @@ } } -.channels-section-head-row .channels-refresh-btn { - width: auto; - padding: 6px 14px; - font-size: 12px; - flex-shrink: 0; - margin-top: 14px; - align-self: flex-start; -} - -.channels-view-empty { - text-align: center; - padding: 40px 16px; - color: var(--text-muted); - font-size: 13px; -} - .status-pill { display: inline-block; padding: 3px 10px; diff --git a/apps/payments/web/src/components/ChannelsView.tsx b/apps/payments/web/src/components/ChannelsView.tsx index d71d58658..49f8f84f0 100644 --- a/apps/payments/web/src/components/ChannelsView.tsx +++ b/apps/payments/web/src/components/ChannelsView.tsx @@ -7,6 +7,7 @@ import { CHANNELS_ABI } from '../channels-abi'; import { getErrorMessage, usePaymentNetwork } from '../payment-network'; import { useChannels } from '../hooks/useChannels'; import { useAuthorizedWallet } from '../context/AuthorizedWalletContext'; +import { formatTimestampDate, formatUsd, parseUsd, truncateAddress } from '../utils/format'; import { Button } from './Button'; import './ChannelsView.scss'; @@ -16,10 +17,7 @@ interface ChannelsViewProps { const GRACE_PERIOD = 900; // 15 minutes in seconds const PAGE_SIZE = 10; - -function truncateAddress(addr: string): string { - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; -} +type ActivityFilter = 'all' | 'settlements' | 'closes'; type RowStatus = | 'active' @@ -48,6 +46,19 @@ const STATUS_META: Record = { closed: { label: 'Closed', modifier: 'status-pill--muted' }, }; +function matchesFilter(session: ChannelData, filter: ActivityFilter): boolean { + if (filter === 'all') return true; + const status = getRowStatus(session); + if (filter === 'settlements') return status === 'settled'; + return status === 'closing' || status === 'withdrawable' || status === 'closed' || status === 'timedout'; +} + +function getEmptyMessage(filter: ActivityFilter): string { + if (filter === 'settlements') return 'No settlements match this filter.'; + if (filter === 'closes') return 'No channel closes match this filter.'; + return 'No activity yet. Complete a request to see settlements here.'; +} + function formatTimeRemaining(closeRequestedAt: number): string { const now = Math.floor(Date.now() / 1000); const remaining = closeRequestedAt + GRACE_PERIOD - now; @@ -57,16 +68,8 @@ function formatTimeRemaining(closeRequestedAt: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } -// Accepts either seconds (on-chain style) or milliseconds (Date.now()) — the -// channel store mixes units because `deadline` is a block timestamp (seconds) -// while `reservedAt` is wall-clock ms. Values ≥ 1e12 are treated as ms. -function toMs(ts: number): number { - return ts > 1e12 ? ts : ts * 1000; -} - function formatDate(ts: number): string { - if (!ts) return '—'; - return new Date(toMs(ts)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + return formatTimestampDate(ts, { month: 'short', day: 'numeric', year: 'numeric' }); } const parsedAbi = parseAbi(CHANNELS_ABI); @@ -175,6 +178,7 @@ function ChannelRow({ export function ChannelsView({ config }: ChannelsViewProps) { const { channels, history, loading, refetch } = useChannels(config); const [page, setPage] = useState(0); + const [filter, setFilter] = useState('all'); const fetchData = useCallback(async () => { await refetch(); @@ -182,11 +186,11 @@ export function ChannelsView({ config }: ChannelsViewProps) { // Active first, then history — keeps actionable rows on page one. const allChannels = useMemo(() => [...channels, ...history], [channels, history]); - const totals = useMemo(() => { - const reserved = channels.reduce((a, c) => a + (parseFloat(c.deposit) || 0), 0); - const used = channels.reduce((a, c) => a + (parseFloat(c.settled) || 0), 0); - const totalSpent = allChannels.reduce((a, c) => a + (parseFloat(c.settled) || 0), 0); + const reserved = channels.reduce((total, channel) => total + parseUsd(channel.deposit), 0); + const used = channels.reduce((total, channel) => total + parseUsd(channel.settled), 0); + const totalSpent = allChannels.reduce((total, channel) => total + parseUsd(channel.settled), 0); + return { active: channels.length, reserved, @@ -195,123 +199,148 @@ export function ChannelsView({ config }: ChannelsViewProps) { totalSpent, }; }, [channels, allChannels]); + const filteredChannels = useMemo( + () => allChannels.filter((session) => matchesFilter(session, filter)), + [allChannels, filter], + ); + + const handleFilterChange = useCallback((nextFilter: ActivityFilter) => { + setFilter(nextFilter); + setPage(0); + }, []); - const pageCount = Math.max(1, Math.ceil(allChannels.length / PAGE_SIZE)); + const pageCount = Math.max(1, Math.ceil(filteredChannels.length / PAGE_SIZE)); useEffect(() => { if (page > pageCount - 1) setPage(pageCount - 1); }, [page, pageCount]); const pageRows = useMemo( - () => allChannels.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), - [allChannels, page], + () => filteredChannels.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + [filteredChannels, page], ); + const activitySummary = allChannels.length === filteredChannels.length + ? `${allChannels.length} entries` + : `${filteredChannels.length} of ${allChannels.length} entries`; return ( -
-
-
-
-
Your channels
-

Payment channels

-

+

+
+
+
+
Your channels
+

Payment channels

+

Payment channels between you and sellers. Reserve funds once, then settle per-request against the escrow.

-
-
+ +
-
-
-
-
Active
-
{totals.active} / {totals.total}
-
-
-
Reserved
-
${totals.reserved.toFixed(2)}
-
-
-
Used
-
${totals.used.toFixed(2)}
-
-
-
Total Spent
-
${totals.totalSpent.toFixed(2)}
-
+
+
+
Active
+ {totals.active} / {totals.total} +

Open now / all-time

+
+
+
Reserved
+ ${formatUsd(totals.reserved)} +

Currently locked

+
+
+
Used
+ ${formatUsd(totals.used)} +

On active channels

+
+
Total spent
+ ${formatUsd(totals.totalSpent)} +

Settled all-time

+
+
+ - {/*
- {allChannels.length} channel{allChannels.length === 1 ? '' : 's'} - {channels.length > 0 && ` · ${channels.length} active`} -
*/} +
+
+
+

Activity log

+

Settlements and channel closes across your payment channels.

+
+
+ + + +
+
- {loading && allChannels.length === 0 ? ( -
Loading channels…
- ) : allChannels.length === 0 ? ( -
No channels yet
- ) : ( - <> -
- - - - - - - - - - - - - - {pageRows.map((session) => ( - config ? ( - - ) : null - ))} - -
SellerChannelStatusReservedUsedOpened
-
+
{activitySummary}
- {pageCount > 1 && ( -
- - - Page {page + 1} of {pageCount} - - -
- )} - - )} -
+ {loading && allChannels.length === 0 ? ( +
Loading activity…
+ ) : filteredChannels.length === 0 ? ( +
{getEmptyMessage(filter)}
+ ) : ( +
+
+ + + + + + + + + + + + + + {pageRows.map((session) => ( + config ? ( + + ) : null + ))} + +
SellerChannelStatusReservedUsedOpened
+
+ + {pageCount > 1 && ( +
+ + + Page {page + 1} of {pageCount} + + +
+ )} +
+ )}
); diff --git a/apps/payments/web/src/components/ConnectWalletAction.tsx b/apps/payments/web/src/components/ConnectWalletAction.tsx new file mode 100644 index 000000000..1b9c8d3b5 --- /dev/null +++ b/apps/payments/web/src/components/ConnectWalletAction.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; + +interface ConnectWalletActionState { + ready: boolean; + connected: boolean; + openConnectModal: () => void; +} + +interface ConnectWalletActionProps { + children?: (state: ConnectWalletActionState) => ReactNode; + className?: string; + disabled?: boolean; + label?: ReactNode; +} + +export function ConnectWalletAction({ + children, + className, + disabled, + label = 'Connect wallet', +}: ConnectWalletActionProps) { + return ( + + {({ account, chain, openConnectModal, authenticationStatus, mounted }) => { + const ready = mounted && authenticationStatus !== 'loading'; + const connected = Boolean( + ready && + account && + chain && + (!authenticationStatus || authenticationStatus === 'authenticated'), + ); + + if (children) return children({ ready, connected, openConnectModal }); + return connected ? null : ( + + ); + }} + + ); +} diff --git a/apps/payments/web/src/components/DepositView.scss b/apps/payments/web/src/components/DepositView.scss index 672c89098..dc97844d0 100644 --- a/apps/payments/web/src/components/DepositView.scss +++ b/apps/payments/web/src/components/DepositView.scss @@ -1,697 +1,520 @@ -.deposit-loading { - font-size: 13px; - color: var(--text-muted); - padding: 12px 0; -} - -.deposit-methods { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - margin-bottom: 18px; -} +/* ──────────────────────────────────────────────── + DepositView — amount-first focused flow + Token-only: no raw hex / px except 1px/2px idioms + ──────────────────────────────────────────────── */ -.deposit-method { +.dv { display: flex; flex-direction: column; - align-items: center; - gap: 5px; - padding: 16px 12px; - background: var(--input-bg); - border: 1px solid var(--card-border); - border-radius: 10px; - cursor: pointer; - transition: all 0.15s; - font-family: inherit; - text-align: center; - - &:hover { border-color: var(--input-border); } - - &--active { - border-color: var(--accent-border); - background: var(--accent-dim); - } -} - -.deposit-method-icon { - color: var(--text-muted); - .deposit-method--active & { color: var(--accent); } -} - -.deposit-method-label { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); } -.deposit-method-desc { - font-size: 11px; - color: var(--text-muted); +/* ── Form layout ── */ +.dv-form { + display: flex; + flex-direction: column; + gap: var(--sp-4); } -.deposit-form { +/* ── Amount block ── */ +.dv-amount-block { display: flex; flex-direction: column; - gap: 12px; + gap: var(--sp-2); } -.deposit-trust-card { - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 14px; - overflow: hidden; +.dv-amount-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: var(--sp-1); } -.deposit-trust-toggle { - width: 100%; +.dv-amount-field { display: flex; align-items: center; - gap: 10px; - padding: 12px 14px; - border: none; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius-lg); + padding: var(--sp-3) var(--sp-4); + transition: border-color 0.14s; - &:hover { background: var(--card-hover); } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; + &:focus-within { + border-color: var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-dim); } -} -.deposit-trust-head-copy { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; - flex: 1; + &--error { + border-color: var(--danger-border); + &:focus-within { box-shadow: 0 0 0 3px var(--danger-dim); } + } } -.deposit-trust-shield { - width: 28px; - height: 28px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; +.dv-amount-cur { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-faint); + margin-right: var(--sp-1); + line-height: 1; flex-shrink: 0; - background: var(--accent); - color: #06351d; - font-size: 14px; - font-weight: 800; - box-shadow: 0 0 0 4px var(--accent-dim); } -.deposit-trust-title { - font-size: 13px; - font-weight: 800; +.dv-amount-input { + flex: 1; + border: none; + background: transparent; + font-size: 1.75rem; + font-weight: 700; color: var(--text-primary); - letter-spacing: -0.01em; -} - -.deposit-trust-subtitle { - font-size: 12px; - color: var(--text-secondary); -} - -.deposit-trust-balance-summary { - display: inline-flex; - flex-direction: column; - align-items: flex-end; - gap: 1px; - flex-shrink: 0; + font-family: var(--font-sans); + outline: none; + min-width: 0; + line-height: 1; - span { - font-size: 9px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-muted); - } + /* Hide number spin arrows */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + -moz-appearance: textfield; - strong { - font-size: 13px; - font-weight: 850; - color: var(--text-primary); - } + &:disabled { opacity: 0.6; } - em { - font-style: normal; - font-size: 10px; - font-weight: 700; - color: var(--accent-text); - } + &::placeholder { color: var(--text-faint); } } -.deposit-trust-chevron { - display: inline-block; +.dv-amount-unit { + font-size: 0.75rem; + font-weight: 600; color: var(--text-muted); - font-size: 18px; - line-height: 1; - transform: rotate(0deg); - transition: transform 0.15s ease; + flex-shrink: 0; + margin-left: var(--sp-2); } - -.deposit-trust-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; +/* ── Quick chips ── */ +.dv-chips { + display: flex; + gap: var(--sp-2); + flex-wrap: wrap; } -.deposit-trust-item { - padding: 9px 10px; - background: var(--input-bg); +.dv-chip { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + background: transparent; border: 1px solid var(--card-border); - border-radius: 9px; - - span { - display: block; - margin-bottom: 2px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-muted); - } + border-radius: var(--radius-sm); + padding: 0.4375rem 0.8125rem; + cursor: pointer; + font-family: inherit; + transition: all 0.12s; + line-height: 1; - strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - font-size: 12px; - font-weight: 700; - font-family: 'SF Mono', 'Fira Code', monospace; + &:hover:not(:disabled) { + border-color: var(--input-border); color: var(--text-primary); } - &--wide { grid-column: 1 / -1; } + &--active { + border-color: var(--accent-border); + background: var(--accent-dim); + color: var(--accent-text); + } + + &--disabled, + &:disabled { + opacity: 0.38; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); + } } -.deposit-trust-foot { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--card-border); - font-size: 12px; - line-height: 1.45; +/* ── Wallet balance hint ── */ +.dv-wallet-hint { + font-size: 0.71875rem; color: var(--text-secondary); + line-height: 1.45; + min-height: 1.1em; } -.deposit-details-overlay { - position: fixed; - inset: 0; - z-index: 80; - display: flex; - align-items: center; - justify-content: center; - padding: 18px; - background: rgba(0, 0, 0, 0.48); - backdrop-filter: blur(4px); +.dv-wallet-hint-addr { + font-family: var(--font-mono); + color: var(--text-muted); } -.deposit-details-modal { - width: min(720px, 100%); - max-height: min(680px, calc(100vh - 36px)); - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--modal-bg, var(--card-bg)); - border: 1px solid var(--card-border); - border-radius: 18px; - box-shadow: 0 22px 70px rgba(0, 0, 0, 0.3); +.dv-wallet-hint-bal { + font-weight: 700; + color: var(--text-primary); } -.deposit-details-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - padding: 18px 18px 14px; - border-bottom: 1px solid var(--card-border); - - h3 { - margin: 2px 0 4px; - font-size: 18px; - line-height: 1.2; - color: var(--text-primary); - } +.dv-wallet-hint-loading { + color: var(--text-faint); + font-style: italic; +} - p { - margin: 0; - font-size: 12px; - line-height: 1.45; - color: var(--text-secondary); - } +.dv-wallet-hint-limit { + color: var(--text-muted); } -.deposit-details-eyebrow { - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent-text); +/* ── Optional target override ── */ +.dv-target { + display: flex; + flex-direction: column; + gap: var(--sp-2); } -.deposit-details-close { - width: 30px; - height: 30px; - border: 1px solid var(--card-border); - border-radius: 999px; - background: var(--input-bg); - color: var(--text-muted); +.dv-target-toggle { + align-self: flex-start; + border: none; + background: transparent; + padding: 0; + color: var(--accent-text); font: inherit; - font-size: 18px; - line-height: 1; + font-size: 0.75rem; + font-weight: 700; cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; - &:hover { color: var(--text-primary); } + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } } -.deposit-details-body { +.dv-target-panel { display: flex; flex-direction: column; - gap: 12px; - padding: 14px 18px 18px; - overflow-y: auto; + gap: var(--sp-2); + padding: var(--sp-3); + border: 1px solid var(--card-border); + border-radius: var(--radius-md); + background: var(--panel-bg); } -.deposit-details-balance-hero { - padding: 12px 14px; - border-radius: 12px; - background: var(--accent-dim); - border: 1px solid var(--accent-border); +.dv-target-input { + width: 100%; + border: 1px solid var(--input-border); + border-radius: var(--radius-sm); + background: var(--input-bg); + color: var(--text-primary); + font: inherit; + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 0.625rem 0.75rem; + outline: none; - span, small { - display: block; - font-size: 12px; - color: var(--text-secondary); + &:focus { + border-color: var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-dim); } - strong { - display: block; - margin: 2px 0; - font-size: 24px; - line-height: 1.1; - color: var(--text-primary); + &--error { + border-color: var(--danger-border); } } -.deposit-wizard { - position: relative; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - padding: 12px; - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 16px; -} +.dv-target-note { + font-size: 0.71875rem; + line-height: 1.45; + color: var(--text-secondary); -.deposit-wizard-track { - position: absolute; - top: 37px; - left: calc(25% + 28px); - right: calc(25% + 28px); - z-index: 0; - height: 3px; - background: var(--card-border); - border-radius: 999px; - overflow: hidden; + &--error { + color: var(--danger); + } } -.deposit-wizard-track-fill { - display: block; - height: 100%; - background: var(--accent); - border-radius: inherit; - transition: width 0.2s ease; +/* ── Validation error ── */ +.dv-validation-error { + font-size: 0.75rem; + color: var(--danger); + line-height: 1.45; } -.deposit-wizard-step { - position: relative; - z-index: 1; +/* ── Two-step stepper ── */ +.dv-steps { display: flex; flex-direction: column; - align-items: center; - text-align: center; - gap: 8px; - padding: 8px 10px 10px; - border-radius: 13px; - border: 1px solid transparent; - color: var(--text-muted); - - &--active { - background: var(--input-bg); - border-color: var(--card-border); - } - - &--complete { - color: var(--text-secondary); - } + gap: 0; + padding: var(--sp-4) 0 var(--sp-3); + border-top: 1px solid var(--divider); +} - &--locked { - opacity: 0.72; - } +.dv-step { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + font-size: 0.75rem; + color: var(--text-muted); + padding: 0.4375rem 0; + line-height: 1.4; } -.deposit-wizard-number { - width: 34px; - height: 34px; - border-radius: 999px; +.dv-step-dot { + width: 1.125rem; + height: 1.125rem; + border-radius: 50%; + border: 1px solid var(--border-2); display: inline-flex; align-items: center; justify-content: center; - background: var(--input-bg); - border: 1px solid var(--card-border); + font-size: 0.625rem; + font-weight: 700; color: var(--text-muted); - font-size: 13px; - font-weight: 900; + flex-shrink: 0; + margin-top: 1px; + transition: background 0.15s, border-color 0.15s; - .deposit-wizard-step--active & { + .dv-step--done & { background: var(--accent); border-color: var(--accent); - color: #06351d; + color: var(--accent-text); } - .deposit-wizard-step--complete & { + .dv-step--active & { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent-text); } } -.deposit-wizard-content { min-width: 0; } +.dv-step-label { + flex: 1; + min-width: 0; -.deposit-wizard-kicker { - font-size: 9px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); + .dv-step--done & { color: var(--text-primary); } + .dv-step--active & { color: var(--text-primary); font-weight: 500; } } -.deposit-wizard-title { - margin-top: 1px; - font-size: 13px; - font-weight: 850; - color: var(--text-primary); -} +/* ── Primary action button ── */ +.dv-btn-primary { + width: 100%; + padding: var(--sp-3) var(--sp-4); + background: var(--accent); + color: var(--accent-text); + border: none; + border-radius: var(--radius-md); + font: inherit; + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + transition: opacity 0.14s, transform 0.1s; + line-height: 1; -.deposit-wizard-copy { - margin-top: 4px; - font-size: 11.5px; - line-height: 1.4; - color: var(--text-muted); + &:hover:not(:disabled) { opacity: 0.88; } + &:active:not(:disabled) { transform: scale(0.99); } + &:disabled { opacity: 0.42; cursor: not-allowed; } + &:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 2px; + } } -.deposit-connect-explainer { - padding: 10px 12px; - border-radius: 10px; - background: var(--accent-dim); - border: 1px solid var(--accent-border); +/* ── Outline button (success) ── */ +.dv-btn-outline { + padding: var(--sp-2) var(--sp-4); + background: transparent; color: var(--text-secondary); - font-size: 12px; - line-height: 1.45; - text-align: center; -} - -.deposit-connect-wrapper { - display: flex; - justify-content: center; - padding: 4px 0; -} + border: 1px solid var(--card-border); + border-radius: var(--radius-md); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.14s, color 0.14s; -.deposit-wrong-chain { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 8px 0; + &:hover { border-color: var(--input-border); color: var(--text-primary); } + &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } } -.deposit-wrong-chain-text { - font-size: 13px; - color: var(--danger); +/* ── Notes ── */ +.dv-approve-note, +.dv-confirm-note { + font-size: 0.6875rem; + color: var(--text-muted); text-align: center; + line-height: 1.45; + margin-top: calc(var(--sp-2) * -0.5); } -.deposit-target-info { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 14px; - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 8px; - font-size: 12px; +.dv-approve-note { + color: var(--accent-text); + font-weight: 500; } -.deposit-target-label { +.dv-help { + font-size: 0.6875rem; color: var(--text-muted); + line-height: 1.5; } -.deposit-target-addr { - font-family: 'SF Mono', 'Fira Code', monospace; - color: var(--text-secondary); +/* ── Chain warning ── */ +.dv-chain-warn { + padding: var(--sp-2) var(--sp-3); + background: var(--amber-dim); + border: 1px solid var(--amber-border); + border-radius: var(--radius-sm); + font-size: 0.75rem; + color: var(--amber); + line-height: 1.4; } -.deposit-success { - display: flex; - flex-direction: column; - align-items: center; - padding: 24px 16px; - gap: 6px; +/* ── Connect hint ── */ +.dv-connect-hint { + padding: var(--sp-3) var(--sp-4); + background: var(--accent-dim); + border: 1px solid var(--accent-border); + border-radius: var(--radius-md); + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.5; text-align: center; } -.deposit-success-icon { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--accent-dim); - color: var(--accent); +/* ── Error block ── */ +.dv-error { + padding: var(--sp-3) var(--sp-3); + background: var(--danger-dim); + border: 1px solid var(--danger-border); + border-radius: var(--radius-md); display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - margin-bottom: 4px; + flex-direction: column; + gap: var(--sp-2); } -.deposit-success-title { - font-size: 15px; +.dv-error-summary { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); + font-size: 0.75rem; + color: var(--danger); font-weight: 600; - color: var(--text-primary); + line-height: 1.4; } -.deposit-success-hash { - font-size: 12px; - font-family: 'SF Mono', 'Fira Code', monospace; +.dv-error-toggle { + flex-shrink: 0; + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 0.6875rem; + font-weight: 600; color: var(--text-muted); -} - -.deposit-success-note { - font-size: 12px; - color: var(--text-secondary); - margin-top: 2px; -} - -.deposit-pay-embed { - border-radius: var(--radius); - overflow: hidden; - border: 1px solid var(--card-border); - - // Override thirdweb iframe sizing - iframe { - width: 100% !important; - min-height: 380px; - } -} + cursor: pointer; + transition: color 0.12s; + text-decoration: underline; + text-underline-offset: 2px; -.deposit-card-coming { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 24px 16px; - gap: 6px; + &:hover { color: var(--text-secondary); } + &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } } -.deposit-card-coming-icon { color: var(--text-faint); margin-bottom: 2px; } - -.deposit-card-coming-title { - font-size: 14px; - font-weight: 600; +.dv-error-detail { + font-size: 0.6875rem; color: var(--text-secondary); -} - -.deposit-card-coming-desc { - font-size: 13px; - color: var(--text-muted); - max-width: 280px; + font-family: var(--font-mono); line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; + padding: var(--sp-2) var(--sp-3); + background: var(--input-bg); + border-radius: var(--radius-sm); + border: 1px solid var(--card-border); } -.deposit-balance-details { - display: flex; - flex-direction: column; - gap: 8px; - padding: 0 14px 12px; - - &--embedded { - padding: 10px 0 0; - margin-top: 10px; - border-top: 1px solid var(--card-border); - } -} - -.deposit-balance-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 12px; +.dv-error-hash { + font-size: 0.6875rem; color: var(--text-muted); - - strong { - font-size: 13px; - font-weight: 750; - color: var(--text-primary); - } -} - -.deposit-balance-breakdown { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: -4px; - - span { - padding: 3px 7px; - border-radius: 999px; - background: var(--input-bg); - border: 1px solid var(--card-border); - font-size: 10px; - font-weight: 650; - color: var(--text-muted); - } } -.deposit-balance-cap { - margin-top: 2px; - padding-top: 9px; - border-top: 1px solid var(--card-border); - font-size: 12px; - line-height: 1.4; +.dv-error-hash-link { color: var(--text-secondary); + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { color: var(--text-primary); } } -.deposit-amount-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - margin-bottom: 5px; +.dv-error-hash-raw { + font-family: var(--font-mono); } -.deposit-advanced { +/* ── Success state ── */ +.dv-success { display: flex; flex-direction: column; - gap: 10px; - margin-top: -4px; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-6) var(--sp-4) var(--sp-5); + text-align: center; } -.deposit-advanced-toggle { - display: inline-flex; +.dv-success-icon { + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + background: var(--accent-dim); + border: 1px solid var(--accent-border); + display: flex; align-items: center; - gap: 6px; - align-self: flex-start; - border: none; - background: none; - padding: 2px 0; - font: inherit; - font-size: 12px; - font-weight: 500; - color: var(--text-muted); - cursor: pointer; - transition: color 0.14s; + justify-content: center; + font-size: 1.25rem; + color: var(--accent-text); + font-weight: 800; + margin-bottom: var(--sp-1); +} - &:hover { color: var(--text-secondary); } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 4px; - } +.dv-success-title { + font-size: 1.0625rem; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.01em; } -.deposit-advanced-chevron { - display: inline-block; - font-size: 14px; +.dv-success-amount { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent-text); + background: var(--accent-dim); + border: 1px solid var(--accent-border); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-4); line-height: 1; - transition: transform 0.15s; - transform: rotate(0deg); - - &--open { transform: rotate(90deg); } } -.deposit-advanced-body { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 14px; - background: var(--input-bg); - border: 1px solid var(--card-border); - border-radius: 8px; +.dv-success-note { + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.5; + max-width: 22rem; } -.deposit-advanced-desc { - margin: 0 0 4px; - font-size: 12px; - line-height: 1.5; +.dv-success-hash { + font-size: 0.75rem; color: var(--text-muted); } -.deposit-advanced-warn { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px; - background: var(--danger-dim, rgba(220, 80, 80, 0.08)); - border: 1px solid var(--danger-border, rgba(220, 80, 80, 0.35)); - border-radius: 6px; - font-size: 12px; - line-height: 1.45; +.dv-success-hash-link { + display: inline-flex; + align-items: center; + gap: var(--sp-1); color: var(--text-secondary); -} - -.deposit-advanced-warn-icon { - flex-shrink: 0; - line-height: 1.4; - color: var(--danger); -} + text-decoration: underline; + text-underline-offset: 2px; + font-family: var(--font-mono); -.input-field--mono { - font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 12px; + &:hover { color: var(--text-primary); } } -.hint--error { color: var(--danger); } -.hint--warn { color: var(--accent-text); } - -.deposit-amount-max { - border: none; - background: none; - padding: 0; - font: inherit; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--accent-text); - cursor: pointer; - transition: opacity 0.14s; - - &:hover:not(:disabled) { opacity: 0.75; } - &:disabled { opacity: 0.4; cursor: default; } +.dv-success-hash-arrow { + font-family: var(--font-sans); + font-size: 0.875rem; } diff --git a/apps/payments/web/src/components/DepositView.tsx b/apps/payments/web/src/components/DepositView.tsx index fc0718b0a..726a11d9f 100644 --- a/apps/payments/web/src/components/DepositView.tsx +++ b/apps/payments/web/src/components/DepositView.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from 'react'; -import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useState, useEffect, useCallback } from 'react'; import { useAccount, useChainId, @@ -10,24 +9,13 @@ import { import { formatUnits, parseUnits } from 'viem'; import type { BalanceData, PaymentConfig } from '../types'; import { getErrorMessage, usePaymentNetwork } from '../payment-network'; -import { Button } from './Button'; +import { formatAmountInput, formatUsd, parseUsd, truncateAddress } from '../utils/format'; +import { getExplorerTxUrl } from '../utils/txLink'; +import { ConnectWalletAction } from './ConnectWalletAction'; import './DepositView.scss'; const MIN_FIRST_DEPOSIT = 1; // USDC — matches AntseedDeposits.MIN_BUYER_DEPOSIT - -function formatUsd(n: number): string { - return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - -function parseUsd(value?: string | null): number { - const parsed = Number.parseFloat(value ?? '0'); - return Number.isFinite(parsed) ? parsed : 0; -} - -function formatAmountInput(n: number): string { - if (!Number.isFinite(n) || n <= 0) return ''; - return n.toFixed(6).replace(/\.?(0+)$/, ''); -} +const QUICK_CHIPS = [10, 25, 50, 100] as const; function safeParseUsdc(value: string): bigint { try { @@ -37,12 +25,6 @@ function safeParseUsdc(value: string): bigint { } } -function getSuggestedDeposit(maxDeposit: number, isFirstDeposit: boolean): string { - const floor = isFirstDeposit ? MIN_FIRST_DEPOSIT : 0; - if (maxDeposit <= 0 || maxDeposit < floor) return ''; - return formatAmountInput(Math.max(floor, Math.min(10, maxDeposit))); -} - interface DepositViewProps { config: PaymentConfig | null; balance: BalanceData | null; @@ -93,77 +75,46 @@ const ERC20_ABI = [ }, ] as const; -type DepositMethod = 'crypto' | 'card'; - export function DepositView({ config, balance, buyerAddress, onDeposited }: DepositViewProps) { - const [method, setMethod] = useState('crypto'); - return ( -
-
-
Deposit USDC
-
- Any wallet can fund your AntSeed account. Your signer authorizes spending; the contract holds the balance. -
- -
- - -
- - {method === 'crypto' ? ( - - ) : ( - - )} -
+
+
); } -/* ── Crypto Deposit (wagmi + RainbowKit) ── */ +/* ── Crypto Deposit ── */ -function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { +function CryptoDeposit({ + config, + balance, + buyerAddress, + onDeposited, +}: { config: PaymentConfig | null; balance: BalanceData | null; buyerAddress: string | null; onDeposited: () => void; }) { const { address, isConnected } = useAccount(); + const walletConnected = isConnected && Boolean(address); const connectedChainId = useChainId(); const [amount, setAmount] = useState(''); + const [activeChip, setActiveChip] = useState(null); const [step, setStep] = useState<'idle' | 'approving' | 'checking-allowance' | 'depositing' | 'done'>('idle'); const [error, setError] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); - const [trustDetailsOpen, setTrustDetailsOpen] = useState(false); + const [errorDetail, setErrorDetail] = useState(null); + const [errorOpen, setErrorOpen] = useState(false); + const [depositedTxHash, setDepositedTxHash] = useState(null); + const [showTargetOverride, setShowTargetOverride] = useState(false); const [customTarget, setCustomTarget] = useState(''); const [customTargetEdited, setCustomTargetEdited] = useState(false); - const currentAvailable = parseUsd(balance?.available); - const currentReserved = parseUsd(balance?.reserved); const currentTotal = parseUsd(balance?.total); const creditLimit = parseUsd(balance?.creditLimit); const balanceKnown = balance !== null; @@ -179,8 +130,26 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { isSwitchingChain, ensureCorrectNetwork, } = usePaymentNetwork(config); + const defaultTarget = buyerAddress ?? address; + useEffect(() => { + if (customTargetEdited || !defaultTarget) return; + setCustomTarget(defaultTarget); + }, [customTargetEdited, defaultTarget]); + + const customTargetTrimmed = customTarget.trim(); + const customTargetIsValid = /^0x[a-fA-F0-9]{40}$/.test(customTargetTrimmed); + const customTargetInvalid = showTargetOverride && customTargetTrimmed !== '' && !customTargetIsValid; + const depositTarget = showTargetOverride && customTargetIsValid + ? customTargetTrimmed + : defaultTarget; + const isOverridingTarget = showTargetOverride + && customTargetIsValid + && Boolean(defaultTarget) + && customTargetTrimmed.toLowerCase() !== defaultTarget?.toLowerCase(); + + // Wallet USDC balance const { data: walletUsdcRaw, refetch: refetchWalletUsdc, @@ -192,26 +161,34 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { functionName: 'balanceOf', chainId: expectedChainId, args: [address as `0x${string}`], - query: { enabled: isConnected && !!config && !!address }, + query: { enabled: walletConnected && !!config && !!address }, }); - const walletUsdcBalance = walletUsdcRaw === undefined ? null : Number.parseFloat(formatUnits(walletUsdcRaw, 6)); + const walletUsdcBalance = walletUsdcRaw === undefined + ? null + : Number.parseFloat(formatUnits(walletUsdcRaw, 6)); const walletUsdcKnown = walletUsdcBalance !== null && Number.isFinite(walletUsdcBalance); - const maxDeposit = Math.max(0, Math.min(remainingCreditLimit, walletUsdcKnown ? walletUsdcBalance : remainingCreditLimit)); - const maxDepositReason = remainingCreditLimit <= 0 - ? 'limit' - : walletUsdcKnown && walletUsdcBalance <= remainingCreditLimit - ? 'wallet' - : 'limit'; - - // Default amount: suggest 10 USDC capped by both remaining headroom and wallet USDC. + const maxDeposit = Math.max( + 0, + Math.min( + remainingCreditLimit, + walletUsdcKnown ? walletUsdcBalance : remainingCreditLimit, + ), + ); + + // Default amount once data loads useEffect(() => { if (amount !== '' || !balance) return; - const suggested = getSuggestedDeposit(maxDeposit, isFirstDeposit); - if (suggested) setAmount(suggested); - // eslint-disable-next-line react-hooks/exhaustive-deps + const suggested = maxDeposit >= 10 ? '10' : maxDeposit > 0 ? formatAmountInput(maxDeposit) : ''; + if (suggested) { + setAmount(suggested); + const chipVal = [10, 25, 50, 100].find((c) => c === Number(suggested)); + if (chipVal) setActiveChip(chipVal); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [balance, walletUsdcRaw]); const amountNum = amount ? Number.parseFloat(amount) : 0; + let validationError: string | null = null; if (amount !== '' && balance) { if (!Number.isFinite(amountNum) || amountNum <= 0) { @@ -232,32 +209,7 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { } const isValidAmount = amount !== '' && !validationError && amountNum > 0; - - // Pre-fill the override input with the signer/buyer address once available, - // until the user manually edits it. This lets people see what the deposit - // will credit to, and gives them a concrete address to replace. Falls back - // to the connected wallet only when the buyer address isn't known yet. - useEffect(() => { - if (customTargetEdited) return; - const next = buyerAddress ?? address; - if (!next) return; - setCustomTarget(next); - }, [buyerAddress, address, customTargetEdited]); - - const customTargetTrimmed = customTarget.trim(); - const customTargetIsValid = /^0x[a-fA-F0-9]{40}$/.test(customTargetTrimmed); - const customTargetInvalid = showAdvanced && customTargetTrimmed !== '' && !customTargetIsValid; - const depositTarget = - showAdvanced && customTargetIsValid - ? (customTargetTrimmed as `0x${string}`) - : defaultTarget; - const isOverridingTarget = - showAdvanced && - customTargetIsValid && - defaultTarget !== undefined && - customTargetTrimmed.toLowerCase() !== (defaultTarget as string).toLowerCase(); - - // Read on-chain allowance (always, when connected) + // Read on-chain allowance const { data: allowance, refetch: refetchAllowance, @@ -269,7 +221,7 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { functionName: 'allowance', chainId: expectedChainId, args: [address as `0x${string}`, config?.depositsContractAddress as `0x${string}`], - query: { enabled: isConnected && !!config && !!address }, + query: { enabled: walletConnected && !!config && !!address }, }); const usdcAmount = safeParseUsdc(amount); @@ -277,27 +229,20 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { const isCheckingAllowance = allowanceLoading || allowanceFetching || step === 'checking-allowance'; const hasAllowance = allowanceKnown && allowance >= usdcAmount && usdcAmount > 0n; const allowanceShortfall = isValidAmount && allowanceKnown && allowance < usdcAmount; + const needsApproval = allowanceShortfall; + + // Step 1 = needs approval, step 2 = has allowance & ready to deposit const currentWizardStep = !isValidAmount ? 1 : hasAllowance ? 2 : 1; // Approve USDC - const { - writeContract: writeApprove, - data: approveTxHash, - reset: resetApprove, - } = useWriteContract(); - + const { writeContract: writeApprove, data: approveTxHash, reset: resetApprove } = useWriteContract(); const { isSuccess: approveConfirmed } = useWaitForTransactionReceipt({ hash: approveTxHash, chainId: expectedChainId, query: { enabled: step === 'approving' && !!approveTxHash }, }); - const { - writeContract: writeDeposit, - data: depositTxHash, - reset: resetDeposit, - } = useWriteContract(); - + const { writeContract: writeDeposit, data: depositTxHash, reset: resetDeposit } = useWriteContract(); const { isSuccess: depositConfirmed } = useWaitForTransactionReceipt({ hash: depositTxHash, chainId: expectedChainId, @@ -318,18 +263,41 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { if (hasAllowance) setStep('idle'); }, [step, hasAllowance]); - // After deposit confirms → done + // After deposit confirms, show the real tx result and let the parent refresh + // from the payments API. Avoid inventing local balance values here. useEffect(() => { - if (step === 'depositing' && depositConfirmed) { - setStep('done'); - onDeposited(); + if (step !== 'depositing' || !depositConfirmed) return; + setDepositedTxHash(depositTxHash ?? null); + setStep('done'); + onDeposited(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [depositConfirmed, step]); + + const handleAmountChange = useCallback((value: string) => { + setAmount(value); + // Clear chip selection when user types freely + const n = Number.parseFloat(value); + if (Number.isFinite(n)) { + const chipVal = QUICK_CHIPS.find((c) => c === n); + setActiveChip(chipVal ?? null); + } else { + setActiveChip(null); + } + }, []); + + const selectChip = useCallback((chip: number | 'max') => { + setActiveChip(chip); + if (chip === 'max') { + setAmount(formatAmountInput(maxDeposit)); + } else { + setAmount(String(chip)); } - }, [depositConfirmed, step, onDeposited]); + }, [maxDeposit]); - async function handleDeposit() { + async function handleAction() { if (!address || !isValidAmount || !config || !depositTarget) return; - setError(null); + setErrorDetail(null); try { await ensureCorrectNetwork(); @@ -341,8 +309,11 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { resetApprove(); resetDeposit(); + // Refresh balances const walletResult = await refetchWalletUsdc(); - const latestWalletUsdc = walletResult.data === undefined ? null : Number.parseFloat(formatUnits(walletResult.data, 6)); + const latestWalletUsdc = walletResult.data === undefined + ? null + : Number.parseFloat(formatUnits(walletResult.data, 6)); if (latestWalletUsdc === null || !Number.isFinite(latestWalletUsdc)) { setError('Could not check your wallet USDC balance. Please try again.'); return; @@ -362,513 +333,341 @@ function CryptoDeposit({ config, balance, buyerAddress, onDeposited }: { // Step 2: allowance is already sufficient — deposit directly. if (latestAllowance >= usdcAmount) { setStep('depositing'); - writeDeposit({ - address: config.depositsContractAddress as `0x${string}`, - abi: DEPOSITS_ABI, - functionName: 'deposit', - chainId: expectedChainId, - args: [depositTarget as `0x${string}`, usdcAmount], - }, { - onError: (err) => { - setStep('idle'); - setError(getErrorMessage(err)); + writeDeposit( + { + address: config.depositsContractAddress as `0x${string}`, + abi: DEPOSITS_ABI, + functionName: 'deposit', + chainId: expectedChainId, + args: [depositTarget as `0x${string}`, usdcAmount], + }, + { + onError: (err) => { + setStep('idle'); + setError(getErrorMessage(err)); + }, }, - }); + ); return; } // Step 1: approve USDC first. The user will click again to deposit after // approval is confirmed and allowance has been rechecked. setStep('approving'); - writeApprove({ - address: config.usdcContractAddress as `0x${string}`, - abi: ERC20_ABI, - functionName: 'approve', - chainId: expectedChainId, - args: [config.depositsContractAddress as `0x${string}`, usdcAmount], - }, { - onError: (err) => { - setStep('idle'); - setError(getErrorMessage(err)); + writeApprove( + { + address: config.usdcContractAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: 'approve', + chainId: expectedChainId, + args: [config.depositsContractAddress as `0x${string}`, usdcAmount], + }, + { + onError: (err) => { + setStep('idle'); + setError(getErrorMessage(err)); + }, }, - }); + ); } function resetForm() { setStep('idle'); setError(null); - setAmount(getSuggestedDeposit(maxDeposit, isFirstDeposit)); + setErrorDetail(null); + setErrorOpen(false); + setDepositedTxHash(null); resetApprove(); resetDeposit(); + setAmount(''); + setActiveChip(null); } - return ( -
- {!isConnected ? ( - <> - -
- Connect a wallet so AntSeed can check whether you already approved USDC. If you have, this wizard will jump directly to step 2. -
-
- - {({ openConnectModal, mounted }) => ( - - )} - -
- - ) : step === 'done' ? ( -
-
-
Deposit confirmed!
-
{depositTxHash?.slice(0, 18)}...
- {depositTarget && depositTarget !== address && ( -
- Credits added to {depositTarget.slice(0, 6)}...{depositTarget.slice(-4)} -
- )} -
- Your credits are now available. You can return to AntSeed Desktop to continue. + const explorerUrl = depositedTxHash + ? getExplorerTxUrl(depositedTxHash, expectedChainId ?? connectedChainId) + : null; + + const isWorking = step === 'approving' || step === 'depositing' || step === 'checking-allowance'; + const isLoadingWallet = walletUsdcLoading || walletUsdcFetching; + const isLoadingAllowance = allowanceLoading || allowanceFetching; + + /* ── Done state ── */ + if (step === 'done') { + return ( +
+ +
Deposit confirmed!
+
${formatUsd(amountNum)} USDC
+
+ The transaction confirmed. Your balance will refresh from the payments server. +
+ {depositedTxHash && ( +
+ {explorerUrl ? ( + + {depositedTxHash.slice(0, 10)}…{depositedTxHash.slice(-8)} + + + ) : ( + {depositedTxHash.slice(0, 10)}…{depositedTxHash.slice(-8)} + )}
- + )} + +
+ ); + } + + /* ── Not connected ── */ + if (!walletConnected) { + return ( +
+
+ Connect a wallet so AntSeed can check whether you already approved USDC. If you have, this wizard will jump directly to step 2.
- ) : ( - <> - {wrongChain && ( -
- Wallet is on chain {walletChainId ?? connectedChainId}. Switch to {targetChainName} before depositing. -
- )} + +
+ ); + } - + /* ── Main form ── */ + const actionButtonLabel = (() => { + if (isSwitchingChain) return `Switching to ${targetChainName}...`; + if (wrongChain) return `Switch to ${targetChainName}`; + if (isLoadingWallet || !walletUsdcKnown) return 'Loading wallet USDC...'; + if (isCheckingAllowance) return 'Checking approval...'; + if (step === 'approving') return 'Approve USDC in wallet...'; + if (step === 'depositing') return 'Depositing...'; + if (needsApproval) return `Step 1: Approve ${amount || '0'} USDC`; + return 'Step 2: Deposit USDC'; + })(); + + const actionButtonDisabled = + isWorking || + !isValidAmount || + !config || + isSwitchingChain || + customTargetInvalid || + !depositTarget || + isLoadingAllowance || + isLoadingWallet || + !walletUsdcKnown; - setTrustDetailsOpen(true)} - targetChainName={targetChainName} - walletAddress={address} - antseedAddress={depositTarget as string | undefined} - depositsContract={config?.depositsContractAddress} - usdcContract={config?.usdcContractAddress} - balanceKnown={balanceKnown} - currentTotal={currentTotal} - currentAvailable={currentAvailable} - currentReserved={currentReserved} - creditLimit={creditLimit} - remainingCreditLimit={remainingCreditLimit} - walletUsdcBalance={walletUsdcBalance} - walletUsdcKnown={walletUsdcKnown} - walletUsdcLoading={walletUsdcLoading || walletUsdcFetching} - maxDeposit={maxDeposit} - maxDepositReason={maxDepositReason} - /> + return ( +
+ {wrongChain && ( +
+ Wallet is on chain {walletChainId ?? connectedChainId}. Switch to {targetChainName} to continue. +
+ )} - setTrustDetailsOpen(false)} - targetChainName={targetChainName} - walletAddress={address} - antseedAddress={depositTarget as string | undefined} - depositsContract={config?.depositsContractAddress} - usdcContract={config?.usdcContractAddress} - balanceKnown={balanceKnown} - currentTotal={currentTotal} - currentAvailable={currentAvailable} - currentReserved={currentReserved} - creditLimit={creditLimit} - remainingCreditLimit={remainingCreditLimit} - walletUsdcBalance={walletUsdcBalance} - walletUsdcKnown={walletUsdcKnown} - walletUsdcLoading={walletUsdcLoading || walletUsdcFetching} - maxDeposit={maxDeposit} - maxDepositReason={maxDepositReason} + {/* Amount field */} +
+ +
+ + handleAmountChange(e.target.value)} + disabled={isWorking} + aria-describedby="dv-amount-hint" + autoFocus /> + +
-
-
- - {balance && maxDeposit > 0 && ( - - )} -
- setAmount(e.target.value)} - disabled={step !== 'idle'} - /> - {balance ? ( - - {isFirstDeposit - ? `Min ${MIN_FIRST_DEPOSIT} USDC · ` - : ''} - Add up to ${formatUsd(maxDeposit)} now — ${formatUsd(remainingCreditLimit)} remaining limit, {walletUsdcKnown ? `${formatUsd(walletUsdcBalance)} USDC in wallet` : 'wallet balance loading'}. - - ) : ( - Loading your credit limit… - )} -
- - {validationError && ( -
- {validationError} -
- )} - -
+ {/* Quick chips */} +
+ {QUICK_CHIPS.map((chip) => ( - {showAdvanced && ( -
-

- Deposits credit the AntSeed account whose address you enter below. - Anyone can fund any AntSeed account — the balance is still spendable - only by that account's signer. Override only if you mean to top up - someone else's AntSeed account (e.g. a teammate). This does not change - which account spends the credits. -

- - { - setCustomTargetEdited(true); - setCustomTarget(e.target.value); - }} - disabled={step !== 'idle'} - /> -
- - - Do not send USDC directly to this address — it will not be credited. - Use the Deposit button below; funds must go through the AntSeed - Deposits contract. - -
- {customTargetInvalid && ( - Enter a valid 0x… address (42 chars). - )} - {isOverridingTarget && ( - - Credits will go to {customTargetTrimmed.slice(0, 6)}…{customTargetTrimmed.slice(-4)}, - not your connected wallet. - - )} -
- )} -
- - - - )} - - {error && ( -
- {error} + Max +
- )} -
- ); -} - -function shortAddress(addr?: string | null): string { - return addr ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : '—'; -} - -function DepositTrustCard({ - onOpenDetails, - balanceKnown, - currentTotal, - maxDeposit, -}: { - onOpenDetails: () => void; - balanceKnown: boolean; - currentTotal: number; - maxDeposit: number; -}) { - return ( -
- -
- ); -} - -function TrustDetailsModal({ - isOpen, - onClose, - targetChainName, - walletAddress, - antseedAddress, - depositsContract, - usdcContract, - balanceKnown, - currentTotal, - currentAvailable, - currentReserved, - creditLimit, - remainingCreditLimit, - walletUsdcBalance, - walletUsdcKnown, - walletUsdcLoading, - maxDeposit, - maxDepositReason, -}: { - isOpen: boolean; - onClose: () => void; - targetChainName: string; - walletAddress?: string; - antseedAddress?: string; - depositsContract?: string; - usdcContract?: string; - balanceKnown: boolean; - currentTotal: number; - currentAvailable: number; - currentReserved: number; - creditLimit: number; - remainingCreditLimit: number; - walletUsdcBalance: number | null; - walletUsdcKnown: boolean; - walletUsdcLoading: boolean; - maxDeposit: number; - maxDepositReason: 'wallet' | 'limit'; -}) { - useEffect(() => { - if (!isOpen) return; - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') onClose(); - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [isOpen, onClose]); - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > -
-
-
Deposit safety
-

Safe deposit flow

-

USDC stays on-chain in the AntSeed Deposits contract.

-
- + {/* Wallet balance inline */} +
+ {isLoadingWallet && !walletUsdcKnown ? ( + Loading wallet balance… + ) : walletUsdcKnown && address ? ( + + {truncateAddress(address)} + {' · '} + ${formatUsd(walletUsdcBalance ?? 0)} USDC + {' available in wallet'} + + ) : null} + {balanceKnown && remainingCreditLimit < (walletUsdcBalance ?? Infinity) && remainingCreditLimit > 0 && ( + + {' · '}${formatUsd(remainingCreditLimit)} deposit headroom + + )}
-
-
- In AntSeed now - {balanceKnown ? `$${formatUsd(currentTotal)}` : 'Loading…'} - {balanceKnown ? `You can deposit up to $${formatUsd(maxDeposit)} now.` : 'Loading account balance and limit…'} -
- -
-
- Available {balanceKnown ? `$${formatUsd(currentAvailable)}` : 'Loading…'} - Reserved {balanceKnown ? `$${formatUsd(currentReserved)}` : 'Loading…'} -
-
- Account limit - {balanceKnown ? `$${formatUsd(creditLimit)}` : 'Loading…'} -
-
- Can add before limit - {balanceKnown ? `$${formatUsd(remainingCreditLimit)}` : 'Loading…'} -
-
- Wallet USDC - {walletUsdcKnown ? `$${formatUsd(walletUsdcBalance ?? 0)}` : walletUsdcLoading ? 'Loading…' : '—'} -
-
- {balanceKnown - ? <>Max deposit is ${formatUsd(maxDeposit)} based on your {maxDepositReason === 'wallet' ? 'connected wallet USDC balance' : 'remaining AntSeed limit'}. Your deposit availability grows as you use AntSeed and build account history. - : 'Loading your AntSeed balance and account limit…'} -
-
+ {validationError && ( +
{validationError}
+ )} +
-
-
- Network - {targetChainName} -
-
- Pays from wallet - {shortAddress(walletAddress)} -
-
- Credits AntSeed account - {shortAddress(antseedAddress)} -
-
- USDC contract - {shortAddress(usdcContract)} -
-
- Deposits contract - {shortAddress(depositsContract)} +
+ + {showTargetOverride && ( +
+ { + setCustomTargetEdited(true); + setCustomTarget(event.target.value); + }} + disabled={isWorking} + /> +
+ {customTargetInvalid + ? 'Enter a valid 0x… address (42 chars).' + : isOverridingTarget + ? `Crediting ${truncateAddress(customTargetTrimmed, 6, 4, '...')}.` + : 'Credits go to this AntSeed account via the Deposits contract.'}
- -
- You will see two wallet confirmations only when needed: first an ERC‑20 approval, then the actual deposit. -
-
+ )}
-
- ); -} -function DepositWizard({ - currentStep, - isApproved, - isCheckingAllowance, - isApproving, - isDepositing, - amount, -}: { - currentStep: 1 | 2; - isApproved: boolean; - isCheckingAllowance: boolean; - isApproving: boolean; - isDepositing: boolean; - amount: string; -}) { - return ( -
- -
-
{isApproved ? '✓' : '1'}
-
-
Step 1
-
Approve USDC
-
- {isCheckingAllowance - ? 'Checking your existing approval on-chain…' - : isApproved - ? 'Already approved. You can skip straight to step 2.' - : isApproving - ? 'Confirm approval in your wallet. This does not move funds.' - : `Permit AntSeed's Deposits contract to use ${amount} USDC. Approval only grants permission.`} -
+ {/* Two-step stepper */} +
+
+ + + {hasAllowance + ? 'Already approved. You can skip straight to step 2.' + : step === 'approving' + ? 'Confirm approval in your wallet. This does not move funds.' + : step === 'checking-allowance' + ? 'Checking your existing approval on-chain…' + : `Permit AntSeed's Deposits contract to use ${amount || 'your chosen amount'} USDC. Approval only grants permission.`} +
-
-
-
2
-
-
Step 2
-
Deposit credits
-
- {isDepositing +
+ + + {step === 'depositing' ? 'Confirm the deposit transaction. This moves USDC into your AntSeed balance.' - : isApproved + : currentWizardStep === 2 ? 'Approval detected. The next click deposits USDC into your AntSeed balance.' : 'Locked until approval is confirmed.'} -
+
-
- ); -} -/* ── Credit Card (coming soon) ── */ + {/* Primary action */} + -function CardDepositPlaceholder() { - return ( -
-
-
- + {/* Approving note */} + {needsApproval && !isWorking && ( +
+ Approves only this deposit amount. After approval confirms, confirm the deposit.
-
Credit card deposits coming soon
-
- Direct credit card deposits are being integrated. - For now, use the crypto wallet option. + )} + + {/* Confirm note (step 2 ready) */} + {!needsApproval && isValidAmount && !isWorking && ( +
+ ≈ one wallet confirmation
+ )} + + {/* Helper text */} +
+ Funds credit your available balance right after the transaction confirms. Withdraw unused funds anytime.
+ + {/* Error with expandable detail */} + {error && ( +
+
+ {error} + {errorDetail && ( + + )} +
+ {errorOpen && errorDetail && ( +
{errorDetail}
+ )} + {depositTxHash && ( +
+ {getExplorerTxUrl(depositTxHash, expectedChainId ?? connectedChainId) ? ( + + View tx ↗ + + ) : ( + {depositTxHash.slice(0, 18)}… + )} +
+ )} +
+ )}
); } diff --git a/apps/payments/web/src/components/UsageChart.scss b/apps/payments/web/src/components/UsageChart.scss index bdcaaa705..e489608f0 100644 --- a/apps/payments/web/src/components/UsageChart.scss +++ b/apps/payments/web/src/components/UsageChart.scss @@ -1,63 +1,107 @@ .usage-chart { - position: relative; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; width: 100%; } .usage-chart--empty { - height: 260px; + min-height: 92px; display: flex; align-items: center; justify-content: center; - border: 1px dashed var(--card-border); - border-radius: 16px; - background: rgba(255, 255, 255, 0.01); + border: 1px dashed var(--line); + border-radius: 10px; + background: var(--panel-bg); + padding: 24px 16px; } .usage-chart-empty-text { - font-size: 13px; color: var(--text-muted); + font-size: 12px; + text-align: center; } -.usage-chart-tooltip { - min-width: 160px; - background: var(--page-bg); - border: 1px solid var(--card-border); - border-radius: 10px; - padding: 10px 12px; - box-shadow: - 0 20px 40px -18px rgba(10, 14, 22, 0.55), - 0 8px 20px -8px rgba(10, 14, 22, 0.35); - font-variant-numeric: tabular-nums; -} - -.usage-chart-tooltip-date { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); - margin-bottom: 8px; +.usage-chart-totbar { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.usage-chart-tot { + color: var(--text-secondary); + font-size: 12px; + + b { + display: block; + margin-top: 2px; + color: var(--text-primary); + font-size: 16px; + line-height: 1; + font-weight: 700; + font-variant-numeric: tabular-nums; + } } -.usage-chart-tooltip-rows { +.portal-spark { display: flex; - flex-direction: column; - gap: 4px; + align-items: flex-end; + gap: 6px; + height: 80px; } -.usage-chart-tooltip-row { +.portal-spark-col { + position: relative; display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; - font-size: 12px; + align-items: flex-end; + flex: 1; + height: 100%; + min-width: 0; + cursor: default; } -.usage-chart-tooltip-label { - color: var(--text-muted); +.portal-spark-bar { + width: 100%; + min-height: 3px; + border-radius: 4px 4px 0 0; + background: var(--chart-bar); + transition: background 0.12s; +} + +.portal-spark-col:hover .portal-spark-bar { + background: var(--chart-bar-peak); +} + +.portal-spark-tip { + position: absolute; + bottom: 100%; + left: 50%; + z-index: 8; + margin-bottom: 7px; + padding: 7px 9px; + border-radius: 7px; + background: var(--chart-tip-bg); + color: var(--chart-tip-fg); + font-size: 10px; + line-height: 1.5; + white-space: nowrap; + text-align: left; + opacity: 0; + pointer-events: none; + transform: translateX(-50%); + transition: opacity 0.12s; + + b { + color: var(--chart-tip-fg); + font-weight: 600; + } + + .acc { + color: var(--chart-tip-accent); + } } -.usage-chart-tooltip-value { - color: var(--text-primary); - font-weight: 600; +.portal-spark-col:hover .portal-spark-tip { + opacity: 1; } diff --git a/apps/payments/web/src/components/UsageChart.tsx b/apps/payments/web/src/components/UsageChart.tsx index f1ed73200..887947423 100644 --- a/apps/payments/web/src/components/UsageChart.tsx +++ b/apps/payments/web/src/components/UsageChart.tsx @@ -1,125 +1,91 @@ import { useMemo } from 'react'; -import { - ResponsiveContainer, - AreaChart, - Area, - XAxis, - YAxis, - Tooltip, - CartesianGrid, - type TooltipProps, -} from 'recharts'; import type { BuyerUsageChannelPoint } from '../api'; -import { formatCompact } from '../utils/format'; +import { formatCompact, formatNumber, formatTimestampDate, formatUsd } from '../utils/format'; import './UsageChart.scss'; interface UsageChartProps { channels: BuyerUsageChannelPoint[]; + days?: number; } -interface BucketPoint { - t: number; // bucket start (unix ms) - date: string; // short label for axis - fullDate: string; // full label for tooltip +interface DayBucket { + t: number; + fullDate: string; requests: number; - tokens: number; // input + output + tokens: number; + spendUsd: number; } const DAY_MS = 86_400_000; +const FULL_DATE_OPTIONS = { weekday: 'short', month: 'short', day: 'numeric' } as const; -function bucketByDay(channels: BuyerUsageChannelPoint[]): BucketPoint[] { - if (channels.length === 0) return []; +export function bucketByDay(channels: BuyerUsageChannelPoint[], days = 14): DayBucket[] { + const now = Date.now(); + const cutoff = now - days * DAY_MS; + const active = channels.filter((channel) => channel.requestCount > 0); + const map = new Map(); - // Drop channels that were reserved but never saw a request. They still - // exist in the local DB as `ghost`/`timeout` rows but don't represent any - // real activity, and including them would stretch the X axis back to - // whenever the empty channel was opened. - const active = channels.filter((c) => c.requestCount > 0); - if (active.length === 0) return []; - - // Timestamps are produced by Date.now() on the buyer side, so they are - // already in milliseconds. Bucket to UTC day start. - const map = new Map(); - let minT = Infinity; - let maxT = -Infinity; - - for (const c of active) { - const stamp = c.updatedAt || c.reservedAt; + for (const channel of active) { + const stamp = channel.updatedAt || channel.reservedAt; if (!Number.isFinite(stamp) || stamp <= 0) continue; const t = Math.floor(stamp / DAY_MS) * DAY_MS; - if (t < minT) minT = t; - if (t > maxT) maxT = t; + if (t < cutoff) continue; + let tokens = 0; try { - tokens = Number(BigInt(c.inputTokens || '0') + BigInt(c.outputTokens || '0')); - } catch { /* skip */ } + tokens = Number(BigInt(channel.inputTokens || '0') + BigInt(channel.outputTokens || '0')); + } catch { + // skip malformed token totals + } + const existing = map.get(t); if (existing) { - existing.requests += c.requestCount; + existing.requests += channel.requestCount; existing.tokens += tokens; + existing.spendUsd += estimateSpend(tokens); } else { map.set(t, { t, - date: formatShortDate(t), - fullDate: formatFullDate(t), - requests: c.requestCount, + fullDate: formatTimestampDate(t, FULL_DATE_OPTIONS), + requests: channel.requestCount, tokens, + spendUsd: estimateSpend(tokens), }); } } - if (!Number.isFinite(minT) || !Number.isFinite(maxT)) return []; - - // Fill empty days between min and max so the X axis reads as continuous - // time instead of "days that happened to have activity". - const points: BucketPoint[] = []; - for (let t = minT; t <= maxT; t += DAY_MS) { - points.push( + const todayStart = Math.floor(now / DAY_MS) * DAY_MS; + const windowStart = todayStart - (days - 1) * DAY_MS; + const buckets: DayBucket[] = []; + for (let t = windowStart; t <= todayStart; t += DAY_MS) { + buckets.push( map.get(t) ?? { t, - date: formatShortDate(t), - fullDate: formatFullDate(t), + fullDate: formatTimestampDate(t, FULL_DATE_OPTIONS), requests: 0, tokens: 0, + spendUsd: 0, }, ); } - return points; + return buckets; } -function formatShortDate(ms: number): string { - return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); -} - -function formatFullDate(ms: number): string { - return new Date(ms).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); -} - -function ChartTooltip({ active, payload }: TooltipProps) { - if (!active || !payload || payload.length === 0) return null; - const p = payload[0]?.payload as BucketPoint | undefined; - if (!p) return null; - return ( -
-
{p.fullDate}
-
-
- Requests - {p.requests.toLocaleString('en-US')} -
-
- Tokens - {formatCompact(p.tokens)} -
-
-
- ); +function estimateSpend(tokens: number): number { + return (tokens / 1_000_000) * 3; } -export function UsageChart({ channels }: UsageChartProps) { - const buckets = useMemo(() => bucketByDay(channels), [channels]); +export function UsageChart({ channels, days = 14 }: UsageChartProps) { + const buckets = useMemo(() => bucketByDay(channels, days), [channels, days]); + const maxRequests = useMemo(() => Math.max(1, ...buckets.map((bucket) => bucket.requests)), [buckets]); + const totals = useMemo(() => { + const requests = buckets.reduce((sum, bucket) => sum + bucket.requests, 0); + const tokens = buckets.reduce((sum, bucket) => sum + bucket.tokens, 0); + const spendUsd = buckets.reduce((sum, bucket) => sum + bucket.spendUsd, 0); + return { requests, tokens, spendUsd }; + }, [buckets]); - if (buckets.length === 0) { + if (totals.requests === 0) { return (
@@ -131,50 +97,43 @@ export function UsageChart({ channels }: UsageChartProps) { return (
- - - - - - - - - - - formatCompact(v)} - /> - } - cursor={{ stroke: 'var(--accent)', strokeWidth: 1, strokeDasharray: '3 3' }} - /> - - - +
+
+ Requests + {formatNumber(totals.requests)} +
+
+ Tokens + {formatCompact(totals.tokens)} +
+
+ Spent + ${formatUsd(totals.spendUsd)} +
+
+ +
+ {buckets.map((bucket) => { + const heightPct = bucket.requests === 0 ? 4 : Math.max(4, (bucket.requests / maxRequests) * 100); + return ( +
+
+
+ {bucket.fullDate} +
+ Requests {formatNumber(bucket.requests)} +
+ Tokens {formatCompact(bucket.tokens)} +
+ Spent ${formatUsd(bucket.spendUsd)} +
+
+ ); + })} +
); } diff --git a/apps/payments/web/src/components/WithdrawView.tsx b/apps/payments/web/src/components/WithdrawView.tsx index 3e7c155ff..2bc902d49 100644 --- a/apps/payments/web/src/components/WithdrawView.tsx +++ b/apps/payments/web/src/components/WithdrawView.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; import { useAccount } from 'wagmi'; -import { ConnectButton } from '@rainbow-me/rainbowkit'; import type { BalanceData, PaymentConfig } from '../types'; import { useAuthorizedWallet } from '../context/AuthorizedWalletContext'; import { useWithdraw } from '../hooks/useWithdraw'; import { usePaymentNetwork } from '../payment-network'; +import { formatUsd, truncateAddress } from '../utils/format'; import { Button } from './Button'; +import { ConnectWalletAction } from './ConnectWalletAction'; import './WithdrawView.scss'; interface WithdrawViewProps { @@ -16,13 +17,10 @@ interface WithdrawViewProps { const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; -function shortAddr(addr: string): string { - return `${addr.slice(0, 6)}...${addr.slice(-4)}`; -} - export function WithdrawView({ config, balance, onAction }: WithdrawViewProps) { const [amount, setAmount] = useState(''); const { address, isConnected } = useAccount(); + const walletConnected = isConnected && Boolean(address); const { requireAuthorization, operator } = useAuthorizedWallet(); const { targetChainName, walletChainId, wrongChain, isSwitchingChain } = usePaymentNetwork(config); @@ -44,7 +42,7 @@ export function WithdrawView({ config, balance, onAction }: WithdrawViewProps) { const operatorSet = !!operator && operator !== ZERO_ADDR; const wrongWallet = Boolean( - isConnected && operatorSet && address && address.toLowerCase() !== operator!.toLowerCase(), + walletConnected && operatorSet && address && address.toLowerCase() !== operator!.toLowerCase(), ); const amountNum = amount ? parseFloat(amount) : 0; @@ -78,31 +76,31 @@ export function WithdrawView({ config, balance, onAction }: WithdrawViewProps) {
Withdrawal confirmed!
{txHash &&
{txHash.slice(0, 18)}...
}
- Funds were sent to {address ? shortAddr(address) : 'your authorized wallet'}. + Funds were sent to {address ? truncateAddress(address, 6, 4, '...') : 'your authorized wallet'}.
- ) : !isConnected ? ( + ) : !walletConnected ? (
- - {({ openConnectModal, mounted }) => ( + + {({ openConnectModal, ready, connected }) => connected ? null : ( )} - +
) : ( <>
- {address ? shortAddr(address) : ''} + {address ? truncateAddress(address, 6, 4, '...') : ''} Connected
@@ -114,7 +112,7 @@ export function WithdrawView({ config, balance, onAction }: WithdrawViewProps) { {wrongWallet && operator && (
- This account is authorized to {shortAddr(operator)}. Connect that wallet + This account is authorized to {truncateAddress(operator, 6, 4, '...')}. Connect that wallet to withdraw, or transfer authorization to the connected wallet first.
)} @@ -132,7 +130,7 @@ export function WithdrawView({ config, balance, onAction }: WithdrawViewProps) { onChange={(e) => setAmount(e.target.value)} disabled={running} /> - Available: ${availableAmount.toFixed(2)} USDC + Available: ${formatUsd(availableAmount)} USDC
))} - -
-
- -
- - Base -
-
-
); } diff --git a/apps/payments/web/src/layout/TopBar.tsx b/apps/payments/web/src/layout/TopBar.tsx index a1a5530f2..d6ec93515 100644 --- a/apps/payments/web/src/layout/TopBar.tsx +++ b/apps/payments/web/src/layout/TopBar.tsx @@ -1,31 +1,33 @@ import type { TabId } from './Sidebar'; import type { BalanceData } from '../types'; +import { formatUsd, truncateAddress } from '../utils/format'; interface TopBarProps { activeTab: TabId; balance: BalanceData | null; + buyerEvmAddress: string | null; + atRisk: boolean; + isDark: boolean; + onToggleTheme: () => void; onOpenWallet: () => void; - onOpenDeposit: () => void; } const TAB_TITLES: Record = { - dashboard: 'Dashboard', - channels: 'Channels', - emissions: 'Emissions', + overview: 'Overview', + rewards: '$ANTS', 'diem-rewards': 'DIEM $ANTS', + activity: 'Activity', + settings: 'Settings', }; const TAB_SUBTITLES: Record = { - dashboard: 'Your balance, usage, and network activity at a glance.', - channels: 'Active and historical payment channels.', - emissions: 'Earn and claim ANTS rewards from network activity.', - 'diem-rewards': 'Track and claim ANTS rewards from DIEM staking.', + overview: 'Your AntSeed account at a glance.', + rewards: '$ANTS earned from AntSeed network usage.', + 'diem-rewards': '$ANTS earned from DIEM staking.', + activity: 'Settlements and channel closes.', + settings: 'Wallet, network, and appearance.', }; -function formatUsd(n: number): string { - return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - function SignerIcon() { return (
{TAB_SUBTITLES[activeTab]}
-
- + - -
+ + +
); diff --git a/apps/payments/web/src/layout/WalletDrawer.tsx b/apps/payments/web/src/layout/WalletDrawer.tsx index a5dc641c2..194ccc697 100644 --- a/apps/payments/web/src/layout/WalletDrawer.tsx +++ b/apps/payments/web/src/layout/WalletDrawer.tsx @@ -1,11 +1,12 @@ import { useEffect, useState, useCallback } from 'react'; import { useAccount, useDisconnect } from 'wagmi'; -import { ConnectButton } from '@rainbow-me/rainbowkit'; import type { BalanceData, PaymentConfig } from '../types'; import { useSetOperator, useTransferOperator } from '../hooks/useSetOperator'; import { useAuthorizedWallet } from '../context/AuthorizedWalletContext'; import { Button, Drawer, TextField } from '@antseed/ui'; import { InfoHint } from '../components/InfoHint'; +import { ConnectWalletAction } from '../components/ConnectWalletAction'; +import { formatUsd, truncateAddress } from '../utils/format'; interface WalletDrawerProps { isOpen: boolean; @@ -13,20 +14,10 @@ interface WalletDrawerProps { balance: BalanceData | null; config: PaymentConfig | null; buyerEvmAddress: string | null; - onOpenDeposit: () => void; - onOpenWithdraw: () => void; } const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; -function truncate(addr: string): string { - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; -} - -function formatUsd(n: number): string { - return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - function CopyIcon() { return ( - {buyerEvmAddress ? truncate(buyerEvmAddress) : '—'} + {buyerEvmAddress ? truncateAddress(buyerEvmAddress) : '—'} {copied === 'buyer' ? : } @@ -167,25 +150,17 @@ export function WalletDrawer({
-
- - -
{/* ── Your wallet ───────────────────────────────────── */}
Your wallet
- {!isConnected ? ( + {!walletConnected ? (
- - Wallet + + Not connected

Connect a wallet to receive withdrawals, claim ANTS rewards, and @@ -193,23 +168,23 @@ export function WalletDrawer({ fund your AntSeed account above.

- - {({ openConnectModal, mounted }) => ( + + {({ openConnectModal, ready, connected }) => connected ? null : ( )} - +
) : (
- + Connected
+
- {networkStatsError && ( -
- Couldn't load network stats: {networkStatsError} +
+
Claimable rewards
+
+ + {formatAntsAmount(totalClaimableRewards)} $ANTS
- )} +

{rewardSourceLabel}

+
+ + +
+
-
-
-
Your activity
-

Your usage

-

- Requests and tokens flowing through your signer over time. -

-
+
+
+

Your usage

+

Account-level requests, tokens, sellers, and channel activity.

+
-
-
-
-
Requests
-
{formatNumber(personalRequests)}
-
-
-
Tokens
-
{formatCompact(personalTokens)}
-
-
-
Settlements
-
{formatNumber(personalSettlements)}
-
-
-
Sellers
-
{formatNumber(personalUniqueSellers)}
-
+
+
+
Requests (all-time)
+ {formatNumber(buyerUsage?.totalRequests ?? 0)} +
+
+
Tokens (all-time)
+ {formatCompact(personalTokens)}
+
+
Sellers used
+ {formatNumber(buyerUsage?.uniqueSellers ?? 0)} +
+
+
Active channels
+ {formatNumber(buyerUsage?.activeChannels ?? 0)} +
+
- - {buyerUsageError && ( -
- Couldn't load your usage: {buyerUsageError} +
+
+
Usage · last 14 days
+ +
+
+
+
Recent activity
+
- )} -
+ {recentChannels.length === 0 ? ( +

No channel activity yet.

+ ) : ( +
+ {recentChannels.map((channel) => ( +
+
+ {getChannelStatusLabel(channel)} + {formatTimestampDate(channel.reservedAt)} +
+
+ ${formatUsd(channel.settled)} + of ${formatUsd(channel.deposit)} +
+
+ ))} +
+ )} +
+
+ {(networkStats || (networkStatsUrl && networkStatsError)) && ( +
+
+

Network activity

+

Aggregate activity across sellers on the AntSeed network.

+
+ + {networkStats && ( +
+
+
Active peers
+ {formatNumber(networkStats.totals.activePeers)} +
+
+
Requests
+ {formatCompact(bigintFromString(networkStats.totals.totalRequests))} +
+
+
Tokens
+ {formatCompact(networkTokens)} +
+
+
Settlements
+ {formatNumber(networkStats.totals.totalSettlements)} +
+
+ )} + + {networkStatsUrl && networkStatsError && ( +
+
+ )} +
+ )}
); } diff --git a/apps/payments/web/src/views/DiemRewardsView.tsx b/apps/payments/web/src/views/DiemRewardsView.tsx index 30b75e0aa..d725c4fe3 100644 --- a/apps/payments/web/src/views/DiemRewardsView.tsx +++ b/apps/payments/web/src/views/DiemRewardsView.tsx @@ -1,65 +1,39 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ConnectButton } from '@rainbow-me/rainbowkit'; import { useAccount, usePublicClient, useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; -import { formatUnits, parseAbi } from 'viem'; import type { PaymentConfig } from '../types'; -import { DIEM_STAKING_PROXY_ABI, DIEM_STAKING_PROXY_ADDRESS } from '../diem-proxy-abi'; +import { DIEM_STAKING_PROXY_ADDRESS } from '../diem-proxy-abi'; import { getErrorMessage, usePaymentNetwork } from '../payment-network'; -import { Button } from '../components/Button'; +import { ConnectWalletAction } from '../components/ConnectWalletAction'; +import { + DIEM_PROXY_ABI, + formatDiemEpochRange, + getDiemClaimableEpochs, + getDiemPendingTotal, + loadDiemRewardSnapshot, + type DiemRewardRow, + type DiemRewardSnapshot, +} from '../utils/diemRewards'; +import { formatAntsAmount } from '../utils/format'; interface DiemRewardsViewProps { config: PaymentConfig | null; } -interface DiemRewardRow { - epoch: number; - amount: bigint; - claimed: boolean; -} - -interface DiemRewardSnapshot { - firstRewardEpoch: number; - finalizedRewardEpoch: number; - syncedRewardEpoch: number; - userLastClaimedEpoch: number; - rows: DiemRewardRow[]; - hasMore: boolean; -} - -const ANTS_DECIMALS = 18; -const MAX_EPOCHS_PREVIEW = 16; -const DIEM_PROXY_ABI = parseAbi(DIEM_STAKING_PROXY_ABI); - -function formatAnts(amountWei: bigint): string { - const n = parseFloat(formatUnits(amountWei, ANTS_DECIMALS)); - if (n === 0) return '0'; - if (n < 0.0001) return '< 0.0001'; - return n.toLocaleString(undefined, { maximumFractionDigits: 4 }); -} - -function formatEpochRange(snapshot: DiemRewardSnapshot): string { - if (snapshot.rows.length === 0) return 'No finalized epochs in range'; - const first = snapshot.rows[0]?.epoch; - const last = snapshot.rows[snapshot.rows.length - 1]?.epoch; - return first === last ? `Epoch #${first}` : `Epochs #${first}–#${last}`; -} - -function asNumber(value: unknown): number { - return typeof value === 'number' ? value : Number(value ?? 0); -} - -function asBigint(value: unknown): bigint { - return typeof value === 'bigint' ? value : 0n; +export function DiemRewardsView({ config }: DiemRewardsViewProps) { + return ( +
+ +
+ ); } -export function DiemRewardsView({ config }: DiemRewardsViewProps) { +function DiemRewardsSection({ config }: DiemRewardsViewProps) { const { address, isConnected } = useAccount(); const publicClient = usePublicClient(); const accountAddress = address ?? null; const { expectedChainId, ensureCorrectNetwork } = usePaymentNetwork(config); - const [snapshot, setSnapshot] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [claimError, setClaimError] = useState(null); const [claimSuccess, setClaimSuccess] = useState(false); @@ -84,59 +58,8 @@ export function DiemRewardsView({ config }: DiemRewardsViewProps) { setLoading(true); setLoadError(null); try { - const [firstRewardEpochRaw, finalizedRewardEpochRaw, syncedRewardEpochRaw, userLastClaimedEpochRaw] = await publicClient.multicall({ - allowFailure: true, - contracts: [ - { address: DIEM_STAKING_PROXY_ADDRESS, abi: DIEM_PROXY_ABI, functionName: 'firstRewardEpoch' }, - { address: DIEM_STAKING_PROXY_ADDRESS, abi: DIEM_PROXY_ABI, functionName: 'finalizedRewardEpoch' }, - { address: DIEM_STAKING_PROXY_ADDRESS, abi: DIEM_PROXY_ABI, functionName: 'syncedRewardEpoch' }, - { address: DIEM_STAKING_PROXY_ADDRESS, abi: DIEM_PROXY_ABI, functionName: 'userLastClaimedEpoch', args: [accountAddress] }, - ], - }); - - const firstRewardEpoch = asNumber(firstRewardEpochRaw.result); - const finalizedRewardEpoch = asNumber(finalizedRewardEpochRaw.result); - const syncedRewardEpoch = asNumber(syncedRewardEpochRaw.result); - const userLastClaimedEpoch = asNumber(userLastClaimedEpochRaw.result); - const from = Math.max(userLastClaimedEpoch, firstRewardEpoch); - const to = Math.min(finalizedRewardEpoch, from + MAX_EPOCHS_PREVIEW); - const epochs: number[] = []; - for (let e = from; e < to; e += 1) epochs.push(e); - - const rows = epochs.length === 0 - ? [] - : await publicClient.multicall({ - allowFailure: true, - contracts: epochs.flatMap((epoch) => [ - { - address: DIEM_STAKING_PROXY_ADDRESS, - abi: DIEM_PROXY_ABI, - functionName: 'pendingAntsForEpoch', - args: [accountAddress, epoch] as const, - }, - { - address: DIEM_STAKING_PROXY_ADDRESS, - abi: DIEM_PROXY_ABI, - functionName: 'userEpochClaimed', - args: [accountAddress, epoch] as const, - }, - ]), - }); - - const rewardRows: DiemRewardRow[] = epochs.map((epoch, i) => ({ - epoch, - amount: asBigint(rows[i * 2]?.result), - claimed: rows[i * 2 + 1]?.result === true, - })); - - setSnapshot({ - firstRewardEpoch, - finalizedRewardEpoch, - syncedRewardEpoch, - userLastClaimedEpoch, - rows: rewardRows, - hasMore: from + MAX_EPOCHS_PREVIEW < finalizedRewardEpoch, - }); + const nextSnapshot = await loadDiemRewardSnapshot(publicClient, accountAddress as `0x${string}`); + setSnapshot(nextSnapshot); } catch (err) { setLoadError(getErrorMessage(err, 'Unable to load DIEM rewards.')); } finally { @@ -147,20 +70,14 @@ export function DiemRewardsView({ config }: DiemRewardsViewProps) { useEffect(() => { void load(); }, [load]); useEffect(() => { - if (claimConfirmed) { - setClaimSuccess(true); - resetClaim(); - void load(); - } + if (!claimConfirmed) return; + setClaimSuccess(true); + resetClaim(); + void load(); }, [claimConfirmed, load, resetClaim]); - const claimableEpochs = useMemo(() => ( - snapshot?.rows.filter((r) => !r.claimed).map((r) => r.epoch) ?? [] - ), [snapshot]); - - const totalPending = useMemo(() => ( - snapshot?.rows.reduce((sum, row) => sum + row.amount, 0n) ?? 0n - ), [snapshot]); + const claimableEpochs = useMemo(() => getDiemClaimableEpochs(snapshot), [snapshot]); + const totalPending = useMemo(() => getDiemPendingTotal(snapshot), [snapshot]); const handleClaim = useCallback(() => { if (!snapshot || claimableEpochs.length === 0) return; @@ -184,137 +101,118 @@ export function DiemRewardsView({ config }: DiemRewardsViewProps) { })(); }, [claimableEpochs, ensureCorrectNetwork, expectedChainId, snapshot, writeContract]); - if (!isConnected || !accountAddress) { - return ( -
-
-
Connect your staking wallet
-
- Connect the same wallet you used on the DIEM staking portal to view and claim $ANTS. + return ( + <> +
+
+
+
DIEM staking
+

Your DIEM $ANTS

+

+ Rewards earned from staking DIEM through the AntSeed proxy. The 10% DIEM pool fee flows + to the Protocol Reserve to strengthen the AntSeed ecosystem and ANTS. Connect the same + wallet you used for staking. +

-
+ {!isConnected || !accountAddress ? ( + + ) : null}
-
- ); - } - - if (loading && !snapshot) { - return ( -
-
Loading DIEM rewards…
-
- ); - } + - if (loadError) { - return ( -
-
-
Unable to load DIEM rewards
-
{loadError}
+ {!isConnected || !accountAddress ? null : loading && !snapshot ? ( +
Loading DIEM rewards…
+ ) : loadError ? ( +
+
-
- ); - } + ) : ( + <> +
+
+

Reward summary

+
- return ( -
-
-
-
DIEM staking
-

Your DIEM $ANTS

-

- Rewards earned from staking DIEM through the AntSeed proxy. The 10% DIEM pool fee flows to the Protocol Reserve to strengthen the AntSeed ecosystem and ANTS. Connect the same wallet you used for staking. -

-
+
+
+
Pending $ANTS
+ {formatAntsAmount(totalPending)} $ANTS +

Across scanned finalized epochs

+
+
+
Claimable epochs
+ {claimableEpochs.length} +

Includes 0-$ANTS epochs to clear cursor

+
+
+
Finalized epoch
+ #{snapshot?.finalizedRewardEpoch ?? '—'} +

Last claimable reward epoch boundary

+
+
+
Scanned range
+ {snapshot ? formatDiemEpochRange(snapshot) : '—'} +

{snapshot?.hasMore ? 'More epochs available after claim' : 'Up to date'}

+
+
-
-
-
Pending $ANTS
-
{formatAnts(totalPending)}
-
Across scanned finalized epochs
-
-
-
Claimable epochs
-
{claimableEpochs.length}
-
Includes 0-ANTS epochs to clear cursor
-
-
-
Finalized epoch
-
#{snapshot?.finalizedRewardEpoch ?? '—'}
-
Last claimable reward epoch boundary
-
-
-
Scanned range
-
{snapshot ? formatEpochRange(snapshot) : '—'}
-
{snapshot?.hasMore ? 'More epochs available after claim' : 'Up to date'}
-
-
-
+
+ + {snapshot?.hasMore && More finalized epochs load after claiming this range.} +
+
-
-
-
History
-

Diem proxy epochs

-

- Claimable epochs are finalized by the DiemStakingProxy. Current epochs appear after the proxy closes them. -

-
-
- - {(claimError || claimSuccess) && ( -
- {claimError ?? 'DIEM $ANTS claim confirmed.'} +
+
+

Diem proxy epochs

+

Claimable epochs are finalized by the DiemStakingProxy. Current epochs appear after the proxy closes them.

- )} - -
-
-
+ + + + {(claimError || claimSuccess) && ( +
+ {claimError ?? 'DIEM $ANTS claim confirmed.'} +
+ )} + + + )} + ); } -function DiemRewardsTable({ rows }: { rows: DiemRewardRow[] }) { +function DiemRewardsList({ rows }: { rows: DiemRewardRow[] }) { if (rows.length === 0) { - return
No finalized DIEM proxy epochs to show.
; + return

No finalized DIEM proxy epochs to show.

; } return ( -
- - - - - - - - - - {rows.slice().reverse().map((row) => { - const claimable = !row.claimed; - const statusLabel = row.claimed ? 'Claimed' : row.amount > 0n ? 'Claimable' : 'Clearable'; - const statusClass = row.claimed ? 'emissions-status--claimed' : 'emissions-status--pending'; - return ( - - - - - - ); - })} - -
EpochPendingStatus
#{row.epoch}{formatAnts(row.amount)} ANTS{statusLabel}
+
+
DIEM epochs
+ {rows.slice().reverse().map((row) => { + const statusLabel = row.claimed ? 'Claimed' : row.amount > 0n ? 'Claimable' : 'Clearable'; + return ( +
+ Epoch {row.epoch} + {formatAntsAmount(row.amount)} $ANTS + {statusLabel} +
+ ); + })}
); } diff --git a/apps/payments/web/src/views/EmissionsView.tsx b/apps/payments/web/src/views/EmissionsView.tsx index c13eb2e1a..4ae902e57 100644 --- a/apps/payments/web/src/views/EmissionsView.tsx +++ b/apps/payments/web/src/views/EmissionsView.tsx @@ -15,7 +15,6 @@ import { import { EMISSIONS_CLAIM_ABI } from '../emissions-abi'; import { getErrorMessage, usePaymentNetwork } from '../payment-network'; import { useAuthorizedWallet } from '../context/AuthorizedWalletContext'; -import { Button } from '../components/Button'; interface EmissionsViewProps { config: PaymentConfig | null; @@ -36,7 +35,7 @@ function formatAnts(amountWei: string): string { const n = parseFloat(formatUnits(BigInt(amountWei), ANTS_DECIMALS)); if (n === 0) return '0'; if (n < 0.0001) return '< 0.0001'; - return n.toLocaleString(undefined, { maximumFractionDigits: 4 }); + return n.toLocaleString('en-US', { maximumFractionDigits: 4 }); } catch { return '0'; } @@ -104,14 +103,41 @@ function computeEpochShare( return Number((reward * 10000n) / emission) / 100; } +function getSettledRowReward( + row: EmissionsPendingResponse['rows'][number], + fallback: SharesType | null | undefined, +): string { + const params = getEffectiveParams(row, fallback); + if (!params) return addWei(row.seller.amount, row.buyer.amount); + const sellerReward = row.seller.claimed + ? estimateSideReward( + row.epochEmission, + params.sellerSharePct, + params.maxSellerSharePct, + row.seller.userPoints, + row.seller.totalPoints, + ).toString() + : row.seller.amount; + const buyerReward = row.buyer.claimed + ? estimateSideReward( + row.epochEmission, + params.buyerSharePct, + params.maxBuyerSharePct, + row.buyer.userPoints, + row.buyer.totalPoints, + ).toString() + : row.buyer.amount; + return addWei(sellerReward, buyerReward); +} + function formatTimeRemaining(seconds: number): string { if (seconds <= 0) return 'ending now'; - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; } export function EmissionsView({ config }: EmissionsViewProps) { @@ -242,23 +268,15 @@ export function EmissionsView({ config }: EmissionsViewProps) { }, [buyerClaimConfirmed, resetBuyerClaim, load]); if (loading && !info) { - return ( -
-
-
Loading…
-
-
- ); + return
Loading…
; } if (loadError || !info) { return ( -
+
Emissions not available
-
- {loadError ?? 'The Emissions contract is not configured for this chain.'} -
+
{loadError ?? 'The Emissions contract is not configured for this chain.'}
); @@ -271,6 +289,7 @@ export function EmissionsView({ config }: EmissionsViewProps) { const epochsUntilHalving = info.halvingInterval - (info.currentEpoch % info.halvingInterval); const rows = pending?.rows ?? []; + const pastRows = rows.filter((r) => !r.isCurrent); const currentRow = rows.find((r) => r.isCurrent); const currentParams = getEffectiveParams(currentRow, shares); const currentEstimate = currentRow ? estimateRowReward(currentRow, shares) : '0'; @@ -278,12 +297,8 @@ export function EmissionsView({ config }: EmissionsViewProps) { let totalClaimable = 0n; let totalClaimed = 0n; - for (const r of rows) { - if (r.isCurrent) continue; + for (const r of pastRows) { const params = getEffectiveParams(r, shares); - // Per-side: pendingEmissions returns 0 for claimed sides, so estimate from points. - // Use the epoch's snapshotted params so historical rows don't change when - // owner-controlled global shares are updated for future epochs. if (r.seller.claimed && params) { totalClaimed += estimateSideReward( r.epochEmission, @@ -307,132 +322,63 @@ export function EmissionsView({ config }: EmissionsViewProps) { totalClaimable += safeBigint(r.buyer.amount); } } + const totalEarned = safeBigint(currentEstimate) + totalClaimable + totalClaimed; + + const chartRows = pastRows + .slice() + .sort((a, b) => Number(a.epoch) - Number(b.epoch)) + .slice(-6); + const rewardBars = [ + ...chartRows.map((row) => ({ + key: String(row.epoch), + label: String(row.epoch), + amount: getSettledRowReward(row, shares), + isCurrent: false, + })), + ...(currentRow ? [{ + key: 'current', + label: `${currentRow.epoch}·now`, + amount: currentEstimate, + isCurrent: true, + }] : []), + ]; + const maxReward = rewardBars.reduce((max, bar) => { + const amount = safeBigint(bar.amount); + return amount > max ? amount : max; + }, 0n); return ( -
-
-
-
Current epoch
-

Epoch #{info.currentEpoch}

- {currentParams && ( -

- Split: {currentParams.sellerSharePct}% sellers · {currentParams.buyerSharePct}% buyers ·{' '} - {currentParams.reserveSharePct}% reserve · {currentParams.teamSharePct}% team -

- )} -
- -
-
-
Ends in
-
{formatTimeRemaining(timeRemaining)}
-
-
-
Epoch pool
-
{formatAnts(info.epochEmission)}
-
ANTS this epoch
-
-
-
Epoch duration
-
{Math.round(info.epochDuration / 86400)}d
-
{(info.epochDuration / 3600).toFixed(0)} hours
-
-
-
Next halving
-
{epochsUntilHalving}
-
Epochs remaining
-
-
-
- -
-
-
Your position
-

This epoch

-

- Your share of this epoch's rewards. Updates after each on-chain settlement. -

-
- -
-
-
Estimated reward
-
{formatAnts(currentEstimate)}
-
ANTS (not yet final)
-
-
-
Your epoch share
-
{epochSharePct > 0 ? `${epochSharePct.toFixed(2)}%` : '—'}
-
Of total epoch emission
-
-
-
Claimable
-
{formatAnts(totalClaimable.toString())}
-
From past epochs
-
-
-
Already claimed
-
{formatAnts(totalClaimed.toString())}
-
ANTS total
-
-
-
- -
-
-
History
-

Your emissions

-

- Current epoch is an estimate that updates after each on-chain settlement. -

-
-
- - {(sellerClaimError || buyerClaimError) && ( -
{sellerClaimError || buyerClaimError}
- )} -
- - +
+
+
+
Claimable now
+
+ {formatAnts(totalClaimable.toString())} + $ANTS
+

From closed epochs

-
- -
-
-
Info
-

About $ANTS

-
-
-
-

- $ANTS is the native token of the AntSeed network. It is minted - each epoch to active sellers and buyers in proportion to their - on-chain activity. There is no pre-mining — you earn - simply by using the network. -

-

- Claims are non-custodial: seller claims mint to the claiming wallet, - and buyer claims mint to the wallet you've authorized for your - buyer identity. -

-
+
+ + {config?.antsTokenAddress && connector && ( - + )}
+
+
+

Epoch #{info.currentEpoch}

+ {currentParams && ( +

+ Split: {currentParams.sellerSharePct}% sellers · {currentParams.buyerSharePct}% buyers ·{' '} + {currentParams.reserveSharePct}% reserve · {currentParams.teamSharePct}% team +

+ )} +
+ +
+
+
Ends in
+ {formatTimeRemaining(timeRemaining)} +
+
+
Epoch pool
+ {formatAnts(info.epochEmission)} +
+
+
Epoch duration
+ {Math.round(info.epochDuration / 86400)}d +
+
+
Next halving
+ {epochsUntilHalving} +
+
+
Estimated reward
+ ~{formatAnts(currentEstimate)} +
+
+
Your epoch share
+ {epochSharePct > 0 ? `${epochSharePct.toFixed(2)}%` : '—'} +
+
+
Claimable
+ {formatAnts(totalClaimable.toString())} +
+
+
Already claimed
+ {formatAnts(totalClaimed.toString())} +
+
+
+ +
+
+

About $ANTS

+
+
+

+ $ANTS is the native token of the AntSeed network. It is minted + each epoch to active sellers and buyers in proportion to their + on-chain activity. There is no pre-mining — you earn + simply by using the network. +

+

+ Claims are non-custodial: seller claims mint to the claiming wallet, + and buyer claims mint to the wallet you've authorized for your + buyer identity. +

+
+
+ +
+
+

Emission history

+

Current epoch is an estimate that updates after each on-chain settlement.

+
+ +
+
+
Reward growth · per epoch
+

Total earned to date {formatAnts(totalEarned.toString())} $ANTS

+
+ +
+ +
+
Epoch history
+ {pastRows.length === 0 ? ( +

No closed epochs yet.

+ ) : ( + pastRows.slice().reverse().map((row) => ( +
+ Epoch {row.epoch} + {formatAnts(getSettledRowReward(row, shares))} $ANTS +
+ )) + )} +
+
+ + {(sellerClaimError || buyerClaimError) && ( +
{sellerClaimError || buyerClaimError}
+ )} {transfersEnabled === false && ( -
- ANTS is not yet transferable. - Claimed tokens remain in your wallet until governance enables transfers. +
+
)}
); } - -function EmissionsTable({ rows, shares }: { - rows: EmissionsPendingResponse['rows']; - shares?: SharesType | null; -}) { - if (rows.length === 0) { - return
No recent epochs to show.
; - } - return ( -
- - - - - - - - - - - {rows.slice().reverse().map((row) => { - const total = row.isCurrent || row.seller.claimed || row.buyer.claimed - ? estimateRowReward(row, shares) - : addWei(row.seller.amount, row.buyer.amount); - const share = computeEpochShare(row, shares); - // "Fully resolved" = each side is either claimed or has no points - const sellerDone = row.seller.claimed || row.seller.userPoints === '0'; - const buyerDone = row.buyer.claimed || row.buyer.userPoints === '0'; - const fullyClaimed = !row.isCurrent && sellerDone && buyerDone && (row.seller.claimed || row.buyer.claimed); - const nothingToClaim = total === '0'; - const statusLabel = fullyClaimed - ? 'Claimed' - : row.isCurrent - ? 'Estimate' - : nothingToClaim - ? '—' - : 'Claimable'; - const statusClass = fullyClaimed - ? 'emissions-status--claimed' - : row.isCurrent - ? 'emissions-status--estimate' - : nothingToClaim - ? '' - : 'emissions-status--pending'; - return ( - - - - - - - ); - })} - -
EpochRewardYour shareStatus
#{row.epoch}{formatAnts(total)} ANTS{share > 0 ? `${share.toFixed(2)}%` : '—'}{statusLabel}
-
- ); -} diff --git a/apps/payments/web/src/views/SettingsView.tsx b/apps/payments/web/src/views/SettingsView.tsx new file mode 100644 index 000000000..d1620fea7 --- /dev/null +++ b/apps/payments/web/src/views/SettingsView.tsx @@ -0,0 +1,117 @@ +import { useAccount, useDisconnect } from 'wagmi'; +import type { PaymentConfig } from '../types'; +import { useAuthorizedWallet } from '../context/AuthorizedWalletContext'; +import { ConnectWalletAction } from '../components/ConnectWalletAction'; +import { truncateAddress } from '../utils/format'; + +interface SettingsViewProps { + config: PaymentConfig | null; +} + +export function SettingsView({ config }: SettingsViewProps) { + const { operatorSet, operator, requireAuthorization } = useAuthorizedWallet(); + const { address: connectedAddress, isConnected, connector } = useAccount(); + const { disconnect } = useDisconnect(); + + return ( +
+
+
+
Wallet
+
+
+
+

Connected wallet

+

+ The external wallet used to sign and submit on-chain actions + (withdrawals, claims, channel closes). +

+
+
+ {isConnected && connectedAddress ? ( + <> + {truncateAddress(connectedAddress)} + {connector?.name && {connector.name}} + + + ) : ( + + )} +
+
+ +
+
+

Authorized wallet

+

+ Your AntSeed node signs spending requests but never holds USDC or ANTS. + This external wallet can recover funds and claim rewards. +

+
+
+ {operatorSet ? ( + <> + {operator ? truncateAddress(operator) : 'Not configured'} + + + + ) : ( + + )} +
+
+
+
+ +
+
Network
+
+
+
+

Chain

+

Network the AntSeed protocol contracts live on.

+
+
+ {config?.chainId ?? 'Unknown'} + + +
+
+ +
+
+

RPC endpoint

+

Configured by the active AntSeed node. Change it in node config, then reopen the portal.

+
+
+ +
+
+
+
+
+
+ ); +} diff --git a/packages/ui/src/primitives/Drawer.tsx b/packages/ui/src/primitives/Drawer.tsx index 5b17b648b..b3be98f7a 100644 --- a/packages/ui/src/primitives/Drawer.tsx +++ b/packages/ui/src/primitives/Drawer.tsx @@ -1,6 +1,6 @@ import FocusLock from 'react-focus-lock'; import { RemoveScroll } from 'react-remove-scroll'; -import { useId, useRef, type ReactNode } from 'react'; +import { useEffect, useId, useRef, type ReactNode } from 'react'; import { useDialogBehavior } from './useDialogBehavior'; export type DrawerSide = 'left' | 'right'; @@ -44,6 +44,7 @@ export function Drawer({ const titleId = useId(); const subtitleId = useId(); const { closeDialog: closeDrawer, isTopDialog } = useDialogBehavior(isOpen, panelRef, onClose); + const isActiveDialog = isOpen && isTopDialog; const backdropClasses = [ 'as-drawer-backdrop', @@ -58,6 +59,26 @@ export function Drawer({ ].filter(Boolean).join(' '); const bodyClasses = ['as-drawer__body', bodyClassName].filter(Boolean).join(' '); + useEffect(() => { + if (!isOpen || !isTopDialog) return; + + function onPointerDown(event: PointerEvent) { + const panel = panelRef.current; + if (!panel) return; + if (event.target instanceof Node && panel.contains(event.target)) return; + if ( + event.target instanceof Element && + event.target.closest('[role="dialog"]') !== null + ) { + return; + } + closeDrawer(); + } + + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [closeDrawer, isOpen, isTopDialog]); + return ( <>