@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.
- package/CHANGELOG.md +230 -0
- package/README.md +179 -0
- package/package.json +13 -6
- package/src/components/index.ts +1 -1
- package/src/cover-offer/README.md +46 -0
- package/src/cover-offer/components/CoverOfferCard.tsx +445 -0
- package/src/cover-offer/components/CoverRequestForm.tsx +342 -0
- package/src/cover-offer/components/CoveredEventsInfo.tsx +120 -0
- package/src/cover-offer/components/ExistingCoverInfo.tsx +74 -0
- package/src/cover-offer/components/NexusCoverSection.tsx +49 -0
- package/src/cover-offer/components/PurchaseButtonSection.tsx +106 -0
- package/src/cover-offer/constants.ts +32 -0
- package/src/cover-offer/hooks/useCheckNexusMembership.ts +32 -0
- package/src/cover-offer/hooks/useCoverQuote.ts +126 -0
- package/src/cover-offer/hooks/useCoverTokenSelection.ts +84 -0
- package/src/cover-offer/hooks/useDebouncedValue.ts +12 -0
- package/src/cover-offer/hooks/useExistingCovers.ts +101 -0
- package/src/cover-offer/hooks/useNexusProduct.ts +79 -0
- package/src/cover-offer/hooks/useNexusPurchase.ts +67 -0
- package/src/cover-offer/hooks/useTokenApproval.ts +118 -0
- package/src/cover-offer/hooks/useUserCoverNfts.ts +26 -0
- package/src/cover-offer/index.ts +4 -0
- package/src/cover-offer/types/index.ts +41 -0
- package/src/cover-offer/utils/index.ts +90 -0
- package/src/deposit/NativeDepositSection.tsx +220 -0
- package/src/deposit/TemporalWrapper.tsx +82 -0
- package/src/{components → deposit/components}/balances-data-table.tsx +6 -0
- package/src/deposit/components/index.ts +3 -0
- package/src/deposit/components/swap-input-v3.tsx +194 -0
- package/src/deposit/components/token-selector-v3.tsx +122 -0
- package/src/deposit/index.ts +4 -0
- package/src/index.ts +9 -0
- package/src/opportunity-actions/OpportunityActions.tsx +191 -0
- package/src/opportunity-actions/index.ts +1 -0
- package/src/opportunity-table/components/opportunities-table.tsx +6 -5
- package/src/route-details/index.ts +6 -0
- package/src/route-details/route-details-v2.tsx +137 -0
- package/src/route-details/route-details.tsx +5 -4
- package/src/route-details/types.ts +7 -0
- package/src/transaction-status/hooks/useTransactionQueue.ts +1 -1
- package/src/withdraw/NativeWithdrawSection.tsx +45 -0
- 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 };
|