@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,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { useState, useEffect, useMemo } from "react";
|
|
4
|
+
import { ChevronDownIcon } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
cn,
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@turtleclub/ui";
|
|
12
|
+
import type { TokenBalance } from "@turtleclub/hooks";
|
|
13
|
+
import { BalancesDataTable } from "./balances-data-table";
|
|
14
|
+
|
|
15
|
+
interface TokenSelectorV3Props {
|
|
16
|
+
balances: TokenBalance[];
|
|
17
|
+
value?: string;
|
|
18
|
+
onValueChange?: (value: string) => void;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
size?: "xs" | "sm" | "default";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* TokenSelectorV3 - Opens a dialog with BalancesDataTable for selection
|
|
27
|
+
*/
|
|
28
|
+
const TokenSelectorV3 = ({
|
|
29
|
+
balances,
|
|
30
|
+
value,
|
|
31
|
+
onValueChange,
|
|
32
|
+
placeholder = "Select token",
|
|
33
|
+
disabled,
|
|
34
|
+
className,
|
|
35
|
+
size = "default",
|
|
36
|
+
}: TokenSelectorV3Props) => {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
// Auto-select first token if no value is provided and balances exist
|
|
40
|
+
const effectiveValue =
|
|
41
|
+
value || (balances.length > 0 ? balances[0].token.address : undefined);
|
|
42
|
+
|
|
43
|
+
// Find the selected balance
|
|
44
|
+
const selectedBalance = useMemo(
|
|
45
|
+
() => balances.find((b) => b.token.address === effectiveValue),
|
|
46
|
+
[balances, effectiveValue]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Auto-select first token on mount
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!value && balances.length > 0 && onValueChange) {
|
|
52
|
+
onValueChange(balances[0].token.address);
|
|
53
|
+
}
|
|
54
|
+
}, [value, balances, onValueChange]);
|
|
55
|
+
|
|
56
|
+
const handleSelect = (balance: TokenBalance) => {
|
|
57
|
+
onValueChange?.(balance.token.address);
|
|
58
|
+
setOpen(false);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
{/* Trigger Button */}
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => !disabled && setOpen(true)}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
className={cn(
|
|
69
|
+
"bg-muted hover:bg-muted/80 flex items-center gap-1.5 rounded-full px-3 py-2 font-medium transition-colors",
|
|
70
|
+
"focus:ring-primary/20 focus:ring-2 focus:outline-none",
|
|
71
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
72
|
+
size === "xs" && "px-2 py-1 text-xs",
|
|
73
|
+
size === "sm" && "px-2.5 py-1.5 text-sm",
|
|
74
|
+
size === "default" && "px-3 py-2 text-sm",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{selectedBalance ? (
|
|
79
|
+
<>
|
|
80
|
+
{selectedBalance.token.logoUrl && (
|
|
81
|
+
<img
|
|
82
|
+
src={selectedBalance.token.logoUrl}
|
|
83
|
+
alt={selectedBalance.token.symbol}
|
|
84
|
+
className={cn(
|
|
85
|
+
"rounded-full",
|
|
86
|
+
size === "xs" && "size-3",
|
|
87
|
+
size === "sm" && "size-4",
|
|
88
|
+
size === "default" && "size-4"
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
<span>{selectedBalance.token.symbol}</span>
|
|
93
|
+
</>
|
|
94
|
+
) : (
|
|
95
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
96
|
+
)}
|
|
97
|
+
<ChevronDownIcon className="size-4 opacity-50" />
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
{/* Token Selection Dialog */}
|
|
101
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
102
|
+
<DialogContent className="max-w-md p-0">
|
|
103
|
+
<DialogHeader className="px-4 pt-4">
|
|
104
|
+
<DialogTitle>Select Token</DialogTitle>
|
|
105
|
+
</DialogHeader>
|
|
106
|
+
|
|
107
|
+
{/* BalancesDataTable with native scroll */}
|
|
108
|
+
<div className="px-4 pb-4">
|
|
109
|
+
<BalancesDataTable
|
|
110
|
+
balances={balances}
|
|
111
|
+
onBalanceSelect={handleSelect}
|
|
112
|
+
emptyStateMessage="No tokens available"
|
|
113
|
+
size="450px"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</DialogContent>
|
|
117
|
+
</Dialog>
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export { TokenSelectorV3 };
|
package/src/deposit/index.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
1
|
export * from "./components";
|
|
2
|
+
export { NativeDepositSection } from "./NativeDepositSection";
|
|
3
|
+
export type { NativeDepositSectionProps } from "./NativeDepositSection";
|
|
4
|
+
export { TemporalWrapper } from "./TemporalWrapper";
|
|
5
|
+
export type { TemporalWrapperProps, DepositMode } from "./TemporalWrapper";
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,18 @@ export * from "./components";
|
|
|
4
4
|
// Deposit Components
|
|
5
5
|
export * from "./deposit";
|
|
6
6
|
|
|
7
|
+
// Withdraw Components
|
|
8
|
+
export * from "./withdraw";
|
|
9
|
+
|
|
10
|
+
// Opportunity Actions
|
|
11
|
+
export * from "./opportunity-actions";
|
|
12
|
+
|
|
7
13
|
// Transaction Status
|
|
8
14
|
export * from "./transaction-status";
|
|
9
15
|
|
|
16
|
+
// Cover Offer
|
|
17
|
+
export * from "./cover-offer";
|
|
18
|
+
|
|
10
19
|
// Opportunity Table
|
|
11
20
|
export * from "./opportunity-table/components";
|
|
12
21
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from "react";
|
|
4
|
+
import { SegmentControl, TurtleTooltip } from "@turtleclub/ui";
|
|
5
|
+
import {
|
|
6
|
+
useGetOnChainBalance,
|
|
7
|
+
useBalance,
|
|
8
|
+
filterExcludedTokens,
|
|
9
|
+
type Opportunity,
|
|
10
|
+
type TokenBalance,
|
|
11
|
+
type TransactionRequest,
|
|
12
|
+
} from "@turtleclub/hooks";
|
|
13
|
+
import { NativeDepositSection } from "../deposit/NativeDepositSection";
|
|
14
|
+
import { NativeWithdrawSection } from "../withdraw/NativeWithdrawSection";
|
|
15
|
+
|
|
16
|
+
export interface OpportunityActionsProps {
|
|
17
|
+
opportunity: Opportunity;
|
|
18
|
+
address: string | undefined;
|
|
19
|
+
distributorId: string;
|
|
20
|
+
/**
|
|
21
|
+
* Execute a transaction and wait for receipt confirmation.
|
|
22
|
+
* IMPORTANT: This function MUST wait for the transaction receipt before returning.
|
|
23
|
+
* The returned hash should only be provided after the transaction is confirmed on-chain.
|
|
24
|
+
*/
|
|
25
|
+
executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
|
|
26
|
+
onDepositSuccess?: () => void;
|
|
27
|
+
/** Current wallet chain ID */
|
|
28
|
+
chainId?: number;
|
|
29
|
+
/** Function to switch chain */
|
|
30
|
+
switchChain?: (chainId: number) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sortByBalance(balances: TokenBalance[]): TokenBalance[] {
|
|
34
|
+
return [...balances].sort((a, b) => {
|
|
35
|
+
const aValue = BigInt(a.amount);
|
|
36
|
+
const bValue = BigInt(b.amount);
|
|
37
|
+
if (bValue > aValue) return 1;
|
|
38
|
+
if (bValue < aValue) return -1;
|
|
39
|
+
return 0;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function OpportunityActions({
|
|
44
|
+
opportunity,
|
|
45
|
+
address,
|
|
46
|
+
distributorId,
|
|
47
|
+
executeTransactionAndWait,
|
|
48
|
+
onDepositSuccess,
|
|
49
|
+
chainId,
|
|
50
|
+
switchChain,
|
|
51
|
+
}: OpportunityActionsProps) {
|
|
52
|
+
const [actionMode, setActionMode] = useState<"deposit" | "withdraw">("deposit");
|
|
53
|
+
const [depositMode, setDepositMode] = useState<"native" | "route">("native");
|
|
54
|
+
|
|
55
|
+
const isSecondaryOnly = opportunity.vaultConfig?.secondaryOnly === true;
|
|
56
|
+
|
|
57
|
+
// For secondary market, always use swap mode
|
|
58
|
+
const effectiveDepositMode = isSecondaryOnly ? "route" : depositMode;
|
|
59
|
+
const opportunityChainId = Number(opportunity.receiptToken.chain.chainId)
|
|
60
|
+
|
|
61
|
+
// Use on-chain balances when: direct deposit OR withdraw
|
|
62
|
+
const useOnChainBalances = effectiveDepositMode === "native" || actionMode === "withdraw";
|
|
63
|
+
|
|
64
|
+
// Always fetch both balance sources to avoid loading states when switching modes
|
|
65
|
+
const {
|
|
66
|
+
balances: depositTokenBalances,
|
|
67
|
+
isLoading: isDepositBalancesLoading,
|
|
68
|
+
refetch: refetchDepositBalances,
|
|
69
|
+
} = useGetOnChainBalance({
|
|
70
|
+
tokens: opportunity.depositTokens,
|
|
71
|
+
chainId: opportunityChainId,
|
|
72
|
+
address,
|
|
73
|
+
enabled: !!address,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
balances: allChainBalances,
|
|
78
|
+
isLoading: isAllChainBalancesLoading,
|
|
79
|
+
refetchAll: refetchAllBalances,
|
|
80
|
+
} = useBalance({
|
|
81
|
+
address,
|
|
82
|
+
chainIds: [opportunityChainId],
|
|
83
|
+
depositOpportunity: opportunity,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const rawBalances = useMemo(() => {
|
|
87
|
+
if (useOnChainBalances) {
|
|
88
|
+
if (depositTokenBalances.length > 0) {
|
|
89
|
+
return depositTokenBalances;
|
|
90
|
+
}
|
|
91
|
+
// Fallback: create TokenBalance entries from deposit tokens with 0 balance
|
|
92
|
+
return opportunity.depositTokens.map((token) => ({
|
|
93
|
+
token,
|
|
94
|
+
amount: "0",
|
|
95
|
+
source: "onchain" as const,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
return allChainBalances;
|
|
99
|
+
}, [useOnChainBalances, depositTokenBalances, allChainBalances, opportunity.depositTokens]);
|
|
100
|
+
|
|
101
|
+
const isBalancesLoading = useOnChainBalances
|
|
102
|
+
? isDepositBalancesLoading
|
|
103
|
+
: isAllChainBalancesLoading;
|
|
104
|
+
|
|
105
|
+
const refetchBalances = useOnChainBalances
|
|
106
|
+
? refetchDepositBalances
|
|
107
|
+
: refetchAllBalances;
|
|
108
|
+
|
|
109
|
+
const balances = useMemo(() => {
|
|
110
|
+
let filtered = rawBalances;
|
|
111
|
+
|
|
112
|
+
// Filter by opportunity chain
|
|
113
|
+
if (!useOnChainBalances && opportunityChainId) {
|
|
114
|
+
filtered = filtered.filter((b) => {
|
|
115
|
+
const tokenChainId = Number(b.token.chain?.chainId);
|
|
116
|
+
return tokenChainId === opportunityChainId;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Filter zero balances (only for all-chain mode)
|
|
121
|
+
if (!useOnChainBalances) {
|
|
122
|
+
filtered = filtered.filter((b) => BigInt(b.amount) > 0n);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Filter excluded tokens
|
|
126
|
+
filtered = filterExcludedTokens(filtered);
|
|
127
|
+
|
|
128
|
+
return sortByBalance(filtered);
|
|
129
|
+
}, [rawBalances, useOnChainBalances, opportunityChainId]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="flex flex-col gap-3">
|
|
133
|
+
<SegmentControl
|
|
134
|
+
value={actionMode}
|
|
135
|
+
onChange={(value) => setActionMode(value as "deposit" | "withdraw")}
|
|
136
|
+
items={[
|
|
137
|
+
{
|
|
138
|
+
value: "deposit",
|
|
139
|
+
label: opportunity.depositDisabled ? (
|
|
140
|
+
<TurtleTooltip
|
|
141
|
+
trigger={<span>{isSecondaryOnly ? "Buy" : "Deposit"}</span>}
|
|
142
|
+
content={<span className="block p-2">{isSecondaryOnly ? "Purchases are" : "Deposits are"} disabled for this opportunity</span>}
|
|
143
|
+
/>
|
|
144
|
+
) : (
|
|
145
|
+
isSecondaryOnly ? "Buy" : "Deposit"
|
|
146
|
+
),
|
|
147
|
+
disabled: opportunity.depositDisabled,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
value: "withdraw",
|
|
151
|
+
label: opportunity.withdrawalDisabled ? (
|
|
152
|
+
<TurtleTooltip
|
|
153
|
+
trigger={<span>{isSecondaryOnly ? "Sell" : "Withdraw"}</span>}
|
|
154
|
+
content={<span className="block p-2">{isSecondaryOnly ? "Sales are" : "Withdrawals are"} disabled for this opportunity</span>}
|
|
155
|
+
/>
|
|
156
|
+
) : (
|
|
157
|
+
isSecondaryOnly ? "Sell" : "Withdraw"
|
|
158
|
+
),
|
|
159
|
+
disabled: opportunity.withdrawalDisabled,
|
|
160
|
+
},
|
|
161
|
+
]}
|
|
162
|
+
variant="segment"
|
|
163
|
+
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{actionMode === "deposit" ? (
|
|
167
|
+
<NativeDepositSection
|
|
168
|
+
opportunity={opportunity}
|
|
169
|
+
address={address}
|
|
170
|
+
distributorId={distributorId}
|
|
171
|
+
balances={balances}
|
|
172
|
+
isBalancesLoading={isBalancesLoading}
|
|
173
|
+
refetchBalances={refetchBalances}
|
|
174
|
+
executeTransactionAndWait={executeTransactionAndWait}
|
|
175
|
+
onDepositSuccess={onDepositSuccess}
|
|
176
|
+
depositMode={effectiveDepositMode}
|
|
177
|
+
onDepositModeChange={opportunity.earnEnabled && !isSecondaryOnly ? setDepositMode : undefined}
|
|
178
|
+
chainId={chainId}
|
|
179
|
+
switchChain={switchChain}
|
|
180
|
+
/>
|
|
181
|
+
) : (
|
|
182
|
+
<NativeWithdrawSection
|
|
183
|
+
opportunity={opportunity}
|
|
184
|
+
address={address}
|
|
185
|
+
balances={balances}
|
|
186
|
+
isBalancesLoading={isBalancesLoading}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpportunityActions, type OpportunityActionsProps } from "./OpportunityActions";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Opportunity, TokenBalance } from "@turtleclub/hooks";
|
|
2
2
|
import { APIStatus, DataTable } from "@turtleclub/ui";
|
|
3
|
-
import type { ColumnDef } from "@tanstack/react-table";
|
|
3
|
+
import type { ColumnDef, Row } from "@tanstack/react-table";
|
|
4
4
|
import { formatCurrency } from "@turtleclub/utils";
|
|
5
5
|
import { getTotalYield } from "../hooks/useTotalYield";
|
|
6
6
|
import { calculateNetAPR } from "../utils/calculateNetAPR";
|
|
@@ -145,7 +145,7 @@ export function OpportunitiesTable({
|
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
id: "yourTvl",
|
|
148
|
-
|
|
148
|
+
accessorFn: (row) => userTvlByOpportunity[row.id || ""] || 0,
|
|
149
149
|
header: () => <div className="flex w-full justify-end">Your TVL</div>,
|
|
150
150
|
cell: ({ row }) => {
|
|
151
151
|
const yourTvl = userTvlByOpportunity[row.original.id || ""] || 0;
|
|
@@ -156,7 +156,7 @@ export function OpportunitiesTable({
|
|
|
156
156
|
);
|
|
157
157
|
},
|
|
158
158
|
enableSorting: true,
|
|
159
|
-
sortingFn: (rowA
|
|
159
|
+
sortingFn: (rowA: Row<Opportunity>, rowB: Row<Opportunity>) => {
|
|
160
160
|
const a = userTvlByOpportunity[rowA.original.id || ""] || 0;
|
|
161
161
|
const b = userTvlByOpportunity[rowB.original.id || ""] || 0;
|
|
162
162
|
return a - b;
|
|
@@ -180,7 +180,8 @@ export function OpportunitiesTable({
|
|
|
180
180
|
enableSorting: true,
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
|
-
|
|
183
|
+
id: "totalYield",
|
|
184
|
+
accessorFn: (row) => calculateNetAPR(row.incentives, row.vaultConfig ?? undefined),
|
|
184
185
|
header: () => <div className="flex w-full justify-end">Est. APR</div>,
|
|
185
186
|
cell: ({ row }) => (
|
|
186
187
|
<div className="flex flex-row-reverse">
|
|
@@ -192,7 +193,7 @@ export function OpportunitiesTable({
|
|
|
192
193
|
</div>
|
|
193
194
|
),
|
|
194
195
|
enableSorting: true,
|
|
195
|
-
sortingFn: (rowA
|
|
196
|
+
sortingFn: (rowA: Row<Opportunity>, rowB: Row<Opportunity>) => {
|
|
196
197
|
const a = calculateNetAPR(rowA.original.incentives, rowA.original.vaultConfig ?? undefined);
|
|
197
198
|
const b = calculateNetAPR(rowB.original.incentives, rowB.original.vaultConfig ?? undefined);
|
|
198
199
|
return a - b;
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
// V1 - Legacy (uses TokenStep from hooks)
|
|
1
2
|
export { RouteDetails } from "./route-details";
|
|
2
3
|
export type { TokenStep } from "./route-details";
|
|
4
|
+
|
|
5
|
+
// V2 - New API structure (uses RouteMetadata)
|
|
6
|
+
export { RouteDetailsV2 } from "./route-details-v2";
|
|
7
|
+
export type { RouteDetailsV2Props } from "./route-details-v2";
|
|
8
|
+
export type { RouteMetadata, RouteStep, RouteToken } from "./types";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Card, CardContent } from "@turtleclub/ui";
|
|
2
|
+
import { formatBalance } from "@turtleclub/utils";
|
|
3
|
+
import { formatUnits } from "viem";
|
|
4
|
+
import { ArrowRight } from "lucide-react";
|
|
5
|
+
import type { RouteMetadata, RouteStep } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface RouteDetailsV2Props {
|
|
8
|
+
metadata: RouteMetadata;
|
|
9
|
+
showApprove?: boolean;
|
|
10
|
+
approveAmount?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MIN_FORMAT_THRESHOLD = 0.0001;
|
|
15
|
+
const SMALL_NUMBER_DECIMALS = 4;
|
|
16
|
+
|
|
17
|
+
function formatSmallNumber(val: string | number): string {
|
|
18
|
+
const num = typeof val === "string" ? parseFloat(val) : val;
|
|
19
|
+
if (isNaN(num) || num === 0) return "0";
|
|
20
|
+
if (num < MIN_FORMAT_THRESHOLD) return `<${MIN_FORMAT_THRESHOLD}`;
|
|
21
|
+
return formatBalance(num, SMALL_NUMBER_DECIMALS);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function TokenIcon({ token }: { token: RouteStep["from"] }) {
|
|
25
|
+
if (token.logoUrl) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex h-7 w-7 max-h-7 max-w-7 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
|
28
|
+
<img
|
|
29
|
+
src={token.logoUrl}
|
|
30
|
+
alt={token.symbol}
|
|
31
|
+
className="h-5 w-5 max-h-5 max-w-5 rounded-full object-cover"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex h-7 w-7 max-h-7 max-w-7 shrink-0 items-center justify-center rounded-full">
|
|
39
|
+
<div className="bg-neutral-alpha-10 flex h-5 w-5 items-center justify-center rounded-full">
|
|
40
|
+
<span className="text-muted-foreground text-[10px] font-medium">
|
|
41
|
+
{token.symbol?.charAt(0).toUpperCase() || "?"}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function StepCard({
|
|
49
|
+
label,
|
|
50
|
+
fromToken,
|
|
51
|
+
toToken,
|
|
52
|
+
amount,
|
|
53
|
+
}: {
|
|
54
|
+
label: string;
|
|
55
|
+
fromToken: RouteStep["from"];
|
|
56
|
+
toToken?: RouteStep["to"];
|
|
57
|
+
amount?: string;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<Card variant="border" className="h-[72px] min-w-[120px] flex-1">
|
|
61
|
+
<CardContent className="flex h-full flex-col justify-between px-5 py-2.5">
|
|
62
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
63
|
+
<div className="flex items-center gap-1.5">
|
|
64
|
+
<TokenIcon token={fromToken} />
|
|
65
|
+
<span className="text-sm font-medium">
|
|
66
|
+
{amount ? `${formatSmallNumber(amount)} ${fromToken.symbol}` : fromToken.symbol || "?"}
|
|
67
|
+
</span>
|
|
68
|
+
{toToken && (
|
|
69
|
+
<>
|
|
70
|
+
<ArrowRight className="text-muted-foreground size-3 shrink-0" />
|
|
71
|
+
<TokenIcon token={toToken} />
|
|
72
|
+
<span className="text-sm font-medium">{toToken.symbol || "?"}</span>
|
|
73
|
+
</>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</CardContent>
|
|
77
|
+
</Card>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Displays route details for routed deposits with provider info and step cards */
|
|
82
|
+
export function RouteDetailsV2({ metadata, showApprove, approveAmount, className }: RouteDetailsV2Props) {
|
|
83
|
+
const { provider, providerImg, route, amountOut } = metadata;
|
|
84
|
+
|
|
85
|
+
if (!route || route.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const approveToken = route[0]?.from;
|
|
90
|
+
const outputToken = route[route.length - 1]?.to;
|
|
91
|
+
|
|
92
|
+
// Format amountOut using the output token's decimals
|
|
93
|
+
const formattedAmountOut =
|
|
94
|
+
amountOut && outputToken
|
|
95
|
+
? formatSmallNumber(formatUnits(BigInt(amountOut), outputToken.decimals))
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={className}>
|
|
100
|
+
<div className="mb-3 space-y-3 px-2 text-xs">
|
|
101
|
+
{formattedAmountOut && outputToken && (
|
|
102
|
+
<div className="flex items-center gap-1.5">
|
|
103
|
+
<span className="text-muted-foreground">You receive</span>
|
|
104
|
+
<span className="text-sm font-medium">
|
|
105
|
+
{formattedAmountOut} {outputToken.symbol}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
<div className="flex items-center ">
|
|
110
|
+
<span className="text-muted-foreground">Route powered by</span>
|
|
111
|
+
{providerImg ? (
|
|
112
|
+
<img src={providerImg} alt={provider} className="h-2 px-2" />
|
|
113
|
+
) : (
|
|
114
|
+
<span className="text-sm font-medium capitalize">{provider}</span>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:overflow-x-auto">
|
|
120
|
+
{showApprove && approveToken && (
|
|
121
|
+
<StepCard label="Approve" fromToken={approveToken} amount={approveAmount} />
|
|
122
|
+
)}
|
|
123
|
+
{route.map((step, index) => {
|
|
124
|
+
const label = step.action.charAt(0).toUpperCase() + step.action.slice(1);
|
|
125
|
+
return (
|
|
126
|
+
<StepCard
|
|
127
|
+
key={`${step.action}-${step.from.address}-${index}`}
|
|
128
|
+
label={label}
|
|
129
|
+
fromToken={step.from}
|
|
130
|
+
toToken={step.to}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { Card, CardContent, LabelWithIcon, ScrollArea } from "@turtleclub/ui";
|
|
3
3
|
import { ChevronsRight } from "lucide-react";
|
|
4
|
-
import ensoLogo from "../images/enso.png";
|
|
5
4
|
import { useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
const ENSO_LOGO_URL = "https://storage.googleapis.com/turtle-assets/partners/enso/enso.png";
|
|
6
7
|
import type { TokenStep } from "@turtleclub/hooks";
|
|
7
8
|
|
|
8
9
|
export type { TokenStep } from "@turtleclub/hooks";
|
|
@@ -38,7 +39,7 @@ export const RouteDetails = ({ value, className }: RouteDetailsProps) => {
|
|
|
38
39
|
<div className="text-sm font-medium">Swap Route</div>
|
|
39
40
|
<div className="mb-1 flex items-center gap-1 text-[10px]">
|
|
40
41
|
<span className="text-sm opacity-50">Powered by</span>
|
|
41
|
-
<img src={
|
|
42
|
+
<img src={ENSO_LOGO_URL} className="w-10" alt="Enso" />
|
|
42
43
|
</div>
|
|
43
44
|
{/* Route visualization */}
|
|
44
45
|
<ScrollArea className="mt-2 rounded-md whitespace-nowrap">
|
|
@@ -69,7 +70,7 @@ export const RouteDetails = ({ value, className }: RouteDetailsProps) => {
|
|
|
69
70
|
)}
|
|
70
71
|
</LabelWithIcon>
|
|
71
72
|
|
|
72
|
-
{step.type !== "approve" && (
|
|
73
|
+
{step.type !== "approve" && step.out && (
|
|
73
74
|
<>
|
|
74
75
|
{/* Arrow between tokens */}
|
|
75
76
|
<ChevronsRight className="text-primary size-4 shrink-0" />
|
|
@@ -82,7 +83,7 @@ export const RouteDetails = ({ value, className }: RouteDetailsProps) => {
|
|
|
82
83
|
>
|
|
83
84
|
{!isLongRoute && (
|
|
84
85
|
<span className="hidden gap-2 sm:block">
|
|
85
|
-
{step.
|
|
86
|
+
{step.out.symbol}
|
|
86
87
|
</span>
|
|
87
88
|
)}
|
|
88
89
|
</LabelWithIcon>
|
|
@@ -32,7 +32,7 @@ interface UseTransactionQueueProps {
|
|
|
32
32
|
earnRoute: EarnRouteResponse | null;
|
|
33
33
|
userAddress?: string;
|
|
34
34
|
chainId?: number;
|
|
35
|
-
sendTransaction: (transaction: StepTx) => Promise<`0x${string}` | undefined>;
|
|
35
|
+
sendTransaction: (transaction: StepTx & { chainId?: number }) => Promise<`0x${string}` | undefined>;
|
|
36
36
|
onError?: (error: Error) => void;
|
|
37
37
|
onSuccess?: () => void;
|
|
38
38
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@turtleclub/ui";
|
|
5
|
+
import type { Opportunity, TokenBalance } from "@turtleclub/hooks";
|
|
6
|
+
import { GeoCheckBlocker, SwapInputV3 } from "../deposit/components";
|
|
7
|
+
|
|
8
|
+
export interface NativeWithdrawSectionProps {
|
|
9
|
+
opportunity: Opportunity;
|
|
10
|
+
address: string | undefined;
|
|
11
|
+
balances: TokenBalance[];
|
|
12
|
+
isBalancesLoading?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function NativeWithdrawSection({
|
|
16
|
+
opportunity,
|
|
17
|
+
address,
|
|
18
|
+
balances,
|
|
19
|
+
isBalancesLoading = false,
|
|
20
|
+
}: NativeWithdrawSectionProps) {
|
|
21
|
+
const [amount, setAmount] = useState<string | undefined>();
|
|
22
|
+
const [selectedTokenAddress, setSelectedTokenAddress] = useState<string | undefined>();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-col gap-3">
|
|
26
|
+
<SwapInputV3
|
|
27
|
+
value={amount ?? ""}
|
|
28
|
+
balances={balances}
|
|
29
|
+
selectedTokenAddress={selectedTokenAddress}
|
|
30
|
+
disabled={isBalancesLoading}
|
|
31
|
+
isWalletConnected={!!address}
|
|
32
|
+
onChange={setAmount}
|
|
33
|
+
onTokenChange={setSelectedTokenAddress}
|
|
34
|
+
showBalance={true}
|
|
35
|
+
className="border-border border p-5"
|
|
36
|
+
/>
|
|
37
|
+
|
|
38
|
+
<GeoCheckBlocker>
|
|
39
|
+
<Button disabled className="w-full">
|
|
40
|
+
Withdraw (Coming Soon)
|
|
41
|
+
</Button>
|
|
42
|
+
</GeoCheckBlocker>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NativeWithdrawSection, type NativeWithdrawSectionProps } from "./NativeWithdrawSection";
|