From c6bbd9f073b18b8c4860a555fdc98be9d3b5d911 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 25 Aug 2026 13:35:40 -0700 Subject: [PATCH] feat(account-demo): unify Features tiles and toast send results Share one card chrome so action buttons stay left-aligned, and surface transaction outcomes with Sonner instead of only the inline banner. Co-Authored-By: Cursor --- app/vibenet/demos/account/AccountDemo.tsx | 343 ++++++++---------- .../demos/account/components/AppsView.tsx | 206 ++++++----- .../account/components/FeatureGridCard.tsx | 50 +++ 3 files changed, 316 insertions(+), 283 deletions(-) create mode 100644 app/vibenet/demos/account/components/FeatureGridCard.tsx diff --git a/app/vibenet/demos/account/AccountDemo.tsx b/app/vibenet/demos/account/AccountDemo.tsx index 54f6347..9b40653 100644 --- a/app/vibenet/demos/account/AccountDemo.tsx +++ b/app/vibenet/demos/account/AccountDemo.tsx @@ -30,7 +30,6 @@ import { useEffect, useMemo, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Button } from '../../../components/ui/Button'; -import { Card } from '../../../components/ui/Card'; import { cn } from '../../../components/ui/cn'; import { CloseIcon } from '../../../components/ui/icons'; import { Modal } from '../../../components/ui/Modal'; @@ -48,6 +47,7 @@ import { AccountSwitcher } from '../_shared/AccountSwitcher'; import { AddressAutocomplete, type AddressBookEntry } from '../_shared/AddressAutocomplete'; import { ActivityLog } from './components/ActivityLog'; import { AppCard, AppCardPlaceholder, AppsNetworkNotice } from './components/AppsView'; +import { FeatureGridCard, FeatureGridPlaceholder } from './components/FeatureGridCard'; import { Badge, CheckIcon, KindBadge } from '../_shared/primitives'; import { DEMO_APPS, type DemoApp } from './library/apps'; import { DEMO_CHAINS, estimateTxGas, PAYER_URL } from './library/chains'; @@ -241,6 +241,7 @@ export function AccountDemo() { extraChanges: string[] = [], ) => { setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote }); + toast.success(pending ? 'Submitted — awaiting confirmation' : 'Transaction landed onchain'); pushActivity({ kind: a.deployed && !pending ? 'transact' : 'create', txHash, @@ -278,16 +279,21 @@ export function AccountDemo() { ...pendingScope.map((o) => `scope ${o.label} → ${scopeLabel(o.toScope)}`), ]; + const surfaceSendError = (message: string) => { + setError(message); + toast.error(message); + }; + // Transact: native offline sign, own ETH gas. const doSignNative = async () => { if (!acct || !txSigner || !callsValid) return; const sessionPolicy = activeSessionKey?.policy; if (txIsSession && gasMode !== 'free') { - setError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); + surfaceSendError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); return; } if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - setError('Authorize this session key with an owner key first (Apply now).'); + surfaceSendError('Authorize this session key with an owner key first (Apply now).'); return; } setSigning(true); @@ -335,7 +341,7 @@ export function AccountDemo() { } catch (err) { if (handleSeqMismatch(err, seqCtx)) return false; const e = err as { message?: string; name?: string }; - setError(conciseError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err)))); + surfaceSendError(conciseError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err)))); return false; } finally { setSigning(false); @@ -352,11 +358,11 @@ export function AccountDemo() { if (!acct || !txSigner || !callsValid) return; const sessionPolicy = activeSessionKey?.policy; if (txIsSession && gasMode !== 'free') { - setError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); + surfaceSendError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); return; } if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - setError('Authorize this session key with an owner key first (Apply now).'); + surfaceSendError('Authorize this session key with an owner key first (Apply now).'); return; } setSigning(true); @@ -453,7 +459,7 @@ export function AccountDemo() { if (handleSeqMismatch(err, seqCtx)) return false; const e = err as { message?: string; name?: string }; const msg = e.message ?? String(err); - setError( + surfaceSendError( conciseError( e.name === 'NotAllowedError' ? 'Signature was dismissed.' @@ -700,7 +706,9 @@ export function AccountDemo() { {FEATURES.map((feature) => ( ))} - Features + + Features + {/* Transact + the app cards, as peers in one grid. `initial={false}`: the crossfade is for switching accounts, not first paint. */} @@ -722,7 +730,7 @@ export function AccountDemo() { - {error && !estimateBlocked ? ( + {error && !estimateBlocked && !transactModalOpen ? (
- Advanced Transactions - Create and select an account to transact. - + ); return ( <> - -
- - Advanced Transactions -
-

- Compose and send EIP-8130 transactions from your account. -

-
- -
-
+ + + + } + title="Advanced Transactions" + description="Compose and send EIP-8130 transactions from your account." + > + + - Sponsorship - Create and select an account to send a sponsored transaction. - - ); + if (!acct) { + return ( + + ); + } return ( - -
- - Sponsorship -
-

- Send a transaction with its gas paid by a payer — no ETH required in your account. -

-
- -
-
+ + + + + } + title="Sponsorship" + description="Send a transaction with its gas paid by a payer — no ETH required in your account." + > + + ); } function renderOwners() { - if (!acct) return ( - - Modify Owners - Create and select an account to manage owners. - - ); + if (!acct) { + return ( + + ); + } return ( - -
- - Modify Owners -
-

- Add or revoke owner keys and rotate signers — swap keys anytime without ever migrating - accounts. -

-
- -
-
+ + + + + } + title="Modify Owners" + description="Add or revoke owner keys and rotate signers — swap keys anytime without ever migrating accounts." + > + + ); } function renderBatchedCalls() { - if (!acct) return ( - - Batched Calls - Create and select an account to send a batched transaction. - - ); + if (!acct) { + return ( + + ); + } return ( - -
- - Batched Calls -
-

- Send multiple actions in one transaction — e.g. approve and swap — so they either all land - together or none do. -

-
- -
-
+ + + + + + } + title="Batched Calls" + description="Send multiple actions in one transaction — e.g. approve and swap — so they either all land together or none do." + > + + ); } function renderGasToken() { - if (!acct) return ( - - Pay Gas in Any Token - Create and select an account to pay gas in a token. - - ); + if (!acct) { + return ( + + ); + } return ( - -
- - Pay Gas in Any Token -
-

- Pay gas with any supported token including stablecoins — settle a batched transaction's - fees in USDV with no ETH in your account. -

-
- -
-
+ + + + + } + title="Pay Gas in Any Token" + description="Pay gas with any supported token including stablecoins — settle a batched transaction's fees in USDV with no ETH in your account." + > + + ); } } @@ -1745,9 +1717,8 @@ type SubmittedResult = { } | null; // Third stage of the Transact modal: shown once "Send" is pressed. Renders the -// in-flight signing/broadcast status, then whichever of success or error the -// send actually resolved to — this is the single place a sent transaction's -// outcome is surfaced (no toasts). +// in-flight signing/broadcast status, then success or error. Outcomes also +// fire a sonner toast so the result is visible if the modal is dismissed. function SubmittedBody({ signing, submitStatus, diff --git a/app/vibenet/demos/account/components/AppsView.tsx b/app/vibenet/demos/account/components/AppsView.tsx index 754eee0..d433b22 100644 --- a/app/vibenet/demos/account/components/AppsView.tsx +++ b/app/vibenet/demos/account/components/AppsView.tsx @@ -1,13 +1,14 @@ 'use client'; import { Button } from '../../../../components/ui/Button'; -import { Card } from '../../../../components/ui/Card'; -import { Text } from '../../../../components/ui/Text'; import type { DemoApp } from '../library/apps'; -import type { DemoChain } from '../library/chains'; import { formatExpiry, type AppSessionKey, type AppSubAccount, type StoredAccount } from '../library/model'; import { short, type WalletSigner } from '../shared'; import { Badge } from '../../_shared/primitives'; +import { FeatureGridCard, FeatureGridPlaceholder } from './FeatureGridCard'; + +const CONNECTED_FOOTER = + 'border-t border-bds-gray-10 pt-3 dark:border-white/10'; // Banner shown above the app cards when the active network doesn't support // native EIP-8130 grants yet — apps stay disabled until the user switches. @@ -44,113 +45,124 @@ type AppCardProps = { deleteVault: (sub: AppSubAccount) => void; }; +function AppIcon({ app }: { app: DemoApp }) { + if (app.id === 'monthly-vibes') { + return ( + + + + + ); + } + if (app.id === 'spending-account') { + return ( + + + + + ); + } + return {app.emoji}; +} + // A single demo app: connects to the active account through a scoped grant it // fully controls — a capped session key (subscriptions) or a delegated -// sub-account. Ported from the source `renderApps`. +// sub-account. Idle chrome matches the other Features tiles; once a grant is +// live the footer becomes a status row with a divider. export function AppCard(p: AppCardProps) { - const { acct, app } = p; + const { app } = p; const sk = app.grant === 'session' ? p.sessionKeyFor(app.name) : undefined; const sub = app.grant === 'subaccount' ? p.subAccountFor(app.name) : undefined; const connecting = p.appBusy === app.id; - return ( - -
- - {app.name} -
-

