From c12218a7b6022d8a14d0c2e93ac84f1b1e544687 Mon Sep 17 00:00:00 2001 From: Esther Date: Mon, 29 Jun 2026 17:10:57 +0100 Subject: [PATCH] Add empty-state deposit and withdraw intent actions Wire empty-state CTAs to shared vaultIntentActions helper that navigates to the dashboard and dispatches TRIGGER_DEPOSIT/TRIGGER_WITHDRAW events. Closes #734. --- CHANGELOG.md | 1 + .../VaultDashboard.emptystate.test.tsx | 12 ++-- frontend/src/components/VaultDashboard.tsx | 15 +++-- frontend/src/i18n/locales/en.ts | 22 ++++++++ frontend/src/i18n/locales/es.ts | 22 ++++++++ frontend/src/lib/vaultIntentActions.test.ts | 55 +++++++++++++++++++ frontend/src/lib/vaultIntentActions.ts | 37 +++++++++++++ frontend/src/pages/Analytics.tsx | 5 +- .../src/pages/Portfolio.emptystate.test.tsx | 18 +++++- frontend/src/pages/Portfolio.tsx | 3 +- frontend/src/pages/TransactionHistory.tsx | 55 ++++++++++++++++--- 11 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 frontend/src/lib/vaultIntentActions.test.ts create mode 100644 frontend/src/lib/vaultIntentActions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc11dbe..2730b029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Features +- Add empty-state deposit and withdraw intent actions across dashboard pages (#734) - CORS configuration for cross-origin API access - Structured logging, graceful shutdown, caching, and API key authentication - Network badge showing testnet vs mainnet status in the frontend diff --git a/frontend/src/components/VaultDashboard.emptystate.test.tsx b/frontend/src/components/VaultDashboard.emptystate.test.tsx index 4408733a..58f867c2 100644 --- a/frontend/src/components/VaultDashboard.emptystate.test.tsx +++ b/frontend/src/components/VaultDashboard.emptystate.test.tsx @@ -128,12 +128,12 @@ describe("VaultDashboard — empty state", () => { renderDashboard("GABC123", 0); await waitFor(() => { - expect(screen.getByText("No deposits yet.")).toBeInTheDocument(); + expect(screen.getByText(/No deposits yet/i)).toBeInTheDocument(); }); expect( screen.getByText( - /Start earning yield by depositing USDC into our high-efficiency vaults\./i, + /Start earning yield by depositing USDC into our high-efficiency vaults/i, ), ).toBeInTheDocument(); }); @@ -156,9 +156,11 @@ describe("VaultDashboard — empty state", () => { const cta = await screen.findByRole("button", { name: "Deposit Now" }); fireEvent.click(cta); - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ type: "TRIGGER_DEPOSIT" }), - ); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_DEPOSIT" }), + ); + }); }); it("does NOT show the empty state when balance is non-zero", async () => { diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 83cde7a7..b256e706 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -34,6 +34,9 @@ import { useFeeEstimate } from "../hooks/useFeeEstimate"; import { useSlippage } from "../hooks/useSlippage"; import HelpIcon from "./ui/HelpIcon"; import EmptyState from "./ui/EmptyState"; +import { useTranslation } from "../i18n"; +import { useNavigate } from "react-router-dom"; +import { triggerDepositIntent } from "../lib/vaultIntentActions"; import { networkConfig } from "../config/network"; import { useDashboardUrlState, type TransactionTab, type TransactionStep } from "../hooks/useDashboardUrlState"; import RefreshControl from "./RefreshControl"; @@ -173,6 +176,8 @@ const VaultDashboard: React.FC = ({ lastUpdate, refresh, } = useVault(); + const { t } = useTranslation(); + const navigate = useNavigate(); const toast = useToast(); const delayedLoading = useDelayedLoading(isLoading); @@ -841,13 +846,11 @@ const VaultDashboard: React.FC = ({ {!isLoading && walletAddress && usdcBalance === 0 && ( } - actionLabel="Deposit Now" - onAction={() => { - window.dispatchEvent(new Event("TRIGGER_DEPOSIT")); - }} + actionLabel={t("emptyState.depositNow")} + onAction={() => triggerDepositIntent(navigate, walletAddress)} /> )} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1c5ab413..60198c1d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -400,6 +400,18 @@ export const en = { failed: "Failed", }, }, + dashboard: { + emptyState: { + noDeposits: { + title: "No deposits yet.", + desc: "Start earning yield by depositing USDC into our high-efficiency vaults.", + }, + }, + }, + emptyState: { + depositNow: "Deposit Now", + withdrawNow: "Withdraw Now", + }, txHistory: { pageTitle: "Transaction History", pageDesc: "View all your past deposits and withdrawals.", @@ -420,6 +432,15 @@ export const en = { }, resetFilters: "Reset filters", depositNow: "Deposit Now", + noWithdrawals: { + title: "No withdrawals yet", + desc: "Your deposits are active — withdraw anytime from the vault.", + }, + connectWallet: { + title: "Connect your wallet", + desc: "Connect your wallet to view your transaction history.", + action: "Connect wallet", + }, loadingLabel: "Loading...", upToDateLabel: "Up to date", paginatedView: "Paginated view", @@ -488,5 +509,6 @@ export const en = { homeLabel: "Home", emptyTitle: "No analytics data yet", emptyDesc: "Vault analytics will appear once the first deposit is made.", + depositNow: "Deposit Now", }, } as const; diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 719ee730..dc652257 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -386,6 +386,18 @@ export const es = { failed: "Fallido", }, }, + dashboard: { + emptyState: { + noDeposits: { + title: "Aún no hay depósitos.", + desc: "Comienza a generar rendimiento depositando USDC en nuestros vaults de alta eficiencia.", + }, + }, + }, + emptyState: { + depositNow: "Depositar ahora", + withdrawNow: "Retirar ahora", + }, txHistory: { pageTitle: "Historial de transacciones", pageDesc: "Consulta todos tus depósitos y retiros pasados.", @@ -406,6 +418,15 @@ export const es = { }, resetFilters: "Restablecer filtros", depositNow: "Depositar ahora", + noWithdrawals: { + title: "Aún no hay retiros", + desc: "Tus depósitos están activos — retira en cualquier momento desde el vault.", + }, + connectWallet: { + title: "Conecta tu billetera", + desc: "Conecta tu billetera para ver tu historial de transacciones.", + action: "Conectar billetera", + }, loadingLabel: "Cargando...", upToDateLabel: "Al día", paginatedView: "Vista paginada", @@ -474,5 +495,6 @@ export const es = { homeLabel: "Inicio", emptyTitle: "Aún no hay datos de analítica", emptyDesc: "La analítica de la bóveda aparecerá una vez que se realice el primer depósito.", + depositNow: "Depositar ahora", }, } as const; diff --git a/frontend/src/lib/vaultIntentActions.test.ts b/frontend/src/lib/vaultIntentActions.test.ts new file mode 100644 index 00000000..b1254c74 --- /dev/null +++ b/frontend/src/lib/vaultIntentActions.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + triggerDepositIntent, + triggerWithdrawIntent, + triggerWalletConnectIntent, +} from "./vaultIntentActions"; + +describe("vaultIntentActions", () => { + const navigate = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(window, "dispatchEvent"); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("dispatches wallet connect when deposit intent has no wallet", () => { + triggerDepositIntent(navigate, null); + expect(navigate).not.toHaveBeenCalled(); + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_WALLET_CONNECT" }), + ); + }); + + it("navigates home and dispatches deposit intent when wallet is connected", () => { + triggerDepositIntent(navigate, "GABC123"); + expect(navigate).toHaveBeenCalledWith("/"); + expect(window.dispatchEvent).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_DEPOSIT" }), + ); + }); + + it("navigates home and dispatches withdraw intent when wallet is connected", () => { + triggerWithdrawIntent(navigate, "GABC123"); + expect(navigate).toHaveBeenCalledWith("/"); + vi.advanceTimersByTime(100); + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_WITHDRAW" }), + ); + }); + + it("dispatches wallet connect for withdraw intent without wallet", () => { + triggerWalletConnectIntent(); + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_WALLET_CONNECT" }), + ); + }); +}); diff --git a/frontend/src/lib/vaultIntentActions.ts b/frontend/src/lib/vaultIntentActions.ts new file mode 100644 index 00000000..970fc74b --- /dev/null +++ b/frontend/src/lib/vaultIntentActions.ts @@ -0,0 +1,37 @@ +import type { NavigateFunction } from "react-router-dom"; + +const INTENT_DELAY_MS = 100; + +export function triggerWalletConnectIntent(): void { + window.dispatchEvent(new CustomEvent("TRIGGER_WALLET_CONNECT")); +} + +export function triggerDepositIntent( + navigate: NavigateFunction, + walletAddress: string | null, +): void { + if (!walletAddress) { + triggerWalletConnectIntent(); + return; + } + navigate("/"); + window.setTimeout( + () => window.dispatchEvent(new CustomEvent("TRIGGER_DEPOSIT")), + INTENT_DELAY_MS, + ); +} + +export function triggerWithdrawIntent( + navigate: NavigateFunction, + walletAddress: string | null, +): void { + if (!walletAddress) { + triggerWalletConnectIntent(); + return; + } + navigate("/"); + window.setTimeout( + () => window.dispatchEvent(new CustomEvent("TRIGGER_WITHDRAW")), + INTENT_DELAY_MS, + ); +} diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 54260efe..fd52617a 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -8,6 +8,7 @@ import Skeleton from "../components/Skeleton"; import EmptyState from "../components/ui/EmptyState"; import APYTrendChart from "../components/APYTrendChart"; import { useNavigate } from "react-router-dom"; +import { triggerDepositIntent } from "../lib/vaultIntentActions"; import RefreshControl from "../components/RefreshControl"; import { usePolling } from "../hooks/usePolling"; import { useStaleIndicator } from "../hooks/useStaleIndicator"; @@ -118,8 +119,8 @@ const Analytics: React.FC = () => { title={t("analytics.emptyTitle")} description={t("analytics.emptyDesc")} icon={} - actionLabel={t("txHistory.depositNow")} - onAction={() => navigate("/")} + actionLabel={t("analytics.depositNow")} + onAction={() => triggerDepositIntent(navigate, null)} /> )} diff --git a/frontend/src/pages/Portfolio.emptystate.test.tsx b/frontend/src/pages/Portfolio.emptystate.test.tsx index aff85ab9..647a71af 100644 --- a/frontend/src/pages/Portfolio.emptystate.test.tsx +++ b/frontend/src/pages/Portfolio.emptystate.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -95,6 +95,22 @@ describe("Portfolio — empty state", () => { }); }); + it("Deposit Now CTA dispatches TRIGGER_DEPOSIT and navigates home", async () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + vi.mocked(portfolioApi.getPortfolioHoldings).mockResolvedValue([]); + + renderPortfolio("GABC123"); + + const cta = await screen.findByRole("button", { name: "Deposit Now" }); + fireEvent.click(cta); + + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "TRIGGER_DEPOSIT" }), + ); + }); + }); + it("does NOT show the empty state when holdings have value", async () => { vi.mocked(portfolioApi.getPortfolioHoldings).mockResolvedValue([mockHolding]); diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx index 0f8ed5d0..90215fd8 100644 --- a/frontend/src/pages/Portfolio.tsx +++ b/frontend/src/pages/Portfolio.tsx @@ -30,6 +30,7 @@ import ShareModal from "../components/ShareModal"; import EmptyState from "../components/ui/EmptyState"; import FirstTimePortfolioPanel from "../components/FirstTimePortfolioPanel"; import { useNavigate } from "react-router-dom"; +import { triggerDepositIntent } from "../lib/vaultIntentActions"; import { formatCurrency, formatNumber, formatPercent } from "../lib/formatters"; interface PortfolioProps { @@ -465,7 +466,7 @@ const Portfolio: React.FC = ({ walletAddress }) => { description={t("portfolio.noPositions.desc")} icon={} actionLabel={t("portfolio.depositNow")} - onAction={() => navigate("/")} + onAction={() => triggerDepositIntent(navigate, walletAddress)} /> ) : (
= ({ walletAddress, }) => { const { t } = useTranslation(); + const navigate = useNavigate(); const { tables: tablePreferences, setTransactionViewMode, @@ -413,20 +419,50 @@ const TransactionHistory: React.FC = ({ }; // ── Empty state ───────────────────────────────────────────────────────── + const hasDeposits = transactions.some((tx) => tx.type === "deposit"); + const hasWithdrawals = transactions.some((tx) => tx.type === "withdrawal"); + const withdrawalFilterOnly = + filters.types.length === 1 && filters.types[0] === "withdrawal"; + const showWithdrawIntent = + !hasActiveFilters && + withdrawalFilterOnly && + hasDeposits && + !hasWithdrawals; + const emptyMessage = ( } action={ hasActiveFilters - ? { label: "Reset filters", onClick: clearAll, variant: "secondary" } - : { label: "Deposit Now", href: "/" } + ? { + label: t("txHistory.resetFilters"), + onClick: clearAll, + variant: "secondary", + } + : showWithdrawIntent + ? { + label: t("emptyState.withdrawNow"), + onClick: () => triggerWithdrawIntent(navigate, walletAddress), + } + : { + label: t("emptyState.depositNow"), + onClick: () => triggerDepositIntent(navigate, walletAddress), + } } /> ); @@ -479,10 +515,13 @@ const TransactionHistory: React.FC = ({ {!walletAddress ? ( } - action={{ label: "Go to dashboard", href: "/" }} + action={{ + label: t("txHistory.connectWallet.action"), + onClick: triggerWalletConnectIntent, + }} /> ) : (