diff --git a/web/app/admin/disputes/page.tsx b/web/app/admin/disputes/page.tsx index 39afbf1e..1dcab668 100644 --- a/web/app/admin/disputes/page.tsx +++ b/web/app/admin/disputes/page.tsx @@ -1,45 +1,62 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import Navbar from '@/components/Navbar'; -import AuthGuard from '@/components/AuthGuard'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { useToast } from '@/app/providers/ToastProvider'; -import { predinexReadApi } from '@/app/lib/adapters/predinex-read-api'; -import { predinexContract } from '@/app/lib/adapters/predinex-contract'; -import { getPoolAdminActivityFromSoroban } from '@/app/lib/soroban-event-service'; -import { getRuntimeConfig } from '@/app/lib/runtime-config'; -import { TxStage } from '@/app/lib/soroban-transaction-service'; -import { ProcessedMarket } from '@/app/lib/market-types'; -import type { ActivityItem } from '@/app/lib/adapters/types'; +import { useEffect, useState } from "react"; +import Navbar from "@/components/Navbar"; +import AuthGuard from "@/components/AuthGuard"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { useToast } from "@/app/providers/ToastProvider"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; +import { predinexReadApi } from "@/app/lib/adapters/predinex-read-api"; +import { predinexContract } from "@/app/lib/adapters/predinex-contract"; +import { getPoolAdminActivityFromSoroban } from "@/app/lib/soroban-event-service"; +import { getRuntimeConfig } from "@/app/lib/runtime-config"; +import { TxStage } from "@/app/lib/soroban-transaction-service"; +import { ProcessedMarket } from "@/app/lib/market-types"; +import type { ActivityItem } from "@/app/lib/adapters/types"; import { getPoolCountFromSoroban, getPoolsBatchFromSoroban, -} from '@/app/lib/soroban-read-api'; -import { fetchCurrentBlockHeightLive } from '@/app/lib/market-utils'; -import { Loader2, AlertTriangle, Snowflake, CheckCircle, Clock } from 'lucide-react'; -import RouteErrorBoundary from '@/components/RouteErrorBoundary'; -import { TransactionFeeModal } from '@/components/TransactionFeeModal'; -import MarketCard from '@/components/MarketCard'; -import { formatRelativeTime } from '@/app/lib/formatting'; +} from "@/app/lib/soroban-read-api"; +import { fetchCurrentBlockHeightLive } from "@/app/lib/market-utils"; +import { + Loader2, + AlertTriangle, + Snowflake, + CheckCircle, + Clock, +} from "lucide-react"; +import RouteErrorBoundary from "@/components/RouteErrorBoundary"; +import { TransactionFeeModal } from "@/components/TransactionFeeModal"; +import MarketCard from "@/components/MarketCard"; +import { formatRelativeTime } from "@/app/lib/formatting"; export default function AdminDisputes() { const wallet = useWallet(); const { showToast } = useToast(); - + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + } = useTransactionToast(); + const [isAdmin, setIsAdmin] = useState(null); const [checkingAccess, setCheckingAccess] = useState(true); - + const [pools, setPools] = useState([]); const [loadingPools, setLoadingPools] = useState(false); - - const [selectedPool, setSelectedPool] = useState(null); + + const [selectedPool, setSelectedPool] = useState( + null, + ); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); - const [stage, setStage] = useState('idle'); + const [stage, setStage] = useState("idle"); const [isSubmitting, setIsSubmitting] = useState(false); - const [feePrompt, setFeePrompt] = useState<{ feeStroops: string; resolve: (v: boolean) => void } | null>(null); + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); useEffect(() => { async function checkAccess() { @@ -49,7 +66,7 @@ export default function AdminDisputes() { const freezeAdmin = await predinexReadApi.getFreezeAdmin(); setIsAdmin(wallet.address === freezeAdmin); } catch (err) { - console.error('Failed to get freeze admin', err); + console.error("Failed to get freeze admin", err); setIsAdmin(false); } finally { setCheckingAccess(false); @@ -66,18 +83,24 @@ export default function AdminDisputes() { // We must NOT use fetchAllPools→processMarketData→calculateMarketStatus // because that pipeline only returns 'active'|'settled'|'expired'. const count = await getPoolCountFromSoroban(); - if (count === 0) { setPools([]); return; } + if (count === 0) { + setPools([]); + return; + } const sorobanPools = await getPoolsBatchFromSoroban(1, count); const { height: blockHeight } = await fetchCurrentBlockHeightLive(); // Map Pool (from soroban-read-api/stacks-api) → ProcessedMarket, // preserving the status that normalizePool already set correctly. - const allMarkets: ProcessedMarket[] = sorobanPools.map(pool => { + const allMarkets: ProcessedMarket[] = sorobanPools.map((pool) => { const totalVolume = pool.totalA + pool.totalB; - const oddsA = totalVolume > 0 ? Math.round((pool.totalA / totalVolume) * 100) : 50; - const oddsB = totalVolume > 0 ? Math.round((pool.totalB / totalVolume) * 100) : 50; - const timeRemaining = pool.expiry > blockHeight ? pool.expiry - blockHeight : null; + const oddsA = + totalVolume > 0 ? Math.round((pool.totalA / totalVolume) * 100) : 50; + const oddsB = + totalVolume > 0 ? Math.round((pool.totalB / totalVolume) * 100) : 50; + const timeRemaining = + pool.expiry > blockHeight ? pool.expiry - blockHeight : null; return { poolId: pool.id, @@ -97,11 +120,13 @@ export default function AdminDisputes() { }; }); - const filtered = allMarkets.filter(p => p.status === 'frozen' || p.status === 'disputed'); + const filtered = allMarkets.filter( + (p) => p.status === "frozen" || p.status === "disputed", + ); setPools(filtered); } catch (err) { - console.error('Failed to load pools', err); - showToast('Failed to load pools', 'error'); + console.error("Failed to load pools", err); + showToast("Failed to load pools", "error"); } finally { setLoadingPools(false); } @@ -120,7 +145,7 @@ export default function AdminDisputes() { const evs = await getPoolAdminActivityFromSoroban(poolId, 20, config); setHistory(evs); } catch (err) { - console.error('Failed to load history', err); + console.error("Failed to load history", err); } finally { setLoadingHistory(false); } @@ -132,55 +157,77 @@ export default function AdminDisputes() { } }, [selectedPool]); - const handleAction = async (action: 'freeze' | 'dispute' | 'unfreeze', poolId: number) => { + const handleAction = async ( + action: "freeze" | "dispute" | "unfreeze", + poolId: number, + ) => { if (!wallet.isConnected) return; setIsSubmitting(true); - setStage('idle'); + setStage("idle"); try { - if (action === 'freeze') { + const onStageChange = (s: TxStage) => { + setStage(s); + onTransactionStageChange(s); + }; + + if (action === "freeze") { await predinexContract.freezePoolSoroban({ wallet, poolId, - onStageChange: setStage, - onFeeEstimated: (fee) => new Promise((resolve) => setFeePrompt({ feeStroops: fee, resolve })), + onStageChange, + onFeeEstimated: (fee) => + new Promise((resolve) => + setFeePrompt({ feeStroops: fee, resolve }), + ), }); - } else if (action === 'dispute') { + } else if (action === "dispute") { await predinexContract.disputePoolSoroban({ wallet, poolId, - onStageChange: setStage, - onFeeEstimated: (fee) => new Promise((resolve) => setFeePrompt({ feeStroops: fee, resolve })), + onStageChange, + onFeeEstimated: (fee) => + new Promise((resolve) => + setFeePrompt({ feeStroops: fee, resolve }), + ), }); - } else if (action === 'unfreeze') { + } else if (action === "unfreeze") { await predinexContract.unfreezePoolSoroban({ wallet, poolId, - onStageChange: setStage, - onFeeEstimated: (fee) => new Promise((resolve) => setFeePrompt({ feeStroops: fee, resolve })), + onStageChange, + onFeeEstimated: (fee) => + new Promise((resolve) => + setFeePrompt({ feeStroops: fee, resolve }), + ), }); } - showToast(`Successfully executed ${action} on pool ${poolId}`, 'success'); + showSuccess(`Successfully executed ${action} on pool ${poolId}`); loadPools(); if (selectedPool && selectedPool.poolId === poolId) { loadHistory(poolId); } } catch (err) { console.error(`Failed to ${action} pool`, err); - showToast(`Failed to ${action} pool: ${err instanceof Error ? err.message : String(err)}`, 'error'); + showError(err instanceof Error ? err.message : String(err)); } finally { setIsSubmitting(false); - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }; const getStageLabel = (s: TxStage) => { switch (s) { - case 'simulating': return 'Simulating...'; - case 'signing': return 'Waiting for signature...'; - case 'submitting': return 'Submitting...'; - case 'polling': return 'Confirming...'; - default: return 'Processing...'; + case "simulating": + return "Simulating..."; + case "signing": + return "Waiting for signature..."; + case "submitting": + return "Submitting..."; + case "polling": + return "Confirming..."; + default: + return "Processing..."; } }; @@ -190,7 +237,9 @@ export default function AdminDisputes() {
-

Admin Disputes Dashboard

+

+ Admin Disputes Dashboard +

{checkingAccess ? (
@@ -202,7 +251,9 @@ export default function AdminDisputes() {

Access Denied

-

You must be the FreezeAdmin to view this page.

+

+ You must be the FreezeAdmin to view this page. +

) : ( @@ -210,13 +261,15 @@ export default function AdminDisputes() { {/* List View */}
-

Frozen & Disputed Pools

-
@@ -226,11 +279,11 @@ export default function AdminDisputes() {
) : (
- {pools.map(pool => ( -
( +
setSelectedPool(pool)} - className={`cursor-pointer transition-all ${selectedPool?.poolId === pool.poolId ? 'ring-2 ring-primary scale-[1.02]' : ''}`} + className={`cursor-pointer transition-all ${selectedPool?.poolId === pool.poolId ? "ring-2 ring-primary scale-[1.02]" : ""}`} >
@@ -243,23 +296,42 @@ export default function AdminDisputes() {

Pool Actions

- + {!selectedPool ? ( -

Select a pool to view actions and history.

+

+ Select a pool to view actions and history. +

) : (
-

Selected Pool

-

#{selectedPool.poolId}: {selectedPool.title}

+

+ Selected Pool +

+

+ #{selectedPool.poolId}: {selectedPool.title} +

- Status: {selectedPool.status} + Status:{" "} + + {selectedPool.status} +

{isSubmitting && ( -

{getStageLabel(stage)}

+

+ {getStageLabel(stage)} +

)}
@@ -294,7 +378,9 @@ export default function AdminDisputes() { {loadingHistory ? ( ) : history.length === 0 ? ( -

No recent admin events found.

+

+ No recent admin events found. +

) : (
    {history.map((ev, i) => ( @@ -303,13 +389,15 @@ export default function AdminDisputes() {
-

{ev.functionName.replace('pool_', '')}

+

+ {ev.functionName.replace("pool_", "")} +

{formatRelativeTime(ev.timestamp)}

- @@ -327,11 +415,11 @@ export default function AdminDisputes() {
)} - + { feePrompt?.resolve(true); setFeePrompt(null); @@ -340,9 +428,13 @@ export default function AdminDisputes() { feePrompt?.resolve(false); setFeePrompt(null); setIsSubmitting(false); - setStage('idle'); + setStage("idle"); }} - isConfirming={stage === 'signing' || stage === 'submitting' || stage === 'polling'} + isConfirming={ + stage === "signing" || + stage === "submitting" || + stage === "polling" + } />
diff --git a/web/app/create/page.tsx b/web/app/create/page.tsx index 162f0382..cf2d9539 100644 --- a/web/app/create/page.tsx +++ b/web/app/create/page.tsx @@ -1,50 +1,57 @@ -'use client'; -import { createScopedLogger } from '@/app/lib/logger'; -const log = createScopedLogger('page'); +"use client"; +import { createScopedLogger } from "@/app/lib/logger"; +const log = createScopedLogger("page"); -import { FormEvent, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react'; -import Navbar from '@/components/Navbar'; -import AuthGuard from '@/components/AuthGuard'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { useToast } from '../../providers/ToastProvider'; -import { predinexContract } from '../lib/adapters/predinex-contract'; -import { predinexReadApi } from '../lib/adapters/predinex-read-api'; -import { invalidateOnCreatePool } from '../lib/cache-invalidation'; -import { TxStage } from '../lib/soroban-transaction-service'; -import { TransactionFeeModal } from '@/components/TransactionFeeModal'; -import RouteErrorBoundary from '../../components/RouteErrorBoundary'; -import { useCreateWizard, type WizardStep } from './_wizard/useCreateWizard'; -import { StepIndicator } from './_wizard/StepIndicator'; -import { StepTemplate } from './_wizard/StepTemplate'; -import { StepBasics } from './_wizard/StepBasics'; -import { StepOutcomes } from './_wizard/StepOutcomes'; -import { StepParameters } from './_wizard/StepParameters'; -import { StepReview } from './_wizard/StepReview'; +import { FormEvent, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; +import Navbar from "@/components/Navbar"; +import AuthGuard from "@/components/AuthGuard"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { useToast } from "../../providers/ToastProvider"; +import { useTransactionToast } from "../../lib/hooks/useTransactionToast"; +import { predinexContract } from "../lib/adapters/predinex-contract"; +import { predinexReadApi } from "../lib/adapters/predinex-read-api"; +import { invalidateOnCreatePool } from "../lib/cache-invalidation"; +import { TxStage } from "../lib/soroban-transaction-service"; +import { TransactionFeeModal } from "@/components/TransactionFeeModal"; +import RouteErrorBoundary from "../../components/RouteErrorBoundary"; +import { useCreateWizard, type WizardStep } from "./_wizard/useCreateWizard"; +import { StepIndicator } from "./_wizard/StepIndicator"; +import { StepTemplate } from "./_wizard/StepTemplate"; +import { StepBasics } from "./_wizard/StepBasics"; +import { StepOutcomes } from "./_wizard/StepOutcomes"; +import { StepParameters } from "./_wizard/StepParameters"; +import { StepReview } from "./_wizard/StepReview"; import { buildPoolMetadataUri, loadSavedTemplates, parseTemplateDeepLink, saveTemplateToLocalStorage, -} from './_wizard/pool-templates'; +} from "./_wizard/pool-templates"; export default function CreateMarket() { const wallet = useWallet(); const { showToast } = useToast(); + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + } = useTransactionToast(); const searchParams = useSearchParams(); const wizard = useCreateWizard(); const [isSubmitting, setIsSubmitting] = useState(false); - const [stage, setStage] = useState('idle'); + const [stage, setStage] = useState("idle"); const [txId, setTxId] = useState(null); const [deepLinkHandled, setDeepLinkHandled] = useState(false); - const [feePrompt, setFeePrompt] = useState< - { feeStroops: string; resolve: (v: boolean) => void } | null - >(null); + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); useEffect(() => { if (deepLinkHandled) return; - const parsed = parseTemplateDeepLink(searchParams.get('template')); + const parsed = parseTemplateDeepLink(searchParams.get("template")); if (!parsed) { setDeepLinkHandled(true); return; @@ -59,13 +66,13 @@ export default function CreateMarket() { parsed.source, parsed.id, publicTemplates, - savedTemplates + savedTemplates, ); if (applied) { wizard.goTo(2); - showToast('Template loaded from link', 'success'); + showToast("Template loaded from link", "success"); } else { - showToast('Template from link was not found', 'error'); + showToast("Template from link was not found", "error"); } setDeepLinkHandled(true); })(); @@ -73,20 +80,26 @@ export default function CreateMarket() { return () => { cancelled = true; }; - }, [deepLinkHandled, searchParams, showToast, wizard.applyDeepLinkTemplate, wizard.goTo]); + }, [ + deepLinkHandled, + searchParams, + showToast, + wizard.applyDeepLinkTemplate, + wizard.goTo, + ]); const getStageLabel = (s: TxStage) => { switch (s) { - case 'simulating': - return 'Simulating transaction…'; - case 'signing': - return 'Waiting for signature…'; - case 'submitting': - return 'Submitting to network…'; - case 'polling': - return 'Confirming transaction…'; + case "simulating": + return "Simulating transaction…"; + case "signing": + return "Waiting for signature…"; + case "submitting": + return "Submitting to network…"; + case "polling": + return "Confirming transaction…"; default: - return 'Submitting…'; + return "Submitting…"; } }; @@ -102,7 +115,9 @@ export default function CreateMarket() { if (!valid) { if (wizard.errors.title || wizard.errors.description) { wizard.goTo(2); - } else if (Object.keys(wizard.errors).some((key) => key.startsWith('outcome'))) { + } else if ( + Object.keys(wizard.errors).some((key) => key.startsWith("outcome")) + ) { wizard.goTo(3); } else { wizard.goTo(4); @@ -122,7 +137,7 @@ export default function CreateMarket() { }; setIsSubmitting(true); - setStage('idle'); + setStage("idle"); try { const { txHash, poolId } = await predinexContract.createMarketSoroban({ wallet, @@ -131,7 +146,10 @@ export default function CreateMarket() { outcomeA: wizard.draft.outcomeA, outcomeB: wizard.draft.outcomeB, durationSeconds: duration, - onStageChange: (s) => setStage(s), + onStageChange: (s) => { + setStage(s); + onTransactionStageChange(s); + }, onFeeEstimated: (fee) => { return new Promise((resolve) => { setFeePrompt({ feeStroops: fee, resolve }); @@ -152,24 +170,24 @@ export default function CreateMarket() { coverImage: coverImage || undefined, }); } catch (metaError) { - log.warn('Extended metadata submission failed (non-fatal):', metaError); - showToast('Pool created — metadata could not be saved.', 'error'); + log.warn( + "Extended metadata submission failed (non-fatal):", + metaError, + ); + showToast("Pool created — metadata could not be saved.", "error"); } } setTxId(txHash); wizard.resetDraft(); invalidateOnCreatePool(); - showToast('Pool created successfully!', 'success'); + showSuccess("Pool created successfully!"); } catch (error) { - log.error('Failed to create pool:', error); - showToast( - `Failed to create pool: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error' - ); + log.error("Failed to create pool:", error); + showError(error instanceof Error ? error.message : "Unknown error"); } finally { setIsSubmitting(false); - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }; @@ -180,15 +198,18 @@ export default function CreateMarket() {
-

Create prediction pool

+

+ Create prediction pool +

- Guided wizard with templates, draft auto-save, and on-chain preview before you sign. + Guided wizard with templates, draft auto-save, and on-chain + preview before you sign.

{ feePrompt?.resolve(true); setFeePrompt(null); @@ -197,9 +218,13 @@ export default function CreateMarket() { feePrompt?.resolve(false); setFeePrompt(null); setIsSubmitting(false); - setStage('idle'); + setStage("idle"); }} - isConfirming={stage === 'signing' || stage === 'submitting' || stage === 'polling'} + isConfirming={ + stage === "signing" || + stage === "submitting" || + stage === "polling" + } /> {txId && ( @@ -212,12 +237,18 @@ export default function CreateMarket() {
)} - wizard.goTo(target)} /> + wizard.goTo(target)} + />
{wizard.step === 1 && ( - + )} {wizard.step === 2 && ( Next @@ -298,8 +329,12 @@ export default function CreateMarket() { disabled={isSubmitting} className="px-6 py-2 rounded-lg bg-primary text-primary-foreground font-bold inline-flex items-center gap-2 disabled:opacity-60" > - {isSubmitting && } - {isSubmitting ? getStageLabel(stage) : 'Create pool on-chain'} + {isSubmitting && ( + + )} + {isSubmitting + ? getStageLabel(stage) + : "Create pool on-chain"} )}
diff --git a/web/app/lib/hooks/useClaimAll.ts b/web/app/lib/hooks/useClaimAll.ts index 42cc100d..fc09e5cb 100644 --- a/web/app/lib/hooks/useClaimAll.ts +++ b/web/app/lib/hooks/useClaimAll.ts @@ -1,24 +1,26 @@ -'use client'; +"use client"; -import { useCallback, useState } from 'react'; -import { useToast } from '@/providers/ToastProvider'; -import { predinexContract } from '../adapters/predinex-contract'; -import { invalidateOnClaimWinnings } from '../cache-invalidation'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { TxStage } from '../soroban-transaction-service'; -import { notifyBrowserEvent } from '../notifications'; +import { useCallback, useState } from "react"; +import { useToast } from "@/providers/ToastProvider"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; +import { predinexContract } from "../adapters/predinex-contract"; +import { invalidateOnClaimWinnings } from "../cache-invalidation"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { TxStage } from "../soroban-transaction-service"; +import { notifyBrowserEvent } from "../notifications"; /** Maximum pools the contract's `claim_all_winnings` accepts in one batch. */ export const CLAIM_ALL_MAX_POOLS = 20; -export type ClaimAllPoolStatus = 'pending' | 'claiming' | 'claimed' | 'skipped'; +export type ClaimAllPoolStatus = "pending" | "claiming" | "claimed" | "skipped"; export interface ClaimAllPoolState { poolId: number; status: ClaimAllPoolStatus; } -export type ClaimAllOverallStatus = 'idle' | 'claiming' | 'success' | 'partial' | 'failed'; +export type ClaimAllOverallStatus = + "idle" | "claiming" | "success" | "partial" | "failed"; export interface ClaimAllState { status: ClaimAllOverallStatus; @@ -30,7 +32,7 @@ export interface ClaimAllState { } const emptyState: ClaimAllState = { - status: 'idle', + status: "idle", pools: [], claimedCount: 0, }; @@ -47,15 +49,22 @@ const emptyState: ClaimAllState = { export function useClaimAll(userAddress?: string | null) { const wallet = useWallet(); const { showToast } = useToast(); + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + dismiss, + } = useTransactionToast(); const [state, setState] = useState(emptyState); - const [feePrompt, setFeePrompt] = useState< - { feeStroops: string; resolve: (v: boolean) => void } | null - >(null); - const [stage, setStage] = useState('idle'); + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); + const [stage, setStage] = useState("idle"); const reset = useCallback(() => { setState(emptyState); - setStage('idle'); + setStage("idle"); setFeePrompt(null); }, []); @@ -67,26 +76,32 @@ export function useClaimAll(userAddress?: string | null) { return; } - setStage('idle'); + setStage("idle"); setState({ - status: 'claiming', + status: "claiming", claimedCount: 0, - pools: batch.map((poolId) => ({ poolId, status: 'claiming' })), + pools: batch.map((poolId) => ({ poolId, status: "claiming" })), }); try { - const { txHash, claimedPoolIds } = await predinexContract.claimAllWinningsSoroban({ - wallet, - poolIds: batch, - onStageChange: setStage, - onFeeEstimated: (fee) => - new Promise((resolve) => { - setFeePrompt({ feeStroops: fee, resolve }); - }), - }); + const { txHash, claimedPoolIds } = + await predinexContract.claimAllWinningsSoroban({ + wallet, + poolIds: batch, + onStageChange: (s) => { + setStage(s); + onTransactionStageChange(s); + }, + onFeeEstimated: (fee) => + new Promise((resolve) => { + setFeePrompt({ feeStroops: fee, resolve }); + }), + }); if (userAddress) { - batch.forEach((poolId) => invalidateOnClaimWinnings({ poolId, userAddress })); + batch.forEach((poolId) => + invalidateOnClaimWinnings({ poolId, userAddress }), + ); } // The contract may pay out only a subset (it skips non-claimable pools), @@ -98,44 +113,44 @@ export function useClaimAll(userAddress?: string | null) { const isPartial = claimedCount > 0 && claimedCount < batch.length; setState({ - status: isPartial ? 'partial' : 'success', + status: isPartial ? "partial" : "success", txId: txHash, claimedCount, pools: batch.map((poolId) => ({ poolId, - status: claimed.has(poolId) ? 'claimed' : 'skipped', + status: claimed.has(poolId) ? "claimed" : "skipped", })), }); - notifyBrowserEvent('Claim All submitted', { - body: `Claimed winnings for ${claimedCount} pool${claimedCount === 1 ? '' : 's'}.`, - tag: 'predinex-claim-all', + notifyBrowserEvent("Claim All submitted", { + body: `Claimed winnings for ${claimedCount} pool${claimedCount === 1 ? "" : "s"}.`, + tag: "predinex-claim-all", }); - showToast( - isPartial - ? `Claimed ${claimedCount} of ${batch.length} pools.` - : `Claimed winnings for ${claimedCount} pools!`, - isPartial ? 'info' : 'success' - ); + const successMsg = isPartial + ? `Claimed ${claimedCount} of ${batch.length} pools.` + : `Claimed winnings for ${claimedCount} pools!`; + showSuccess(successMsg); onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to claim winnings'; + const message = + error instanceof Error ? error.message : "Failed to claim winnings"; setState((prev) => ({ ...prev, - status: 'failed', + status: "failed", error: message, - pools: prev.pools.map((p) => ({ ...p, status: 'pending' })), + pools: prev.pools.map((p) => ({ ...p, status: "pending" })), })); - if (message !== 'Transaction cancelled by user') { - showToast(message, 'error'); + if (message !== "Transaction cancelled by user") { + showError(message); } else { - showToast('Claim All transaction cancelled', 'info'); + dismiss(); + showToast("Claim All transaction cancelled", "info"); } } finally { - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }, - [showToast, userAddress, wallet] + [showToast, userAddress, wallet], ); return { state, claimAll, reset, feePrompt, setFeePrompt, stage, setStage }; diff --git a/web/app/lib/hooks/useClaimWinnings.ts b/web/app/lib/hooks/useClaimWinnings.ts index 74d95f76..3b5e9b14 100644 --- a/web/app/lib/hooks/useClaimWinnings.ts +++ b/web/app/lib/hooks/useClaimWinnings.ts @@ -1,14 +1,15 @@ -'use client'; +"use client"; -import { useCallback, useState } from 'react'; -import { useToast } from '@/providers/ToastProvider'; -import { predinexContract } from '../adapters/predinex-contract'; -import { invalidateOnClaimWinnings } from '../cache-invalidation'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { TxStage } from '../soroban-transaction-service'; -import { notifyBrowserEvent } from '../notifications'; +import { useCallback, useState } from "react"; +import { useToast } from "@/providers/ToastProvider"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; +import { predinexContract } from "../adapters/predinex-contract"; +import { invalidateOnClaimWinnings } from "../cache-invalidation"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { TxStage } from "../soroban-transaction-service"; +import { notifyBrowserEvent } from "../notifications"; -export type ClaimTxStatus = 'pending' | 'success' | 'failed'; +export type ClaimTxStatus = "pending" | "success" | "failed"; export interface ClaimTxState { status: ClaimTxStatus; @@ -19,26 +20,42 @@ export interface ClaimTxState { export function useClaimWinnings(userAddress?: string | null) { const wallet = useWallet(); const { showToast } = useToast(); - const [claimTransactions, setClaimTransactions] = useState>(new Map()); - - const [feePrompt, setFeePrompt] = useState<{ feeStroops: string, resolve: (v: boolean) => void } | null>(null); - const [stage, setStage] = useState('idle'); + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + dismiss, + } = useTransactionToast(); + const [claimTransactions, setClaimTransactions] = useState< + Map + >(new Map()); + + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); + const [stage, setStage] = useState("idle"); const claim = useCallback( async (poolId: number, onSuccess?: () => void) => { - setClaimTransactions((prev) => new Map(prev).set(poolId, { status: 'pending' })); - setStage('idle'); + setClaimTransactions((prev) => + new Map(prev).set(poolId, { status: "pending" }), + ); + setStage("idle"); try { const { txHash } = await predinexContract.claimWinningsSoroban({ wallet, poolId, - onStageChange: setStage, + onStageChange: (s) => { + setStage(s); + onTransactionStageChange(s); + }, onFeeEstimated: (fee) => { return new Promise((resolve) => { setFeePrompt({ feeStroops: fee, resolve }); }); - } + }, }); if (userAddress) { @@ -46,30 +63,32 @@ export function useClaimWinnings(userAddress?: string | null) { } setClaimTransactions((prev) => - new Map(prev).set(poolId, { status: 'success', txId: txHash }) + new Map(prev).set(poolId, { status: "success", txId: txHash }), ); - notifyBrowserEvent('Claim submitted', { + notifyBrowserEvent("Claim submitted", { body: `Winnings claim for pool #${poolId} is being processed.`, tag: `predinex-claim-${poolId}`, }); - showToast('Claim submitted successfully!', 'success'); + showSuccess("Claim submitted successfully!"); onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to claim winnings'; + const message = + error instanceof Error ? error.message : "Failed to claim winnings"; setClaimTransactions((prev) => - new Map(prev).set(poolId, { status: 'failed', error: message }) + new Map(prev).set(poolId, { status: "failed", error: message }), ); - if (message !== 'Transaction cancelled by user') { - showToast(message, 'error'); + if (message !== "Transaction cancelled by user") { + showError(message); } else { - showToast('Claim transaction cancelled', 'info'); + dismiss(); + showToast("Claim transaction cancelled", "info"); } } finally { - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }, - [showToast, userAddress, wallet] + [showToast, userAddress, wallet], ); return { claimTransactions, claim, feePrompt, setFeePrompt, stage, setStage }; diff --git a/web/app/lib/hooks/useSettlePool.ts b/web/app/lib/hooks/useSettlePool.ts index b5ce79d5..d85a4915 100644 --- a/web/app/lib/hooks/useSettlePool.ts +++ b/web/app/lib/hooks/useSettlePool.ts @@ -1,12 +1,13 @@ -'use client'; +"use client"; -import { useCallback, useState } from 'react'; -import { useToast } from '@/providers/ToastProvider'; -import { predinexContract } from '../adapters/predinex-contract'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { TxStage } from '../soroban-transaction-service'; +import { useCallback, useState } from "react"; +import { useToast } from "@/providers/ToastProvider"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; +import { predinexContract } from "../adapters/predinex-contract"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { TxStage } from "../soroban-transaction-service"; -export type SettleTxStatus = 'pending' | 'success' | 'failed'; +export type SettleTxStatus = "pending" | "success" | "failed"; export interface SettleTxState { status: SettleTxStatus; @@ -17,51 +18,76 @@ export interface SettleTxState { export function useSettlePool() { const wallet = useWallet(); const { showToast } = useToast(); - const [settleTransactions, setSettleTransactions] = useState>(new Map()); - - const [feePrompt, setFeePrompt] = useState<{ feeStroops: string, resolve: (v: boolean) => void } | null>(null); - const [stage, setStage] = useState('idle'); + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + dismiss, + } = useTransactionToast(); + const [settleTransactions, setSettleTransactions] = useState< + Map + >(new Map()); + + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); + const [stage, setStage] = useState("idle"); const settle = useCallback( async (poolId: number, winningOutcome: number, onSuccess?: () => void) => { - setSettleTransactions((prev) => new Map(prev).set(poolId, { status: 'pending' })); - setStage('idle'); + setSettleTransactions((prev) => + new Map(prev).set(poolId, { status: "pending" }), + ); + setStage("idle"); try { const { txHash } = await predinexContract.settlePoolSoroban({ wallet, poolId, winningOutcome, - onStageChange: setStage, + onStageChange: (s) => { + setStage(s); + onTransactionStageChange(s); + }, onFeeEstimated: (fee) => { return new Promise((resolve) => { setFeePrompt({ feeStroops: fee, resolve }); }); - } + }, }); setSettleTransactions((prev) => - new Map(prev).set(poolId, { status: 'success', txId: txHash }) + new Map(prev).set(poolId, { status: "success", txId: txHash }), ); - showToast('Pool settled successfully!', 'success'); + showSuccess("Pool settled successfully!"); onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to settle pool'; + const message = + error instanceof Error ? error.message : "Failed to settle pool"; setSettleTransactions((prev) => - new Map(prev).set(poolId, { status: 'failed', error: message }) + new Map(prev).set(poolId, { status: "failed", error: message }), ); - if (message !== 'Transaction cancelled by user') { - showToast(message, 'error'); + if (message !== "Transaction cancelled by user") { + showError(message); } else { - showToast('Settle transaction cancelled', 'info'); + dismiss(); + showToast("Settle transaction cancelled", "info"); } } finally { - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }, - [showToast, wallet] + [showToast, wallet], ); - return { settleTransactions, settle, feePrompt, setFeePrompt, stage, setStage }; + return { + settleTransactions, + settle, + feePrompt, + setFeePrompt, + stage, + setStage, + }; } diff --git a/web/components/BettingSection.tsx b/web/components/BettingSection.tsx index 6d7dd2da..76b039ff 100644 --- a/web/components/BettingSection.tsx +++ b/web/components/BettingSection.tsx @@ -1,342 +1,401 @@ -'use client'; -import { createScopedLogger } from '@/app/lib/logger'; -const log = createScopedLogger('BettingSection'); +"use client"; +import { createScopedLogger } from "@/app/lib/logger"; +const log = createScopedLogger("BettingSection"); -import { useMemo, useState } from 'react'; -import { Loader2, Wallet, AlertCircle } from 'lucide-react'; -import { validateBetAmount } from '@/app/lib/validators'; -import type { Pool } from '@/app/lib/adapters/types'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { useToast } from '@/providers/ToastProvider'; -import { predinexContract } from '@/app/lib/adapters/predinex-contract'; -import { invalidateOnPlaceBet } from '@/app/lib/cache-invalidation'; -import { toastMessages, showToastPayload } from '@/lib/toast-messages'; -import { TransactionFeeModal } from '@/components/TransactionFeeModal'; -import { TruncatedAddress } from '@/components/TruncatedAddress'; -import { useNetworkMismatch } from '@/lib/hooks/useNetworkMismatch'; -import type { TxStage } from '@/app/lib/soroban-transaction-service'; +import { useMemo, useState } from "react"; +import { Loader2, Wallet, AlertCircle } from "lucide-react"; +import { validateBetAmount } from "@/app/lib/validators"; +import type { Pool } from "@/app/lib/adapters/types"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { useToast } from "@/providers/ToastProvider"; +import { predinexContract } from "@/app/lib/adapters/predinex-contract"; +import { invalidateOnPlaceBet } from "@/app/lib/cache-invalidation"; +import { toastMessages, showToastPayload } from "@/lib/toast-messages"; +import { TransactionFeeModal } from "@/components/TransactionFeeModal"; +import { TruncatedAddress } from "@/components/TruncatedAddress"; +import { useNetworkMismatch } from "@/lib/hooks/useNetworkMismatch"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; +import type { TxStage } from "@/app/lib/soroban-transaction-service"; interface BettingSectionProps { - pool: Pool; - poolId: number; - onBetSuccess?: (outcome: number, amount: number) => void; + pool: Pool; + poolId: number; + onBetSuccess?: (outcome: number, amount: number) => void; } -export default function BettingSection({ pool, poolId, onBetSuccess }: BettingSectionProps) { - const wallet = useWallet(); - const { isConnected, address, connect } = wallet; - const { showToast } = useToast(); - const [betAmount, setBetAmount] = useState(''); - const [amountTouched, setAmountTouched] = useState(false); - const [isBetting, setIsBetting] = useState(false); - const [feePrompt, setFeePrompt] = useState<{ feeStroops: string; resolve: (v: boolean) => void } | null>(null); - const [stage, setStage] = useState('idle'); +export default function BettingSection({ + pool, + poolId, + onBetSuccess, +}: BettingSectionProps) { + const wallet = useWallet(); + const { isConnected, address, connect } = wallet; + const { showToast } = useToast(); + const { onStageChange, showError, showSuccess } = useTransactionToast(); + const [betAmount, setBetAmount] = useState(""); + const [amountTouched, setAmountTouched] = useState(false); + const [isBetting, setIsBetting] = useState(false); + const [feePrompt, setFeePrompt] = useState<{ + feeStroops: string; + resolve: (v: boolean) => void; + } | null>(null); + const [stage, setStage] = useState("idle"); - const STROOPS_PER_XLM = 10_000_000; + const STROOPS_PER_XLM = 10_000_000; - // Per-pool limits (raw stroops) — optional for legacy pools. - const minBetStroops = pool.minBet ?? 0; - const maxBetStroops = pool.maxBet ?? 0; - const minBetXlm = minBetStroops / STROOPS_PER_XLM; - const hasMinBet = minBetStroops > 0; - const hasMaxBet = maxBetStroops > 0; - const maxBetXlm = hasMaxBet ? maxBetStroops / STROOPS_PER_XLM : null; + // Per-pool limits (raw stroops) — optional for legacy pools. + const minBetStroops = pool.minBet ?? 0; + const maxBetStroops = pool.maxBet ?? 0; + const minBetXlm = minBetStroops / STROOPS_PER_XLM; + const hasMinBet = minBetStroops > 0; + const hasMaxBet = maxBetStroops > 0; + const maxBetXlm = hasMaxBet ? maxBetStroops / STROOPS_PER_XLM : null; - // Derived wallet balance (placeholder — replace with real balance hook if available). - const walletBalance: number | null = isConnected ? 100.0 : null; + // Derived wallet balance (placeholder — replace with real balance hook if available). + const walletBalance: number | null = isConnected ? 100.0 : null; - const { isMismatch, expectedNetworkName } = useNetworkMismatch(); + const { isMismatch, expectedNetworkName } = useNetworkMismatch(); - // Inline, client-side validation of the bet amount. Returns a human-readable - // error string, or null when the amount is valid for this pool. Runs on every - // change/blur so the form can show feedback and disable submission before the - // user ever triggers a transaction. - const amountError = useMemo(() => { - if (betAmount.trim() === '') { - return 'Amount is required'; - } - const amountXlm = parseFloat(betAmount); - // Base checks (number, > 0, global bounds) reuse the shared validator. - const base = validateBetAmount(amountXlm); - if (!base.valid) { - return base.error ?? 'Invalid amount'; - } - // Pool-specific limits take precedence over the global bounds. - if (hasMinBet && amountXlm < minBetXlm) { - return `Minimum bet is ${minBetXlm} XLM`; - } - if (hasMaxBet && maxBetXlm !== null && amountXlm > maxBetXlm) { - return `Maximum bet is ${maxBetXlm} XLM`; - } - if (walletBalance !== null && amountXlm > walletBalance) { - return `Amount exceeds your balance of ${walletBalance.toFixed(2)} XLM`; - } - return null; - }, [betAmount, hasMinBet, minBetXlm, hasMaxBet, maxBetXlm, walletBalance]); - - const isAmountInvalid = amountError !== null; - const showAmountError = amountTouched && isAmountInvalid; + // Inline, client-side validation of the bet amount. Returns a human-readable + // error string, or null when the amount is valid for this pool. Runs on every + // change/blur so the form can show feedback and disable submission before the + // user ever triggers a transaction. + const amountError = useMemo(() => { + if (betAmount.trim() === "") { + return "Amount is required"; + } + const amountXlm = parseFloat(betAmount); + // Base checks (number, > 0, global bounds) reuse the shared validator. + const base = validateBetAmount(amountXlm); + if (!base.valid) { + return base.error ?? "Invalid amount"; + } + // Pool-specific limits take precedence over the global bounds. + if (hasMinBet && amountXlm < minBetXlm) { + return `Minimum bet is ${minBetXlm} XLM`; + } + if (hasMaxBet && maxBetXlm !== null && amountXlm > maxBetXlm) { + return `Maximum bet is ${maxBetXlm} XLM`; + } + if (walletBalance !== null && amountXlm > walletBalance) { + return `Amount exceeds your balance of ${walletBalance.toFixed(2)} XLM`; + } + return null; + }, [betAmount, hasMinBet, minBetXlm, hasMaxBet, maxBetXlm, walletBalance]); - const placeBet = async (outcome: number) => { - if (!isConnected) { - connect(); - return; - } + const isAmountInvalid = amountError !== null; + const showAmountError = amountTouched && isAmountInvalid; - // Numeric validation on the amount input. - const amountXlm = parseFloat(betAmount); - if (!betAmount || isNaN(amountXlm) || amountXlm <= 0) { - showToastPayload(showToast, toastMessages.bet.invalidAmount); - return; - } + const placeBet = async (outcome: number) => { + if (!isConnected) { + connect(); + return; + } - const amountStroops = Math.floor(amountXlm * STROOPS_PER_XLM); + // Numeric validation on the amount input. + const amountXlm = parseFloat(betAmount); + if (!betAmount || isNaN(amountXlm) || amountXlm <= 0) { + showToastPayload(showToast, toastMessages.bet.invalidAmount); + return; + } - if (hasMinBet && amountStroops < minBetStroops) { - showToastPayload(showToast, toastMessages.bet.minBet(minBetXlm)); - return; - } + const amountStroops = Math.floor(amountXlm * STROOPS_PER_XLM); - if (hasMaxBet && maxBetStroops > 0 && amountStroops > maxBetStroops) { - showToastPayload(showToast, toastMessages.bet.maxBet(maxBetXlm ?? 0)); - return; - } + if (hasMinBet && amountStroops < minBetStroops) { + showToastPayload(showToast, toastMessages.bet.minBet(minBetXlm)); + return; + } - if (walletBalance !== null && amountXlm > walletBalance) { - showToastPayload(showToast, toastMessages.bet.insufficientBalance(walletBalance)); - return; - } + if (hasMaxBet && maxBetStroops > 0 && amountStroops > maxBetStroops) { + showToastPayload(showToast, toastMessages.bet.maxBet(maxBetXlm ?? 0)); + return; + } - setIsBetting(true); + if (walletBalance !== null && amountXlm > walletBalance) { + showToastPayload( + showToast, + toastMessages.bet.insufficientBalance(walletBalance), + ); + return; + } - try { - await predinexContract.placeBetSoroban({ - wallet, - poolId, - outcome, - amountStroops, - onStageChange: (s) => setStage(s), - onFeeEstimated: (fee) => - new Promise((resolve) => { - setFeePrompt({ feeStroops: fee, resolve }); - }), - }); + setIsBetting(true); - if (address) { - invalidateOnPlaceBet({ poolId, userAddress: address }); - } + try { + await predinexContract.placeBetSoroban({ + wallet, + poolId, + outcome, + amountStroops, + onStageChange: (s) => { + setStage(s); + onStageChange(s); + }, + onFeeEstimated: (fee) => + new Promise((resolve) => { + setFeePrompt({ feeStroops: fee, resolve }); + }), + }); - showToast('Bet placed successfully!', 'success'); - setBetAmount(''); - setStage('idle'); - setFeePrompt(null); - onBetSuccess?.(outcome, amountStroops); - } catch (error) { - log.error('[BettingSection] Bet transaction failed:', error); - showToast( - `Failed to place bet: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error' - ); - setStage('idle'); - setFeePrompt(null); - } finally { - setIsBetting(false); - } - }; + if (address) { + invalidateOnPlaceBet({ poolId, userAddress: address }); + } - if (pool.settled) { - return ( -
-

This pool has been settled.

-

- Winner: {pool.winningOutcome === 0 ? pool.outcomeA : pool.outcomeB} -

-
- ); + showSuccess("Bet placed successfully!"); + setBetAmount(""); + setStage("idle"); + setFeePrompt(null); + onBetSuccess?.(outcome, amountStroops); + } catch (error) { + log.error("[BettingSection] Bet transaction failed:", error); + showError(error instanceof Error ? error.message : "Unknown error"); + setStage("idle"); + setFeePrompt(null); + } finally { + setIsBetting(false); } + }; - if (!isConnected) { - return ( -
- -

Connect Wallet to Bet

-

- You need to connect your wallet to place bets on this market. -

- -
- ); - } - - const totalPool = pool.totalA + pool.totalB; - const oddsA = totalPool > 0 ? ((pool.totalA / totalPool) * 100).toFixed(1) : '50.0'; - const oddsB = totalPool > 0 ? ((pool.totalB / totalPool) * 100).toFixed(1) : '50.0'; - + if (pool.settled) { return ( -
-

Place Bet

+
+

This pool has been settled.

+

+ Winner: {pool.winningOutcome === 0 ? pool.outcomeA : pool.outcomeB} +

+
+ ); + } - {/* Current odds */} -
-

Current Odds

-
-
-
-
-
-
- {pool.outcomeA} - {oddsA}% -
-
- {oddsB}% - {pool.outcomeB} -
-
-

- Total pool: {(totalPool / STROOPS_PER_XLM).toLocaleString()} XLM -

-
+ if (!isConnected) { + return ( +
+ +

Connect Wallet to Bet

+

+ You need to connect your wallet to place bets on this market. +

+ +
+ ); + } - {/* Transaction fee confirmation modal */} - { - feePrompt?.resolve(true); - setFeePrompt(null); - }} - onCancel={() => { - feePrompt?.resolve(false); - setFeePrompt(null); - setIsBetting(false); - setStage('idle'); - }} - isConfirming={stage === 'signing' || stage === 'submitting' || stage === 'polling'} - /> + const totalPool = pool.totalA + pool.totalB; + const oddsA = + totalPool > 0 ? ((pool.totalA / totalPool) * 100).toFixed(1) : "50.0"; + const oddsB = + totalPool > 0 ? ((pool.totalB / totalPool) * 100).toFixed(1) : "50.0"; - {/* Wallet info */} - {address && ( -
-
-
-

Connected Wallet

- -
-
-

Balance

-

{walletBalance?.toFixed(2) ?? '0'} XLM

-
-
-
- )} + return ( +
+

Place Bet

- {/* Insufficient balance warning */} - {walletBalance !== null && hasMinBet && walletBalance < minBetXlm && !isMismatch && ( -
- -

- Insufficient balance. Minimum bet: {minBetXlm} XLM -

-
- )} + {/* Current odds */} +
+

Current Odds

+
+
+
+
+
+
+ {pool.outcomeA} + {oddsA}% +
+
+ {oddsB}% + {pool.outcomeB} +
+
+

+ Total pool: {(totalPool / STROOPS_PER_XLM).toLocaleString()} XLM +

+
- {/* Network mismatch warning */} - {isMismatch && ( -
- -

- Please switch to {expectedNetworkName} to place bets. -

-
- )} + {/* Transaction fee confirmation modal */} + { + feePrompt?.resolve(true); + setFeePrompt(null); + }} + onCancel={() => { + feePrompt?.resolve(false); + setFeePrompt(null); + setIsBetting(false); + setStage("idle"); + }} + isConfirming={ + stage === "signing" || stage === "submitting" || stage === "polling" + } + /> - {/* Amount input */} + {/* Wallet info */} + {address && ( +
+
- - setBetAmount(e.target.value)} - onBlur={() => setAmountTouched(true)} - disabled={isBetting || (walletBalance !== null && hasMinBet && walletBalance < minBetXlm) || isMismatch} - aria-label="Enter bet amount in XLM" - aria-describedby="bet-limits" - className="w-full px-4 py-3 rounded-lg bg-background border border-input outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 text-base" - /> - {/* #715 — Quick-select percentage buttons for mobile */} - {walletBalance !== null && walletBalance > 0 && ( -
- {[10, 25, 50, 100].map((pct) => { - const amt = Math.floor((walletBalance * pct) / 100 * 10) / 10; - return ( - - ); - })} -
- )} -

- Bet limits:{' '} - {hasMinBet ? `Min ${minBetXlm} XLM` : 'No minimum'} - {hasMaxBet && maxBetXlm !== null ? `, Max ${maxBetXlm} XLM` : ', No maximum'} -

+

Connected Wallet

+
+
+

Balance

+

+ {walletBalance?.toFixed(2) ?? "0"} XLM +

+
+
+
+ )} - {/* Bet buttons */} -
- + {/* Insufficient balance warning */} + {walletBalance !== null && + hasMinBet && + walletBalance < minBetXlm && + !isMismatch && ( +
+ +

+ Insufficient balance. Minimum bet: {minBetXlm} XLM +

+
+ )} + + {/* Network mismatch warning */} + {isMismatch && ( +
+ +

+ Please switch to {expectedNetworkName} to place bets. +

+
+ )} + + {/* Amount input */} +
+ + setBetAmount(e.target.value)} + onBlur={() => setAmountTouched(true)} + disabled={ + isBetting || + (walletBalance !== null && + hasMinBet && + walletBalance < minBetXlm) || + isMismatch + } + aria-label="Enter bet amount in XLM" + aria-describedby="bet-limits" + className="w-full px-4 py-3 rounded-lg bg-background border border-input outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 text-base" + /> + {/* #715 — Quick-select percentage buttons for mobile */} + {walletBalance !== null && walletBalance > 0 && ( +
+ {[10, 25, 50, 100].map((pct) => { + const amt = Math.floor(((walletBalance * pct) / 100) * 10) / 10; + return ( -
-
- ); + ); + })} +
+ )} +

+ Bet limits: {hasMinBet ? `Min ${minBetXlm} XLM` : "No minimum"} + {hasMaxBet && maxBetXlm !== null + ? `, Max ${maxBetXlm} XLM` + : ", No maximum"} +

+
+ + {/* Bet buttons */} +
+ + +
+
+ ); } diff --git a/web/components/pools/CreatePoolForm.tsx b/web/components/pools/CreatePoolForm.tsx index 5ec0802b..e5c448e9 100644 --- a/web/components/pools/CreatePoolForm.tsx +++ b/web/components/pools/CreatePoolForm.tsx @@ -1,4 +1,4 @@ -'use client'; +"use client"; /** * Pool creation form (Issue #677). * @@ -16,11 +16,19 @@ * - Shows a non-blocking error toast for rejected / failed transactions. */ -import { FormEvent, useId, useState, useCallback } from 'react'; -import { ArrowLeft, Coins, FileText, Hash, Loader2, Wallet } from 'lucide-react'; -import { useWallet } from '@/components/WalletAdapterProvider'; -import { TransactionFeeModal } from '@/components/TransactionFeeModal'; -import { useToast } from '@/providers/ToastProvider'; +import { FormEvent, useId, useState, useCallback } from "react"; +import { + ArrowLeft, + Coins, + FileText, + Hash, + Loader2, + Wallet, +} from "lucide-react"; +import { useWallet } from "@/components/WalletAdapterProvider"; +import { TransactionFeeModal } from "@/components/TransactionFeeModal"; +import { useToast } from "@/providers/ToastProvider"; +import { useTransactionToast } from "@/lib/hooks/useTransactionToast"; import { MAX_TITLE_LENGTH, MAX_DESCRIPTION_LENGTH, @@ -32,27 +40,30 @@ import { validatePoolForm, getCharLimit, getHelpText, -} from '@/lib/validators'; -import { createPool } from '@/lib/contract'; -import type { TxStage } from '@/app/lib/soroban-transaction-service'; +} from "@/lib/validators"; +import { createPool } from "@/lib/contract"; +import type { TxStage } from "@/app/lib/soroban-transaction-service"; type PoolFormErrors = Partial< - Record<'name' | 'description' | 'asset' | 'depositAmount' | 'expirySeconds', string> + Record< + "name" | "description" | "asset" | "depositAmount" | "expirySeconds", + string + > >; -const EXPIRY_FIELD = 'expirySeconds'; -const DEPOSIT_FIELD = 'depositAmount'; +const EXPIRY_FIELD = "expirySeconds"; +const DEPOSIT_FIELD = "depositAmount"; function humaniseSeconds(rawDuration: string): string { const seconds = parseInt(rawDuration, 10); - if (!Number.isFinite(seconds) || seconds <= 0) return ''; + if (!Number.isFinite(seconds) || seconds <= 0) return ""; if (seconds < 60) return `${seconds} sec`; const minutes = seconds / 60; - if (minutes < 60) return `≈ ${minutes.toFixed(1).replace(/\.0$/, '')} min`; + if (minutes < 60) return `≈ ${minutes.toFixed(1).replace(/\.0$/, "")} min`; const hours = minutes / 60; - if (hours < 24) return `≈ ${hours.toFixed(1).replace(/\.0$/, '')} hr`; + if (hours < 24) return `≈ ${hours.toFixed(1).replace(/\.0$/, "")} hr`; const days = hours / 24; - return `≈ ${days.toFixed(1).replace(/\.0$/, '')} day${days >= 2 ? 's' : ''}`; + return `≈ ${days.toFixed(1).replace(/\.0$/, "")} day${days >= 2 ? "s" : ""}`; } export interface CreatePoolFormState { @@ -64,11 +75,11 @@ export interface CreatePoolFormState { } export const EMPTY_POOL_FORM: CreatePoolFormState = { - name: '', - description: '', - asset: 'XLM', - depositAmount: '', - expirySeconds: '', + name: "", + description: "", + asset: "XLM", + depositAmount: "", + expirySeconds: "", }; export interface CreatePoolFormProps { @@ -106,16 +117,16 @@ const EMPTY_TOUCHED: FieldTouched = { function getStageLabel(stage: TxStage): string { switch (stage) { - case 'simulating': - return 'Simulating transaction…'; - case 'signing': - return 'Waiting for signature…'; - case 'submitting': - return 'Submitting to network…'; - case 'polling': - return 'Confirming transaction…'; + case "simulating": + return "Simulating transaction…"; + case "signing": + return "Waiting for signature…"; + case "submitting": + return "Submitting to network…"; + case "polling": + return "Confirming transaction…"; default: - return 'Submitting…'; + return "Submitting…"; } } @@ -126,6 +137,11 @@ export function CreatePoolForm({ }: CreatePoolFormProps) { const wallet = useWallet(); const { showToast } = useToast(); + const { + onStageChange: onTransactionStageChange, + showError, + showSuccess, + } = useTransactionToast(); const formId = useId(); const [form, setForm] = useState({ @@ -135,7 +151,7 @@ export function CreatePoolForm({ const [errors, setErrors] = useState({}); const [touched, setTouched] = useState(EMPTY_TOUCHED); const [isSubmitting, setIsSubmitting] = useState(false); - const [stage, setStage] = useState('idle'); + const [stage, setStage] = useState("idle"); const [txHash, setTxHash] = useState(null); const [feePrompt, setFeePrompt] = useState<{ feeStroops: string; @@ -146,7 +162,7 @@ export function CreatePoolForm({ (field: keyof CreatePoolFormState, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); }, - [] + [], ); const validateAll = useCallback((): PoolFormErrors => { @@ -168,7 +184,7 @@ export function CreatePoolForm({ const nextErrors = validateAll(); setErrors(nextErrors); }, - [validateAll] + [validateAll], ); const resetForm = useCallback(() => { @@ -194,7 +210,10 @@ export function CreatePoolForm({ }); setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { - showToast('Please fix the highlighted fields before submitting.', 'error'); + showToast( + "Please fix the highlighted fields before submitting.", + "error", + ); return; } @@ -202,7 +221,7 @@ export function CreatePoolForm({ const expirySeconds = parseInt(form.expirySeconds, 10); setIsSubmitting(true); - setStage('idle'); + setStage("idle"); setTxHash(null); try { const result = await createPool( @@ -215,42 +234,42 @@ export function CreatePoolForm({ }, { wallet, - onStageChange: (s) => setStage(s), + onStageChange: (s) => { + setStage(s); + onTransactionStageChange(s); + }, onFeeEstimated: (fee) => new Promise((resolve) => { setFeePrompt({ feeStroops: fee, resolve }); }), - } + }, ); setTxHash(result.txHash); - showToast('Pool created successfully!', 'success'); + showSuccess("Pool created successfully!"); resetForm(); onSuccess?.({ txHash: result.txHash, poolId: null }); } catch (error) { const message = - error instanceof Error ? error.message : 'Unknown error'; - showToast(`Failed to create pool: ${message}`, 'error'); + error instanceof Error ? error.message : "Unknown error"; + showError(message); } finally { setIsSubmitting(false); - setStage('idle'); + setStage("idle"); setFeePrompt(null); } }, - [ - wallet, - form, - validateAll, - showToast, - resetForm, - onSuccess, - ] + [wallet, form, validateAll, showToast, resetForm, onSuccess], ); // Re-evaluate errors on every render so the field-level copy stays in sync // with current form values (used to apply aria-invalid hints). - const liveErrors = errors.name || errors.description || errors.asset || - errors.depositAmount || errors.expirySeconds + const liveErrors = + errors.name || + errors.description || + errors.asset || + errors.depositAmount || + errors.expirySeconds ? errors : validateAll(); @@ -262,7 +281,7 @@ export function CreatePoolForm({ { feePrompt?.resolve(true); setFeePrompt(null); @@ -271,11 +290,11 @@ export function CreatePoolForm({ feePrompt?.resolve(false); setFeePrompt(null); setIsSubmitting(false); - setStage('idle'); + setStage("idle"); onCancel?.(); }} isConfirming={ - stage === 'signing' || stage === 'submitting' || stage === 'polling' + stage === "signing" || stage === "submitting" || stage === "polling" } /> @@ -305,20 +324,20 @@ export function CreatePoolForm({ type="text" autoComplete="off" value={form.name} - onChange={(e) => setField('name', e.target.value)} - onBlur={() => handleFieldBlur('name')} + onChange={(e) => setField("name", e.target.value)} + onBlur={() => handleFieldBlur("name")} placeholder="e.g. BTC above $100k by 2026" - aria-invalid={!!showError('name')} + aria-invalid={!!showError("name")} aria-describedby={ - showError('name') ? `${formId}-name-error` : `${formId}-name-help` + showError("name") ? `${formId}-name-error` : `${formId}-name-help` } maxLength={MAX_TITLE_LENGTH} className={`w-full px-4 py-2 rounded-lg bg-background border focus:outline-none focus:ring-2 focus:ring-primary/50 ${ - showError('name') ? 'border-red-500' : 'border-input' + showError("name") ? "border-red-500" : "border-input" }`} />
- {showError('name') ? ( + {showError("name") ? ( )} - {form.name.length}/{getCharLimit('title') ?? MAX_TITLE_LENGTH} + {form.name.length}/{getCharLimit("title") ?? MAX_TITLE_LENGTH}
@@ -354,22 +373,22 @@ export function CreatePoolForm({ name="description" rows={4} value={form.description} - onChange={(e) => setField('description', e.target.value)} - onBlur={() => handleFieldBlur('description')} + onChange={(e) => setField("description", e.target.value)} + onBlur={() => handleFieldBlur("description")} placeholder="Describe the pool, including any resolution criteria." - aria-invalid={!!showError('description')} + aria-invalid={!!showError("description")} aria-describedby={ - showError('description') + showError("description") ? `${formId}-description-error` : `${formId}-description-help` } maxLength={MAX_DESCRIPTION_LENGTH} className={`w-full px-4 py-2 rounded-lg bg-background border focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none ${ - showError('description') ? 'border-red-500' : 'border-input' + showError("description") ? "border-red-500" : "border-input" }`} />
- {showError('description') ? ( + {showError("description") ? ( )} @@ -405,16 +424,16 @@ export function CreatePoolForm({ id={`${formId}-asset`} name="asset" value={form.asset} - onChange={(e) => setField('asset', e.target.value)} - onBlur={() => handleFieldBlur('asset')} - aria-invalid={!!showError('asset')} + onChange={(e) => setField("asset", e.target.value)} + onBlur={() => handleFieldBlur("asset")} + aria-invalid={!!showError("asset")} aria-describedby={ - showError('asset') + showError("asset") ? `${formId}-asset-error` : `${formId}-asset-help` } className={`w-full px-4 py-2 rounded-lg bg-background border focus:outline-none focus:ring-2 focus:ring-primary/50 ${ - showError('asset') ? 'border-red-500' : 'border-input' + showError("asset") ? "border-red-500" : "border-input" }`} > {SUPPORTED_POOL_ASSETS.map((symbol) => ( @@ -424,7 +443,7 @@ export function CreatePoolForm({ ))}
- {showError('asset') ? ( + {showError("asset") ? (
- {showError('depositAmount') ? ( + {showError("depositAmount") ? (
- {showError('expirySeconds') ? ( + {showError("expirySeconds") ? (
diff --git a/web/components/ui/Toast.tsx b/web/components/ui/Toast.tsx index 8ea7c7cc..f53d1efe 100644 --- a/web/components/ui/Toast.tsx +++ b/web/components/ui/Toast.tsx @@ -1,13 +1,20 @@ -import { useEffect, useState } from 'react'; -import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'; +import { useEffect, useState } from "react"; +import { + X, + CheckCircle, + AlertCircle, + Info, + AlertTriangle, + Loader2, +} from "lucide-react"; -export type ToastType = 'success' | 'error' | 'info' | 'warning'; +export type ToastType = "success" | "error" | "info" | "warning" | "loading"; interface ToastProps { - message: string; - type?: ToastType; - duration?: number; - onClose: () => void; + message: string; + type?: ToastType; + duration?: number; + onClose: () => void; } /** @@ -15,59 +22,72 @@ interface ToastProps { * #458 a11y: uses role="status"/"alert" and aria-live for screen reader announcements. */ export default function Toast({ - message, - type = 'info', - duration = 5000, - onClose + message, + type = "info", + duration = 5000, + onClose, }: ToastProps) { - const [isVisible, setIsVisible] = useState(true); + const [isVisible, setIsVisible] = useState(true); - useEffect(() => { - const timer = setTimeout(() => { - setIsVisible(false); - setTimeout(onClose, 300); - }, duration); + useEffect(() => { + if (type === "loading") return; - return () => clearTimeout(timer); - }, [duration, onClose]); + const timer = setTimeout(() => { + setIsVisible(false); + setTimeout(onClose, 300); + }, duration); - const icons = { - success: