diff --git a/frontend/src/components/OnboardingPanel.css b/frontend/src/components/OnboardingPanel.css new file mode 100644 index 00000000..bf26455a --- /dev/null +++ b/frontend/src/components/OnboardingPanel.css @@ -0,0 +1,156 @@ +/* ─── OnboardingPanel ─────────────────────────────────────────────────────── */ + +.onboarding-panel { + background: var(--bg-muted); + border: 1px solid var(--border-glass); + border-radius: var(--radius-xl); + padding: var(--space-8) var(--space-8); + backdrop-filter: blur(12px); + animation: emptyStateFadeIn 0.4s ease-out both; + width: 100%; + box-sizing: border-box; +} + +.onboarding-panel-header { + text-align: center; + margin-bottom: var(--space-8); +} + +.onboarding-panel-title { + font-size: var(--text-xl); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-2); +} + +.onboarding-panel-subtitle { + font-size: var(--text-base); + color: var(--text-secondary); + margin: 0; +} + +/* ─── Steps list ──────────────────────────────────────────────────────────── */ + +.onboarding-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.onboarding-step { + display: grid; + grid-template-columns: 40px 40px 1fr auto; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-lg); + border: 1px solid var(--border-glass); + background: var(--bg-card); + transition: border-color 0.2s ease, background 0.2s ease; +} + +/* Active step */ +.onboarding-step--active { + border-color: rgba(0, 240, 255, 0.4); + background: rgba(0, 240, 255, 0.04); +} + +/* Completed step */ +.onboarding-step--completed { + opacity: 0.6; +} + +/* Future (locked) step */ +.onboarding-step--future { + opacity: 0.45; +} + +/* ─── Step indicator (number / check) ────────────────────────────────────── */ + +.onboarding-step-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid var(--border-glass); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-secondary); + flex-shrink: 0; +} + +.onboarding-step--active .onboarding-step-indicator { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.onboarding-step--completed .onboarding-step-indicator { + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); + color: var(--accent-cyan); +} + +.onboarding-step-check, +.onboarding-step-number { + line-height: 1; +} + +/* ─── Step icon ───────────────────────────────────────────────────────────── */ + +.onboarding-step-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + flex-shrink: 0; +} + +.onboarding-step--active .onboarding-step-icon { + color: var(--accent-cyan); +} + +/* ─── Step content ────────────────────────────────────────────────────────── */ + +.onboarding-step-title { + font-size: var(--text-base); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-1); +} + +.onboarding-step-description { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0; + line-height: var(--leading-relaxed); +} + +/* ─── Step action button ──────────────────────────────────────────────────── */ + +.onboarding-step-action { + white-space: nowrap; + flex-shrink: 0; +} + +/* ─── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .onboarding-panel { + padding: var(--space-6) var(--space-4); + } + + .onboarding-step { + grid-template-columns: 32px 32px 1fr; + grid-template-rows: auto auto; + } + + .onboarding-step-action { + grid-column: 1 / -1; + width: 100%; + } +} diff --git a/frontend/src/components/OnboardingPanel.test.tsx b/frontend/src/components/OnboardingPanel.test.tsx new file mode 100644 index 00000000..d194cdb1 --- /dev/null +++ b/frontend/src/components/OnboardingPanel.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import OnboardingPanel from "./OnboardingPanel"; + +function renderPanel(walletConnected = false, overrides = {}) { + const props = { + walletConnected, + onConnectWallet: vi.fn(), + onReviewVault: vi.fn(), + onDeposit: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("OnboardingPanel", () => { + it("renders all three step titles", () => { + renderPanel(); + expect(screen.getByText("Connect Your Wallet")).toBeInTheDocument(); + expect(screen.getByText("Review Vault Details")).toBeInTheDocument(); + expect(screen.getByText("Make Your First Deposit")).toBeInTheDocument(); + }); + + it("has accessible region label", () => { + renderPanel(); + expect(screen.getByRole("region", { name: "Getting started guide" })).toBeInTheDocument(); + }); + + it("marks step 1 as active (aria-current=step) when wallet is not connected", () => { + renderPanel(false); + const items = screen.getAllByRole("listitem"); + expect(items[0]).toHaveAttribute("aria-current", "step"); + expect(items[1]).not.toHaveAttribute("aria-current"); + expect(items[2]).not.toHaveAttribute("aria-current"); + }); + + it("marks step 2 as active when wallet is connected", () => { + renderPanel(true); + const items = screen.getAllByRole("listitem"); + expect(items[0]).not.toHaveAttribute("aria-current"); + expect(items[1]).toHaveAttribute("aria-current", "step"); + }); + + it("shows 'Connect Wallet' button label when wallet is not connected", () => { + renderPanel(false); + expect(screen.getByRole("button", { name: "Connect Wallet" })).toBeInTheDocument(); + }); + + it("shows 'Connected ✓' and disables step 1 button when wallet is connected", () => { + renderPanel(true); + const btn = screen.getByRole("button", { name: "Connected ✓" }); + expect(btn).toBeDisabled(); + }); + + it("calls onConnectWallet when Connect Wallet button is clicked", () => { + const { onConnectWallet } = renderPanel(false); + fireEvent.click(screen.getByRole("button", { name: "Connect Wallet" })); + expect(onConnectWallet).toHaveBeenCalledTimes(1); + }); + + it("calls onReviewVault when View Vault button is clicked (wallet connected)", () => { + const { onReviewVault } = renderPanel(true); + fireEvent.click(screen.getByRole("button", { name: "View Vault" })); + expect(onReviewVault).toHaveBeenCalledTimes(1); + }); + + it("disables future steps when wallet is not connected", () => { + renderPanel(false); + expect(screen.getByRole("button", { name: "View Vault" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Deposit Now" })).toBeDisabled(); + }); + + it("step 1 has completed class when wallet is connected", () => { + renderPanel(true); + const items = screen.getAllByRole("listitem"); + expect(items[0].className).toContain("onboarding-step--completed"); + }); + + it("step 2 has active class when wallet is connected", () => { + renderPanel(true); + const items = screen.getAllByRole("listitem"); + expect(items[1].className).toContain("onboarding-step--active"); + }); + + it("step 3 has future class when wallet is connected", () => { + renderPanel(true); + const items = screen.getAllByRole("listitem"); + expect(items[2].className).toContain("onboarding-step--future"); + }); +}); diff --git a/frontend/src/components/OnboardingPanel.tsx b/frontend/src/components/OnboardingPanel.tsx new file mode 100644 index 00000000..66f76489 --- /dev/null +++ b/frontend/src/components/OnboardingPanel.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Wallet, Layers, TrendingUp } from "./icons"; +import "./OnboardingPanel.css"; + +interface OnboardingStep { + step: number; + icon: React.ReactNode; + title: string; + description: string; + actionLabel: string; + onAction: () => void; + completed: boolean; +} + +interface OnboardingPanelProps { + walletConnected: boolean; + onConnectWallet: () => void; + onReviewVault: () => void; + onDeposit: () => void; +} + +const OnboardingPanel: React.FC = ({ + walletConnected, + onConnectWallet, + onReviewVault, + onDeposit, +}) => { + const steps: OnboardingStep[] = [ + { + step: 1, + icon: , + title: "Connect Your Wallet", + description: "Link your Freighter wallet to get started with YieldVault.", + actionLabel: walletConnected ? "Connected ✓" : "Connect Wallet", + onAction: onConnectWallet, + completed: walletConnected, + }, + { + step: 2, + icon: , + title: "Review Vault Details", + description: "Explore APY, strategy, and TVL before committing funds.", + actionLabel: "View Vault", + onAction: onReviewVault, + completed: false, + }, + { + step: 3, + icon: , + title: "Make Your First Deposit", + description: "Deposit USDC to start earning yield backed by real-world assets.", + actionLabel: "Deposit Now", + onAction: onDeposit, + completed: false, + }, + ]; + + const activeStep = walletConnected ? 1 : 0; + + return ( + + + Get Started with YieldVault + + Follow these steps to start earning institutional-grade yield on your USDC. + + + + + {steps.map((s, idx) => { + const isActive = idx === activeStep; + const isPast = s.completed; + const isFuture = idx > activeStep && !s.completed; + + return ( + + + {isPast ? ( + ✓ + ) : ( + {s.step} + )} + + + + {s.icon} + + + + {s.title} + {s.description} + + + + {s.actionLabel} + + + ); + })} + + + ); +}; + +export default OnboardingPanel; diff --git a/frontend/src/pages/Portfolio.emptystate.test.tsx b/frontend/src/pages/Portfolio.emptystate.test.tsx index 5efd2843..aff85ab9 100644 --- a/frontend/src/pages/Portfolio.emptystate.test.tsx +++ b/frontend/src/pages/Portfolio.emptystate.test.tsx @@ -1,9 +1,10 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } 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"; import Portfolio from "./Portfolio"; import { ToastProvider } from "../context/ToastContext"; +import { PreferencesProvider } from "../context/PreferencesContext"; import * as portfolioApi from "../lib/portfolioApi"; import type { PortfolioHolding } from "../lib/portfolioApi"; @@ -36,9 +37,11 @@ function renderPortfolio(walletAddress: string | null) { return render( - - - + + + + + , ); @@ -117,12 +120,13 @@ describe("Portfolio — empty state", () => { expect(screen.queryByText("Your portfolio is empty.")).not.toBeInTheDocument(); }); - it("shows the wallet-not-connected message when no wallet is provided", () => { + it("shows the onboarding panel when no wallet is provided", () => { renderPortfolio(null); expect( - screen.getByText(/Please connect your wallet to view your portfolio\./i), + screen.getByRole("region", { name: /Getting started guide/i }), ).toBeInTheDocument(); + expect(screen.getByText("Connect Your Wallet")).toBeInTheDocument(); expect(screen.queryByText("Your portfolio is empty.")).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/Portfolio.test.tsx b/frontend/src/pages/Portfolio.test.tsx index 5c6ad7b8..1c664cc0 100644 --- a/frontend/src/pages/Portfolio.test.tsx +++ b/frontend/src/pages/Portfolio.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import Portfolio from "./Portfolio"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ToastProvider } from "../context/ToastContext"; +import { PreferencesProvider } from "../context/PreferencesContext"; const mockHoldings = [ { @@ -100,19 +101,21 @@ function renderPortfolio( return render( - - - - - - > - } - /> - - + + + + + + + > + } + /> + + + , ); @@ -135,12 +138,13 @@ describe("Portfolio", () => { vi.restoreAllMocks(); }); - it("shows the wallet prompt when disconnected", () => { + it("shows the onboarding panel when disconnected", () => { renderPortfolio("/portfolio", null); expect( - screen.getByText(/Please connect your wallet to view your portfolio/i), + screen.getByRole("region", { name: /Getting started guide/i }), ).toBeInTheDocument(); + expect(screen.getByText("Connect Your Wallet")).toBeInTheDocument(); }); it("renders holdings in the reusable table", async () => {
+ Follow these steps to start earning institutional-grade yield on your USDC. +
{s.description}