Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
556 changes: 102 additions & 454 deletions src/app/[locale]/lend/LendPageClient.tsx

Large diffs are not rendered by default.

485 changes: 57 additions & 428 deletions src/app/components/loan-wizard/StepFinalSignature.tsx

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions src/app/components/transaction/TransactionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client';

import { TransactionLifecycle } from '@/app/types/transaction';
import { getStatusLabel, getStatusColor, getStatusIcon, formatTransactionError, getExplorerLink } from '@/app/utils/transactionFormatter';
import { Button } from '@/app/components/global_ui/Button'; // Assuming this exists or use standard button

interface TransactionStatusProps {
lifecycle: TransactionLifecycle;
onRetry: () => void;
onReset: () => void;
network?: 'testnet' | 'public';
}

export function TransactionStatus({ lifecycle, onRetry, onReset, network = 'testnet' }: TransactionStatusProps) {
const { state, error, txHash } = lifecycle;

if (state === 'idle') return null;

return (
<div className="rounded-lg border p-4 space-y-3 bg-white dark:bg-gray-900">
{/* Status Header */}
<div className="flex items-center gap-3">
<span className="text-2xl">{getStatusIcon(state)}</span>
<div className="flex-1">
<p className={`font-medium ${getStatusColor(state)}`}>
{getStatusLabel(state)}
</p>
{txHash && state !== 'success' && (
<p className="text-xs text-gray-500 truncate">
Hash: {txHash.slice(0, 8)}...{txHash.slice(-8)}
</p>
)}
</div>
</div>

{/* Progress Indicator */}
{state !== 'error' && state !== 'success' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-500"
style={{
width: state === 'building' ? '20%' :
state === 'awaiting-signature' ? '40%' :
state === 'submitting' ? '60%' :
state === 'confirming' ? '80%' : '0%'
}}
/>
</div>
)}

{/* Error Display */}
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-3 space-y-2">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
{formatTransactionError(error).title}
</p>
<p className="text-sm text-red-700 dark:text-red-300">
{formatTransactionError(error).description}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
💡 {formatTransactionError(error).action}
</p>
</div>
)}

{/* Success Display */}
{state === 'success' && txHash && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 p-3">
<p className="text-sm text-green-800 dark:text-green-200">
Transaction confirmed successfully!
</p>
<a
href={getExplorerLink(txHash, network)}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline mt-1 inline-block"
>
View on Stellar Expert →
</a>
</div>
)}

{/* Actions */}
<div className="flex gap-2 pt-2">
{state === 'error' && formatTransactionError(error!).canRetry && (
<Button onClick={onRetry} variant="primary">
Retry Transaction
</Button>
)}
{(state === 'success' || state === 'error') && (
<Button onClick={onReset} variant="secondary">
{state === 'success' ? 'Done' : 'Cancel'}
</Button>
)}
</div>
</div>
);
}
115 changes: 115 additions & 0 deletions src/app/hooks/__tests__/useTransactionLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { renderHook, act } from "@testing-library/react";
import { useTransactionLifecycle } from "../useTransactionLifecycle";

