Skip to content

Commit 983c0e1

Browse files
authored
Merge pull request #261 from Phantomcall/feat/optimistic-ui-updates-issue-225
feat: optimistic UI updates for policy, claim, and vote submissions
2 parents ae7a7e1 + 19fdcdb commit 983c0e1

13 files changed

Lines changed: 960 additions & 27 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { OptimisticStatus } from '@/lib/optimistic';
2+
3+
interface PendingBadgeProps {
4+
status: OptimisticStatus;
5+
error?: string;
6+
}
7+
8+
/**
9+
* PendingBadge — shown on rows/cards that have an in-flight optimistic update.
10+
*
11+
* - pending → animated yellow "Pending" pill
12+
* - failed → red "Failed" pill with optional error tooltip
13+
* - confirmed → green "Confirmed" pill (briefly shown before parent removes entry)
14+
*/
15+
export function PendingBadge({ status, error }: PendingBadgeProps) {
16+
if (status === 'pending') {
17+
return (
18+
<span
19+
aria-label="Transaction pending indexer confirmation"
20+
title="Waiting for indexer confirmation (~15 s)"
21+
className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 animate-pulse"
22+
>
23+
<span aria-hidden="true"></span> Pending
24+
</span>
25+
);
26+
}
27+
28+
if (status === 'failed') {
29+
return (
30+
<span
31+
aria-label={`Transaction failed${error ? `: ${error}` : ''}`}
32+
title={error ?? 'Transaction could not be confirmed'}
33+
className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700"
34+
>
35+
<span aria-hidden="true"></span> Failed
36+
</span>
37+
);
38+
}
39+
40+
// confirmed
41+
return (
42+
<span
43+
aria-label="Transaction confirmed"
44+
className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"
45+
>
46+
<span aria-hidden="true"></span> Confirmed
47+
</span>
48+
);
49+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use client';
2+
3+
/**
4+
* useOptimisticClaims
5+
*
6+
* Manages optimistic state for claim filing and vote submission.
7+
*
8+
* After a transaction is submitted the hook:
9+
* 1. Immediately marks the claim row as "pending".
10+
* 2. Polls /api/claims/:id with exponential backoff until the indexer
11+
* reflects the expected status change.
12+
* 3. Rolls back on timeout or hard error.
13+
*
14+
* Optimistic state is session-only (React state) — never persisted to storage.
15+
*/
16+
17+
import { useCallback, useEffect } from 'react';
18+
import { useOptimisticState, useConfirmationPoller } from '@/lib/optimistic';
19+
import type { ClaimBoard } from '@/lib/schemas/claims-board';
20+
21+
// ---------------------------------------------------------------------------
22+
// Confirmation check
23+
// ---------------------------------------------------------------------------
24+
25+
async function checkClaimStatus(
26+
claimId: string,
27+
expectedStatus: string,
28+
signal: AbortSignal,
29+
): Promise<boolean> {
30+
const res = await fetch(`/api/claims/${encodeURIComponent(claimId)}`, {
31+
signal,
32+
cache: 'no-store',
33+
});
34+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
35+
const data = (await res.json()) as { status?: string };
36+
return data.status === expectedStatus;
37+
}
38+
39+
// ---------------------------------------------------------------------------
40+
// Hook
41+
// ---------------------------------------------------------------------------
42+
43+
export interface UseOptimisticClaimsReturn {
44+
/** Apply an optimistic "Processing" state after claim filing. */
45+
applyOptimisticClaim: (claim: ClaimBoard, txHash?: string) => void;
46+
/** Apply an optimistic vote count update after vote submission. */
47+
applyOptimisticVote: (
48+
claim: ClaimBoard,
49+
vote: 'Approve' | 'Reject',
50+
txHash?: string,
51+
) => void;
52+
/** Merge server claims with optimistic overrides. */
53+
mergeWithOptimistic: (serverClaims: ClaimBoard[]) => ClaimBoard[];
54+
/** Get the optimistic status for a single claim (for badge rendering). */
55+
getOptimisticStatus: (claimId: string) => { status: 'pending' | 'confirmed' | 'failed'; error?: string } | undefined;
56+
}
57+
58+
export function useOptimisticClaims(): UseOptimisticClaimsReturn {
59+
const optimistic = useOptimisticState<ClaimBoard>();
60+
61+
// Confirm entries whose expected status is now reflected by the server.
62+
// Called by mergeWithOptimistic so the effect runs on every server refresh.
63+
const syncWithServer = useCallback(
64+
(serverClaims: ClaimBoard[]) => {
65+
optimistic.entries.forEach((entry, key) => {
66+
if (entry.status !== 'pending') return;
67+
const serverClaim = serverClaims.find((c) => c.claim_id === key);
68+
if (!serverClaim) return;
69+
const expectedStatus = entry.optimisticData.status;
70+
if (serverClaim.status === expectedStatus) {
71+
optimistic.confirm(key);
72+
setTimeout(() => optimistic.remove(key), 2_000);
73+
}
74+
});
75+
},
76+
// eslint-disable-next-line react-hooks/exhaustive-deps
77+
[],
78+
);
79+
80+
const applyOptimisticClaim = useCallback(
81+
(claim: ClaimBoard, txHash?: string) => {
82+
const optimisticClaim: ClaimBoard = { ...claim, status: 'Processing' };
83+
optimistic.apply(claim.claim_id, 'claim_filing', optimisticClaim, claim, txHash);
84+
},
85+
// eslint-disable-next-line react-hooks/exhaustive-deps
86+
[],
87+
);
88+
89+
const applyOptimisticVote = useCallback(
90+
(claim: ClaimBoard, vote: 'Approve' | 'Reject', txHash?: string) => {
91+
const optimisticClaim: ClaimBoard = {
92+
...claim,
93+
approve_votes: vote === 'Approve' ? claim.approve_votes + 1 : claim.approve_votes,
94+
reject_votes: vote === 'Reject' ? claim.reject_votes + 1 : claim.reject_votes,
95+
};
96+
optimistic.apply(claim.claim_id, 'vote_submission', optimisticClaim, claim, txHash);
97+
},
98+
// eslint-disable-next-line react-hooks/exhaustive-deps
99+
[],
100+
);
101+
102+
const mergeWithOptimistic = useCallback(
103+
(serverClaims: ClaimBoard[]): ClaimBoard[] => {
104+
syncWithServer(serverClaims);
105+
return serverClaims.map((c) => {
106+
const entry = optimistic.get(c.claim_id);
107+
if (!entry || entry.status === 'confirmed') return c;
108+
return entry.status === 'failed' ? entry.previousData : entry.optimisticData;
109+
});
110+
},
111+
// eslint-disable-next-line react-hooks/exhaustive-deps
112+
[optimistic.entries],
113+
);
114+
115+
const getOptimisticStatus = useCallback(
116+
(claimId: string) => {
117+
const entry = optimistic.get(claimId);
118+
if (!entry) return undefined;
119+
return { status: entry.status, error: entry.error };
120+
},
121+
// eslint-disable-next-line react-hooks/exhaustive-deps
122+
[optimistic.entries],
123+
);
124+
125+
return { applyOptimisticClaim, applyOptimisticVote, mergeWithOptimistic, getOptimisticStatus };
126+
}
127+
128+
// ---------------------------------------------------------------------------
129+
// Per-entry poller (headless component)
130+
// ---------------------------------------------------------------------------
131+
132+
export interface ClaimConfirmationPollerProps {
133+
claimId: string;
134+
expectedStatus: string;
135+
createdAt: number;
136+
enabled: boolean;
137+
onConfirmed: (key: string) => void;
138+
onRollback: (key: string, error: string) => void;
139+
}
140+
141+
export function ClaimConfirmationPoller({
142+
claimId,
143+
expectedStatus,
144+
createdAt,
145+
enabled,
146+
onConfirmed,
147+
onRollback,
148+
}: ClaimConfirmationPollerProps) {
149+
useConfirmationPoller({
150+
key: claimId,
151+
enabled,
152+
createdAt,
153+
check: (signal) => checkClaimStatus(claimId, expectedStatus, signal),
154+
onConfirmed,
155+
onRollback,
156+
});
157+
return null;
158+
}

frontend/src/features/policies/components/PolicyDashboard.tsx

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
44
import { useWallet } from '@/hooks/use-wallet';
55
import { useLatestLedger } from '@/hooks/use-latest-ledger';
66
import { getConfig } from '@/config/env';
7-
import { usePolicies } from '../hooks/usePolicies';
7+
import { useOptimisticPolicies, PolicyConfirmationPoller } from '../hooks/useOptimisticPolicies';
88
import { PolicyCard, PolicyRow } from './PolicyItem';
99
import { PolicyListSkeleton, PolicyEmptyState, PolicyErrorState } from './PolicyStates';
1010
import { RenewModal } from './RenewModal';
@@ -29,8 +29,8 @@ export function PolicyDashboard() {
2929
const [renewTarget, setRenewTarget] = useState<PolicyDto | null>(null);
3030
const [terminateTarget, setTerminateTarget] = useState<PolicyDto | null>(null);
3131

32-
const { policies, total, pageIndex, hasNextPage, hasPrevPage, loading, error, goToPage, retry } =
33-
usePolicies(address, network, status, sort);
32+
const { policies, total, pageIndex, hasNextPage, hasPrevPage, loading, error, goToPage, retry, applyOptimisticPolicy, mergedPolicies, entries: optimisticEntries, confirm: confirmOptimistic, rollback: rollbackOptimistic } =
33+
useOptimisticPolicies(address, network, status, sort);
3434

3535
const handleRenew = useCallback((policy: PolicyDto) => setRenewTarget(policy), []);
3636
const handleTerminate = useCallback((policy: PolicyDto) => setTerminateTarget(policy), []);
@@ -99,15 +99,20 @@ export function PolicyDashboard() {
9999
<PolicyEmptyState filter={status === 'all' ? 'all' : status} />
100100
) : layout === 'card' ? (
101101
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
102-
{policies.map((p) => (
103-
<PolicyCard
104-
key={`${p.holder}:${p.policy_id}`}
105-
policy={p}
106-
onRenew={handleRenew}
107-
onTerminate={handleTerminate}
108-
currentLedger={currentLedger}
109-
/>
110-
))}
102+
{mergedPolicies.map((p) => {
103+
const entry = optimisticEntries.get(String(p.policy_id));
104+
return (
105+
<PolicyCard
106+
key={`${p.holder}:${p.policy_id}`}
107+
policy={p}
108+
onRenew={handleRenew}
109+
onTerminate={handleTerminate}
110+
currentLedger={currentLedger}
111+
optimisticStatus={entry?.status}
112+
optimisticError={entry?.error}
113+
/>
114+
);
115+
})}
111116
</div>
112117
) : (
113118
<div className="overflow-x-auto rounded-lg border border-gray-200">
@@ -123,15 +128,20 @@ export function PolicyDashboard() {
123128
</tr>
124129
</thead>
125130
<tbody>
126-
{policies.map((p) => (
127-
<PolicyRow
128-
key={`${p.holder}:${p.policy_id}`}
129-
policy={p}
130-
onRenew={handleRenew}
131-
onTerminate={handleTerminate}
132-
currentLedger={currentLedger}
133-
/>
134-
))}
131+
{mergedPolicies.map((p) => {
132+
const entry = optimisticEntries.get(String(p.policy_id));
133+
return (
134+
<PolicyRow
135+
key={`${p.holder}:${p.policy_id}`}
136+
policy={p}
137+
onRenew={handleRenew}
138+
onTerminate={handleTerminate}
139+
currentLedger={currentLedger}
140+
optimisticStatus={entry?.status}
141+
optimisticError={entry?.error}
142+
/>
143+
);
144+
})}
135145
</tbody>
136146
</table>
137147
</div>
@@ -166,11 +176,41 @@ export function PolicyDashboard() {
166176

167177
{/* ── Action modals ───────────────────────────────────────────── */}
168178
{renewTarget && (
169-
<RenewModal policy={renewTarget} onClose={() => setRenewTarget(null)} />
179+
<RenewModal
180+
policy={renewTarget}
181+
onClose={() => setRenewTarget(null)}
182+
onSubmitted={(txHash) => {
183+
applyOptimisticPolicy(renewTarget, txHash);
184+
setRenewTarget(null);
185+
}}
186+
/>
170187
)}
171188
{terminateTarget && (
172-
<TerminateModal policy={terminateTarget} onClose={() => setTerminateTarget(null)} />
189+
<TerminateModal
190+
policy={terminateTarget}
191+
onClose={() => setTerminateTarget(null)}
192+
onSubmitted={(txHash) => {
193+
applyOptimisticPolicy(terminateTarget, txHash);
194+
setTerminateTarget(null);
195+
}}
196+
/>
173197
)}
198+
199+
{/* Headless confirmation pollers — one per pending optimistic entry */}
200+
{address && Array.from(optimisticEntries.values())
201+
.filter((e) => e.status === 'pending')
202+
.map((e) => (
203+
<PolicyConfirmationPoller
204+
key={e.key}
205+
holder={address}
206+
policyId={Number(e.key)}
207+
createdAt={e.createdAt}
208+
enabled
209+
onConfirmed={confirmOptimistic}
210+
onRollback={rollbackOptimistic}
211+
/>
212+
))
213+
}
174214
</section>
175215
);
176216
}

frontend/src/features/policies/components/PolicyItem.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import Link from 'next/link';
44
import { SECS_PER_LEDGER } from '@/lib/schemas/vote';
5+
import { PendingBadge } from '@/components/ui/PendingBadge';
6+
import type { OptimisticStatus } from '@/lib/optimistic';
57
import type { PolicyDto } from '../api';
68

79
/** Format stroops → locale-aware XLM string (7 decimals). */
@@ -26,13 +28,15 @@ interface PolicyCardProps {
2628
onRenew: (policy: PolicyDto) => void;
2729
onTerminate: (policy: PolicyDto) => void;
2830
currentLedger: number | null;
31+
optimisticStatus?: OptimisticStatus;
32+
optimisticError?: string;
2933
}
3034

3135
/**
3236
* Card layout — used on mobile and when the user selects card view.
3337
* Actions are disabled with tooltip text when contract rules forbid them.
3438
*/
35-
export function PolicyCard({ policy, onRenew, onTerminate, currentLedger }: PolicyCardProps) {
39+
export function PolicyCard({ policy, onRenew, onTerminate, currentLedger, optimisticStatus, optimisticError }: PolicyCardProps) {
3640
const { coverage_summary: cs, expiry_countdown: ec } = policy;
3741

3842
// Renewal gate: policy must be active and within 30 days (~518_400 ledgers) of expiry
@@ -80,6 +84,9 @@ export function PolicyCard({ policy, onRenew, onTerminate, currentLedger }: Poli
8084
>
8185
{statusLabel}
8286
</span>
87+
{optimisticStatus && optimisticStatus !== 'confirmed' && (
88+
<PendingBadge status={optimisticStatus} error={optimisticError} />
89+
)}
8390
</div>
8491

8592
{/* Amounts */}
@@ -142,7 +149,7 @@ export function PolicyCard({ policy, onRenew, onTerminate, currentLedger }: Poli
142149
/**
143150
* Row layout — used in table view on desktop.
144151
*/
145-
export function PolicyRow({ policy, onRenew, onTerminate, currentLedger }: PolicyCardProps) {
152+
export function PolicyRow({ policy, onRenew, onTerminate, currentLedger, optimisticStatus, optimisticError }: PolicyCardProps) {
146153
const { coverage_summary: cs, expiry_countdown: ec } = policy;
147154

148155
const RENEWAL_WINDOW_LEDGERS = 518_400;
@@ -183,6 +190,9 @@ export function PolicyRow({ policy, onRenew, onTerminate, currentLedger }: Polic
183190
>
184191
{statusLabel}
185192
</span>
193+
{optimisticStatus && optimisticStatus !== 'confirmed' && (
194+
<PendingBadge status={optimisticStatus} error={optimisticError} />
195+
)}
186196
</td>
187197
<td className="px-4 py-3 text-sm text-right tabular-nums">
188198
{formatXlm(cs.coverage_amount)} {cs.currency}

0 commit comments

Comments
 (0)