diff --git a/src/components/common/ExportTradeHistoryCsvButton.tsx b/src/components/common/ExportTradeHistoryCsvButton.tsx new file mode 100644 index 00000000..ca5d617f --- /dev/null +++ b/src/components/common/ExportTradeHistoryCsvButton.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { useAccount } from 'wagmi'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { downloadCsv } from '@/utils/transactionHistory.utils'; +import { + buildTradeHistoryCsvFilename, + generateTradeHistoryCsv, +} from '@/utils/tradeLedger.utils'; +import { fetchAllTrades } from '@/hooks/useAllTradeHistory'; +import type { Trade } from '@/services/tradeHistory.service'; + +export interface ExportTradeHistoryCsvButtonProps { + /** Wallet address whose trades to export. */ + walletAddress: string; + /** Optional authenticated override (e.g. for testing). */ + isAuthenticated?: boolean; + /** Whether the viewer is viewing their own profile dashboard. */ + isOwnProfile?: boolean; + /** Optional custom trade fetcher (e.g. for testing). */ + fetchTrades?: (wallet: string) => Promise; + /** Custom class name. */ + className?: string; + /** Custom test ID. */ + 'data-testid'?: string; +} + +/** + * Trade history CSV export button on the portfolio page (#857). + * + * Placed in the trade history tab header. When clicked by an authenticated + * user, fetches all trades from GET /users/:wallet/trades without pagination + * limit, displays a loading spinner on the button, generates a formatted CSV + * (columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee), and + * triggers a file download with filename trades-[wallet-truncated]-[date].csv. + * Only accessible when the user is authenticated. + */ +export const ExportTradeHistoryCsvButton: React.FC< + ExportTradeHistoryCsvButtonProps +> = ({ + walletAddress, + isAuthenticated: customIsAuthenticated, + isOwnProfile = false, + fetchTrades = fetchAllTrades, + className, + 'data-testid': testId = 'export-trade-history-button', +}) => { + const { address: connectedAddress, isConnected } = useAccount(); + const [isLoading, setIsLoading] = useState(false); + + const isAuth = + customIsAuthenticated !== undefined + ? customIsAuthenticated + : Boolean(connectedAddress && (isConnected ?? true)) || + Boolean(isOwnProfile); + + // Button accessible only when the user is authenticated (#857) + if (!isAuth) { + return null; + } + + const handleExport = async () => { + if (isLoading || !walletAddress) return; + setIsLoading(true); + + try { + const trades = await fetchTrades(walletAddress); + const csv = generateTradeHistoryCsv(trades); + const filename = buildTradeHistoryCsvFilename(walletAddress); + downloadCsv(filename, csv); + } catch (error) { + console.error('Failed to export trade history CSV:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default ExportTradeHistoryCsvButton; diff --git a/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx b/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx new file mode 100644 index 00000000..945c46cf --- /dev/null +++ b/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import ExportTradeHistoryCsvButton from '@/components/common/ExportTradeHistoryCsvButton'; +import type { Trade } from '@/services/tradeHistory.service'; + +const mockUseAccount = vi.fn(); +vi.mock('wagmi', () => ({ + useAccount: () => mockUseAccount(), +})); + +const mockDownloadCsv = vi.fn(); +vi.mock('@/utils/transactionHistory.utils', () => ({ + downloadCsv: (...args: unknown[]) => mockDownloadCsv(...args), +})); + +const mockFetchAllTrades = vi.fn(); +vi.mock('@/hooks/useAllTradeHistory', () => ({ + fetchAllTrades: (...args: unknown[]) => mockFetchAllTrades(...args), +})); + +const WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + +const sampleTrades: Trade[] = [ + { + id: 'trade-1', + keyName: 'Alpha Key', + tradeType: 'Buy', + quantity: 5, + pricePerKey: 2, + feeXlm: 0.1, + timestamp: Date.parse('2026-10-20T10:00:00.000Z'), + transactionHash: 'hash-1', + }, + { + id: 'trade-2', + keyName: 'Beta Key', + tradeType: 'Sell', + quantity: 2, + pricePerKey: 4, + feeXlm: 0.05, + timestamp: Date.parse('2026-10-21T15:30:00.000Z'), + transactionHash: 'hash-2', + }, +]; + +describe('ExportTradeHistoryCsvButton (#857)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAccount.mockReturnValue({ + address: WALLET, + isConnected: true, + }); + mockFetchAllTrades.mockResolvedValue(sampleTrades); + }); + + it('does not render when the user is not authenticated', () => { + mockUseAccount.mockReturnValue({ + address: undefined, + isConnected: false, + }); + + render(); + + expect( + screen.queryByRole('button', { name: /export csv/i }) + ).not.toBeInTheDocument(); + }); + + it('renders the Export CSV button when user is authenticated', () => { + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it('shows loading spinner and disables button while fetch is in progress', async () => { + let resolveFetch: (trades: Trade[]) => void = () => {}; + const pendingPromise = new Promise(resolve => { + resolveFetch = resolve; + }); + mockFetchAllTrades.mockReturnValue(pendingPromise); + + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + // Spinner should be visible and button disabled while fetching + expect( + screen.getByTestId('export-trade-history-spinner') + ).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + + // Resolve fetch + resolveFetch(sampleTrades); + + await waitFor(() => { + expect( + screen.queryByTestId('export-trade-history-spinner') + ).not.toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + }); + + it('fetches all trades for the wallet and downloads CSV with correct columns and filename', async () => { + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockFetchAllTrades).toHaveBeenCalledWith(WALLET); + expect(mockDownloadCsv).toHaveBeenCalledTimes(1); + }); + + const [filename, csv] = mockDownloadCsv.mock.calls[0] as [string, string]; + + // Filename includes truncated wallet and current date + expect(filename).toMatch(/^trades-GDEM…0001-\d{4}-\d{2}-\d{2}\.csv$/); + + // CSV contains correct columns and data + const lines = csv.split('\r\n'); + expect(lines[0]).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + expect(lines[1]).toBe( + '2026-10-20T10:00:00.000Z,Alpha Key,Buy,5,2,10.1,0.1' + ); + expect(lines[2]).toBe( + '2026-10-21T15:30:00.000Z,Beta Key,Sell,2,4,7.95,0.05' + ); + }); + + it('handles fetch error gracefully and resets loading state', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockFetchAllTrades.mockRejectedValue(new Error('Network error')); + + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + expect( + screen.queryByTestId('export-trade-history-spinner') + ).not.toBeInTheDocument(); + }); + + expect(mockDownloadCsv).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useAllTradeHistory.ts b/src/hooks/useAllTradeHistory.ts index cde9eb8e..52219b5d 100644 --- a/src/hooks/useAllTradeHistory.ts +++ b/src/hooks/useAllTradeHistory.ts @@ -10,7 +10,7 @@ export const MAX_TRADE_HISTORY_PAGES = 200; /** * Loads every page of a wallet's trade history (following the cursor) so the - * portfolio table can filter, paginate and export all matching rows (#1114). + * portfolio table can filter, paginate and export all matching rows (#857, #1114). */ export async function fetchAllTrades(wallet: string): Promise { const trades: Trade[] = []; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 57662965..c54f6cc5 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -16,6 +16,7 @@ import HeldKeysGrid from '@/components/common/HeldKeysGrid'; import StakingPositionsList from '@/components/common/StakingPositionsList'; import LiquidityPositionsSection from '@/components/common/LiquidityPositionsSection'; import TradeHistoryLedger from '@/components/common/TradeHistoryLedger'; +import ExportTradeHistoryCsvButton from '@/components/common/ExportTradeHistoryCsvButton'; import AtomicSwapHistory from '@/components/common/AtomicSwapHistory'; import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim'; import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable'; @@ -535,13 +536,19 @@ export default function ProfilePage() { data-testid="portfolio-trade-history-panel" >
-
-

- Trade History -

-

- A full audit trail of past buys and sells -

+
+
+

+ Trade History +

+

+ A full audit trail of past buys and sells +

+
+
{' '} @@ -577,17 +584,19 @@ export default function ProfilePage() { {/* Price alerts panel (#1057) */} {activeTab === 'price-alerts' && ( -
-
-

Price Alerts

- -
-
+
+
+

+ Price Alerts +

+ +
+
)} {/* Activity feed panel */} diff --git a/src/pages/__tests__/ProfilePage.integration.test.tsx b/src/pages/__tests__/ProfilePage.integration.test.tsx index 5813b4e1..bb56c4a3 100644 --- a/src/pages/__tests__/ProfilePage.integration.test.tsx +++ b/src/pages/__tests__/ProfilePage.integration.test.tsx @@ -25,6 +25,11 @@ const { vi.mock('@/hooks/useWallet', () => ({ useWalletHoldings: (...args: unknown[]) => mockUseWalletHoldings(...args), useTradeHistory: (...args: unknown[]) => mockUseTradeHistory(...args), + useWalletBadges: () => ({ + data: [], + isLoading: false, + isError: false, + }), useClaimStakeMutation: () => ({ mutate: vi.fn(), isPending: false, @@ -34,6 +39,7 @@ vi.mock('@/hooks/useWallet', () => ({ vi.mock('@/hooks/useAllTradeHistory', () => ({ useAllTradeHistory: (...args: unknown[]) => mockUseAllTradeHistory(...args), + fetchAllTrades: vi.fn().mockResolvedValue([]), })); vi.mock('@/hooks/useCreatorPrices', () => ({ @@ -272,7 +278,7 @@ describe('ProfilePage — user profile page (#921)', () => { }); }); - it('shows the paginated trade history table with CSV export on the trade tab (#1114)', () => { + it('shows the paginated trade history table with CSV export on the trade tab (#1114, #857)', () => { seedOwnerView(); renderProfile('/profile?tab=trade-history'); @@ -281,6 +287,37 @@ describe('ProfilePage — user profile page (#921)', () => { expect(screen.getByTestId('trade-ledger-table')).toBeInTheDocument(); expect(screen.getByText('Alpha Key')).toBeInTheDocument(); expect(screen.getByTestId('trade-ledger-export')).toBeInTheDocument(); + expect( + screen.getByTestId('export-trade-history-button') + ).toBeInTheDocument(); + }); + + it('hides the header CSV export button when unauthenticated (#857)', () => { + mockUseAccount.mockReturnValue({ + address: undefined, + isConnected: false, + }); + mockUseWalletHoldings.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + mockUseCreatorPrices.mockReturnValue({ data: [], isLoading: false }); + mockUseStakingPositions.mockReturnValue({ + data: { positions: [] }, + isLoading: false, + }); + mockUseAllTradeHistory.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + + renderProfile(`/profile/${DEMO_WALLET}?tab=trade-history`); + + expect( + screen.queryByTestId('export-trade-history-button') + ).not.toBeInTheDocument(); }); it('renders staking positions with lock status and claimable rewards on the staking tab', () => { diff --git a/src/utils/__tests__/tradeExport.utils.test.ts b/src/utils/__tests__/tradeExport.utils.test.ts new file mode 100644 index 00000000..b4ae585d --- /dev/null +++ b/src/utils/__tests__/tradeExport.utils.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTradeHistoryCsvFilename, + generateTradeHistoryCsv, + TRADE_HISTORY_CSV_HEADERS, +} from '@/utils/tradeLedger.utils'; +import type { Trade } from '@/services/tradeHistory.service'; + +const T = (iso: string) => Date.parse(iso); + +function makeTrade(i: number, over: Partial = {}): Trade { + return { + id: `t${i}`, + keyName: `Key ${i}`, + tradeType: i % 2 === 0 ? 'Buy' : 'Sell', + quantity: 2, + pricePerKey: 1.5, + timestamp: T('2026-10-01T12:00:00.000Z') + i * 60_000, + transactionHash: null, + feeXlm: 0.1, + ...over, + }; +} + +describe('trade history CSV export utils (#857)', () => { + describe('buildTradeHistoryCsvFilename', () => { + it('builds filename with truncated wallet and current date in YYYY-MM-DD format', () => { + const wallet = + 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + const date = T('2026-10-22T10:00:00.000Z'); + const filename = buildTradeHistoryCsvFilename(wallet, date); + + expect(filename).toBe('trades-GDEM…0001-2026-10-22.csv'); + expect(filename).toMatch(/^trades-.+-\d{4}-\d{2}-\d{2}\.csv$/); + }); + + it('handles short wallet address without errors', () => { + const filename = buildTradeHistoryCsvFilename( + 'SHORT', + T('2026-10-22T10:00:00.000Z') + ); + expect(filename).toBe('trades-SHORT-2026-10-22.csv'); + }); + }); + + describe('generateTradeHistoryCsv', () => { + it('generates CSV with exact headers: Date, Key Name, Type, Quantity, Price per Key, Total, Fee', () => { + const csv = generateTradeHistoryCsv([]); + expect(csv).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + expect(TRADE_HISTORY_CSV_HEADERS).toEqual([ + 'Date', + 'Key Name', + 'Type', + 'Quantity', + 'Price per Key', + 'Total', + 'Fee', + ]); + }); + + it('formats rows with correct columns, calculations, and RFC 4180 escaping', () => { + const trades: Trade[] = [ + makeTrade(0, { + keyName: 'Alpha Key', + tradeType: 'Buy', + quantity: 10, + pricePerKey: 2.5, + feeXlm: 0.25, + timestamp: T('2026-10-15T14:30:00.000Z'), + }), + makeTrade(1, { + keyName: 'Beta, "Pro" Key', + tradeType: 'Sell', + quantity: 4, + pricePerKey: 5.0, + feeXlm: 0.5, + timestamp: T('2026-10-16T09:15:00.000Z'), + }), + makeTrade(2, { + keyName: '=SUM(1,2)', + tradeType: 'Buy', + quantity: 1, + pricePerKey: 10, + feeXlm: undefined, + timestamp: T('2026-10-17T11:00:00.000Z'), + }), + ]; + + const csv = generateTradeHistoryCsv(trades); + const lines = csv.split('\r\n'); + + expect(lines).toHaveLength(4); // 1 header + 3 rows + expect(lines[0]).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + // Buy: subtotal = 25, fee = 0.25, total = 25.25 + expect(lines[1]).toBe( + '2026-10-15T14:30:00.000Z,Alpha Key,Buy,10,2.5,25.25,0.25' + ); + // Sell: subtotal = 20, fee = 0.5, total = 19.5, quotes escaped + expect(lines[2]).toBe( + '2026-10-16T09:15:00.000Z,"Beta, ""Pro"" Key",Sell,4,5,19.5,0.5' + ); + // Formula injection neutralized with prepended quote and wrapped in quotes due to comma + expect(lines[3]).toContain('"\'=SUM(1,2)",Buy,1,10,10,0'); + }); + }); +}); diff --git a/src/utils/tradeLedger.utils.ts b/src/utils/tradeLedger.utils.ts index 1c3288e7..c07d9a31 100644 --- a/src/utils/tradeLedger.utils.ts +++ b/src/utils/tradeLedger.utils.ts @@ -2,6 +2,7 @@ * Helpers for the portfolio trade history table and its CSV export (#1114). */ import type { Trade } from '@/services/tradeHistory.service'; +import { truncateWalletAddress } from '@/utils/referral.utils'; export const TRADE_LEDGER_PAGE_SIZE = 10; @@ -126,3 +127,50 @@ export function tradesToCsv(trades: Trade[]): string { export function buildTradeCsvFilename(now: number = Date.now()): string { return `accesslayer-trade-history-${new Date(now).toISOString().slice(0, 10)}.csv`; } + +export const TRADE_HISTORY_CSV_HEADERS = [ + 'Date', + 'Key Name', + 'Type', + 'Quantity', + 'Price per Key', + 'Total', + 'Fee', +] as const; + +/** + * Generates formatted CSV content for trade history export (#857). + * Columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee + */ +export function generateTradeHistoryCsv(trades: Trade[]): string { + const rows = trades.map(trade => { + const { fee, total } = computeTradeAmounts(trade); + return [ + new Date(trade.timestamp).toISOString(), + trade.keyName, + trade.tradeType, + String(trade.quantity), + String(trade.pricePerKey), + String(total), + String(fee), + ].map(escapeCsvField); + }); + return [[...TRADE_HISTORY_CSV_HEADERS], ...rows] + .map(row => row.join(',')) + .join('\r\n'); +} + +/** + * Builds the export filename for trade history (#857): + * trades-[wallet-truncated]-[date].csv + */ +export function buildTradeHistoryCsvFilename( + wallet: string, + now: number | Date | string = Date.now() +): string { + const truncated = truncateWalletAddress(wallet) || wallet; + const dateObj = + typeof now === 'number' || typeof now === 'string' ? new Date(now) : now; + const date = dateObj.toISOString().slice(0, 10); + return `trades-${truncated}-${date}.csv`; +}