@turtleclub/opportunities 0.1.0-beta.6 → 0.1.0-beta.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/README.md +179 -0
  3. package/package.json +13 -6
  4. package/src/components/index.ts +1 -1
  5. package/src/cover-offer/README.md +46 -0
  6. package/src/cover-offer/components/CoverOfferCard.tsx +445 -0
  7. package/src/cover-offer/components/CoverRequestForm.tsx +342 -0
  8. package/src/cover-offer/components/CoveredEventsInfo.tsx +120 -0
  9. package/src/cover-offer/components/ExistingCoverInfo.tsx +74 -0
  10. package/src/cover-offer/components/NexusCoverSection.tsx +49 -0
  11. package/src/cover-offer/components/PurchaseButtonSection.tsx +106 -0
  12. package/src/cover-offer/constants.ts +32 -0
  13. package/src/cover-offer/hooks/useCheckNexusMembership.ts +32 -0
  14. package/src/cover-offer/hooks/useCoverQuote.ts +126 -0
  15. package/src/cover-offer/hooks/useCoverTokenSelection.ts +84 -0
  16. package/src/cover-offer/hooks/useDebouncedValue.ts +12 -0
  17. package/src/cover-offer/hooks/useExistingCovers.ts +101 -0
  18. package/src/cover-offer/hooks/useNexusProduct.ts +79 -0
  19. package/src/cover-offer/hooks/useNexusPurchase.ts +67 -0
  20. package/src/cover-offer/hooks/useTokenApproval.ts +118 -0
  21. package/src/cover-offer/hooks/useUserCoverNfts.ts +26 -0
  22. package/src/cover-offer/index.ts +4 -0
  23. package/src/cover-offer/types/index.ts +41 -0
  24. package/src/cover-offer/utils/index.ts +90 -0
  25. package/src/deposit/NativeDepositSection.tsx +220 -0
  26. package/src/deposit/TemporalWrapper.tsx +82 -0
  27. package/src/{components → deposit/components}/balances-data-table.tsx +6 -0
  28. package/src/deposit/components/index.ts +3 -0
  29. package/src/deposit/components/swap-input-v3.tsx +194 -0
  30. package/src/deposit/components/token-selector-v3.tsx +122 -0
  31. package/src/deposit/index.ts +4 -0
  32. package/src/index.ts +9 -0
  33. package/src/opportunity-actions/OpportunityActions.tsx +191 -0
  34. package/src/opportunity-actions/index.ts +1 -0
  35. package/src/opportunity-table/components/opportunities-table.tsx +6 -5
  36. package/src/route-details/index.ts +6 -0
  37. package/src/route-details/route-details-v2.tsx +137 -0
  38. package/src/route-details/route-details.tsx +5 -4
  39. package/src/route-details/types.ts +7 -0
  40. package/src/transaction-status/hooks/useTransactionQueue.ts +1 -1
  41. package/src/withdraw/NativeWithdrawSection.tsx +45 -0
  42. package/src/withdraw/index.ts +1 -0
