From 3d6d3e36e93aba8bbf1851e83ef76b3df81b6dcf Mon Sep 17 00:00:00 2001
From: ABEEGOLD
Date: Sat, 30 May 2026 11:55:53 +0100
Subject: [PATCH] feat(portfolio): add export functionality with privacy
warning
---
.../portfolio/PortfolioDashboard.tsx | 16 +-
.../portfolio/PortfolioExport.test.tsx | 79 ++++++++
.../components/portfolio/PortfolioExport.tsx | 175 ++++++++++++++++++
3 files changed, 264 insertions(+), 6 deletions(-)
create mode 100644 client/src/components/portfolio/PortfolioExport.test.tsx
create mode 100644 client/src/components/portfolio/PortfolioExport.tsx
diff --git a/client/src/components/portfolio/PortfolioDashboard.tsx b/client/src/components/portfolio/PortfolioDashboard.tsx
index 63ac0a6e0..8c1fface2 100644
--- a/client/src/components/portfolio/PortfolioDashboard.tsx
+++ b/client/src/components/portfolio/PortfolioDashboard.tsx
@@ -12,6 +12,7 @@ import PortfolioVisualizer from "../visualizer/PortfolioVisualizer";
import { ExposureMap } from "../../portfolio/ExposureMap";
import PresetsPanel from "../../features/presets/PresetsPanel";
import UnifiedActivityTimeline from "./UnifiedActivityTimeline";
+import PortfolioExport from "./PortfolioExport";
// ── Types ───────────────────────────────────────────────────────────────
@@ -126,12 +127,15 @@ export default function PortfolioDashboard({ walletAddress }: PortfolioDashboard
{walletAddress.slice(0, 8)}...{walletAddress.slice(-8)}
-
+
+
+
+
{/* Stats Cards */}
diff --git a/client/src/components/portfolio/PortfolioExport.test.tsx b/client/src/components/portfolio/PortfolioExport.test.tsx
new file mode 100644
index 000000000..f4188ba96
--- /dev/null
+++ b/client/src/components/portfolio/PortfolioExport.test.tsx
@@ -0,0 +1,79 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import PortfolioExport from "./PortfolioExport";
+
+const STORAGE_KEY = "stellar_yield_portfolio_export_privacy_warning_dismissed";
+
+describe("PortfolioExport", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("shows a privacy warning before export and persists dismissal when requested", async () => {
+ const urlMock = vi.fn().mockReturnValue("blob:url");
+ Object.defineProperty(window, "URL", {
+ configurable: true,
+ value: {
+ ...window.URL,
+ createObjectURL: urlMock,
+ revokeObjectURL: vi.fn(),
+ },
+ });
+
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+
+ (globalThis.fetch as unknown) = vi.fn().mockResolvedValueOnce(
+ new Response(new Blob(["col1,col2\nvalue1,value2"], { type: "text/csv" }), {
+ status: 200,
+ headers: { "Content-Disposition": 'attachment; filename="portfolio-export.csv"' },
+ }),
+ );
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /export portfolio/i }));
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText(/don't show this warning again/i));
+ fireEvent.click(screen.getByRole("button", { name: /confirm export/i }));
+
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
+ expect(urlMock).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
+ });
+
+ it("skips the warning dialog when the warning has already been dismissed", async () => {
+ localStorage.setItem(STORAGE_KEY, "true");
+
+ Object.defineProperty(window, "URL", {
+ configurable: true,
+ value: {
+ ...window.URL,
+ createObjectURL: vi.fn().mockReturnValue("blob:url"),
+ revokeObjectURL: vi.fn(),
+ },
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+
+ (globalThis.fetch as unknown) = vi.fn().mockResolvedValueOnce(
+ new Response(new Blob(["col1,col2\nvalue1,value2"], { type: "text/csv" }), {
+ status: 200,
+ headers: { "Content-Disposition": 'attachment; filename="portfolio-export.csv"' },
+ }),
+ );
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /export portfolio/i }));
+
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/portfolio/PortfolioExport.tsx b/client/src/components/portfolio/PortfolioExport.tsx
new file mode 100644
index 000000000..25fbca5b2
--- /dev/null
+++ b/client/src/components/portfolio/PortfolioExport.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useState } from "react";
+import { Download, AlertTriangle } from "lucide-react";
+
+const STORAGE_KEY = "stellar_yield_portfolio_export_privacy_warning_dismissed";
+
+function readWarningDismissed(): boolean {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+function saveWarningDismissed(): void {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, "true");
+ } catch {
+ // Ignore storage failures intentionally.
+ }
+}
+
+interface PortfolioExportProps {
+ walletAddress: string;
+}
+
+export default function PortfolioExport({ walletAddress }: PortfolioExportProps) {
+ const [showWarningModal, setShowWarningModal] = useState(false);
+ const [warningDismissed, setWarningDismissed] = useState(false);
+ const [rememberWarning, setRememberWarning] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setWarningDismissed(readWarningDismissed());
+ }, []);
+
+ const downloadFile = async () => {
+ setError(null);
+ setIsExporting(true);
+
+ try {
+ const response = await fetch(
+ `/api/users/${encodeURIComponent(walletAddress)}/export`,
+ );
+
+ if (!response.ok) {
+ let message = "Failed to generate export.";
+ try {
+ const body = await response.json();
+ if (body?.message) message = body.message;
+ } catch {
+ // ignore JSON parse errors
+ }
+ throw new Error(message);
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const filename =
+ response.headers
+ .get("Content-Disposition")
+ ?.match(/filename="(.+)"/)?.[1] ?? "portfolio-export.csv";
+
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(anchor);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to generate export.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ const handleExportClick = () => {
+ if (warningDismissed) {
+ void downloadFile();
+ return;
+ }
+
+ setShowWarningModal(true);
+ };
+
+ const handleConfirmExport = async () => {
+ if (rememberWarning) {
+ saveWarningDismissed();
+ setWarningDismissed(true);
+ }
+ setShowWarningModal(false);
+ await downloadFile();
+ };
+
+ return (
+
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+ {showWarningModal ? (
+
+
+
+
+
+
+
Portfolio export privacy warning
+
+ Your export may include wallet address, portfolio balances, asset details, and transaction history.
+
+
+
+
+
What will be included
+
+ - Wallet address and account identifier
+ - Asset holdings and current balances
+ - Recent portfolio transaction history
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}