@turtleclub/opportunities 0.1.0-beta.56 → 0.1.0-beta.58

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.
@@ -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 };
@@ -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 };
@@ -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,6 +4,12 @@ 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
 
@@ -0,0 +1,182 @@
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
+ executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
21
+ onDepositSuccess?: () => void;
22
+ /** Current wallet chain ID */
23
+ chainId?: number;
24
+ /** Function to switch chain */
25
+ switchChain?: (chainId: number) => Promise<void>;
26
+ }
27
+
28
+ function sortByBalance(balances: TokenBalance[]): TokenBalance[] {
29
+ return [...balances].sort((a, b) => {
30
+ const aValue = BigInt(a.amount);
31
+ const bValue = BigInt(b.amount);
32
+ if (bValue > aValue) return 1;
33
+ if (bValue < aValue) return -1;
34
+ return 0;
35
+ });
36
+ }
37
+
38
+ export function OpportunityActions({
39
+ opportunity,
40
+ address,
41
+ distributorId,
42
+ executeTransaction,
43
+ onDepositSuccess,
44
+ chainId,
45
+ switchChain,
46
+ }: OpportunityActionsProps) {
47
+ const [actionMode, setActionMode] = useState<"deposit" | "withdraw">("deposit");
48
+ const [depositMode, setDepositMode] = useState<"native" | "route">("native");
49
+
50
+ const opportunityChainId = Number(opportunity.receiptToken.chain.chainId)
51
+
52
+ // Use on-chain balances when: direct deposit OR withdraw
53
+ const useOnChainBalances = depositMode === "native" || actionMode === "withdraw";
54
+
55
+ // Always fetch both balance sources to avoid loading states when switching modes
56
+ const {
57
+ balances: depositTokenBalances,
58
+ isLoading: isDepositBalancesLoading,
59
+ refetch: refetchDepositBalances,
60
+ } = useGetOnChainBalance({
61
+ tokens: opportunity.depositTokens,
62
+ chainId: opportunityChainId,
63
+ address,
64
+ enabled: !!address,
65
+ });
66
+
67
+ const {
68
+ balances: allChainBalances,
69
+ isLoading: isAllChainBalancesLoading,
70
+ refetchAll: refetchAllBalances,
71
+ } = useBalance({
72
+ address,
73
+ chainIds: [opportunityChainId],
74
+ depositOpportunity: opportunity,
75
+ });
76
+
77
+ const rawBalances = useMemo(() => {
78
+ if (useOnChainBalances) {
79
+ if (depositTokenBalances.length > 0) {
80
+ return depositTokenBalances;
81
+ }
82
+ // Fallback: create TokenBalance entries from deposit tokens with 0 balance
83
+ return opportunity.depositTokens.map((token) => ({
84
+ token,
85
+ amount: "0",
86
+ source: "onchain" as const,
87
+ }));
88
+ }
89
+ return allChainBalances;
90
+ }, [useOnChainBalances, depositTokenBalances, allChainBalances, opportunity.depositTokens]);
91
+
92
+ const isBalancesLoading = useOnChainBalances
93
+ ? isDepositBalancesLoading
94
+ : isAllChainBalancesLoading;
95
+
96
+ const refetchBalances = useOnChainBalances
97
+ ? refetchDepositBalances
98
+ : refetchAllBalances;
99
+
100
+ const balances = useMemo(() => {
101
+ let filtered = rawBalances;
102
+
103
+ // Filter by opportunity chain
104
+ if (!useOnChainBalances && opportunityChainId) {
105
+ filtered = filtered.filter((b) => {
106
+ const tokenChainId = Number(b.token.chain?.chainId);
107
+ return tokenChainId === opportunityChainId;
108
+ });
109
+ }
110
+
111
+ // Filter zero balances (only for all-chain mode)
112
+ if (!useOnChainBalances) {
113
+ filtered = filtered.filter((b) => BigInt(b.amount) > 0n);
114
+ }
115
+
116
+ // Filter excluded tokens
117
+ filtered = filterExcludedTokens(filtered);
118
+
119
+ return sortByBalance(filtered);
120
+ }, [rawBalances, useOnChainBalances, opportunityChainId]);
121
+
122
+ return (
123
+ <div className="flex flex-col gap-3">
124
+ <SegmentControl
125
+ value={actionMode}
126
+ onChange={(value) => setActionMode(value as "deposit" | "withdraw")}
127
+ items={[
128
+ {
129
+ value: "deposit",
130
+ label: opportunity.depositDisabled ? (
131
+ <TurtleTooltip
132
+ trigger={<span>Deposit</span>}
133
+ content={<span className="block p-2">Deposits are disabled for this opportunity</span>}
134
+ />
135
+ ) : (
136
+ "Deposit"
137
+ ),
138
+ disabled: opportunity.depositDisabled,
139
+ },
140
+ {
141
+ value: "withdraw",
142
+ label: opportunity.withdrawalDisabled ? (
143
+ <TurtleTooltip
144
+ trigger={<span>Withdraw</span>}
145
+ content={<span className="block p-2">Withdrawals are disabled for this opportunity</span>}
146
+ />
147
+ ) : (
148
+ "Withdraw"
149
+ ),
150
+ disabled: opportunity.withdrawalDisabled,
151
+ },
152
+ ]}
153
+ variant="segment"
154
+
155
+ />
156
+
157
+ {actionMode === "deposit" ? (
158
+ <NativeDepositSection
159
+ opportunity={opportunity}
160
+ address={address}
161
+ distributorId={distributorId}
162
+ balances={balances}
163
+ isBalancesLoading={isBalancesLoading}
164
+ refetchBalances={refetchBalances}
165
+ executeTransaction={executeTransaction}
166
+ onDepositSuccess={onDepositSuccess}
167
+ depositMode={depositMode}
168
+ onDepositModeChange={opportunity.earnEnabled ? setDepositMode : undefined}
169
+ chainId={chainId}
170
+ switchChain={switchChain}
171
+ />
172
+ ) : (
173
+ <NativeWithdrawSection
174
+ opportunity={opportunity}
175
+ address={address}
176
+ balances={balances}
177
+ isBalancesLoading={isBalancesLoading}
178
+ />
179
+ )}
180
+ </div>
181
+ );
182
+ }
@@ -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
- accessorKey: "yourTvl",
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, rowB) => {
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
- accessorKey: "totalYield",
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, rowB) => {
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";