@@ -0,0 +1,41 @@
1
+ import { CoverAsset } from "@nexusmutual/sdk";
2
+ import type { Opportunity } from "@turtleclub/hooks";
3
+ import type { Asset } from "@turtleclub/ui";
4
+
5
+ // The Nexus SDK doesn't export the NexusProduct type, so we need to define it ourselves.
6
+ // The product has more attributes than just id, name, and coverAssets, but this are the only ones we need for now.
7
+ export type NexusProduct = {
8
+ id: number;
9
+ name: string;
10
+ coverAssets: string[];
11
+ };
12
+
13
+ export interface CoverOfferCardProps {
14
+ productId: number;
15
+ opportunity: Opportunity;
16
+ buyerAddress: string;
17
+ protocolName: string;
18
+ coverProductName: string;
19
+ coverAvailableAssets: string[];
20
+ onSuccess?: (message: string) => void;
21
+ onError?: (message: string) => void;
22
+ startExpanded?: boolean;
23
+ }
24
+
25
+ export interface NexusCoverSectionProps {
26
+ opportunity: Opportunity;
27
+ buyerAddress: string;
28
+ onSuccess?: (message: string) => void;
29
+ onError?: (message: string) => void;
30
+ className?: string;
31
+ startExpanded?: boolean;
32
+ }
33
+
34
+ export interface CoverRequestFormProps {
35
+ protocolName: string;
36
+ baseApyLabel?: string;
37
+ onDismiss?: () => void;
38
+ onSuccess?: (message: string) => void;
39
+ onError?: (message: string) => void;
40
+ startExpanded?: boolean;
41
+ }
@@ -0,0 +1,90 @@
1
+ import { CoverAsset } from "@nexusmutual/sdk";
2
+ import type { Opportunity } from "@turtleclub/hooks";
3
+ import { BaseError, UserRejectedRequestError } from "viem";
4
+
5
+ export function getOpportunityAPY(opportunity: Opportunity): number {
6
+ if (!opportunity.incentives || opportunity.incentives.length === 0) {
7
+ return 0;
8
+ }
9
+
10
+ return opportunity.incentives.reduce((total, incentive) => {
11
+ return total + (incentive.yield ?? 0);
12
+ }, 0);
13
+ }
14
+
15
+ /**
16
+ * Calculate the adjusted APY after subtracting the cover cost
17
+ * @param opportunityAPY - The original APY as a percentage (e.g., 5 for 5%)
18
+ * @param yearlyCostPerc - The yearly cost as a decimal (e.g., 0.025 for 2.5%)
19
+ * @returns The adjusted APY as a percentage
20
+ */
21
+ export function calculateAdjustedAPY(
22
+ opportunityAPY: number,
23
+ yearlyCostPerc: number
24
+ ): number {
25
+ const coverCostPercentage = yearlyCostPerc * 100;
26
+ return Math.max(0, opportunityAPY - coverCostPercentage);
27
+ }
28
+
29
+
30
+ export function formatAPY(apy: number): string {
31
+ return `${apy.toFixed(2)}%`;
32
+ }
33
+
34
+ export function formatEthAmount(amount: string, decimals: number = 6): string {
35
+ const num = parseFloat(amount);
36
+ if (isNaN(num)) return "0";
37
+ return num.toFixed(decimals);
38
+ }
39
+
40
+
41
+ export const formatPurchaseError = (err: unknown): string => {
42
+ console.error("Purchase error:", err, typeof err);
43
+ if (err instanceof UserRejectedRequestError) {
44
+ return "Transaction cancelled";
45
+ }
46
+
47
+ if (err instanceof BaseError) {
48
+ const userRejected =
49
+ typeof err.walk === "function"
50
+ ? err.walk((error) => error instanceof UserRejectedRequestError)
51
+ : false;
52
+ if (userRejected) {
53
+ return "Transaction cancelled";
54
+ }
55
+
56
+ return err.shortMessage || err.message;
57
+ }
58
+
59
+ if (err instanceof Error) {
60
+ return err.message;
61
+ }
62
+
63
+ return "Failed to purchase cover";
64
+ };
65
+
66
+
67
+
68
+ const SECS_IN_DAY = 24 * 60 * 60;
69
+
70
+ export function parseCoverTiming(startSec: number, periodSec: number, gracePeriodSec: number = 0) {
71
+ const expiresSec = startSec + periodSec;
72
+ const graceEndsSec = expiresSec + gracePeriodSec;
73
+
74
+ return {
75
+ startDate: new Date(startSec * 1000),
76
+ expiresDate: new Date(expiresSec * 1000),
77
+ graceEndsDate: new Date(graceEndsSec * 1000),
78
+ periodDays: periodSec / SECS_IN_DAY,
79
+ graceDays: gracePeriodSec / SECS_IN_DAY,
80
+ expiresSec,
81
+ graceEndsSec,
82
+ };
83
+ }
84
+
85
+ export const TOKEN_SYMBOL_TO_COVER_ASSET: Record<string, CoverAsset> = {
86
+ ETH: CoverAsset.ETH,
87
+ DAI: CoverAsset.DAI,
88
+ USDC: CoverAsset.USDC,
89
+ cbBTC: CoverAsset.cbBTC,
90
+ };
@@ -0,0 +1,220 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Button, cn, SegmentControl, SlippageSelector, TurtleTooltip } from "@turtleclub/ui";
5
+ import { InfoIcon } from "lucide-react";
6
+ import {
7
+ useDepositFlow,
8
+ type Opportunity,
9
+ type TokenBalance,
10
+ type TransactionRequest,
11
+ } from "@turtleclub/hooks";
12
+ import { GeoCheckBlocker, SwapInputV3 } from "./components";
13
+ import { RouteDetailsV2 } from "../route-details/route-details-v2";
14
+
15
+ const DEFAULT_SLIPPAGE = 0.005; // 0.5%
16
+ const SECONDS_IN_DAY = 86400;
17
+
18
+ /** Returns true if error is expected user behavior (not a real error to show) */
19
+ function isUserRejectedError(error: Error | null): boolean {
20
+ if (!error) return false;
21
+ const message = error.message.toLowerCase();
22
+ return (
23
+ message.includes("user rejected") ||
24
+ message.includes("user denied") ||
25
+ message.includes("rejected the request") ||
26
+ message.includes("user cancelled") ||
27
+ message.includes("user canceled")
28
+ );
29
+ }
30
+
31
+ export interface NativeDepositSectionProps {
32
+ opportunity: Opportunity;
33
+ address: string | undefined;
34
+ distributorId: string;
35
+ balances: TokenBalance[];
36
+ isBalancesLoading?: boolean;
37
+ refetchBalances?: () => void;
38
+ /**
39
+ * Execute a transaction and wait for receipt confirmation.
40
+ * IMPORTANT: This function MUST wait for the transaction receipt before returning.
41
+ * The returned hash should only be provided after the transaction is confirmed on-chain.
42
+ */
43
+ executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
44
+ onDepositSuccess?: () => void;
45
+ depositMode?: "native" | "route";
46
+ onDepositModeChange?: (mode: "native" | "route") => void;
47
+ /** Current wallet chain ID */
48
+ chainId?: number;
49
+ /** Function to switch chain */
50
+ switchChain?: (chainId: number) => Promise<void>;
51
+ }
52
+
53
+ export function NativeDepositSection({
54
+ opportunity,
55
+ address,
56
+ distributorId,
57
+ balances,
58
+ isBalancesLoading = false,
59
+ refetchBalances,
60
+ executeTransactionAndWait,
61
+ onDepositSuccess,
62
+ depositMode = "native",
63
+ onDepositModeChange,
64
+ chainId,
65
+ switchChain,
66
+ }: NativeDepositSectionProps) {
67
+ const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE);
68
+
69
+ // Secondary market always uses swap mode and hides the mode selector
70
+ const isSecondaryOnly = opportunity.vaultConfig?.secondaryOnly === true;
71
+ const effectiveDepositMode = isSecondaryOnly ? "route" : depositMode;
72
+ const effectiveOnDepositModeChange = isSecondaryOnly ? undefined : onDepositModeChange;
73
+
74
+ // Convert slippage percentage to basis points (0.005 -> 50 bps)
75
+ const slippageBps = Math.round(slippage * 10000);
76
+
77
+ const { selection, validation, deposit } = useDepositFlow({
78
+ opportunity,
79
+ userAddress: address,
80
+ distributorId,
81
+ balances,
82
+ executeTransactionAndWait,
83
+ onDepositSuccess,
84
+ refetchBalances,
85
+ slippageBps: effectiveDepositMode === "route" ? slippageBps : undefined,
86
+ walletChainId: chainId,
87
+ });
88
+
89
+ // Log deposit errors (moved from render to avoid side-effects)
90
+ useEffect(() => {
91
+ if (deposit.error && !isUserRejectedError(deposit.error)) {
92
+ console.error("[NativeDepositSection] Deposit error:", deposit.error);
93
+ }
94
+ }, [deposit.error]);
95
+
96
+ const handleButtonClick = async () => {
97
+ if (validation.isWrongChain && validation.requiredChainId && switchChain) {
98
+ await switchChain(validation.requiredChainId);
99
+ return;
100
+ }
101
+ await deposit.execute();
102
+ };
103
+
104
+ // Only show loading state if we don't have balances yet (initial load)
105
+ // This prevents disabling the input during background refetches
106
+ const isInitialBalancesLoading = isBalancesLoading && balances.length === 0;
107
+
108
+ return (
109
+ <div className="space-y-3">
110
+ {/* Mode selector: only show when mode can be changed */}
111
+ {effectiveOnDepositModeChange && (
112
+ <SegmentControl
113
+ value={effectiveDepositMode === "native" ? "native" : "route"}
114
+ onChange={(value) => effectiveOnDepositModeChange(value as "native" | "route")}
115
+ items={[
116
+ {
117
+ value: "native",
118
+ label: (
119
+ <span className="flex items-center gap-1.5">
120
+ Direct Deposit
121
+ <TurtleTooltip
122
+ trigger={<InfoIcon className="size-3 opacity-60" />}
123
+ content={
124
+ <span className="p-2 block">
125
+ Deposit directly using the vault's accepted tokens. Lower fees, faster
126
+ execution.
127
+ </span>
128
+ }
129
+ />
130
+ </span>
131
+ ),
132
+ },
133
+ {
134
+ value: "route",
135
+ label: (
136
+ <span className="flex items-center gap-1.5">
137
+ Swap
138
+ <TurtleTooltip
139
+ trigger={<InfoIcon className="size-3 opacity-60" />}
140
+ content={
141
+ <span className="p-2 block">
142
+ Deposit any token from the chain. Automatically swaps to the vault's deposit
143
+ token.
144
+ </span>
145
+ }
146
+ />
147
+ </span>
148
+ ),
149
+ },
150
+ ]}
151
+ variant="pill"
152
+ size="sm"
153
+ className="w-fit mt-4"
154
+ />
155
+ )}
156
+
157
+ <SwapInputV3
158
+ value={selection.amount ?? ""}
159
+ balances={balances}
160
+ selectedTokenAddress={selection.selectedTokenAddress}
161
+ disabled={isInitialBalancesLoading}
162
+ isWalletConnected={!!address}
163
+ onChange={selection.setAmount}
164
+ onMaxClick={selection.handleMaxClick}
165
+ onTokenChange={selection.setSelectedTokenAddress}
166
+ showBalance={true}
167
+ className={cn(
168
+ "border border-border p-5",
169
+ validation.hasInsufficientBalance && "border-destructive/50"
170
+ )}
171
+ />
172
+
173
+ {effectiveDepositMode === "route" && (
174
+ <SlippageSelector value={slippage} onChange={setSlippage} className="px-2" />
175
+ )}
176
+
177
+ {deposit.metadata && (
178
+ <RouteDetailsV2
179
+ metadata={deposit.metadata}
180
+ showApprove={deposit.hasApprove}
181
+ approveAmount={selection.amount}
182
+ />
183
+ )}
184
+ {/* Error messages */}
185
+ {deposit.error && !isUserRejectedError(deposit.error) && (
186
+ <p className="text-destructive text-sm">Something went wrong. Please try again.</p>
187
+ )}
188
+
189
+ {validation.validationMessage && validation.hasInsufficientBalance && (
190
+ <p className="text-destructive text-xs">{validation.validationMessage}</p>
191
+ )}
192
+
193
+ <GeoCheckBlocker>
194
+ <Button
195
+ onClick={handleButtonClick}
196
+ disabled={!validation.canDeposit && !validation.isWrongChain}
197
+ className="w-full"
198
+ >
199
+ {validation.buttonText}
200
+ </Button>
201
+ </GeoCheckBlocker>
202
+
203
+ {/* {(validation.depositFee !== null ||
204
+ validation.performanceFee !== null ||
205
+ validation.withdrawalCooldownSecs !== null) && (
206
+ <div className="text-muted-foreground text-xs space-y-1">
207
+ {validation.depositFee !== null && validation.depositFee > 0 && (
208
+ <p>Deposit fee: {validation.depositFee}%</p>
209
+ )}
210
+ {validation.performanceFee !== null && validation.performanceFee > 0 && (
211
+ <p>Performance fee: {validation.performanceFee}%</p>
212
+ )}
213
+ {validation.withdrawalCooldownSecs !== null && validation.withdrawalCooldownSecs > 0 && (
214
+ <p>Withdrawal cooldown: {Math.floor(validation.withdrawalCooldownSecs / SECONDS_IN_DAY)} days</p>
215
+ )}
216
+ </div>
217
+ )} */}
218
+ </div>
219
+ );
220
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ useActionsDefaultParams,
6
+ type Opportunity,
7
+ type TransactionRequest,
8
+ } from "@turtleclub/hooks";
9
+ import { NativeDepositSection } from "./NativeDepositSection";
10
+
11
+ export type DepositMode = "native" | "route";
12
+
13
+ export interface TemporalWrapperProps {
14
+ opportunity: Opportunity;
15
+ address: string | undefined;
16
+ distributorId: string;
17
+ /**
18
+ * Execute a transaction and wait for receipt confirmation.
19
+ * IMPORTANT: This function MUST wait for the transaction receipt before returning.
20
+ * The returned hash should only be provided after the transaction is confirmed on-chain.
21
+ */
22
+ executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
23
+ onDepositSuccess?: () => void;
24
+ /** Current wallet chain ID */
25
+ chainId?: number;
26
+ /** Function to switch chain */
27
+ switchChain?: (chainId: number) => Promise<void>;
28
+ /** Initial deposit mode */
29
+ initialDepositMode?: DepositMode;
30
+ /** Whether to show the deposit mode selector (native/swap) */
31
+ showDepositModeSelector?: boolean;
32
+ }
33
+
34
+ /**
35
+ * TemporalWrapper - A standalone wrapper for NativeDepositSection
36
+ *
37
+ * Includes all balance fetching logic and deposit mode management.
38
+ * Use this when you only need deposit functionality without withdraw.
39
+ */
40
+ export function TemporalWrapper({
41
+ opportunity,
42
+ address,
43
+ distributorId,
44
+ executeTransactionAndWait,
45
+ onDepositSuccess,
46
+ chainId,
47
+ switchChain,
48
+ initialDepositMode = "native",
49
+ showDepositModeSelector = true,
50
+ }: TemporalWrapperProps) {
51
+ const [depositMode, setDepositMode] = useState<DepositMode>(initialDepositMode);
52
+
53
+ // Hook handles isSecondaryOnly internally for balance fetching
54
+ const { balances, isBalancesLoading, refetchBalances } = useActionsDefaultParams({
55
+ opportunity,
56
+ address,
57
+ depositMode,
58
+ });
59
+
60
+ // Determine if we should show the mode selector
61
+ // NativeDepositSection handles isSecondaryOnly internally for UI
62
+ const onDepositModeChange = showDepositModeSelector && opportunity.earnEnabled
63
+ ? setDepositMode
64
+ : undefined;
65
+
66
+ return (
67
+ <NativeDepositSection
68
+ opportunity={opportunity}
69
+ address={address}
70
+ distributorId={distributorId}
71
+ balances={balances}
72
+ isBalancesLoading={isBalancesLoading}
73
+ refetchBalances={refetchBalances}
74
+ executeTransactionAndWait={executeTransactionAndWait}
75
+ onDepositSuccess={onDepositSuccess}
76
+ depositMode={depositMode}
77
+ onDepositModeChange={onDepositModeChange}
78
+ chainId={chainId}
79
+ switchChain={switchChain}
80
+ />
81
+ );
82
+ }
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { useMemo } from "react";
2
4
  import type { ColumnDef } from "@tanstack/react-table";
