Skip to content

Commit 47a1d77

Browse files
authored
Merge pull request #41 from rehna-jp/feat/14-optimistic-pledge-ui
Added optimistic pledge updates (#14) and fix backend TypeScript types
2 parents d7cc943 + 79d8b1f commit 47a1d77

7 files changed

Lines changed: 164 additions & 25 deletions

File tree

backend/package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
{
2-
"name": "stellar-goal-vault-backend",
2+
"name": "stellar-goal-vault-backend",
33
"version": "1.0.0",
4-
"description": "Backend API for Stellar Goal Vault",
4+
"description": "Backend API for Stellar Goal Vault",
55
"main": "dist/index.js",
66
"scripts": {
77
"dev": "ts-node-dev --respawn src/index.ts",
88
"build": "tsc",
99
"start": "node dist/index.js",
1010
"test": "vitest"
1111
},
12-
"dependencies": {
13-
"better-sqlite3": "^12.6.2",
14-
"cors": "^2.8.5",
15-
"dotenv": "^17.3.1",
16-
"express": "^4.21.2",
17-
"zod": "^4.3.6"
18-
},
19-
"devDependencies": {
20-
"@types/cors": "^2.8.17",
21-
"@types/express": "^5.0.3",
22-
"@types/node": "^22.14.1",
23-
"ts-node-dev": "^2.0.0",
24-
"typescript": "^5.2.2",
25-
"vitest": "^1.0.0"
26-
}
27-
}
12+
"dependencies": {
13+
"better-sqlite3": "^12.6.2",
14+
"cors": "^2.8.5",
15+
"dotenv": "^17.3.1",
16+
"express": "^4.21.2",
17+
"zod": "^4.3.6"
18+
},
19+
"devDependencies": {
20+
"@types/better-sqlite3": "^7.6.13",
21+
"@types/cors": "^2.8.17",
22+
"@types/express": "^5.0.3",
23+
"@types/node": "^22.14.1",
24+
"ts-node-dev": "^2.0.0",
25+
"typescript": "^5.2.2",
26+
"vitest": "^1.0.0"
27+
}
28+
}

frontend/package-lock.json

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/App.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,51 @@ import {
1515
} from "./services/api";
1616
import { Campaign, CampaignEvent, OpenIssue } from "./types/campaign";
1717

