diff --git a/frontend/src/components/CampaignCard.tsx b/frontend/src/components/CampaignCard.tsx index 0761322..8ce8ee6 100644 --- a/frontend/src/components/CampaignCard.tsx +++ b/frontend/src/components/CampaignCard.tsx @@ -1,4 +1,5 @@ import { Campaign } from "../types/campaign"; +import { CopyButton } from "./CopyButton"; interface CampaignCardProps { campaign: Campaign; @@ -24,8 +25,16 @@ export function CampaignCard({ {campaign.title}
#{campaign.id}
-
- {campaign.creator.slice(0, 8)}... +
+ {campaign.creator.slice(0, 8)}... +
diff --git a/frontend/src/components/CampaignDetailPanel.tsx b/frontend/src/components/CampaignDetailPanel.tsx index 3eed980..b6d2911 100644 --- a/frontend/src/components/CampaignDetailPanel.tsx +++ b/frontend/src/components/CampaignDetailPanel.tsx @@ -1,6 +1,4 @@ import { FormEvent, useEffect, useState } from "react"; -import { MousePointer2 } from "lucide-react"; -import { ApiError, Campaign } from "../types/campaign"; import { ContributorSummary } from "./ContributorSummary"; import { EmptyState } from "./EmptyState"; @@ -135,9 +133,15 @@ export function CampaignDetailPanel({
Creator - - {activeCampaign.creator.slice(0, 16)}... - +
+ + {activeCampaign.creator.slice(0, 16)}... + + +
Asset @@ -153,6 +157,7 @@ export function CampaignDetailPanel({
+ +

Contributors

{Array.from({ length: 3 }).map((_, i) => (
-
+
))}
@@ -36,7 +47,13 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo ); } - const { rows, uniqueAddresses, activeAddresses, activeGrandTotal, refundedGrandTotal } = useMemo(() => { + const { + rows, + uniqueAddresses, + activeAddresses, + activeGrandTotal, + refundedGrandTotal, + } = useMemo(() => { const list = pledges ?? []; const byContributor = new Map< string, @@ -75,16 +92,16 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo } } - const aggregated: AggregatedContributor[] = [...byContributor.entries()].map( - ([contributor, bucket]) => ({ - contributor, - activeTotal: round2(bucket.activeTotal), - activePledgeCount: bucket.activePledgeCount, - refundedTotal: round2(bucket.refundedTotal), - refundedPledgeCount: bucket.refundedPledgeCount, - hasPending: bucket.hasPending, - }), - ); + const aggregated: AggregatedContributor[] = [ + ...byContributor.entries(), + ].map(([contributor, bucket]) => ({ + contributor, + activeTotal: round2(bucket.activeTotal), + activePledgeCount: bucket.activePledgeCount, + refundedTotal: round2(bucket.refundedTotal), + refundedPledgeCount: bucket.refundedPledgeCount, + hasPending: bucket.hasPending, + })); aggregated.sort((a, b) => { if (b.activeTotal !== a.activeTotal) { @@ -96,9 +113,15 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo return a.contributor.localeCompare(b.contributor); }); - const activeAddresses = aggregated.filter((row) => row.activePledgeCount > 0).length; - const activeGrandTotal = round2(aggregated.reduce((sum, row) => sum + row.activeTotal, 0)); - const refundedGrandTotal = round2(aggregated.reduce((sum, row) => sum + row.refundedTotal, 0)); + const activeAddresses = aggregated.filter( + (row) => row.activePledgeCount > 0, + ).length; + const activeGrandTotal = round2( + aggregated.reduce((sum, row) => sum + row.activeTotal, 0), + ); + const refundedGrandTotal = round2( + aggregated.reduce((sum, row) => sum + row.refundedTotal, 0), + ); return { rows: aggregated, @@ -122,7 +145,6 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo

Contributor summary

-
@@ -145,32 +167,61 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo {activeGrandTotal} {assetCode} - Sum of all non-refunded pledges. + + Sum of all non-refunded pledges. +
Refunded total {refundedGrandTotal} {assetCode} - Historical refunds only; not counted in active. + + Historical refunds only; not counted in active. +
-
-
+
+
Contributor Active Refunded
-
+
{rows.map((row) => ( -
-
+
+
{row.contributor.slice(0, 12)}… + {row.hasPending ? ( - Pending + + Pending + ) : null}
@@ -181,7 +232,8 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo {" "} - ({row.activePledgeCount} pledge{row.activePledgeCount === 1 ? "" : "s"}) + ({row.activePledgeCount} pledge + {row.activePledgeCount === 1 ? "" : "s"}) ) : ( diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx new file mode 100644 index 0000000..a6411fa --- /dev/null +++ b/frontend/src/components/CopyButton.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; + +interface CopyButtonProps { + value: string; + ariaLabel?: string; + className?: string; +} + +export function CopyButton({ + value, + ariaLabel = "Copy value", + className = "", +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + // navigator.clipboard may be unavailable in some embedded contexts; fallback + const ta = document.createElement("textarea"); + ta.value = value; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch (e) { + // ignore + } finally { + document.body.removeChild(ta); + } + } + } + + return ( + + ); +} + +export default CopyButton;