3
5
  import { DataTable } from "@turtleclub/ui";
@@ -9,6 +11,8 @@ export interface BalancesDataTableProps {
9
11
  isLoading?: boolean;
10
12
  onBalanceSelect?: (balance: TokenBalance) => void;
11
13
  emptyStateMessage?: string;
14
+ /** ScrollArea height - passed to DataTable size prop */
15
+ size?: string;
12
16
  }
13
17
 
14
18
  export function BalancesDataTable({
@@ -16,6 +20,7 @@ export function BalancesDataTable({
16
20
  isLoading = false,
17
21
  onBalanceSelect,
18
22
  emptyStateMessage = "No balances found",
23
+ size,
19
24
  }: BalancesDataTableProps) {
20
25
  const columns = useMemo<ColumnDef<TokenBalance>[]>(
21
26
  () => [
@@ -80,6 +85,7 @@ export function BalancesDataTable({
80
85
  onRowClick={onBalanceSelect}
81
86
  emptyState={emptyState}
82
87
  className="w-full"
88
+ size={size}
83
89
  />
84
90
  );
85
91
  }
@@ -1,2 +1,5 @@
1
+ export { BalancesDataTable, type BalancesDataTableProps } from "./balances-data-table";
1
2
  export { ConfirmButton } from "./confirm-button";
2
3
  export { GeoCheckBlocker } from "./geo-check-blocker";
4
+ export { SwapInputV3, type SwapInputV3Props } from "./swap-input-v3";
5
+ export { TokenSelectorV3 } from "./token-selector-v3";
@@ -0,0 +1,194 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { useMemo } from "react";
4
+ import { formatUnits } from "viem";
5
+ import { cn, Card, Input, Chip } from "@turtleclub/ui";
6
+ import type { TokenBalance } from "@turtleclub/hooks";
7
+ import { TokenSelectorV3 } from "./token-selector-v3";
8
+
9
+ const MIN_DISPLAY_AMOUNT = 0.0001;
10
+ const MIN_USD_DISPLAY_AMOUNT = 0.01;
11
+ interface SwapInputV3Props extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
12
+ /** Current input value */
13
+ value?: string;
14
+ /** Token balances to display in selector */
15
+ balances: TokenBalance[];
16
+ /** Currently selected token address */
17
+ selectedTokenAddress?: string;
18
+ /** Placeholder text for input */
19
+ placeholder?: string;
20
+ /** Disable input and selector */
21
+ disabled?: boolean;
22
+ /** Whether wallet is connected */
23
+ isWalletConnected?: boolean;
24
+ /** Called when input value changes */
25
+ onChange?: (value: string) => void;
26
+ /** Called when MAX button is clicked */
27
+ onMaxClick?: () => void;
28
+ /** Called when token selection changes */
29
+ onTokenChange?: (tokenAddress: string) => void;
30
+ /** Show balance info below input */
31
+ showBalance?: boolean;
32
+ }
33
+
34
+ /**
35
+ * SwapInputV3 - Uses TokenSelectorV3 with BalancesDataTable
36
+ */
37
+ const SwapInputV3 = React.forwardRef<HTMLDivElement, SwapInputV3Props>(
38
+ (
39
+ {
40
+ className,
41
+ value = "",
42
+ balances,
43
+ selectedTokenAddress,
44
+ placeholder = "0",
45
+ disabled = false,
46
+ isWalletConnected = true,
47
+ onChange,
48
+ onMaxClick,
49
+ onTokenChange,
50
+ showBalance = true,
51
+ ...props
52
+ },
53
+ ref,
54
+ ) => {
55
+ // Find the selected balance
56
+ const selectedBalance = useMemo(
57
+ () => balances.find((b) => b.token.address === selectedTokenAddress) ?? null,
58
+ [balances, selectedTokenAddress]
59
+ );
60
+
61
+ // Format balance for display
62
+ const formattedBalance = useMemo(() => {
63
+ if (!selectedBalance) return undefined;
64
+ const formatted = formatUnits(BigInt(selectedBalance.amount), selectedBalance.token.decimals);
65
+ const num = parseFloat(formatted);
66
+ if (num === 0) return "0";
67
+ if (num < MIN_DISPLAY_AMOUNT) return `<${MIN_DISPLAY_AMOUNT}`;
68
+ if (num < 1) return num.toFixed(4);
69
+ if (num < 1000) return num.toFixed(2);
70
+ return num.toLocaleString(undefined, { maximumFractionDigits: 2 });
71
+ }, [selectedBalance]);
72
+
73
+ // Calculate USD value of input amount
74
+ const usdValue = useMemo(() => {
75
+ if (!value || !selectedBalance?.token.priceUsd) return undefined;
76
+ const amount = parseFloat(value);
77
+ if (isNaN(amount) || amount === 0) return undefined;
78
+ const usd = amount * selectedBalance.token.priceUsd;
79
+ if (usd < MIN_USD_DISPLAY_AMOUNT) return `<$${MIN_USD_DISPLAY_AMOUNT}`;
80
+ return `$${usd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
81
+ }, [value, selectedBalance]);
82
+
83
+ // Check if input amount exceeds balance
84
+ const hasInsufficientBalance = useMemo(() => {
85
+ if (!value || !selectedBalance) return false;
86
+ try {
87
+ const inputAmount = parseFloat(value);
88
+ const balance = parseFloat(formatUnits(BigInt(selectedBalance.amount), selectedBalance.token.decimals));
89
+ return inputAmount > balance;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }, [value, selectedBalance]);
94
+
95
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
96
+ const newValue = e.target.value;
97
+ // Allow only numbers and decimal point
98
+ if (/^\d*\.?\d*$/.test(newValue)) {
99
+ onChange?.(newValue);
100
+ }
101
+ };
102
+
103
+ // If wallet is not connected, show connection message
104
+ if (!isWalletConnected) {
105
+ return (
106
+ <Card
107
+ ref={ref}
108
+ className={cn("flex items-center justify-center py-8", className)}
109
+ {...props}
110
+ >
111
+ <div className="text-center">
112
+ <p className="text-muted-foreground text-sm">
113
+ Connect your wallet to continue
114
+ </p>
115
+ </div>
116
+ </Card>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <Card
122
+ variant="shadow"
123
+ ref={ref}
124
+ className={cn("space-y-3", disabled && "opacity-50", className)}
125
+ {...props}
126
+ >
127
+ {/* Main input row */}
128
+ <div className="flex items-center gap-6">
129
+ {/* Amount input */}
130
+ <div className="flex-1">
131
+ <Input
132
+ variant="nofocus"
133
+ value={value}
134
+ onChange={handleInputChange}
135
+ placeholder={placeholder}
136
+ disabled={disabled}
137
+ className="text-foreground/90 h-auto border-none bg-transparent p-0 text-3xl font-[400] shadow-none focus:ring-0"
138
+ />
139
+ </div>
140
+
141
+ {/* Token selector */}
142
+ <TokenSelectorV3
143
+ balances={balances}
144
+ value={selectedTokenAddress}
145
+ onValueChange={onTokenChange}
146
+ disabled={disabled}
147
+ placeholder="Select token"
148
+ size="sm"
149
+ />
150
+ </div>
151
+
152
+ {/* Bottom row: USD value and balance with MAX */}
153
+ {showBalance && (
154
+ <div className="text-muted-foreground flex items-center justify-between text-sm">
155
+ <div>
156
+ {usdValue ? (
157
+ <span>≈ {usdValue}</span>
158
+ ) : value && hasInsufficientBalance ? (
159
+ <span className="text-destructive/50">
160
+ Insufficient balance
161
+ </span>
162
+ ) : null}
163
+ </div>
164
+ <div className="flex items-center gap-2">
165
+ {formattedBalance !== undefined && (
166
+ <span className="text-sm">
167
+ Balance: {formattedBalance} {selectedBalance?.token.symbol || ""}
168
+ </span>
169
+ )}
170
+ {onMaxClick && (
171
+ <Chip
172
+ variant="default"
173
+ size="xs"
174
+ onClick={onMaxClick}
175
+ className={cn(
176
+ "hover:bg-primary hover:text-primary-foreground h-5 cursor-pointer px-2 py-0.5 text-xs transition-colors",
177
+ disabled && "cursor-not-allowed opacity-50",
178
+ )}
179
+ >
180
+ MAX
181
+ </Chip>
182
+ )}
183
+ </div>
184
+ </div>
185
+ )}
186
+ </Card>
187
+ );
188
+ },
189
+ );
190
+
191
+ SwapInputV3.displayName = "SwapInputV3";
192
+
193
+ export { SwapInputV3 };
194
+ export type { SwapInputV3Props };