Skip to content
20 changes: 20 additions & 0 deletions frontend/src/components/TipHistory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -255,6 +258,15 @@ export default function TipHistory({ userAddress, addToast }) {
</div>
<div className="flex items-center gap-3">
{lastTipsRefresh && <span className="text-xs text-gray-400">{lastTipsRefresh.toLocaleTimeString()}</span>}
<button
onClick={() => setShowExportModal(true)}
aria-label="Export tip history to CSV"
disabled={tips.length === 0}
className="px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
Export
</button>
<button onClick={handleRefresh} aria-label="Refresh activity" className="px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">Refresh</button>
</div>
</div>
Expand Down Expand Up @@ -363,6 +375,14 @@ export default function TipHistory({ userAddress, addToast }) {
)}
</div>
)}

{showExportModal && (
<TipHistoryExport
tips={tips}
onClose={() => setShowExportModal(false)}
userAddress={userAddress || demoWalletAddress}
/>
)}
</div>
);
}
155 changes: 155 additions & 0 deletions frontend/src/components/TipHistoryExport.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Export Tip History</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close export dialog"
>
<X className="w-5 h-5" />
</button>
</div>

<div className="space-y-4 mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">
Export your tip history as a CSV file for accounting or tax purposes.
</p>

<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
<Calendar className="w-4 h-4" />
<span>Date Range Filter (Optional)</span>
</div>

<div className="space-y-3">
<div>
<label htmlFor="start-date" className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Start Date
</label>
<input
id="start-date"
type="date"
value={startDate}
onChange={(e) => {
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"
/>
</div>

<div>
<label htmlFor="end-date" className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
End Date
</label>
<input
id="end-date"
type="date"
value={endDate}
onChange={(e) => {
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"
/>
</div>

{(startDate || endDate) && (
<button
onClick={handleClearDates}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline"
>
Clear date filters
</button>
)}
</div>
</div>

{filterError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
{filterError}
</div>
)}

<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-xs text-blue-700 dark:text-blue-400">
<p className="font-semibold mb-1">CSV will include:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Transaction ID</li>
<li>Direction (sent/received)</li>
<li>Sender and recipient addresses</li>
<li>Amount in STX and microSTX</li>
<li>Message and category</li>
<li>Timestamp and formatted date</li>
</ul>
</div>
</div>

<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg font-semibold hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-900 dark:bg-amber-500 text-white dark:text-black rounded-lg font-semibold hover:bg-gray-800 dark:hover:bg-amber-400 transition-colors"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</div>
</div>
);
}
91 changes: 91 additions & 0 deletions frontend/src/lib/csvExport.js
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading
Loading