{app.desc}

- {app.id === 'monthly-vibes' ? ( - sk ? ( -
- Active - - {sk.policy?.params ?? 'capped'} · {formatExpiry(sk.expiry)} + let footer = null; + let connected = false; + if (app.id === 'monthly-vibes') { + if (sk) { + connected = true; + footer = ( + <> + Active + + {sk.policy?.params ?? 'capped'} · {formatExpiry(sk.expiry)} + + + + ); + } else { + footer = ( + + ); + } + } else if (app.id === 'spending-account') { + if (sub) { + connected = true; + footer = ( + <> + -
- ) : ( -
- -
- ) - ) : null} + + + + ); + } else { + footer = ( + + ); + } + } - {app.id === 'spending-account' ? ( - sub ? ( -
- - -
- ) : ( -
- -
- ) - ) : null} -
+ return ( + } + title={app.name} + description={app.desc} + footerClassName={connected ? CONNECTED_FOOTER : undefined} + > + {footer} + ); } -// Placeholder shown per app before an account is created/selected. export function AppCardPlaceholder({ app }: { app: DemoApp }) { return ( - - {app.name} - Create and select an account to connect. - + ); } - diff --git a/app/vibenet/demos/account/components/FeatureGridCard.tsx b/app/vibenet/demos/account/components/FeatureGridCard.tsx new file mode 100644 index 0000000..2bf7aa6 --- /dev/null +++ b/app/vibenet/demos/account/components/FeatureGridCard.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; + +import { Card } from '../../../../components/ui/Card'; +import { cn } from '../../../../components/ui/cn'; +import { Text } from '../../../../components/ui/Text'; + +// Shared chrome for Features-grid tiles. Default footer matches the transact +// cards (left-aligned, no divider). Pass `footerClassName` when a connected +// grant needs the divided, status-row treatment. + +export function FeatureGridCard({ + icon, + title, + description, + footerClassName, + children, +}: { + icon: ReactNode; + title: string; + description: string; + footerClassName?: string; + children: ReactNode; +}) { + return ( + +
+ + {title} +
+

{description}

+
{children}
+
+ ); +} + +export function FeatureGridPlaceholder({ title, message }: { title: string; message: string }) { + return ( + + {title} + + {message} + + + ); +}