diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 02ab880..53e2712 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -4,8 +4,10 @@ import { Navbar } from "@/components/Navbar"; import { MemberList } from "@/components/MemberList"; import { RoundProgress } from "@/components/RoundProgress"; import { ContributeModal } from "@/components/ContributeModal"; -import { useState } from "react"; +import { ExportButton } from "@/components/ExportButton"; +import { useState, useMemo } from "react"; import { formatAmount, GroupStatus } from "@sorosave/sdk"; +import type { GroupSummary, ContributionRecord } from "@/lib/export"; // TODO: Fetch real data from contract const MOCK_GROUP = { @@ -36,15 +38,50 @@ export default function GroupDetailPage() { const [showContributeModal, setShowContributeModal] = useState(false); const group = MOCK_GROUP; + // Build export summary from group data + const groupSummary = useMemo(() => { + // TODO: Replace with real contribution data from contract queries + const mockContributions: ContributionRecord[] = group.members.map( + (member, i) => ({ + date: new Date( + (group.createdAt + i * group.cycleLength) * 1000, + ).toLocaleDateString(), + member, + amount: formatAmount(group.contributionAmount), + round: group.currentRound, + status: i === 0 ? "Confirmed" : "Pending", + }), + ); + + return { + groupName: group.name, + groupId: group.id, + admin: group.admin, + token: group.token, + contributionAmount: formatAmount(group.contributionAmount), + cycleLengthDays: group.cycleLength / 86400, + totalRounds: group.totalRounds, + currentRound: group.currentRound, + memberCount: group.members.length, + maxMembers: group.maxMembers, + status: group.status, + createdAt: new Date(group.createdAt * 1000).toLocaleDateString(), + contributions: mockContributions, + }; + }, [group]); + return ( <>
-
-

{group.name}

-

- {formatAmount(group.contributionAmount)} tokens per cycle -

+
+
+

{group.name}

+

+ {formatAmount(group.contributionAmount)} tokens per cycle +

+
+
diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx new file mode 100644 index 0000000..2a01f17 --- /dev/null +++ b/src/components/ExportButton.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { + downloadCSV, + downloadPDF, + type ContributionRecord, + type GroupSummary, +} from "@/lib/export"; + +interface ExportButtonProps { + /** Group summary data used for PDF export. */ + groupSummary: GroupSummary; + /** Optional additional CSS classes. */ + className?: string; +} + +/** + * Export button with dropdown for CSV and PDF export. + * Place this on the group detail page or dashboard. + */ +export function ExportButton({ groupSummary, className = "" }: ExportButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [exporting, setExporting] = useState<"csv" | "pdf" | null>(null); + + async function handleExportCSV() { + setExporting("csv"); + setIsOpen(false); + try { + downloadCSV(groupSummary.contributions, groupSummary.groupName); + } finally { + setExporting(null); + } + } + + async function handleExportPDF() { + setExporting("pdf"); + setIsOpen(false); + try { + downloadPDF(groupSummary); + } finally { + setExporting(null); + } + } + + return ( +
+ + + {isOpen && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..d1e5c6d --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,229 @@ +/** + * Export utilities for SoroSave group data. + * Supports CSV and PDF export of contribution history and group summaries. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ContributionRecord { + date: string; + member: string; + amount: string; + round: number; + status: string; +} + +export interface GroupSummary { + groupName: string; + groupId: number; + admin: string; + token: string; + contributionAmount: string; + cycleLengthDays: number; + totalRounds: number; + currentRound: number; + memberCount: number; + maxMembers: number; + status: string; + createdAt: string; + contributions: ContributionRecord[]; +} + +// --------------------------------------------------------------------------- +// CSV Export +// --------------------------------------------------------------------------- + +/** + * Convert contribution records to a CSV string. + */ +export function contributionsToCSV( + contributions: ContributionRecord[], + groupName: string, +): string { + const header = ["Date", "Member", "Amount", "Round", "Status"]; + const rows = contributions.map((c) => [ + c.date, + c.member, + c.amount, + String(c.round), + c.status, + ]); + + const csvContent = [ + `# SoroSave - ${groupName} - Contribution History`, + `# Exported: ${new Date().toISOString()}`, + "", + header.join(","), + ...rows.map((row) => + row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","), + ), + ].join("\n"); + + return csvContent; +} + +/** + * Trigger a CSV file download in the browser. + */ +export function downloadCSV( + contributions: ContributionRecord[], + groupName: string, +): void { + const csv = contributionsToCSV(contributions, groupName); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = `sorosave-${slugify(groupName)}-contributions.csv`; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// --------------------------------------------------------------------------- +// PDF Export +// --------------------------------------------------------------------------- + +/** + * Generate and download a PDF summary report for a group. + * Uses the browser print API to produce a styled PDF without external deps. + */ +export function downloadPDF(summary: GroupSummary): void { + const html = buildPDFHtml(summary); + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + alert("Please allow popups to export PDF reports."); + return; + } + + printWindow.document.write(html); + printWindow.document.close(); + + // Wait for content to render before triggering print + printWindow.onload = () => { + printWindow.print(); + }; +} + +function buildPDFHtml(summary: GroupSummary): string { + const contributionRows = summary.contributions + .map( + (c) => ` + + ${escapeHtml(c.date)} + ${escapeHtml(c.member)} + ${escapeHtml(c.amount)} + ${c.round} + ${escapeHtml(c.status)} + `, + ) + .join(""); + + return ` + + + + SoroSave Report - ${escapeHtml(summary.groupName)} + + + +

SoroSave Group Report

+

${escapeHtml(summary.groupName)} — Exported ${new Date().toLocaleDateString()}

+ +
+
Group ID${summary.groupId}
+
Status${escapeHtml(summary.status)}
+
Admin${escapeHtml(truncateAddress(summary.admin))}
+
Contribution${escapeHtml(summary.contributionAmount)} tokens
+
Cycle${summary.cycleLengthDays} days
+
Round${summary.currentRound} / ${summary.totalRounds}
+
Members${summary.memberCount} / ${summary.maxMembers}
+
Created${escapeHtml(summary.createdAt)}
+
+ +

Contribution History

+ ${ + summary.contributions.length === 0 + ? '

No contributions recorded yet.

' + : ` + + + + ${contributionRows} +
DateMemberAmountRoundStatus
` + } + + + +`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (c) => map[c] || c); +} + +function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}