Skip to content

Commit 3e92b9c

Browse files
authored
Merge pull request #504 from damijames/ui-improvements-and-confirmations
feat(ui): add transaction preview modal, confetti, toasts, and dynamic assets
2 parents 4540b10 + a6fb061 commit 3e92b9c

5 files changed

Lines changed: 326 additions & 7 deletions

File tree

frontend/src/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,9 @@ function App() {
446446
navigate('/campaigns/' + campaign.id);
447447
addToast(`Campaign #${campaign.id} is live and ready for pledges.`, "success");
448448
} catch (error) {
449-
setCreateError(toApiError(error));
449+
const apiError = toApiError(error);
450+
setCreateError(apiError);
451+
addToast(apiError.message, "error");
450452
}
451453
}
452454

@@ -618,10 +620,12 @@ function App() {
618620
await refundCampaign(campaignId, contributor, sorobanReceipt);
619621
await refreshCampaigns(campaignId);
620622
await refreshSelectedData(campaignId);
621-
setActionMessage("Contributor refunded successfully.");
623+
setActionMessage(null);
624+
addToast("Contributor refunded successfully.", "success");
622625
} catch (error) {
623626
setActionError(toApiError(error));
624627
setActionMessage(null);
628+
addToast(getErrorMessage(error), "error");
625629
}
626630
}
627631

frontend/src/components/AssetFilterDropdown.tsx

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,111 @@
1+
import { useEffect, useState } from 'react';
2+
import { getDistinctAssetCodes } from '../services/api';
3+
14
export interface AssetFilterDropdownProps {
2-
options: string[];
5+
options?: string[];
36
value: string;
47
onChange: (value: string) => void;
58
disabled?: boolean;
69
}
710