describe("useTransactionLifecycle", () => {
it("starts in idle state", () => {
const { result } = renderHook(() => useTransactionLifecycle());
expect(result.current.lifecycle.state).toBe("idle");
expect(result.current.canSubmit).toBe(true);
expect(result.current.isProcessing).toBe(false);
});

it("transitions through full success flow", async () => {
const { result } = renderHook(() => useTransactionLifecycle());

act(() => {
result.current.transition({
type: "START_BUILD",
context: { operation: "test" },
});
});
expect(result.current.lifecycle.state).toBe("building");

act(() => result.current.transition({ type: "BUILD_SUCCESS" }));
expect(result.current.lifecycle.state).toBe("awaiting-signature");

act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" }));
expect(result.current.lifecycle.state).toBe("submitting");

act(() => result.current.transition({ type: "SUBMIT_SUCCESS", txHash: "abc123" }));
expect(result.current.lifecycle.state).toBe("confirming");
expect(result.current.lifecycle.txHash).toBe("abc123");

act(() => result.current.transition({ type: "CONFIRM_SUCCESS" }));
expect(result.current.lifecycle.state).toBe("success");
expect(result.current.isSuccess).toBe(true);
});

it("prevents double-submit via idempotency lock", () => {
const { result } = renderHook(() => useTransactionLifecycle());

act(() => {
result.current.transition({
type: "START_BUILD",
context: { operation: "test" },
});
});
act(() => result.current.transition({ type: "BUILD_SUCCESS" }));
act(() => result.current.transition({ type: "SIGNATURE_SUCCESS" }));
act(() => result.current.transition({ type: "SUBMIT" }));

// Second submit should be ignored
act(() => result.current.transition({ type: "SUBMIT" }));
expect(result.current.lifecycle.state).toBe("submitting");
});

it("handles errors and allows retry", () => {
const { result } = renderHook(() => useTransactionLifecycle());

act(() => {
result.current.transition({
type: "START_BUILD",
context: { operation: "test" },
});
});
act(() =>
result.current.transition({
type: "ERROR",
error: new Error("User declined"),
}),
);

expect(result.current.lifecycle.state).toBe("error");
expect(result.current.lifecycle.error?.code).toBe("USER_REJECTED");
expect(result.current.lifecycle.error?.retryable).toBe(true);

act(() => result.current.transition({ type: "RETRY" }));
expect(result.current.lifecycle.state).toBe("building");
expect(result.current.lifecycle.error).toBeNull();
});

it("maps contract errors as non-retryable", () => {
const { result } = renderHook(() => useTransactionLifecycle());

act(() => {
result.current.transition({
type: "START_BUILD",
context: { operation: "test" },
});
});
act(() =>
result.current.transition({
type: "ERROR",
error: new Error("invoke_host_function failed"),
}),
);

expect(result.current.lifecycle.error?.code).toBe("CONTRACT_ERROR");
expect(result.current.lifecycle.error?.retryable).toBe(false);
});

it("resets to idle", () => {
const { result } = renderHook(() => useTransactionLifecycle());

act(() => {
result.current.transition({
type: "START_BUILD",
context: { operation: "test" },
});
});
act(() => result.current.transition({ type: "RESET" }));

expect(result.current.lifecycle.state).toBe("idle");
expect(result.current.canSubmit).toBe(true);
});
});
112 changes: 30 additions & 82 deletions src/app/hooks/useConfirmedMutation.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,38 @@
"use client";
'use client';

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

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

/**
* Wraps any async mutation with a confirmation dialog flow.
*
* Usage:
* ```tsx
* const { dialogProps, trigger, isLoading } = useConfirmedMutation(
* (vars) => approveLoanMutation.mutateAsync(vars),
* {
* title: "Approve Loan",
* buildSummary: (vars) => [
* { label: "Loan ID", value: String(vars.loanId) },
* { label: "Amount", value: `${vars.amount} USDC` },
* ],
* },
* );
*
* // Render the dialog using dialogProps, trigger on button click:
* <button onClick={() => trigger(variables)}>Approve</button>
* <ConfirmTransactionDialog {...dialogProps} />
* ```
*/
export function useConfirmedMutation<TVariables>(
action: (variables: TVariables) => Promise<unknown>,
options: ConfirmedMutationOptions<TVariables> = {},
export function useConfirmedMutation<TData = unknown, TVariables = unknown>(
options: ConfirmedMutationOptions<TData, TVariables>
) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [pendingVariables, setPendingVariables] = useState<TVariables | null>(null);
const [summary, setSummary] = useState<TransactionSummaryItem[]>([]);
const queryClient = useQueryClient();
const { confirm } = useConfirmation(); // Existing confirmation hook

const trigger = useCallback(
(variables: TVariables) => {
setPendingVariables(variables);
setSummary(options.buildSummary ? options.buildSummary(variables) : []);
setIsOpen(true);
const mutation = useContractMutation({
...options,
confirmTx: async (hash: string) => {
// Use existing confirmation hook with unified timeout
return confirm(hash, { timeout: 30000, pollingInterval: 2000 });
},
[options],
);

const handleConfirm = useCallback(async () => {
if (pendingVariables === null) return;
setIsLoading(true);
try {
await action(pendingVariables);
} finally {
setIsLoading(false);
setIsOpen(false);
setPendingVariables(null);
}
}, [action, pendingVariables]);

const handleClose = useCallback(() => {
if (isLoading) return; // block dismiss while tx is in-flight
setIsOpen(false);
setPendingVariables(null);
}, [isLoading]);

return {
/** Spread onto <ConfirmTransactionDialog> */
dialogProps: {
isOpen,
onClose: handleClose,
onConfirm: handleConfirm,
title: options.title,
description: options.description,
confirmLabel: options.confirmLabel,
summary,
isLoading,
onSuccess: (data, variables) => {
// Invalidate relevant queries
options.queryKeys.forEach((key) => {
queryClient.invalidateQueries({ queryKey: [key] });
});
options.onSuccess?.(data, variables);
},
/** Call with mutation variables to open the dialog */
trigger,
isLoading,
};
}
});

return mutation;
}
Loading