Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions stellargrant-fe/app/grants/[id]/GrantDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MilestoneTimeline } from "@/components/milestones/MilestoneTimeline";
import { FundGrantModal } from "@/components/grants/FundGrantModal";
import { WatchButton } from "@/components/grants/WatchButton";
import { ShareButton } from "@/components/grants/ShareButton";
import { ExportButton } from "@/components/grants/ExportButton";
import type { FunderRecord } from "@/lib/utils/export";
import RichTextRenderer from "@/components/ui/RichTextRenderer";
import { formatDate } from "@/lib/utils";
import { formatTokenAmount, getTokenMetadata } from "@/lib/tokens";
Expand Down Expand Up @@ -194,6 +196,7 @@ function GrantDetailContent({ grantId }: { grantId: string }) {
grantTitle={grant.title}
fundedPercent={fundedPercent}
/>
<ExportButton grant={grant} milestones={milestones} funders={funders.map((f) => ({ ...f, token: "native", timestamp: null } as FunderRecord))} />
</div>
</div>

Expand Down
187 changes: 187 additions & 0 deletions stellargrant-fe/components/grants/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"use client";

/**
* ExportButton Component
*
* A dropdown-style export button for the Grant Detail page.
* Allows users to download grant data as JSON or milestones as CSV.
* Works without a wallet connection (read-only feature).
*
* @see https://github.com/StellarGrant/StellarGrant-fe/issues/388
*/

import { useState, useRef, useEffect, useCallback } from "react";
import type { Grant, Milestone } from "@/types";
import {
exportGrantAsJSON,
exportGrantAsCSV,
type FunderRecord,
exportFundersAsCSV,
} from "@/lib/utils/export";

interface ExportButtonProps {
grant: Grant;
milestones: Milestone[];
funders?: FunderRecord[];
}

export function ExportButton({ grant, milestones, funders }: ExportButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);

// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}

if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);

const handleExportJSON = useCallback(() => {
exportGrantAsJSON(grant, milestones);
setIsOpen(false);
}, [grant, milestones]);

const handleExportMilestonesCSV = useCallback(() => {
exportGrantAsCSV(grant, milestones);
setIsOpen(false);
}, [grant, milestones]);

const handleExportFundersCSV = useCallback(() => {
if (funders && funders.length > 0) {
exportFundersAsCSV(funders);
}
setIsOpen(false);
}, [funders]);

