Skip to content

Commit e2b8fec

Browse files
authored
Merge pull request #49 from simplicityf/feat/share-campaign
feat: feat: sync selected campaign to URL query param
2 parents d7541e0 + 73c2858 commit e2b8fec

2 files changed

Lines changed: 144 additions & 100 deletions

File tree

frontend/src/App.tsx

Lines changed: 110 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ function delay(ms: number): Promise<void> {
2626
});
2727
}
2828

29+
function getCampaignIdFromUrl(): string | null {
30+
const params = new URLSearchParams(window.location.search);
31+
return params.get("campaign");
32+
}
33+
34+
function setCampaignIdInUrl(campaignId: string | null): void {
35+
const url = new URL(window.location.href);
36+
if (campaignId) {
37+
url.searchParams.set("campaign", campaignId);
38+
} else {
39+
url.searchParams.delete("campaign");
40+
}
41+
window.history.replaceState(null, "", url.toString());
42+
}
43+
2944
function toOptimisticPledgedCampaign(campaign: Campaign, amount: number): Campaign {
3045
const nowInSeconds = Math.floor(Date.now() / 1000);
3146
const nextPledgedAmount = round(campaign.pledgedAmount + amount);
@@ -74,6 +89,11 @@ function App() {
7489
const [actionError, setActionError] = useState<string | null>(null);
7590
const [actionMessage, setActionMessage] = useState<string | null>(null);
7691
const [pendingPledgeCampaignId, setPendingPledgeCampaignId] = useState<string | null>(null);
92+
const [invalidUrlCampaignId, setInvalidUrlCampaignId] = useState<string | null>(null);
93+
94+
useEffect(() => {
95+
setCampaignIdInUrl(selectedCampaignId);
96+
}, [selectedCampaignId]);
7797

7898
async function refreshCampaigns(nextSelectedId?: string | null) {
7999
const startedAt = Date.now();
@@ -100,17 +120,6 @@ function App() {
100120
return;
101121
}
102122

103-
const startedAt = Date.now();
104-
setIsSelectedLoading(true);
105-
try {
106-
const data = await getCampaignHistory(campaignId);
107-
setHistory(data);
108-
} finally {
109-
const elapsed = Date.now() - startedAt;
110-
const minMs = 200;
111-
if (elapsed < minMs) await delay(minMs - elapsed);
112-
setIsSelectedLoading(false);
113-
}
114123
}
115124

116125
async function refreshSelectedCampaign(campaignId: string | null) {
@@ -133,23 +142,7 @@ function App() {
133142

134143
useEffect(() => {
135144
async function bootstrap() {
136-
const startedAt = Date.now();
137-
setIsCampaignsLoading(true);
138-
try {
139-
const [campaignData, issueData] = await Promise.all([
140-
listCampaigns(),
141-
listOpenIssues(),
142-
]);
143-
144-
setCampaigns(campaignData);
145-
setIssues(issueData);
146-
setSelectedCampaignId(campaignData[0]?.id ?? null);
147-
} finally {
148-
const elapsed = Date.now() - startedAt;
149-
const minMs = 350;
150-
if (elapsed < minMs) await delay(minMs - elapsed);
151-
setIsCampaignsLoading(false);
152-
setInitialLoad(false);
145+
153146
}
154147
}
155148

@@ -158,29 +151,26 @@ function App() {
158151

159152
useEffect(() => {
160153
setSelectedCampaignDetails(null);
161-
void Promise.all([refreshHistory(selectedCampaignId), refreshSelectedCampaign(selectedCampaignId)]);
154+
void Promise.all([
155+
refreshHistory(selectedCampaignId),
156+
refreshSelectedCampaign(selectedCampaignId),
157+
]);
162158
}, [selectedCampaignId]);
163159

160+
// ── derived state ────────────────────────────────────────────────────────
161+
164162
const selectedCampaign = useMemo(() => {
165163
const baseCampaign =
166164
campaigns.find((campaign) => campaign.id === selectedCampaignId) ?? null;
167-
if (!baseCampaign) {
168-
return null;
169-
}
170-
if (selectedCampaignDetails?.id !== baseCampaign.id) {
171-
return baseCampaign;
172-
}
173-
return {
174-
...baseCampaign,
175-
pledges: selectedCampaignDetails.pledges,
176-
};
165+
if (!baseCampaign) return null;
166+
if (selectedCampaignDetails?.id !== baseCampaign.id) return baseCampaign;
167+
return { ...baseCampaign, pledges: selectedCampaignDetails.pledges };
177168
}, [campaigns, selectedCampaignDetails, selectedCampaignId]);
178169

179170
const metrics = useMemo(() => {
180-
const open = campaigns.filter((campaign) => campaign.progress.status === "open").length;
181-
const funded = campaigns.filter((campaign) => campaign.progress.status === "funded").length;
182-
const pledged = campaigns.reduce((sum, campaign) => sum + campaign.pledgedAmount, 0);
183-
171+
const open = campaigns.filter((c) => c.progress.status === "open").length;
172+
const funded = campaigns.filter((c) => c.progress.status === "funded").length;
173+
const pledged = campaigns.reduce((sum, c) => sum + c.pledgedAmount, 0);
184174
return {
185175
total: campaigns.length,
186176
open,
@@ -189,6 +179,8 @@ function App() {
189179
};
190180
}, [campaigns]);
191181

182+
// ── action handlers ──────────────────────────────────────────────────────
183+
192184
async function handleCreate(payload: Parameters<typeof createCampaign>[0]) {
193185
setCreateError(null);
194186
setActionError(null);
@@ -197,14 +189,23 @@ function App() {
197189
try {
198190
const campaign = await createCampaign(payload);
199191
await refreshCampaigns(campaign.id);
200-
await Promise.all([refreshHistory(campaign.id), refreshSelectedCampaign(campaign.id)]);
192+
await Promise.all([
193+
refreshHistory(campaign.id),
194+
refreshSelectedCampaign(campaign.id),
195+
]);
201196
setActionMessage(`Campaign #${campaign.id} is live and ready for pledges.`);
202197
} catch (error) {
203-
setCreateError(error instanceof Error ? error.message : "Failed to create campaign.");
198+
setCreateError(
199+
error instanceof Error ? error.message : "Failed to create campaign.",
200+
);
204201
}
205202
}
206203

207-
async function handlePledge(campaignId: string, contributor: string, amount: number) {
204+
async function handlePledge(
205+
campaignId: string,
206+
contributor: string,
207+
amount: number,
208+
) {
208209
setActionError(null);
209210
setActionMessage(null);
210211

@@ -222,15 +223,13 @@ function App() {
222223
metadata: { pending: true },
223224
};
224225

225-
setCampaigns((currentCampaigns) =>
226-
currentCampaigns.map((campaign) =>
227-
campaign.id === campaignId ? toOptimisticPledgedCampaign(campaign, amount) : campaign,
226+
setCampaigns((current) =>
227+
current.map((c) =>
228+
c.id === campaignId ? toOptimisticPledgedCampaign(c, amount) : c,
228229
),
229230
);
230-
setSelectedCampaignDetails((currentDetails) => {
231-
if (!currentDetails || currentDetails.id !== campaignId) {
232-
return currentDetails;
233-
}
231+
setSelectedCampaignDetails((current) => {
232+
if (!current || current.id !== campaignId) return current;
234233
const optimisticPledge = {
235234
id: -Date.now(),
236235
campaignId,
@@ -239,13 +238,13 @@ function App() {
239238
createdAt: optimisticTimestamp,
240239
};
241240
return {
242-
...toOptimisticPledgedCampaign(currentDetails, amount),
243-
pledges: [optimisticPledge, ...(currentDetails.pledges ?? [])],
241+
...toOptimisticPledgedCampaign(current, amount),
242+
pledges: [optimisticPledge, ...(current.pledges ?? [])],
244243
};
245244
});
246245
setPendingPledgeCampaignId(campaignId);
247246
if (selectedCampaignId === campaignId) {
248-
setHistory((currentHistory) => [optimisticEvent, ...currentHistory]);
247+
setHistory((current) => [optimisticEvent, ...current]);
249248
}
250249
setActionMessage("Submitting pledge...");
251250

@@ -255,69 +254,103 @@ function App() {
255254
try {
256255
await addPledge(campaignId, { contributor, amount });
257256
const elapsedMs = Date.now() - pendingStartedAt;
258-
if (elapsedMs < minimumPendingMs) {
259-
await delay(minimumPendingMs - elapsedMs);
260-
}
257+
if (elapsedMs < minimumPendingMs) await delay(minimumPendingMs - elapsedMs);
261258
await refreshCampaigns(campaignId);
262-
await Promise.all([refreshHistory(campaignId), refreshSelectedCampaign(campaignId)]);
259+
await Promise.all([
260+
refreshHistory(campaignId),
261+
refreshSelectedCampaign(campaignId),
262+
]);
263263
setPendingPledgeCampaignId(null);
264264
setActionMessage("Pledge recorded in the local goal vault.");
265265
} catch (error) {
266266
const elapsedMs = Date.now() - pendingStartedAt;
267-
if (elapsedMs < minimumPendingMs) {
268-
await delay(minimumPendingMs - elapsedMs);
269-
}
267+
if (elapsedMs < minimumPendingMs) await delay(minimumPendingMs - elapsedMs);
270268
setCampaigns(previousCampaigns);
271269
setSelectedCampaignDetails(previousSelectedDetails);
272270
await refreshSelectedCampaign(campaignId);
273-
if (selectedCampaignId === campaignId) {
274-
setHistory(previousHistory);
275-
}
271+
if (selectedCampaignId === campaignId) setHistory(previousHistory);
276272
setPendingPledgeCampaignId(null);
277-
setActionError(error instanceof Error ? error.message : "Failed to add pledge.");
273+
setActionError(
274+
error instanceof Error ? error.message : "Failed to add pledge.",
275+
);
278276
setActionMessage(null);
279277
}
280278
}
281279

282280
async function handleClaim(campaign: Campaign) {
283281
setActionError(null);
284282
setActionMessage(null);
285-
286283
try {
287284
await claimCampaign(campaign.id, campaign.creator);
288285
await refreshCampaigns(campaign.id);
289-
await Promise.all([refreshHistory(campaign.id), refreshSelectedCampaign(campaign.id)]);
286+
await Promise.all([
287+
refreshHistory(campaign.id),
288+
refreshSelectedCampaign(campaign.id),
289+
]);
290290
setActionMessage("Campaign claimed successfully.");
291291
} catch (error) {
292-
setActionError(error instanceof Error ? error.message : "Failed to claim campaign.");
292+
setActionError(
293+
error instanceof Error ? error.message : "Failed to claim campaign.",
294+
);
293295
}
294296
}
295297

296298
async function handleRefund(campaignId: string, contributor: string) {
297299
setActionError(null);
298300
setActionMessage(null);
299-
300301
try {
301302
await refundCampaign(campaignId, contributor);
302303
await refreshCampaigns(campaignId);
303-
await Promise.all([refreshHistory(campaignId), refreshSelectedCampaign(campaignId)]);
304+
await Promise.all([
305+
refreshHistory(campaignId),
306+
refreshSelectedCampaign(campaignId),
307+
]);
304308
setActionMessage("Refund recorded for the selected contributor.");
305309
} catch (error) {
306-
setActionError(error instanceof Error ? error.message : "Failed to refund contributor.");
310+
setActionError(
311+
error instanceof Error ? error.message : "Failed to refund contributor.",
312+
);
307313
}
308314
}
309315

316+
/** Called when the user picks a campaign in the table. Clears any
317+
* "invalid link" banner since they are now navigating intentionally. */
318+
function handleSelect(campaignId: string) {
319+
setInvalidUrlCampaignId(null);
320+
setSelectedCampaignId(campaignId);
321+
}
322+
323+
// ── render ───────────────────────────────────────────────────────────────
324+
310325
return (
311326
<div className="app-shell">
312327
<header className="hero">
313328
<p className="eyebrow">Soroban crowdfunding MVP</p>
314329
<h1>Stellar Goal Vault</h1>
315330
<p className="hero-copy">
316-
Create funding goals, collect pledges, and model claim or refund flows before
317-
wiring the full Soroban transaction path.
331+
Create funding goals, collect pledges, and model claim or refund flows
332+
before wiring the full Soroban transaction path.
318333
</p>
319334
</header>
320335

336+
{/* Invalid shared-link banner */}
337+
{invalidUrlCampaignId && (
338+
<div className="invalid-campaign-banner" role="alert">
339+
<strong>Campaign not found.</strong> The link you followed referenced
340+
campaign&nbsp;<code>{invalidUrlCampaignId}</code>, which doesn't exist or
341+
may have been removed.
342+
{campaigns.length > 0 && " Showing the first available campaign instead."}
343+
<button
344+
className="btn-ghost invalid-campaign-dismiss"
345+
type="button"
346+
onClick={() => setInvalidUrlCampaignId(null)}
347+
aria-label="Dismiss"
348+
>
349+
350+
</button>
351+
</div>
352+
)}
353+
321354
<section className="metric-grid">
322355
<article className="metric-card">
323356
<span>Total campaigns</span>
@@ -355,7 +388,7 @@ function App() {
355388
campaigns={campaigns}
356389
isLoading={isCampaignsLoading || initialLoad}
357390
selectedCampaignId={selectedCampaignId}
358-
onSelect={setSelectedCampaignId}
391+
onSelect={handleSelect}
359392
/>
360393

361394
<section className="secondary-grid">
@@ -366,4 +399,4 @@ function App() {
366399
);
367400
}
368401

369-
export default App;
402+
export default App;

0 commit comments

Comments
 (0)