diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index eced1889..38b3833c 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -9,6 +9,8 @@ import ShareTip from './ShareTip'; import { useDemoMode } from '../context/DemoContext'; import RefundRequest from './RefundRequest'; import RefundApproval from './RefundApproval'; +import TipHistoryExport from './TipHistoryExport'; +import { Download } from 'lucide-react'; const CATEGORY_LABELS = { 0: 'General', 1: 'Content Creation', 2: 'Open Source', @@ -60,6 +62,7 @@ export default function TipHistory({ userAddress, addToast }) { const [tab, setTab] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); const [loadingMore, setLoadingMore] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; const demoWalletAddress = demoEnabled ? getDemoData().mockWalletAddress : null; @@ -255,6 +258,15 @@ export default function TipHistory({ userAddress, addToast }) {
{lastTipsRefresh && {lastTipsRefresh.toLocaleTimeString()}} +
@@ -363,6 +375,14 @@ export default function TipHistory({ userAddress, addToast }) { )} )} + + {showExportModal && ( + setShowExportModal(false)} + userAddress={userAddress || demoWalletAddress} + /> + )} ); } diff --git a/frontend/src/components/TipHistoryExport.jsx b/frontend/src/components/TipHistoryExport.jsx new file mode 100644 index 00000000..7ed3e35c --- /dev/null +++ b/frontend/src/components/TipHistoryExport.jsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { Download, X, Calendar } from 'lucide-react'; +import { generateTipHistoryCSV, downloadCSV, filterTipsByDateRange } from '../lib/csvExport'; + +export default function TipHistoryExport({ tips, onClose, userAddress }) { + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [filterError, setFilterError] = useState(''); + + const handleExport = () => { + setFilterError(''); + + let filteredTips = tips; + + if (startDate || endDate) { + const start = startDate ? new Date(startDate) : null; + const end = endDate ? new Date(endDate) : null; + + if (start && end && start > end) { + setFilterError('Start date must be before end date'); + return; + } + + filteredTips = filterTipsByDateRange(tips, start, end); + + if (filteredTips.length === 0) { + setFilterError('No tips found in the selected date range'); + return; + } + } + + const csvContent = generateTipHistoryCSV(filteredTips); + const timestamp = Date.now(); + const dateRange = startDate || endDate + ? `_${startDate || 'start'}_to_${endDate || 'end'}` + : ''; + const filename = `tip-history_${userAddress.slice(0, 8)}${dateRange}_${timestamp}.csv`; + + downloadCSV(csvContent, filename); + onClose(); + }; + + const handleClearDates = () => { + setStartDate(''); + setEndDate(''); + setFilterError(''); + }; + + return ( +
+
+
+

Export Tip History

+ +
+ +
+

+ Export your tip history as a CSV file for accounting or tax purposes. +

+ +
+
+ + Date Range Filter (Optional) +
+ +
+
+ + { + setStartDate(e.target.value); + setFilterError(''); + }} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500" + /> +
+ +
+ + { + setEndDate(e.target.value); + setFilterError(''); + }} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500" + /> +
+ + {(startDate || endDate) && ( + + )} +
+
+ + {filterError && ( +
+ {filterError} +
+ )} + +
+

CSV will include:

