Skip to content
Merged
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
13 changes: 11 additions & 2 deletions frontend/src/components/CampaignCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Campaign } from "../types/campaign";
import { CopyButton } from "./CopyButton";

interface CampaignCardProps {
campaign: Campaign;
Expand All @@ -24,8 +25,16 @@ export function CampaignCard({
<strong className="campaign-title">{campaign.title}</strong>
<div className="muted">#{campaign.id}</div>
</div>
<div className="campaign-creator mono">
{campaign.creator.slice(0, 8)}...
<div
className="campaign-creator mono"
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span>{campaign.creator.slice(0, 8)}...</span>
<CopyButton
value={campaign.creator}
ariaLabel="Copy creator address"
className="small"
/>
</div>
</div>

Expand Down
15 changes: 10 additions & 5 deletions frontend/src/components/CampaignDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -135,9 +133,15 @@ export function CampaignDetailPanel({
<div className="detail-grid">
<article className="detail-stat">
<span>Creator</span>
<strong className="mono">
{activeCampaign.creator.slice(0, 16)}...
</strong>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<strong className="mono">
{activeCampaign.creator.slice(0, 16)}...
</strong>
<CopyButton
value={activeCampaign.creator}
ariaLabel="Copy creator address"
/>
</div>
</article>
<article className="detail-stat">
<span>Asset</span>
Expand All @@ -153,6 +157,7 @@ export function CampaignDetailPanel({
</article>
</div>


<ContributorSummary
pledges={activeCampaign.pledges}
assetCode={activeCampaign.assetCode}
Expand Down
106 changes: 79 additions & 27 deletions frontend/src/components/ContributorSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { CopyButton } from "./CopyButton";
import { Pledge } from "../types/campaign";

function round2(value: number): number {
Expand All @@ -19,24 +20,40 @@ interface ContributorSummaryProps {
assetCode: string;
isLoading?: boolean;
}
export function ContributorSummary({ pledges, assetCode, isLoading }: ContributorSummaryProps) {
export function ContributorSummary({
pledges,
assetCode,
isLoading,
}: ContributorSummaryProps) {
if (isLoading || pledges === undefined) {
return (
<section className="contributor-summary contributor-summary-loading" aria-label="Contributor summary">
<section
className="contributor-summary contributor-summary-loading"
aria-label="Contributor summary"
>
<h3 className="contributor-summary-title">Contributors</h3>
<div className="contributor-summary-stats" style={{ marginTop: 12 }}>
{Array.from({ length: 3 }).map((_, i) => (
<article key={i} className="contributor-stat">
<div className="skeleton skeleton-line" style={{ width: 100 }} />
<div className="skeleton skeleton-line" style={{ width: 60, height: 20, marginTop: 8 }} />
<div
className="skeleton skeleton-line"
style={{ width: 60, height: 20, marginTop: 8 }}
/>
</article>
))}
</div>
</section>
);
}

const { rows, uniqueAddresses, activeAddresses, activeGrandTotal, refundedGrandTotal } = useMemo(() => {
const {
rows,
uniqueAddresses,
activeAddresses,
activeGrandTotal,
refundedGrandTotal,
} = useMemo(() => {
const list = pledges ?? [];
const byContributor = new Map<
string,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -122,7 +145,6 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo
<section className="contributor-summary" aria-label="Contributor summary">
<div className="contributor-summary-heading">
<h3 className="contributor-summary-title">Contributor summary</h3>

</div>

<div className="contributor-summary-stats">
Expand All @@ -145,32 +167,61 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo
<strong>
{activeGrandTotal} {assetCode}
</strong>
<span className="contributor-stat-hint muted">Sum of all non-refunded pledges.</span>
<span className="contributor-stat-hint muted">
Sum of all non-refunded pledges.
</span>
</article>
<article className="contributor-stat">
<span className="contributor-stat-label">Refunded total</span>
<strong>
{refundedGrandTotal} {assetCode}
</strong>
<span className="contributor-stat-hint muted">Historical refunds only; not counted in active.</span>
<span className="contributor-stat-hint muted">
Historical refunds only; not counted in active.
</span>
</article>
</div>

<div className="contributor-table-wrap" role="table" aria-label="Contributors by address">
<div className="contributor-table contributor-table-head" role="rowgroup">
<div
className="contributor-table-wrap"
role="table"
aria-label="Contributors by address"
>
<div
className="contributor-table contributor-table-head"
role="rowgroup"
>
<div role="row" className="contributor-table-row">
<span role="columnheader">Contributor</span>
<span role="columnheader">Active</span>
<span role="columnheader">Refunded</span>
</div>
</div>
<div className="contributor-table contributor-table-body" role="rowgroup">
<div
className="contributor-table contributor-table-body"
role="rowgroup"
>
{rows.map((row) => (
<div key={row.contributor} role="row" className="contributor-table-row">
<div role="cell" className="contributor-address">
<div
key={row.contributor}
role="row"
className="contributor-table-row"
>
<div
role="cell"
className="contributor-address"
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span className="mono">{row.contributor.slice(0, 12)}…</span>
<CopyButton
value={row.contributor}
ariaLabel={`Copy contributor ${row.contributor}`}
className="small"
/>
{row.hasPending ? (
<span className="badge badge-neutral contributor-pending-badge">Pending</span>
<span className="badge badge-neutral contributor-pending-badge">
Pending
</span>
) : null}
</div>
<div role="cell" className="contributor-amounts">
Expand All @@ -181,7 +232,8 @@ export function ContributorSummary({ pledges, assetCode, isLoading }: Contributo
</strong>
<span className="muted">
{" "}
({row.activePledgeCount} pledge{row.activePledgeCount === 1 ? "" : "s"})
({row.activePledgeCount} pledge
{row.activePledgeCount === 1 ? "" : "s"})
</span>
</span>
) : (
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
className={`btn-ghost btn-copy ${className}`}
onClick={handleCopy}
aria-label={ariaLabel}
title={copied ? "Copied!" : "Copy full address"}
>
{copied ? "Copied" : "Copy"}
</button>
);
}

export default CopyButton;
Loading