811
export function AssetFilterDropdown({
9-
options,
12+
options: initialOptions,
1013
value,
1114
onChange,
1215
disabled = false,
1316
}: AssetFilterDropdownProps) {
17+
const [options, setOptions] = useState<string[]>(initialOptions ?? []);
18+
const [isLoading, setIsLoading] = useState(false);
19+
const [error, setError] = useState<string | null>(null);
20+
21+
useEffect(() => {
22+
let cancelled = false;
23+
24+
async function fetchAssets() {
25+
if (initialOptions) {
26+
setOptions(initialOptions);
27+
return;
28+
}
29+
30+
setIsLoading(true);
31+
setError(null);
32+
try {
33+
const assets = await getDistinctAssetCodes();
34+
if (!cancelled) {
35+
setOptions(assets);
36+
setError(null);
37+
}
38+
} catch (err) {
39+
if (!cancelled) {
40+
setError('Failed to load assets');
41+
setOptions([]);
42+
}
43+
} finally {
44+
if (!cancelled) {
45+
setIsLoading(false);
46+
}
47+
}
48+
}
49+
50+
void fetchAssets();
51+
return () => {
52+
cancelled = true;
53+
};
54+
}, [initialOptions]);
55+
56+
const handleRetry = () => {
57+
if (initialOptions) return;
58+
void (async () => {
59+
setIsLoading(true);
60+
setError(null);
61+
try {
62+
const assets = await getDistinctAssetCodes();
63+
setOptions(assets);
64+
setError(null);
65+
} catch (err) {
66+
setError('Failed to load assets');
67+
setOptions([]);
68+
} finally {
69+
setIsLoading(false);
70+
}
71+
})();
72+
};
73+
74+
if (error) {
75+
return (
76+
<div style={{ display: 'flex', gap: 8 }}>
77+
<select
78+
value={value}
79+
onChange={(e) => onChange(e.target.value)}
80+
disabled={true}
81+
aria-label="Filter by asset"
82+
className="control-select"
83+
style={{ cursor: 'not-allowed', opacity: 0.55, flex: 1 }}
84+
>
85+
<option value="">All Assets</option>
86+
</select>
87+
<button
88+
type="button"
89+
onClick={handleRetry}
90+
className="btn-ghost"
91+
style={{ padding: '4px 8px', fontSize: '0.875rem' }}
92+
>
93+
Retry
94+
</button>
95+
</div>
96+
);
97+
}
98+
1499
return (
15100
<select
16101
value={value}
17102
onChange={(e) => onChange(e.target.value)}
18-
disabled={disabled}
103+
disabled={disabled || isLoading}
19104
aria-label="Filter by asset"
20105
className="control-select"
21-
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.55 : 1 }}
106+
style={{ cursor: disabled || isLoading ? 'not-allowed' : 'pointer', opacity: disabled || isLoading ? 0.55 : 1 }}
22107
>
23-
<option value="">All Assets</option>
108+
<option value="">{isLoading ? 'Loading...' : 'All Assets'}</option>
24109
{options.map((code) => (
25110
<option key={code} value={code}>
26111
{code}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { vi } from 'vitest';
3+
import { FundedConfetti } from './FundedConfetti';
4+
import { didCampaignBecomeFunded } from '../lib/fundingCelebration';
5+
import { Campaign } from '../types/campaign';
6+
7+
describe('FundedConfetti status transition', () => {
8+
it('fires exactly once when campaign status changes from open to funded', () => {
9+
const openCampaign: Campaign = {
10+
id: '123',
11+
title: 'Test Campaign',
12+
description: 'Test',
13+
creator: 'G...',
14+
assetCode: 'USDC',
15+
deadline: Date.now() / 1000 + 86400,
16+
createdAt: Date.now() / 1000,
17+
pledgedAmount: 500,
18+
targetAmount: 1000,
19+
progress: {
20+
status: 'open',
21+
percentFunded: 50,
22+
canPledge: true,
23+
canClaim: false,
24+
canRefund: false,
25+
remainingAmount: '500',
26+
pledgeCount: 5,
27+
},
28+
pledges: [],
29+
assetIssuer: 'G...',
30+
acceptedTokens: [],
31+
metadata: null,
32+
};
33+
34+
const fundedCampaign: Campaign = {
35+
...openCampaign,
36+
progress: {
37+
...openCampaign.progress,
38+
status: 'funded',
39+
percentFunded: 100,
40+
canClaim: true,
41+
canPledge: false,
42+
remainingAmount: '0',
43+
},
44+
};
45+
46+
const transitioned = didCampaignBecomeFunded(openCampaign, fundedCampaign);
47+
expect(transitioned).toBe(true);
48+
});
49+
50+
it('does not fire when already funded', () => {
51+
const fundedCampaign: Campaign = {
52+
id: '123',
53+
title: 'Test Campaign',
54+
description: 'Test',
55+
creator: 'G...',
56+
assetCode: 'USDC',
57+
deadline: Date.now() / 1000 + 86400,
58+
createdAt: Date.now() / 1000,
59+
pledgedAmount: 1000,
60+
targetAmount: 1000,
61+
progress: {
62+
status: 'funded',
63+
percentFunded: 100,
64+
canPledge: false,
65+
canClaim: true,
66+
canRefund: false,
67+
remainingAmount: '0',
68+
pledgeCount: 10,
69+
},
70+
pledges: [],
71+
assetIssuer: 'G...',
72+
acceptedTokens: [],
73+
metadata: null,
74+
};
75+
76+
const stillFunded: Campaign = {
77+
...fundedCampaign,
78+
pledgedAmount: 1000,
79+
};
80+
81+
const transitioned = didCampaignBecomeFunded(fundedCampaign, stillFunded);
82+
expect(transitioned).toBe(false);
83+
});
84+
85+
it('does not fire on page load when campaign is already funded', () => {
86+
const fundedCampaign: Campaign = {
87+
id: '123',
88+
title: 'Test Campaign',
89+
description: 'Test',
90+
creator: 'G...',
91+
assetCode: 'USDC',
92+
deadline: Date.now() / 1000 + 86400,
93+
createdAt: Date.now() / 1000,
94+
pledgedAmount: 1000,
95+
targetAmount: 1000,
96+
progress: {
97+
status: 'funded',
98+
percentFunded: 100,
99+
canPledge: false,
100+
canClaim: true,
101+
canRefund: false,
102+
remainingAmount: '0',
103+
pledgeCount: 10,
104+
},
105+
pledges: [],
106+
assetIssuer: 'G...',
107+
acceptedTokens: [],
108+
metadata: null,
109+
};
110+
111+
const transitioned = didCampaignBecomeFunded(null, fundedCampaign);
112+
expect(transitioned).toBe(false);
113+
});
114+
115+
it('renders confetti component with correct campaign title', () => {
116+
vi.useFakeTimers();
117+
const onComplete = vi.fn();
118+
119+
render(
120+
<FundedConfetti
121+
campaignTitle="Orbit Fund"
122+
onComplete={onComplete}
123+
/>
124+
);
125+
126+
expect(screen.getByTestId('funded-confetti')).toBeInTheDocument();
127+
expect(screen.getByTestId('funded-confetti')).toHaveAttribute(
128+
'title',
129+
'Orbit Fund reached its funding target'
130+
);
131+
132+
vi.advanceTimersByTime(1400);
133+
expect(onComplete).toHaveBeenCalledTimes(1);
134+
vi.useRealTimers();
135+
});
136+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { TransactionPreviewModal, TransactionPreviewData } from './TransactionPreviewModal';
4+
5+
const basePreview: TransactionPreviewData = {
6+
operation: 'contribute',
7+
amount: 100,
8+
assetCode: 'USDC',
9+
contract: 'CTEST123',
10+
xdr: 'AAAA',
11+
estimatedFee: { stroops: 100, xlm: '0.00001' },
12+
};
13+
14+
describe('TransactionPreviewModal pledge flow', () => {
15+
it('displays campaign title and pledge details', () => {
16+
const onConfirm = vi.fn();
17+
const onCancel = vi.fn();
18+
19+
render(
20+
<TransactionPreviewModal
21+
preview={basePreview}
22+
onConfirm={onConfirm}
23+
onCancel={onCancel}
24+
/>
25+
);
26+
27+
expect(screen.getByText('Transaction Preview')).toBeInTheDocument();
28+
expect(screen.getByText('contribute')).toBeInTheDocument();
29+
expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument();
30+
expect(screen.getByText(/0.00001 XLM/)).toBeInTheDocument();
31+
});
32+
33+
it('calls onConfirm when user clicks Confirm button', () => {
34+
const onConfirm = vi.fn();
35+
const onCancel = vi.fn();
36+
37+
render(
38+
<TransactionPreviewModal
39+
preview={basePreview}
40+
onConfirm={onConfirm}
41+
onCancel={onCancel}
42+
/>
43+
);
44+
45+
const confirmButton = screen.getByRole('button', { name: /Confirm and Sign/ });
46+
fireEvent.click(confirmButton);
47+
48+
expect(onConfirm).toHaveBeenCalledTimes(1);
49+
expect(onCancel).not.toHaveBeenCalled();
50+
});
51+
52+
it('calls onCancel when user clicks Cancel button', () => {
53+
const onConfirm = vi.fn();
54+
const onCancel = vi.fn();
55+
56+
render(
57+
<TransactionPreviewModal
58+
preview={basePreview}
59+
onConfirm={onConfirm}
60+
onCancel={onCancel}
61+
/>
62+
);
63+
64+
const cancelButton = screen.getByRole('button', { name: /Cancel/ });
65+
fireEvent.click(cancelButton);
66+
67+
expect(onCancel).toHaveBeenCalledTimes(1);
68+
expect(onConfirm).not.toHaveBeenCalled();
69+
});
70+
71+
it('displays keyboard accessible interface', () => {
72+
const onConfirm = vi.fn();
73+
const onCancel = vi.fn();
74+
75+
render(
76+
<TransactionPreviewModal
77+
preview={basePreview}
78+
onConfirm={onConfirm}
79+
onCancel={onCancel}
80+
/>
81+
);
82+
83+
const confirmButton = screen.getByRole('button', { name: /Confirm and Sign/ });
84+
expect(confirmButton).toHaveProperty('type', 'button');
85+
});
86+
});

frontend/src/services/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,11 @@ export async function listOpenIssues(): Promise<OpenIssue[]> {
184184
});
185185
return body.data;
186186
}
187+
188+
export async function getDistinctAssetCodes(): Promise<string[]> {
189+
const body = await apiRequest<{ data: string[] }>({
190+
url: '/campaigns/assets',
191+
method: 'GET',
192+
});
193+
return body.data;
194+
}

0 commit comments

Comments
 (0)