Skip to content

Commit 3dd0746

Browse files
committed
feat: add fund disbursement tracker and milestone dispute management components
1 parent b4550d3 commit 3dd0746

21 files changed

Lines changed: 1451 additions & 63 deletions

app/me/crowdfunding/[slug]/components/CampaignTabs.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function CampaignTabs({ campaign }: CampaignTabsProps) {
6767
fundingGoal={campaign.fundingGoal}
6868
fundingCurrency={campaign.fundingCurrency}
6969
fundingEndDate={campaign.fundingEndDate}
70+
milestones={campaign.milestones || []}
7071
/>
7172
</TabsContent>
7273
</Tabs>

app/me/crowdfunding/[slug]/components/FundingProgress.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ export function FundingProgress({ campaign }: FundingProgressProps) {
1414
campaign.fundingGoal > 0
1515
? (campaign.fundingRaised / campaign.fundingGoal) * 100
1616
: 0;
17+
18+
const milestones = campaign.milestones ?? [];
19+
const releasedCount = milestones.filter(m => m.releaseTransactionHash).length;
20+
const totalReleased = milestones
21+
.filter(m => m.releaseTransactionHash)
22+
.reduce((sum, m) => sum + m.amount, 0);
23+
const disbursementProgress =
24+
campaign.fundingGoal > 0
25+
? Math.min((totalReleased / campaign.fundingGoal) * 100, 100)
26+
: 0;
1727
const daysLeft = Math.ceil(
1828
(new Date(campaign.fundingEndDate).getTime() - new Date().getTime()) /
1929
(1000 * 60 * 60 * 24)
@@ -70,6 +80,24 @@ export function FundingProgress({ campaign }: FundingProgressProps) {
7080
</span>
7181
</div>
7282
</div>
83+
84+
{milestones.length > 0 && (
85+
<>
86+
<Separator className='bg-border' />
87+
<div className='space-y-2'>
88+
<div className='flex items-center justify-between text-sm'>
89+
<span className='text-white/60'>Disbursed</span>
90+
<span className='font-medium text-green-400'>
91+
{disbursementProgress.toFixed(1)}%
92+
</span>
93+
</div>
94+
<Progress value={disbursementProgress} className='h-2' />
95+
<p className='text-center text-xs text-white/40'>
96+
{releasedCount}/{milestones.length} milestones released
97+
</p>
98+
</div>
99+
</>
100+
)}
73101
</CardContent>
74102
</Card>
75103
);

app/me/crowdfunding/[slug]/contributions/contributions-data-table.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,24 @@ import {
2222
} from '@tanstack/react-table';
2323
import { DataTablePagination } from '@/components/ui/data-table-pagination';
2424
import { Contributor } from '@/features/projects/types';
25-
import { contributionsTableColumns } from './contributions-table-columns';
25+
import { getContributionsTableColumns } from './contributions-table-columns';
2626
import { Input } from '@/components/ui/input';
2727
import { Search } from 'lucide-react';
2828

2929
interface ContributionsDataTableProps {
3030
data: Contributor[];
3131
loading?: boolean;
32+
campaignStatus?: string;
3233
}
3334

3435
export function ContributionsDataTable({
3536
data,
3637
loading = false,
38+
campaignStatus,
3739
}: ContributionsDataTableProps) {
40+
const showRefund =
41+
campaignStatus === 'FAILED' || campaignStatus === 'CANCELLED';
42+
const columns = getContributionsTableColumns(showRefund);
3843
const [sorting, setSorting] = React.useState<SortingState>([
3944
{ id: 'date', desc: true }, // Sort by date descending by default
4045
]);
@@ -48,7 +53,7 @@ export function ContributionsDataTable({
4853

4954
const table = useReactTable({
5055
data,
51-
columns: contributionsTableColumns,
56+
columns,
5257
onSortingChange: setSorting,
5358
onColumnFiltersChange: setColumnFilters,
5459
getCoreRowModel: getCoreRowModel(),
@@ -117,7 +122,7 @@ export function ContributionsDataTable({
117122
{loading ? (
118123
<TableRow>
119124
<TableCell
120-
colSpan={contributionsTableColumns.length}
125+
colSpan={columns.length}
121126
className='h-24 text-center'
122127
>
123128
<div className='flex items-center justify-center'>
@@ -145,7 +150,7 @@ export function ContributionsDataTable({
145150
) : (
146151
<TableRow>
147152
<TableCell
148-
colSpan={contributionsTableColumns.length}
153+
colSpan={columns.length}
149154
className='h-24 text-center'
150155
>
151156
<div className='flex flex-col items-center gap-2'>

app/me/crowdfunding/[slug]/contributions/contributions-table-columns.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@ import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
88
import { Button } from '@/components/ui/button';
99
import { format } from 'date-fns';
1010
import Link from 'next/link';
11+
import { RefundStatusBadge } from '@/components/crowdfunding/refund-status-badge';
12+
13+
const refundColumn: ColumnDef<Contributor> = {
14+
accessorKey: 'refundStatus',
15+
header: 'Refund',
16+
cell: ({ row }) => {
17+
const contributor = row.original;
18+
const status = contributor.refundStatus ?? 'PENDING';
19+
20+
return (
21+
<div className='flex items-center gap-2'>
22+
<RefundStatusBadge status={status} />
23+
{contributor.refundTransactionHash && (
24+
<a
25+
href={getTransactionExplorerUrl(contributor.refundTransactionHash)}
26+
target='_blank'
27+
rel='noopener noreferrer'
28+
className='text-blue-400 transition-opacity hover:opacity-70'
29+
title='View refund transaction'
30+
>
31+
<ExternalLink className='h-3.5 w-3.5' />
32+
</a>
33+
)}
34+
</div>
35+
);
36+
},
37+
};
38+
39+
/**
40+
* Returns the contributions table columns.
41+
* Pass `showRefund: true` for failed/cancelled campaigns to include the
42+
* per-backer refund status column.
43+
*/
44+
export function getContributionsTableColumns(
45+
showRefund = false
46+
): ColumnDef<Contributor>[] {
47+
const base = contributionsTableColumns;
48+
return showRefund ? [...base, refundColumn] : base;
49+
}
1150

1251
export const contributionsTableColumns: ColumnDef<Contributor>[] = [
1352
{

app/me/crowdfunding/[slug]/contributions/page.tsx

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,129 @@
33
import { use, useEffect, useState } from 'react';
44
import { getCrowdfundingProject } from '@/features/projects/api';
55
import type { Crowdfunding } from '@/features/projects/types';
6+
import {
7+
normalizeCampaignStatus,
8+
CampaignStatus,
9+
} from '@/features/projects/types';
610
import { ContributionsDataTable } from './contributions-data-table';
7-
import { ArrowLeft } from 'lucide-react';
11+
import {
12+
AlertTriangle,
13+
ArrowLeft,
14+
Ban,
15+
ExternalLink,
16+
RefreshCw,
17+
} from 'lucide-react';
818
import { Button } from '@/components/ui/button';
19+
import { Progress } from '@/components/ui/progress';
920
import { useRouter } from 'next/navigation';
1021
import { ContributionsMetrics } from '@/components/crowdfunding/contributions-metrics';
22+
import { RefundStatusBadge } from '@/components/crowdfunding/refund-status-badge';
23+
import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
24+
25+
function RefundBanner({ project }: { project: Crowdfunding }) {
26+
const status = normalizeCampaignStatus(project.project.status);
27+
const isFailed = status === CampaignStatus.FAILED;
28+
const isCancelled = status === CampaignStatus.CANCELLED;
29+
if (!isFailed && !isCancelled) return null;
30+
31+
const contributors = project.contributors ?? [];
32+
const refundedCount = contributors.filter(
33+
c => c.refundStatus === 'PROCESSED'
34+
).length;
35+
const totalCount = contributors.length;
36+
const refundProgress =
37+
totalCount > 0 ? (refundedCount / totalCount) * 100 : 0;
38+
39+
return (
40+
<div
41+
className={`mb-6 rounded-xl border p-5 ${
42+
isFailed
43+
? 'border-red-500/30 bg-red-500/10'
44+
: 'border-white/10 bg-white/5'
45+
}`}
46+
>
47+
<div className='flex items-start gap-3'>
48+
{isFailed ? (
49+
<AlertTriangle className='mt-0.5 h-5 w-5 shrink-0 text-red-400' />
50+
) : (
51+
<Ban className='mt-0.5 h-5 w-5 shrink-0 text-white/40' />
52+
)}
53+
<div className='flex-1 space-y-4'>
54+
<div>
55+
<h3
56+
className={`font-semibold ${isFailed ? 'text-red-400' : 'text-white/70'}`}
57+
>
58+
Campaign {isFailed ? 'Failed' : 'Cancelled'} — Refunds
59+
</h3>
60+
<p className='mt-1 text-sm text-white/60'>
61+
{isFailed
62+
? 'This campaign did not reach its funding goal.'
63+
: 'This campaign was cancelled by the creator.'}{' '}
64+
All backers are eligible for a full refund.
65+
</p>
66+
</div>
67+
68+
{totalCount > 0 && (
69+
<div className='space-y-2'>
70+
<div className='flex items-center justify-between text-sm'>
71+
<span className='flex items-center gap-1.5 text-white/60'>
72+
<RefreshCw className='h-3.5 w-3.5' />
73+
Refund progress
74+
</span>
75+
<span className='font-medium text-white'>
76+
{refundedCount}/{totalCount} backers refunded
77+
</span>
78+
</div>
79+
<Progress value={refundProgress} className='h-2' />
80+
</div>
81+
)}
82+
83+
{/* Per-backer summary for processed refunds */}
84+
{refundedCount > 0 && (
85+
<div className='space-y-1.5'>
86+
<p className='text-xs font-medium text-white/50'>
87+
Processed refunds
88+
</p>
89+
{contributors
90+
.filter(c => c.refundStatus === 'PROCESSED')
91+
.slice(0, 5)
92+
.map((c, i) => (
93+
<div
94+
key={i}
95+
className='flex items-center justify-between rounded-md border border-white/10 bg-white/5 px-3 py-2'
96+
>
97+
<span className='text-sm text-white/80'>
98+
{c.name || c.username || 'Anonymous'}
99+
</span>
100+
<div className='flex items-center gap-2'>
101+
<RefundStatusBadge status='PROCESSED' />
102+
{c.refundTransactionHash && (
103+
<a
104+
href={getTransactionExplorerUrl(
105+
c.refundTransactionHash
106+
)}
107+
target='_blank'
108+
rel='noopener noreferrer'
109+
className='text-blue-400 hover:opacity-70'
110+
>
111+
<ExternalLink className='h-3.5 w-3.5' />
112+
</a>
113+
)}
114+
</div>
115+
</div>
116+
))}
117+
{refundedCount > 5 && (
118+
<p className='text-xs text-white/40'>
119+
+{refundedCount - 5} more in the table below
120+
</p>
121+
)}
122+
</div>
123+
)}
124+
</div>
125+
</div>
126+
</div>
127+
);
128+
}
11129

12130
interface ContributionsPageProps {
13131
params: Promise<{
@@ -82,13 +200,15 @@ export default function ContributionsPage({ params }: ContributionsPageProps) {
82200
</div>
83201
) : project ? (
84202
<div>
203+
<RefundBanner project={project} />
85204
<ContributionsMetrics
86205
contributors={project.contributors}
87206
currencySymbol={'USDC'}
88207
/>
89208
<ContributionsDataTable
90209
data={project.contributors}
91210
loading={false}
211+
campaignStatus={project.project.status}
92212
/>
93213
</div>
94214
) : (

app/me/crowdfunding/[slug]/milestones/[milestoneIndex]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ export default function MilestoneDetailPage({ params }: PageProps) {
180180

181181
toast('Updating milestone...');
182182

183-
await updateMilestone(campaign.slug, milestoneIndex, {
183+
await updateMilestone(campaign.slug, String(milestoneIndex), {
184184
status: data.status as
185185
| 'pending'
186186
| 'in_progress'

components/crowdfunding/campaign-funding-tab.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/badge';
77
import { Progress } from '@/components/ui/progress';
88
import { Separator } from '@/components/ui/separator';
99
import { DollarSign, TrendingUp, Users, Calendar } from 'lucide-react';
10+
import { Milestone } from '@/features/projects/types';
11+
import { FundDisbursementTracker } from './fund-disbursement-tracker';
1012

1113
interface Contributor {
1214
name: string;
@@ -22,6 +24,7 @@ interface CampaignFundingTabProps {
2224
fundingGoal: number;
2325
fundingCurrency: string;
2426
fundingEndDate: string;
27+
milestones?: Milestone[];
2528
}
2629

2730
export function CampaignFundingTab({
@@ -30,6 +33,7 @@ export function CampaignFundingTab({
3033
fundingGoal,
3134
fundingCurrency,
3235
fundingEndDate,
36+
milestones = [],
3337
}: CampaignFundingTabProps) {
3438
const fundingProgress =
3539
fundingGoal > 0 ? (fundingRaised / fundingGoal) * 100 : 0;
@@ -181,6 +185,15 @@ export function CampaignFundingTab({
181185
</div>
182186
</CardContent>
183187
</Card>
188+
189+
{/* Fund Disbursements */}
190+
{milestones.length > 0 && (
191+
<FundDisbursementTracker
192+
milestones={milestones}
193+
fundingGoal={fundingGoal}
194+
fundingCurrency={fundingCurrency}
195+
/>
196+
)}
184197
</div>
185198
);
186199
}

components/crowdfunding/campaign-milestones-tab.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { format } from 'date-fns';
44
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
55
import { Badge } from '@/components/ui/badge';
66
import { Progress } from '@/components/ui/progress';
7-
import { Target, Calendar } from 'lucide-react';
7+
import { Target, Calendar, ExternalLink } from 'lucide-react';
88
import { Milestone } from '@/features/projects/types';
9+
import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
910

1011
interface CampaignMilestonesTabProps {
1112
milestones: Milestone[];
@@ -134,6 +135,37 @@ export function CampaignMilestonesTab({
134135
</span>
135136
</div>
136137
</div>
138+
139+
{milestone.releaseTransactionHash && (
140+
<div className='mt-3 flex items-center justify-between rounded-md border border-green-500/20 bg-green-500/10 px-3 py-2'>
141+
<div className='flex items-center gap-2'>
142+
<div className='h-2 w-2 rounded-full bg-green-400' />
143+
<span className='text-sm font-medium text-green-400'>
144+
Funds Released
145+
</span>
146+
{milestone.completedAt && (
147+
<span className='text-xs text-green-400/70'>
148+
{' '}
149+
{format(
150+
new Date(milestone.completedAt),
151+
'MMM dd, yyyy'
152+
)}
153+
</span>
154+
)}
155+
</div>
156+
<a
157+
href={getTransactionExplorerUrl(
158+
milestone.releaseTransactionHash
159+
)}
160+
target='_blank'
161+
rel='noopener noreferrer'
162+
className='flex items-center gap-1 text-xs text-green-400 transition-opacity hover:opacity-70'
163+
>
164+
View tx
165+
<ExternalLink className='h-3 w-3' />
166+
</a>
167+
</div>
168+
)}
137169
</div>
138170
</div>
139171
);

0 commit comments

Comments
 (0)