18+
function round(value: number): number {
19+
return Number(value.toFixed(2));
20+
}
21+
22+
function delay(ms: number): Promise<void> {
23+
return new Promise((resolve) => {
24+
setTimeout(resolve, ms);
25+
});
26+
}
27+
28+
function toOptimisticPledgedCampaign(campaign: Campaign, amount: number): Campaign {
29+
const nowInSeconds = Math.floor(Date.now() / 1000);
30+
const nextPledgedAmount = round(campaign.pledgedAmount + amount);
31+
const deadlineReached = nowInSeconds >= campaign.deadline;
32+
const status =
33+
campaign.claimedAt !== undefined
34+
? "claimed"
35+
: nextPledgedAmount >= campaign.targetAmount
36+
? "funded"
37+
: deadlineReached
38+
? "failed"
39+
: "open";
40+
41+
return {
42+
...campaign,
43+
pledgedAmount: nextPledgedAmount,
44+
progress: {
45+
...campaign.progress,
46+
status,
47+
percentFunded: round((nextPledgedAmount / campaign.targetAmount) * 100),
48+
remainingAmount: round(Math.max(0, campaign.targetAmount - nextPledgedAmount)),
49+
pledgeCount: campaign.progress.pledgeCount + 1,
50+
canPledge: campaign.claimedAt === undefined && !deadlineReached,
51+
canClaim:
52+
campaign.claimedAt === undefined &&
53+
deadlineReached &&
54+
nextPledgedAmount >= campaign.targetAmount,
55+
canRefund:
56+
campaign.claimedAt === undefined &&
57+
deadlineReached &&
58+
nextPledgedAmount < campaign.targetAmount,
59+
},
60+
};
61+
}
62+
1863
function App() {
1964
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
2065
const [issues, setIssues] = useState<OpenIssue[]>([]);
@@ -23,6 +68,7 @@ function App() {
2368
const [createError, setCreateError] = useState<string | null>(null);
2469
const [actionError, setActionError] = useState<string | null>(null);
2570
const [actionMessage, setActionMessage] = useState<string | null>(null);
71+
const [pendingPledgeCampaignId, setPendingPledgeCampaignId] = useState<string | null>(null);
2672

2773
async function refreshCampaigns(nextSelectedId?: string | null) {
2874
const data = await listCampaigns();
@@ -98,13 +144,55 @@ function App() {
98144
setActionError(null);
99145
setActionMessage(null);
100146

147+
const previousCampaigns = campaigns;
148+
const previousHistory = history;
149+
const optimisticTimestamp = Math.floor(Date.now() / 1000);
150+
const optimisticEvent: CampaignEvent = {
151+
id: -Date.now(),
152+
campaignId,
153+
eventType: "pledged",
154+
timestamp: optimisticTimestamp,
155+
actor: contributor,
156+
amount,
157+
metadata: { pending: true },
158+
};
159+
160+
setCampaigns((currentCampaigns) =>
161+
currentCampaigns.map((campaign) =>
162+
campaign.id === campaignId ? toOptimisticPledgedCampaign(campaign, amount) : campaign,
163+
),
164+
);
165+
setPendingPledgeCampaignId(campaignId);
166+
if (selectedCampaignId === campaignId) {
167+
setHistory((currentHistory) => [optimisticEvent, ...currentHistory]);
168+
}
169+
setActionMessage("Submitting pledge...");
170+
171+
const pendingStartedAt = Date.now();
172+
const minimumPendingMs = 800;
173+
101174
try {
102175
await addPledge(campaignId, { contributor, amount });
176+
const elapsedMs = Date.now() - pendingStartedAt;
177+
if (elapsedMs < minimumPendingMs) {
178+
await delay(minimumPendingMs - elapsedMs);
179+
}
103180
await refreshCampaigns(campaignId);
104181
await refreshHistory(campaignId);
182+
setPendingPledgeCampaignId(null);
105183
setActionMessage("Pledge recorded in the local goal vault.");
106184
} catch (error) {
185+
const elapsedMs = Date.now() - pendingStartedAt;
186+
if (elapsedMs < minimumPendingMs) {
187+
await delay(minimumPendingMs - elapsedMs);
188+
}
189+
setCampaigns(previousCampaigns);
190+
if (selectedCampaignId === campaignId) {
191+
setHistory(previousHistory);
192+
}
193+
setPendingPledgeCampaignId(null);
107194
setActionError(error instanceof Error ? error.message : "Failed to add pledge.");
195+
setActionMessage(null);
108196
}
109197
}
110198

@@ -172,6 +260,7 @@ function App() {
172260
campaign={selectedCampaign}
173261
actionError={actionError}
174262
actionMessage={actionMessage}
263+
isPledgePending={pendingPledgeCampaignId === selectedCampaignId}
175264
onPledge={handlePledge}
176265
onClaim={handleClaim}
177266
onRefund={handleRefund}

frontend/src/components/CampaignDetailPanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface CampaignDetailPanelProps {
55
campaign: Campaign | null;
66
actionError?: string | null;
77
actionMessage?: string | null;
8+
isPledgePending?: boolean;
89
onPledge: (campaignId: string, contributor: string, amount: number) => Promise<void>;
910
onClaim: (campaign: Campaign) => Promise<void>;
1011
onRefund: (campaignId: string, contributor: string) => Promise<void>;
@@ -14,6 +15,7 @@ export function CampaignDetailPanel({
1415
campaign,
1516
actionError,
1617
actionMessage,
18+
isPledgePending = false,
1719
onPledge,
1820
onClaim,
1921
onRefund,
@@ -118,7 +120,7 @@ export function CampaignDetailPanel({
118120
type="submit"
119121
disabled={isSubmitting || !activeCampaign.progress.canPledge}
120122
>
121-
Add pledge
123+
{isPledgePending ? "Submitting..." : "Add pledge"}
122124
</button>
123125
<button
124126
className="btn-ghost"
@@ -139,6 +141,9 @@ export function CampaignDetailPanel({
139141
</div>
140142
</form>
141143

144+
{isPledgePending ? (
145+
<p className="pending-note">Pledge is pending confirmation and will reconcile automatically.</p>
146+
) : null}
142147
{actionError ? <p className="form-error">{actionError}</p> : null}
143148
{actionMessage ? <p className="form-success">{actionMessage}</p> : null}
144149

frontend/src/components/CampaignTimeline.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,25 @@ export function CampaignTimeline({ history }: CampaignTimelineProps) {
3535
<div className="empty-state">Select a campaign to see lifecycle events.</div>
3636
) : (
3737
<div className="timeline">
38-
{history.map((event) => (
39-
<article key={event.id} className="timeline-item">
38+
{history.map((event) => {
39+
const isPending = event.metadata?.pending === true;
40+
return (
41+
<article key={event.id} className={`timeline-item ${isPending ? "pending" : ""}`}>
4042
<div className="timeline-dot" aria-hidden />
4143
<div className="timeline-copy">
42-
<strong>{describeEvent(event)}</strong>
44+
<strong>
45+
{describeEvent(event)}
46+
{isPending ? " (pending...)" : ""}
47+
</strong>
4348
<span className="muted">{formatTimestamp(event.timestamp)}</span>
4449
<span className="muted">
4550
{event.actor ? `Actor: ${event.actor.slice(0, 10)}...` : "System event"}
4651
{typeof event.amount === "number" ? ` | Amount: ${event.amount}` : ""}
4752
</span>
4853
</div>
4954
</article>
50-
))}
55+
);
56+
})}
5157
</div>
5258
)}
5359
</section>

0 commit comments

Comments
 (0)