@@ -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+
2944function 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 < 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