Skip to content

Commit e162c52

Browse files
authored
Merge pull request #35 from Peolite001/feature/ROADMAP-122-unify-transaction-lifecycle
feat(ROADMAP-122): unify transaction lifecycle UX across all flows
2 parents 44976c4 + 1e2afe3 commit e162c52

12 files changed

Lines changed: 1010 additions & 1381 deletions

src/app/[locale]/lend/LendPageClient.tsx

Lines changed: 102 additions & 454 deletions
Large diffs are not rendered by default.

src/app/components/loan-wizard/StepFinalSignature.tsx

Lines changed: 57 additions & 428 deletions
Large diffs are not rendered by default.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client';
2+
3+
import { TransactionLifecycle } from '@/app/types/transaction';
4+
import { getStatusLabel, getStatusColor, getStatusIcon, formatTransactionError, getExplorerLink } from '@/app/utils/transactionFormatter';
5+
import { Button } from '@/app/components/global_ui/Button'; // Assuming this exists or use standard button
6+
7+
interface TransactionStatusProps {
8+
lifecycle: TransactionLifecycle;
9+
onRetry: () => void;
10+
onReset: () => void;
11+
network?: 'testnet' | 'public';
12+
}
13+
14+
export function TransactionStatus({ lifecycle, onRetry, onReset, network = 'testnet' }: TransactionStatusProps) {
15+
const { state, error, txHash } = lifecycle;
16+
17+
if (state === 'idle') return null;
18+
19+
return (
20+
<div className="rounded-lg border p-4 space-y-3 bg-white dark:bg-gray-900">
21+
{/* Status Header */}
22+
<div className="flex items-center gap-3">
23+
<span className="text-2xl">{getStatusIcon(state)}</span>
24+
<div className="flex-1">
25+
<p className={`font-medium ${getStatusColor(state)}`}>
26+
{getStatusLabel(state)}
27+
</p>
28+
{txHash && state !== 'success' && (
29+
<p className="text-xs text-gray-500 truncate">
30+
Hash: {txHash.slice(0, 8)}...{txHash.slice(-8)}
31+
</p>
32+
)}
33+
</div>
34+
</div>
35+
36+
{/* Progress Indicator */}
37+
{state !== 'error' && state !== 'success' && (
38+
<div className="w-full bg-gray-200 rounded-full h-2">
39+
<div
40+
className="bg-blue-600 h-2 rounded-full transition-all duration-500"
41+
style={{
42+
width: state === 'building' ? '20%' :
43+
state === 'awaiting-signature' ? '40%' :
44+
state === 'submitting' ? '60%' :
45+
state === 'confirming' ? '80%' : '0%'
46+
}}
47+
/>
48+
</div>
49+
)}
50+
51+
{/* Error Display */}
52+
{error && (
53+
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-3 space-y-2">
54+
<p className="text-sm font-medium text-red-800 dark:text-red-200">
55+
{formatTransactionError(error).title}
56+
</p>
57+
<p className="text-sm text-red-700 dark:text-red-300">
58+
{formatTransactionError(error).description}
59+
</p>
60+
<p className="text-sm text-red-600 dark:text-red-400">
61+
💡 {formatTransactionError(error).action}
62+
</p>
63+
</div>
64+
)}
65+
66+
{/* Success Display */}
67+
{state === 'success' && txHash && (
68+
<div className="rounded-md bg-green-50 dark:bg-green-900/20 p-3">
69+
<p className="text-sm text-green-800 dark:text-green-200">
70+
Transaction confirmed successfully!
71+
</p>
72+
<a
73+
href={getExplorerLink(txHash, network)}
74+
target="_blank"
75+
rel="noopener noreferrer"
76+
className="text-sm text-blue-600 hover:underline mt-1 inline-block"
77+
>
78+
View on Stellar Expert →
79+
</a>
80+
</div>
81+
)}
82+
83+
{/* Actions */}
84+
<div className="flex gap-2 pt-2">
85+
{state === 'error' && formatTransactionError(error!).canRetry && (
86+
<Button onClick={onRetry} variant="primary">
87+
Retry Transaction
88+
</Button>
89+
)}
90+
{(state === 'success' || state === 'error') && (
91+
<Button onClick={onReset} variant="secondary">
92+
{state === 'success' ? 'Done' : 'Cancel'}
93+
</Button>
94+
)}
95+
</div>
96+
</div>
97+
);
98+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { renderHook, act } from "@testing-library/react";
2+
import { useTransactionLifecycle } from "../useTransactionLifecycle";
3+
4+
describe("useTransactionLifecycle", () => {
5+
it("starts in idle state", () => {
6+
const { result } = renderHook(() => useTransactionLifecycle());
7+
expect(result.current.lifecycle.state).toBe("idle");
8+
expect(result.current.canSubmit).toBe(true);
9+
expect(result.current.isProcessing).toBe(false);
10+
});
11+
12+
it("transitions through full success flow", async () => {
13+
const { result } = renderHook(() => useTransactionLifecycle());
14+
15+
act(() => {
16+
result.current.transition({
17+
type: "START_BUILD",
18+
context: { operation: "test" },
19+
});
20+
});
21+
expect(result.current.lifecycle.state).toBe("building");
22+
23+
act(() => result.current.transition({ type: "BUILD_SUCCESS" }));
24+
expect(result.current.lifecycle.state).toBe("awaiting-signature");
25+
26+
act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" }));
27+
expect(result.current.lifecycle.state).toBe("submitting");
28+
29+
act(() => result.current.transition({ type: "SUBMIT_SUCCESS", txHash: "abc123" }));
30+
expect(result.current.lifecycle.state).toBe("confirming");
31+
expect(result.current.lifecycle.txHash).toBe("abc123");
32+
33+
act(() => result.current.transition({ type: "CONFIRM_SUCCESS" }));
34+
expect(result.current.lifecycle.state).toBe("success");
35+
expect(result.current.isSuccess).toBe(true);
36+
});
37+
38+
it("prevents double-submit via idempotency lock", () => {
39+
const { result } = renderHook(() => useTransactionLifecycle());
40+
41+
act(() => {
42+
result.current.transition({
43+
type: "START_BUILD",
44+
context: { operation: "test" },
45+
});
46+
});
47+
act(() => result.current.transition({ type: "BUILD_SUCCESS" }));
48+
act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" }));
49+
act(() => result.current.transition({ type: "SUBMIT" }));
50+
51+
// Second submit should be ignored
52+
act(() => result.current.transition({ type: "SUBMIT" }));
53+
expect(result.current.lifecycle.state).toBe("submitting");
54+
});
55+
56+
it("handles errors and allows retry", () => {
57+
const { result } = renderHook(() => useTransactionLifecycle());
58+
59+
act(() => {
60+
result.current.transition({
61+
type: "START_BUILD",
62+
context: { operation: "test" },
63+
});
64+
});
65+
act(() =>
66+
result.current.transition({
67+
type: "ERROR",
68+
error: new Error("User declined"),
69+
}),
70+
);
71+
72+
expect(result.current.lifecycle.state).toBe("error");
73+
expect(result.current.lifecycle.error?.code).toBe("USER_REJECTED");
74+
expect(result.current.lifecycle.error?.retryable).toBe(true);
75+
76+
act(() => result.current.transition({ type: "RETRY" }));
77+
expect(result.current.lifecycle.state).toBe("building");
78+
expect(result.current.lifecycle.error).toBeNull();
79+
});
80+
81+
it("maps contract errors as non-retryable", () => {
82+
const { result } = renderHook(() => useTransactionLifecycle());
83+
84+
act(() => {
85+
result.current.transition({
86+
type: "START_BUILD",
87+
context: { operation: "test" },
88+
});
89+
});
90+
act(() =>
91+
result.current.transition({
92+
type: "ERROR",
93+
error: new Error("invoke_host_function failed"),
94+
}),
95+
);
96+
97+
expect(result.current.lifecycle.error?.code).toBe("CONTRACT_ERROR");
98+
expect(result.current.lifecycle.error?.retryable).toBe(false);
99+
});
100+
101+
it("resets to idle", () => {
102+
const { result } = renderHook(() => useTransactionLifecycle());
103+
104+
act(() => {
105+
result.current.transition({
106+
type: "START_BUILD",
107+
context: { operation: "test" },
108+
});
109+
});
110+
act(() => result.current.transition({ type: "RESET" }));
111+
112+
expect(result.current.lifecycle.state).toBe("idle");
113+
expect(result.current.canSubmit).toBe(true);
114+
});
115+
});
Lines changed: 30 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,38 @@
1-
"use client";
1+
'use client';
22

3-
import { useState, useCallback } from "react";
4-
import type { TransactionSummaryItem } from "../components/ui/ConfirmTransactionDialog";
3+
import { useQueryClient } from '@tanstack/react-query';
4+
import { useContractMutation } from './useContractMutation';
5+
import { useConfirmation } from './useConfirmation'; // Existing hook
56

6-
interface ConfirmedMutationOptions<TVariables> {
7-
/** Build the summary rows from the mutation variables. */
8-
buildSummary?: (variables: TVariables) => TransactionSummaryItem[];
9-
/** Dialog title. */
10-
title?: string;
11-
/** Dialog description / warning text. */
12-
description?: string;
13-
/** Label for the confirm button. */
14-
confirmLabel?: string;
7+
interface ConfirmedMutationOptions<TData, TVariables> {
8+
operation: string;
9+
buildTx: (variables: TVariables) => Promise<string>;
10+
signTx: (xdr: string) => Promise<string>;
11+
submitTx: (signedXdr: string) => Promise<{ hash: string }>;
12+
queryKeys: string[]; // Queries to invalidate on success
13+
onSuccess?: (data: TData, variables: TVariables) => void;
1514
}
1615

17-
/**
18-
* Wraps any async mutation with a confirmation dialog flow.
19-
*
20-
* Usage:
21-
* ```tsx
22-
* const { dialogProps, trigger, isLoading } = useConfirmedMutation(
23-
* (vars) => approveLoanMutation.mutateAsync(vars),
24-
* {
25-
* title: "Approve Loan",
26-
* buildSummary: (vars) => [
27-
* { label: "Loan ID", value: String(vars.loanId) },
28-
* { label: "Amount", value: `${vars.amount} USDC` },
29-
* ],
30-
* },
31-
* );
32-
*
33-
* // Render the dialog using dialogProps, trigger on button click:
34-
* <button onClick={() => trigger(variables)}>Approve</button>
35-
* <ConfirmTransactionDialog {...dialogProps} />
36-
* ```
37-
*/
38-
export function useConfirmedMutation<TVariables>(
39-
action: (variables: TVariables) => Promise<unknown>,
40-
options: ConfirmedMutationOptions<TVariables> = {},
16+
export function useConfirmedMutation<TData = unknown, TVariables = unknown>(
17+
options: ConfirmedMutationOptions<TData, TVariables>
4118
) {
42-
const [isOpen, setIsOpen] = useState(false);
43-
const [isLoading, setIsLoading] = useState(false);
44-
const [pendingVariables, setPendingVariables] = useState<TVariables | null>(null);
45-
const [summary, setSummary] = useState<TransactionSummaryItem[]>([]);
19+
const queryClient = useQueryClient();
20+
const { confirm } = useConfirmation(); // Existing confirmation hook
4621

47-
const trigger = useCallback(
48-
(variables: TVariables) => {
49-
setPendingVariables(variables);
50-
setSummary(options.buildSummary ? options.buildSummary(variables) : []);
51-
setIsOpen(true);
22+
const mutation = useContractMutation({
23+
...options,
24+
confirmTx: async (hash: string) => {
25+
// Use existing confirmation hook with unified timeout
26+
return confirm(hash, { timeout: 30000, pollingInterval: 2000 });
5227
},
53-
[options],
54-
);
55-
56-
const handleConfirm = useCallback(async () => {
57-
if (pendingVariables === null) return;
58-
setIsLoading(true);
59-
try {
60-
await action(pendingVariables);
61-
} finally {
62-
setIsLoading(false);
63-
setIsOpen(false);
64-
setPendingVariables(null);
65-
}
66-
}, [action, pendingVariables]);
67-
68-
const handleClose = useCallback(() => {
69-
if (isLoading) return; // block dismiss while tx is in-flight
70-
setIsOpen(false);
71-
setPendingVariables(null);
72-
}, [isLoading]);
73-
74-
return {
75-
/** Spread onto <ConfirmTransactionDialog> */
76-
dialogProps: {
77-
isOpen,
78-
onClose: handleClose,
79-
onConfirm: handleConfirm,
80-
title: options.title,
81-
description: options.description,
82-
confirmLabel: options.confirmLabel,
83-
summary,
84-
isLoading,
28+
onSuccess: (data, variables) => {
29+
// Invalidate relevant queries
30+
options.queryKeys.forEach((key) => {
31+
queryClient.invalidateQueries({ queryKey: [key] });
32+
});
33+
options.onSuccess?.(data, variables);
8534
},
86-
/** Call with mutation variables to open the dialog */
87-
trigger,
88-
isLoading,
89-
};
90-
}
35+
});
36+
37+
return mutation;
38+
}

0 commit comments

Comments
 (0)