diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index 14e47629..b4713897 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -28,6 +28,9 @@ import type { OhlcBar } from "../../lib/oracle" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { formatUsd } from "@/shared/lib/format" import { ENV } from "@/app/config/env" +import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts" +import type { OhlcBar } from "../../lib/oracle" +import { useWalletStore } from "@/features/wallet/store/wallet-store" type ChartLine = { id: string diff --git a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx index a5cded55..79d6acd8 100644 --- a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx +++ b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx @@ -217,7 +217,7 @@ export function DepthLadder({ symbol, compact = false }: Props) { // OB-054: Determine reference price — prefer last trade, fall back to mid price const lastTradePrice = trades.length > 0 ? trades[0].price : null const referencePrice = lastTradePrice ?? midPrice - const referencePriceLabel = lastTradePrice !== null ? "Last" : midPrice !== null ? "Mid" : "Mark" + const referencePriceLabel = lastTradePrice ? "Last" : midPrice ? "Mid" : "Mark" // OB-056: Cumulative depth inspection state const [hoveredLevel, setHoveredLevel] = useState<{ level: OrderBookLevel; side: "bid" | "ask" } | null>(null) diff --git a/apps/web/src/shared/hooks/useTxFeedback.tsx b/apps/web/src/shared/hooks/useTxFeedback.tsx new file mode 100644 index 00000000..278148b3 --- /dev/null +++ b/apps/web/src/shared/hooks/useTxFeedback.tsx @@ -0,0 +1,176 @@ +import { useCallback, useRef } from "react" +import { toast } from "@workspace/ui/components/toast" +import { explorerTxUrl } from "@/app/config/network" + +// #732: Explicit lifecycle events for transaction feedback without English message matching +export type TxFeedbackEvent = + | { type: "signing"; message: string } + | { type: "signed" } + | { type: "submitting"; message: string } + | { type: "submitted"; hash: string; description?: string } + | { type: "confirming" } + | { type: "confirmed"; hash: string } + | { type: "ordering"; message: string } + | { type: "order_settled"; message: string; description?: string } + | { type: "error"; message: string } + | { type: "unknown_outcome"; hash?: string } + +// #732: Each attempt gets its own stable toast ID; concurrent transactions don't interfere +type TxAttempt = { + readonly attemptId: string + readonly createdAt: number + toastId: string | null + status: "pending" | "signed" | "submitted" | "confirmed" | "settled" | "failed" + hash: string | null +} + +export function useTxFeedback() { + const attemptRef = useRef(null) + + const createAttempt = useCallback((attemptId: string): TxAttempt => { + const attempt: TxAttempt = { + attemptId, + createdAt: Date.now(), + toastId: null, + status: "pending", + hash: null, + } + attemptRef.current = attempt + return attempt + }, []) + + const handleEvent = useCallback((event: TxFeedbackEvent) => { + const attempt = attemptRef.current + if (!attempt) return + + switch (event.type) { + case "signing": + attempt.toastId = toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + break + + case "signed": + // Signing complete, ready for submission + break + + case "submitting": + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "submitted": + attempt.hash = event.hash + attempt.status = "submitted" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Confirming on ledger...", + variant: "transaction-progress", + duration: 0, + persistent: true, + description: event.description, + }) + } + break + + case "confirming": + // Ledger confirmation in progress + break + + case "confirmed": + attempt.hash = event.hash + attempt.status = "confirmed" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Transaction confirmed", + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "ordering": + // For order settlement flow + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "order_settled": + attempt.status = "settled" + if (attempt.toastId) { + const explorerLink = attempt.hash + ? `View transaction →` + : null + + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "success", + duration: 4000, + description: event.description || explorerLink, + }) + } + break + + case "error": + attempt.status = "failed" + if (attempt.toastId) { + toast.error(event.message, { id: attempt.attemptId }) + } else { + toast.error(event.message) + } + break + + case "unknown_outcome": + // #733: Unknown submission outcome — status should be looked up by hash if available + attempt.status = "submitted" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Submission status unknown", + description: "Checking ledger...", + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + } + }, []) + + const getCurrentAttempt = useCallback((): TxAttempt | null => { + return attemptRef.current + }, []) + + const clearAttempt = useCallback(() => { + attemptRef.current = null + }, []) + + return { + createAttempt, + handleEvent, + getCurrentAttempt, + clearAttempt, + } +} diff --git a/apps/web/src/shared/hooks/useTxSubmit.tsx b/apps/web/src/shared/hooks/useTxSubmit.tsx index 11ebc073..7dff23a0 100644 --- a/apps/web/src/shared/hooks/useTxSubmit.tsx +++ b/apps/web/src/shared/hooks/useTxSubmit.tsx @@ -1,7 +1,8 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useRef } from "react" import { toast } from "@workspace/ui/components/toast" import { sendAndPoll } from "@/lib/tx-builder" import { explorerTxUrl } from "@/app/config/network" +import { useTxFeedback } from "./useTxFeedback" export type SubmitTxOptions = { loadingMessage: string @@ -79,13 +80,25 @@ export function useTxSubmit() { txHash: null, error: null, }) + const feedback = useTxFeedback() + // #733: Track attempt identity to detect and prevent duplicate submissions + const attemptIdRef = useRef(null) + // #733: Guard against double-click, Enter+click, component rerender triggering multiple submissions + const submitInProgressRef = useRef(false) const submit = useCallback( async (getSignedXdr: () => Promise, options: SubmitTxOptions): Promise => { + // #733: Prevent duplicate submission (double-click, accidental Enter+click, rerender) + if (submitInProgressRef.current) { + throw new Error("Submission already in progress") + } + submitInProgressRef.current = true + setState({ isLoading: true, txHash: null, error: null }) try { const hash = await submitTx(getSignedXdr, options) setState({ isLoading: false, txHash: hash, error: null }) + submitInProgressRef.current = false return hash } catch (error) { setState({ @@ -93,16 +106,95 @@ export function useTxSubmit() { txHash: null, error: error instanceof Error ? error.message : "Transaction failed", }) + submitInProgressRef.current = false throw error } }, [], ) + // #732: Extended submit that integrates with transaction feedback controller + const submitWithFeedback = useCallback( + async (getSignedXdr: () => Promise, options: SubmitTxOptions): Promise => { + const attemptId = `txn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + attemptIdRef.current = attemptId + + const attempt = feedback.createAttempt(attemptId) + + // #733: Prevent duplicate submission with attempt tracking + if (submitInProgressRef.current) { + feedback.handleEvent({ + type: "error", + message: "Transaction already being submitted. Please wait.", + }) + throw new Error("Submission already in progress") + } + submitInProgressRef.current = true + + try { + feedback.handleEvent({ type: "signing", message: options.loadingMessage }) + + const hash = options.execute + ? await options.execute() + : await (async () => { + const signedXdr = await getSignedXdr() + feedback.handleEvent({ type: "submitting", message: "Submitting transaction..." }) + + try { + const response = await sendAndPoll(signedXdr) + return response.hash + } catch (submitError) { + // #733: Unknown submission outcome — signal for status lookup + const errorMsg = submitError instanceof Error ? submitError.message : "Submission failed" + if (errorMsg.includes("unknown")) { + feedback.handleEvent({ type: "unknown_outcome" }) + } + throw submitError + } + })() + + feedback.handleEvent({ + type: "submitted", + hash, + description: options.successDescription?.(hash), + }) + + await options.onSuccess?.(hash) + + feedback.handleEvent({ + type: "order_settled", + message: options.successMessage, + description: options.successDescription?.(hash), + }) + + setState({ isLoading: false, txHash: hash, error: null }) + submitInProgressRef.current = false + return hash + } catch (error) { + const message = options.onError?.(error) ?? "Transaction failed" + feedback.handleEvent({ type: "error", message }) + setState({ + isLoading: false, + txHash: null, + error: message, + }) + submitInProgressRef.current = false + throw error + } finally { + // #733: Keep attempt ID for status lookup in case of unknown outcomes + // Do NOT clear immediately — let status lookup use it if needed + } + }, + [feedback], + ) + return { submit, + // #732 & #733: Extended submit with transaction feedback controller + submitWithFeedback, isLoading: state.isLoading, txHash: state.txHash, error: state.error, + feedback, } }