+
    +
  • Transaction ID
  • +
  • Direction (sent/received)
  • +
  • Sender and recipient addresses
  • +
  • Amount in STX and microSTX
  • +
  • Message and category
  • +
  • Timestamp and formatted date
  • +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/lib/csvExport.js b/frontend/src/lib/csvExport.js new file mode 100644 index 00000000..14860da3 --- /dev/null +++ b/frontend/src/lib/csvExport.js @@ -0,0 +1,91 @@ +import { formatSTX } from './utils'; + +const CATEGORY_LABELS = { + 0: 'General', + 1: 'Content Creation', + 2: 'Open Source', + 3: 'Community Help', + 4: 'Appreciation', + 5: 'Education', + 6: 'Bug Bounty', +}; + +function escapeCSVField(field) { + if (field === null || field === undefined) return ''; + const str = String(field); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function formatTimestamp(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toISOString(); +} + +export function generateTipHistoryCSV(tips) { + const headers = [ + 'Transaction ID', + 'Direction', + 'Sender', + 'Recipient', + 'Amount (STX)', + 'Amount (microSTX)', + 'Message', + 'Category', + 'Timestamp', + 'Date', + ]; + + const rows = tips.map(tip => [ + escapeCSVField(tip.txId), + escapeCSVField(tip.direction), + escapeCSVField(tip.sender), + escapeCSVField(tip.recipient), + escapeCSVField(formatSTX(tip.amount, 6)), + escapeCSVField(tip.amount), + escapeCSVField(tip.message || ''), + escapeCSVField(tip.category !== null ? CATEGORY_LABELS[tip.category] || tip.category : ''), + escapeCSVField(tip.timestamp || ''), + escapeCSVField(formatTimestamp(tip.timestamp)), + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')), + ].join('\n'); + + return csvContent; +} + +export function downloadCSV(csvContent, filename) { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export function filterTipsByDateRange(tips, startDate, endDate) { + if (!startDate && !endDate) return tips; + + return tips.filter(tip => { + if (!tip.timestamp) return false; + const tipDate = new Date(tip.timestamp * 1000); + + if (startDate && tipDate < startDate) return false; + if (endDate) { + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + if (tipDate > endOfDay) return false; + } + + return true; + }); +} diff --git a/frontend/src/test/TipHistoryExport.test.jsx b/frontend/src/test/TipHistoryExport.test.jsx new file mode 100644 index 00000000..36dadbee --- /dev/null +++ b/frontend/src/test/TipHistoryExport.test.jsx @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TipHistoryExport from '../components/TipHistoryExport'; +import * as csvExport from '../lib/csvExport'; + +vi.mock('../lib/csvExport', () => ({ + generateTipHistoryCSV: vi.fn(), + downloadCSV: vi.fn(), + filterTipsByDateRange: vi.fn((tips) => tips), +})); + +describe('TipHistoryExport', () => { + const mockTips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'Test tip', + category: 0, + timestamp: 1640000000, + }, + { + txId: '0x456', + direction: 'received', + sender: 'SP789', + recipient: 'SP123', + amount: '2000000', + message: 'Another tip', + category: 1, + timestamp: 1640100000, + }, + ]; + + const mockOnClose = vi.fn(); + const mockUserAddress = 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7'; + + beforeEach(() => { + vi.clearAllMocks(); + csvExport.generateTipHistoryCSV.mockReturnValue('mock,csv,content'); + csvExport.filterTipsByDateRange.mockImplementation((tips) => tips); + }); + + it('should render export modal', () => { + render( + + ); + + expect(screen.getByText('Export Tip History')).toBeInTheDocument(); + expect(screen.getByText(/Export your tip history as a CSV file/)).toBeInTheDocument(); + }); + + it('should display date range inputs', () => { + render( + + ); + + expect(screen.getByLabelText('Start Date')).toBeInTheDocument(); + expect(screen.getByLabelText('End Date')).toBeInTheDocument(); + }); + + it('should display CSV content information', () => { + render( + + ); + + expect(screen.getByText('CSV will include:')).toBeInTheDocument(); + expect(screen.getByText(/Transaction ID/)).toBeInTheDocument(); + expect(screen.getByText(/Direction/)).toBeInTheDocument(); + expect(screen.getByText(/Sender and recipient addresses/)).toBeInTheDocument(); + }); + + it('should call onClose when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when X button is clicked', () => { + render( + + ); + + const closeButton = screen.getByLabelText('Close export dialog'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should export CSV without date filter', async () => { + render( + + ); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + expect(csvExport.generateTipHistoryCSV).toHaveBeenCalledWith(mockTips); + expect(csvExport.downloadCSV).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should generate filename with user address', async () => { + render( + + ); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + const downloadCall = csvExport.downloadCSV.mock.calls[0]; + const filename = downloadCall[1]; + expect(filename).toContain('tip-history_SP2J6ZY4'); + expect(filename).toContain('.csv'); + }); + }); + + it('should update start date input', () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + + expect(startDateInput.value).toBe('2024-01-01'); + }); + + it('should update end date input', () => { + render( + + ); + + const endDateInput = screen.getByLabelText('End Date'); + fireEvent.change(endDateInput, { target: { value: '2024-12-31' } }); + + expect(endDateInput.value).toBe('2024-12-31'); + }); + + it('should show clear dates button when dates are set', () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + + expect(screen.getByText('Clear date filters')).toBeInTheDocument(); + }); + + it('should clear dates when clear button is clicked', () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + + const clearButton = screen.getByText('Clear date filters'); + fireEvent.click(clearButton); + + expect(startDateInput.value).toBe(''); + }); + + it('should show error when start date is after end date', async () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + const endDateInput = screen.getByLabelText('End Date'); + + fireEvent.change(startDateInput, { target: { value: '2024-12-31' } }); + fireEvent.change(endDateInput, { target: { value: '2024-01-01' } }); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + expect(screen.getByText('Start date must be before end date')).toBeInTheDocument(); + }); + + expect(csvExport.downloadCSV).not.toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should show error when no tips match date range', async () => { + csvExport.filterTipsByDateRange.mockReturnValue([]); + + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '2025-01-01' } }); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + expect(screen.getByText('No tips found in the selected date range')).toBeInTheDocument(); + }); + + expect(csvExport.downloadCSV).not.toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should filter tips by date range before export', async () => { + const filteredTips = [mockTips[0]]; + csvExport.filterTipsByDateRange.mockReturnValue(filteredTips); + + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + const endDateInput = screen.getByLabelText('End Date'); + + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + fireEvent.change(endDateInput, { target: { value: '2024-01-31' } }); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + expect(csvExport.filterTipsByDateRange).toHaveBeenCalled(); + expect(csvExport.generateTipHistoryCSV).toHaveBeenCalledWith(filteredTips); + }); + }); + + it('should include date range in filename when dates are set', async () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + const endDateInput = screen.getByLabelText('End Date'); + + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + fireEvent.change(endDateInput, { target: { value: '2024-12-31' } }); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + const downloadCall = csvExport.downloadCSV.mock.calls[0]; + const filename = downloadCall[1]; + expect(filename).toContain('2024-01-01'); + expect(filename).toContain('2024-12-31'); + }); + }); + + it('should clear error when date is changed', async () => { + render( + + ); + + const startDateInput = screen.getByLabelText('Start Date'); + const endDateInput = screen.getByLabelText('End Date'); + + fireEvent.change(startDateInput, { target: { value: '2024-12-31' } }); + fireEvent.change(endDateInput, { target: { value: '2024-01-01' } }); + + const exportButton = screen.getByText('Export CSV'); + fireEvent.click(exportButton); + + await waitFor(() => { + expect(screen.getByText('Start date must be before end date')).toBeInTheDocument(); + }); + + fireEvent.change(startDateInput, { target: { value: '2024-01-01' } }); + + expect(screen.queryByText('Start date must be before end date')).not.toBeInTheDocument(); + }); + + it('should handle empty tips array', () => { + render( + + ); + + expect(screen.getByText('Export Tip History')).toBeInTheDocument(); + expect(screen.getByText('Export CSV')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/csvExport.test.js b/frontend/src/test/csvExport.test.js new file mode 100644 index 00000000..f70f895a --- /dev/null +++ b/frontend/src/test/csvExport.test.js @@ -0,0 +1,324 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { generateTipHistoryCSV, downloadCSV, filterTipsByDateRange } from '../lib/csvExport'; + +describe('csvExport', () => { + describe('generateTipHistoryCSV', () => { + it('should generate CSV with correct headers', () => { + const tips = []; + const csv = generateTipHistoryCSV(tips); + const lines = csv.split('\n'); + + expect(lines[0]).toBe('Transaction ID,Direction,Sender,Recipient,Amount (STX),Amount (microSTX),Message,Category,Timestamp,Date'); + }); + + it('should generate CSV rows for tip data', () => { + const tips = [ + { + txId: '0x123abc', + direction: 'sent', + sender: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + recipient: 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE', + amount: '1000000', + message: 'Great work', + category: 1, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + const lines = csv.split('\n'); + + expect(lines.length).toBe(2); + expect(lines[1]).toContain('0x123abc'); + expect(lines[1]).toContain('sent'); + expect(lines[1]).toContain('1.000000'); + expect(lines[1]).toContain('1000000'); + expect(lines[1]).toContain('Great work'); + expect(lines[1]).toContain('Content Creation'); + }); + + it('should escape CSV fields with commas', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'Hello, world', + category: 0, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + expect(csv).toContain('"Hello, world"'); + }); + + it('should escape CSV fields with quotes', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'He said "hello"', + category: 0, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + expect(csv).toContain('"He said ""hello"""'); + }); + + it('should handle empty messages', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: '', + category: 0, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + const lines = csv.split('\n'); + expect(lines[1].split(',')[6]).toBe(''); + }); + + it('should handle null category', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'Test', + category: null, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + const lines = csv.split('\n'); + expect(lines[1].split(',')[7]).toBe(''); + }); + + it('should format timestamp as ISO date', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'Test', + category: 0, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + expect(csv).toContain('2021-12-20'); + }); + + it('should handle multiple tips', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '1000000', + message: 'First', + category: 0, + timestamp: 1640000000, + }, + { + txId: '0x456', + direction: 'received', + sender: 'SP789', + recipient: 'SP123', + amount: '2000000', + message: 'Second', + category: 1, + timestamp: 1640100000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + const lines = csv.split('\n'); + + expect(lines.length).toBe(3); + expect(lines[1]).toContain('0x123'); + expect(lines[2]).toContain('0x456'); + }); + + it('should handle large amounts correctly', () => { + const tips = [ + { + txId: '0x123', + direction: 'sent', + sender: 'SP123', + recipient: 'SP456', + amount: '100000000', + message: 'Large tip', + category: 0, + timestamp: 1640000000, + }, + ]; + + const csv = generateTipHistoryCSV(tips); + expect(csv).toContain('100.000000'); + expect(csv).toContain('100000000'); + }); + }); + + describe('downloadCSV', () => { + let mockLink; + + beforeEach(() => { + mockLink = { + href: '', + download: '', + click: vi.fn(), + }; + + vi.spyOn(document, 'createElement').mockReturnValue(mockLink); + vi.spyOn(document.body, 'appendChild').mockImplementation(() => {}); + vi.spyOn(document.body, 'removeChild').mockImplementation(() => {}); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create and download CSV file', () => { + const csvContent = 'header1,header2\nvalue1,value2'; + const filename = 'test.csv'; + + downloadCSV(csvContent, filename); + + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('should set correct filename', () => { + const csvContent = 'test'; + const filename = 'my-export.csv'; + + downloadCSV(csvContent, filename); + + expect(mockLink.download).toBe(filename); + }); + + it('should trigger download', () => { + const csvContent = 'test'; + const filename = 'test.csv'; + + downloadCSV(csvContent, filename); + + expect(mockLink.click).toHaveBeenCalled(); + }); + }); + + describe('filterTipsByDateRange', () => { + const tips = [ + { + txId: '0x1', + timestamp: new Date('2024-01-15').getTime() / 1000, + }, + { + txId: '0x2', + timestamp: new Date('2024-02-15').getTime() / 1000, + }, + { + txId: '0x3', + timestamp: new Date('2024-03-15').getTime() / 1000, + }, + { + txId: '0x4', + timestamp: null, + }, + ]; + + it('should return all tips when no date range specified', () => { + const filtered = filterTipsByDateRange(tips, null, null); + expect(filtered.length).toBe(4); + }); + + it('should filter by start date only', () => { + const startDate = new Date('2024-02-01'); + const filtered = filterTipsByDateRange(tips, startDate, null); + + expect(filtered.length).toBe(2); + expect(filtered[0].txId).toBe('0x2'); + expect(filtered[1].txId).toBe('0x3'); + }); + + it('should filter by end date only', () => { + const endDate = new Date('2024-02-20'); + const filtered = filterTipsByDateRange(tips, null, endDate); + + expect(filtered.length).toBe(2); + expect(filtered[0].txId).toBe('0x1'); + expect(filtered[1].txId).toBe('0x2'); + }); + + it('should filter by both start and end date', () => { + const startDate = new Date('2024-02-01'); + const endDate = new Date('2024-02-28'); + const filtered = filterTipsByDateRange(tips, startDate, endDate); + + expect(filtered.length).toBe(1); + expect(filtered[0].txId).toBe('0x2'); + }); + + it('should exclude tips without timestamp', () => { + const startDate = new Date('2024-01-01'); + const filtered = filterTipsByDateRange(tips, startDate, null); + + expect(filtered.every(tip => tip.timestamp !== null)).toBe(true); + }); + + it('should include tips on the start date', () => { + const startDate = new Date('2024-01-15'); + const filtered = filterTipsByDateRange(tips, startDate, null); + + expect(filtered.some(tip => tip.txId === '0x1')).toBe(true); + }); + + it('should include tips on the end date', () => { + const endDate = new Date('2024-03-15'); + const filtered = filterTipsByDateRange(tips, null, endDate); + + expect(filtered.some(tip => tip.txId === '0x3')).toBe(true); + }); + + it('should handle same start and end date', () => { + const date = new Date('2024-02-15'); + const filtered = filterTipsByDateRange(tips, date, date); + + expect(filtered.length).toBe(1); + expect(filtered[0].txId).toBe('0x2'); + }); + + it('should return empty array when no tips match range', () => { + const startDate = new Date('2025-01-01'); + const endDate = new Date('2025-12-31'); + const filtered = filterTipsByDateRange(tips, startDate, endDate); + + expect(filtered.length).toBe(0); + }); + }); +});