diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx
index 02ab880..b4766c1 100644
--- a/src/app/groups/[id]/page.tsx
+++ b/src/app/groups/[id]/page.tsx
@@ -6,6 +6,7 @@ import { RoundProgress } from "@/components/RoundProgress";
import { ContributeModal } from "@/components/ContributeModal";
import { useState } from "react";
import { formatAmount, GroupStatus } from "@sorosave/sdk";
+import { exportToCSV, exportToPDF, type GroupExportData } from "@/lib/export";
// TODO: Fetch real data from contract
const MOCK_GROUP = {
@@ -34,8 +35,37 @@ const MOCK_GROUP = {
export default function GroupDetailPage() {
const [showContributeModal, setShowContributeModal] = useState(false);
+ const [exporting, setExporting] = useState<"csv" | "pdf" | null>(null);
const group = MOCK_GROUP;
+ const buildExportData = (): GroupExportData => ({
+ groupName: group.name,
+ admin: group.admin,
+ totalRounds: group.totalRounds,
+ currentRound: group.currentRound,
+ members: group.members,
+ payoutOrder: group.payoutOrder,
+ cycleLength: group.cycleLength,
+ contributionAmount: formatAmount(group.contributionAmount),
+ contributions: group.members.map((m, i) => ({
+ round: group.currentRound,
+ member: m,
+ amount: formatAmount(group.contributionAmount),
+ date: new Date(group.createdAt * 1000).toLocaleDateString(),
+ status: i === 0 ? "paid" : "pending",
+ })),
+ });
+
+ const handleExportCSV = () => {
+ setExporting("csv");
+ try { exportToCSV(buildExportData()); } finally { setExporting(null); }
+ };
+
+ const handleExportPDF = async () => {
+ setExporting("pdf");
+ try { await exportToPDF(buildExportData()); } finally { setExporting(null); }
+ };
+
return (
<>
@@ -83,6 +113,24 @@ export default function GroupDetailPage() {
Join Group
)}
+
+
+
Export
+
+
+
diff --git a/src/lib/export.ts b/src/lib/export.ts
new file mode 100644
index 0000000..6f130a2
--- /dev/null
+++ b/src/lib/export.ts
@@ -0,0 +1,169 @@
+/**
+ * Export utilities for SoroSave
+ * Issue #60 โ Add data export feature (CSV/PDF)
+ */
+
+export interface ContributionRecord {
+ round: number;
+ member: string;
+ amount: string;
+ date: string;
+ status: "paid" | "pending" | "missed";
+}
+
+export interface GroupExportData {
+ groupName: string;
+ admin: string;
+ totalRounds: number;
+ currentRound: number;
+ members: string[];
+ payoutOrder: string[];
+ cycleLength: number;
+ contributionAmount: string;
+ contributions: ContributionRecord[];
+}
+
+// โโ CSV Export โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+export function exportToCSV(data: GroupExportData): void {
+ const headers = ["Round", "Member", "Amount", "Date", "Status"];
+
+ const rows = data.contributions.map((c) => [
+ String(c.round),
+ c.member,
+ c.amount,
+ c.date,
+ c.status,
+ ]);
+
+ const csvContent = [headers, ...rows]
+ .map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
+ .join("\n");
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.setAttribute("href", url);
+ link.setAttribute(
+ "download",
+ `${data.groupName.replace(/\s+/g, "_")}_contributions.csv`
+ );
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+// โโ PDF Export โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+export async function exportToPDF(data: GroupExportData): Promise {
+ // Build an HTML string and use the browser's print-to-PDF
+ const html = buildPDFHTML(data);
+
+ const printWindow = window.open("", "_blank");
+ if (!printWindow) {
+ alert("Please allow pop-ups to export PDF.");
+ return;
+ }
+
+ printWindow.document.write(html);
+ printWindow.document.close();
+
+ // Wait for resources then trigger print
+ printWindow.onload = () => {
+ printWindow.focus();
+ printWindow.print();
+ printWindow.close();
+ };
+}
+
+function buildPDFHTML(data: GroupExportData): string {
+ const memberRows = data.members
+ .map(
+ (m, i) =>
+ `
+ | ${i + 1} |
+ ${m} |
+ ${data.payoutOrder.indexOf(m) + 1} |
+
`
+ )
+ .join("");
+
+ const contributionRows = data.contributions
+ .map(
+ (c) =>
+ `
+ | ${c.round} |
+ ${c.member.slice(0, 12)}...${c.member.slice(-4)} |
+ ${c.amount} |
+ ${c.date} |
+ ${c.status.toUpperCase()} |
+
`
+ )
+ .join("");
+
+ return `
+
+
+
+ SoroSave โ ${data.groupName} Report
+
+
+
+ SoroSave Group Report
+ Generated on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}
+
+
+
+
+
Members & Payout Order
+
+ | # | Wallet Address | Payout Round |
+ ${memberRows}
+
+
+
+
+
Contribution History
+
+ | Round | Member | Amount | Date | Status |
+ ${contributionRows || '| No contribution records yet |
'}
+
+
+
+
+
+`;
+}