return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 text-sm font-medium rounded-sm border border-accent-secondary text-accent-secondary hover:bg-accent-secondary/10 transition-colors flex items-center gap-2"
aria-expanded={isOpen}
aria-haspopup="true"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={`transition-transform ${isOpen ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>

{isOpen && (
<div
className="absolute right-0 mt-1 z-50 min-w-[200px] rounded-sm border py-1 shadow-lg"
style={{
background: "#111D35",
borderColor: "#1E3A5F",
}}
role="menu"
>
<button
onClick={handleExportJSON}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:bg-accent-secondary/10 hover:text-accent-secondary transition-colors flex items-center gap-2"
role="menuitem"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Export as JSON
</button>
<button
onClick={handleExportMilestonesCSV}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:bg-accent-secondary/10 hover:text-accent-secondary transition-colors flex items-center gap-2"
role="menuitem"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
Export milestones as CSV
</button>
{funders && funders.length > 0 && (
<button
onClick={handleExportFundersCSV}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:bg-accent-secondary/10 hover:text-accent-secondary transition-colors flex items-center gap-2"
role="menuitem"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Export funders as CSV
</button>
)}
</div>
)}
</div>
);
}
182 changes: 182 additions & 0 deletions stellargrant-fe/lib/utils/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Grant Export Utilities
*
* Provides functions to export grant data as JSON or CSV files.
* Works without a wallet connection (read-only feature).
*
* @see https://github.com/StellarGrant/StellarGrant-fe/issues/388
*/

import type { Grant, Milestone } from "@/types";

// ── Types ────────────────────────────────────────────────────────────────────

/** A record representing a funder/contributor to a grant. */
export interface FunderRecord {
address: string;
amount: bigint;
token: string;
timestamp: bigint | null;
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/**
* Slug a grant title for use in file names.
* Lowercases, replaces non-alphanumeric chars with hyphens, truncates to 30 chars.
*/
function slugTitle(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
}

/**
* Trigger a browser download for the given content.
*/
function downloadFile(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

/**
* Convert a bigint to string safely (handles BigInt serialization).
*/
function bigintToString(value: bigint | null | undefined): string {
if (value === null || value === undefined) return "0";
return value.toString();
}

/**
* Format a bigint timestamp (seconds since epoch) to ISO string.
*/
function timestampToISO(ts: bigint | null): string | null {
if (ts === null || ts === undefined) return null;
try {
return new Date(Number(ts) * 1000).toISOString();
} catch {
return null;
}
}

/**
* Derive the status string from milestone boolean flags.
*/
function milestoneStatus(m: Milestone): string {
if (m.paid) return "paid";
if (m.approved) return "approved";
if (m.submitted) return "submitted";
return "pending";
}

// ── JSON Export ──────────────────────────────────────────────────────────────

/**
* Export a grant and its milestones as a JSON file.
* BigInt values are serialized as strings to avoid JSON.stringify errors.
*/
export function exportGrantAsJSON(grant: Grant, milestones: Milestone[]): void {
const data = {
exportedAt: new Date().toISOString(),
grant: {
id: grant.id,
title: grant.title,
owner: grant.owner,
recipient: grant.recipient,
budget: bigintToString(grant.budget),
funded: bigintToString(grant.funded),
token: grant.token ?? "native",
status: grant.status,
deadline: new Date(Number(grant.deadline) * 1000).toISOString(),
createdAt: new Date(Number(grant.created_at) * 1000).toISOString(),
reviewers: grant.reviewers,
},
milestones: milestones.map((m) => ({
index: m.idx,
title: m.title,
description: m.description,
reward: bigintToString(m.amount),
token: m.token ?? "native",
status: milestoneStatus(m),
proofHash: m.proof_hash,
submittedAt: timestampToISO(m.submitted_at),
paidAt: timestampToISO(m.paid_at),
})),
};

const json = JSON.stringify(data, null, 2);
const filename = `stellargrant-${grant.id}-${slugTitle(grant.title)}-${Date.now()}.json`;
downloadFile(filename, json, "application/json");
}

// ── Milestones CSV Export ────────────────────────────────────────────────────

/**
* Escape a CSV field value (wraps in quotes if it contains commas, quotes, or newlines).
*/
function escapeCSV(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}

/**
* Export milestones as a CSV file.
*/
export function exportGrantAsCSV(grant: Grant, milestones: Milestone[]): void {
const headers = [
"Index",
"Title",
"Reward (stroops)",
"Token",
"Status",
"Proof Hash",
"Submitted At",
"Paid At",
];

const rows = milestones.map((m) => [
m.idx.toString(),
escapeCSV(m.title),
bigintToString(m.amount),
m.token ?? "native",
milestoneStatus(m),
m.proof_hash ?? "",
timestampToISO(m.submitted_at) ?? "",
timestampToISO(m.paid_at) ?? "",
]);

const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const filename = `stellargrant-${grant.id}-milestones.csv`;
downloadFile(filename, csv, "text/csv");
}

// ── Funders CSV Export ───────────────────────────────────────────────────────

/**
* Export funders as a CSV file.
*/
export function exportFundersAsCSV(funders: FunderRecord[]): void {
const headers = ["Address", "Amount (stroops)", "Token", "Timestamp"];

const rows = funders.map((f) => [
f.address,
bigintToString(f.amount),
f.token ?? "native",
timestampToISO(f.timestamp) ?? "",
]);

const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const filename = `stellargrant-funders-${Date.now()}.csv`;
downloadFile(filename, csv, "text/csv");
}
Loading