Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions client/src/components/portfolio/PortfolioDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -126,12 +127,15 @@ export default function PortfolioDashboard({ walletAddress }: PortfolioDashboard
{walletAddress.slice(0, 8)}...{walletAddress.slice(-8)}
</p>
</div>
<button
onClick={() => { setIsLoading(true); setTimeout(() => setIsLoading(false), 500); }}
className="btn-secondary flex items-center gap-2 text-sm"
>
<RefreshCw size={14} /> Refresh
</button>
<div className="flex items-center gap-3">
<PortfolioExport walletAddress={walletAddress} />
<button
onClick={() => { setIsLoading(true); setTimeout(() => setIsLoading(false), 500); }}
className="btn-secondary flex items-center gap-2 text-sm"
>
<RefreshCw size={14} /> Refresh
</button>
</div>
</div>

{/* Stats Cards */}
Expand Down
79 changes: 79 additions & 0 deletions client/src/components/portfolio/PortfolioExport.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PortfolioExport walletAddress="GDETESTWALLET123" />);

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(<PortfolioExport walletAddress="GDETESTWALLET123" />);

fireEvent.click(screen.getByRole("button", { name: /export portfolio/i }));

await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
175 changes: 175 additions & 0 deletions client/src/components/portfolio/PortfolioExport.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-col items-end gap-2">
<button
type="button"
onClick={handleExportClick}
disabled={isExporting}
className="btn-secondary flex items-center gap-2 text-sm"
>
<Download size={14} />
Export Portfolio
</button>

{error ? (
<p className="text-right text-sm text-red-400">{error}</p>
) : null}

{showWarningModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70">
<div
role="dialog"
aria-modal="true"
aria-labelledby="portfolio-export-warning-title"
className="w-full max-w-lg rounded-[32px] border border-slate-800 bg-slate-950 p-6 text-white shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="rounded-2xl bg-orange-500/10 p-3 text-orange-300">
<AlertTriangle size={20} />
</div>
<div className="space-y-3">
<div>
<h2 className="text-xl font-semibold">Portfolio export privacy warning</h2>
<p className="text-sm text-slate-400 mt-1">
Your export may include wallet address, portfolio balances, asset details, and transaction history.
</p>
</div>

<div className="rounded-3xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300">
<p className="font-semibold text-slate-100">What will be included</p>
<ul className="mt-2 space-y-2 list-disc pl-5">
<li>Wallet address and account identifier</li>
<li>Asset holdings and current balances</li>
<li>Recent portfolio transaction history</li>
</ul>
</div>

<label className="inline-flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={rememberWarning}
onChange={(event) => setRememberWarning(event.target.checked)}
className="h-4 w-4 rounded border-slate-600 bg-slate-800 text-slate-100"
/>
Don't show this warning again
</label>

<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setShowWarningModal(false)}
className="w-full rounded-2xl border border-slate-700 bg-slate-800 px-4 py-3 text-sm text-slate-200 transition hover:border-slate-500 sm:w-auto"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirmExport}
disabled={isExporting}
className="w-full rounded-2xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-950 transition hover:bg-slate-200 disabled:opacity-60 sm:w-auto"
>
Confirm export
</button>
</div>
</div>
</div>
</div>
</div>
) : null}
</div>
);
}