@turtleclub/opportunities 0.1.0-beta.0

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 (30) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +28 -0
  3. package/src/components/balances-data-table.tsx +85 -0
  4. package/src/components/index.ts +1 -0
  5. package/src/constants.ts +1 -0
  6. package/src/deposit/components/confirm-button.tsx +137 -0
  7. package/src/deposit/components/geo-check-blocker.tsx +40 -0
  8. package/src/deposit/components/index.ts +2 -0
  9. package/src/deposit/index.ts +1 -0
  10. package/src/images/enso.png +0 -0
  11. package/src/index.ts +17 -0
  12. package/src/opportunity-table/components/apr-breakdown-tooltip.tsx +103 -0
  13. package/src/opportunity-table/components/chain-list.tsx +28 -0
  14. package/src/opportunity-table/components/incentives-breakdown.tsx +263 -0
  15. package/src/opportunity-table/components/index.ts +4 -0
  16. package/src/opportunity-table/components/opportunities-table.tsx +218 -0
  17. package/src/opportunity-table/hooks/index.ts +2 -0
  18. package/src/opportunity-table/hooks/useNetAPR.ts +15 -0
  19. package/src/opportunity-table/hooks/useTotalYield.ts +12 -0
  20. package/src/opportunity-table/utils/calculateNetAPR.ts +37 -0
  21. package/src/opportunity-table/utils/index.ts +1 -0
  22. package/src/route-details/index.ts +2 -0
  23. package/src/route-details/route-details.tsx +100 -0
  24. package/src/transaction-status/components/TransactionStatusSection.tsx +81 -0
  25. package/src/transaction-status/hooks/useTransactionQueue.ts +322 -0
  26. package/src/transaction-status/index.ts +8 -0
  27. package/src/transaction-status/types/index.ts +22 -0
  28. package/src/transaction-status/utils/index.ts +80 -0
  29. package/src/transaction-status/utils/selectors.ts +66 -0
  30. package/tsconfig.json +22 -0
@@ -0,0 +1,263 @@
1
+ import { Separator, TurtleTooltip, cn } from "@turtleclub/ui";
2
+ import { useMemo } from "react";
3
+ import { InfoIcon } from "lucide-react";
4
+ import type { Incentive, VaultConfig } from "@turtleclub/hooks";
5
+ import { formatNumber } from "@turtleclub/utils";
6
+ import { useNetAPR } from "../hooks/useNetAPR";
7
+
8
+ interface IncentivesBreakdownProps {
9
+ incentives: Incentive[];
10
+ vaultConfig?: VaultConfig;
11
+ showIcons?: boolean;
12
+ showNestedTooltips?: boolean;
13
+ showDescriptions?: boolean;
14
+ size?: "xs" | "sm";
15
+ }
16
+
17
+ export function IncentivesBreakdown({
18
+ incentives,
19
+ vaultConfig,
20
+ showIcons = true,
21
+ showNestedTooltips = true,
22
+ showDescriptions = false,
23
+ size = "xs",
24
+ }: IncentivesBreakdownProps) {
25
+ const sortedIncentives = useMemo(() => {
26
+ return incentives
27
+ .filter(
28
+ (incentive) => incentive.rewardType === "points" || (incentive.yield !== null && incentive.yield !== undefined),
29
+ )
30
+ .sort((a, b) => {
31
+ // Points always go to the end
32
+ if (a.rewardType === "points" && b.rewardType !== "points") return 1;
33
+ if (a.rewardType !== "points" && b.rewardType === "points") return -1;
34
+ // Sort by yield for non-points
35
+ return (b.yield ?? 0) - (a.yield ?? 0);
36
+ });
37
+ }, [incentives]);
38
+
39
+ // Calculate fee amounts for display
40
+ const { performanceFeeAmount, managementFeeAmount } = useMemo(() => {
41
+ const native = sortedIncentives.filter((incentive) => incentive.name === "Native Yield");
42
+ const nativeYieldValue = native.reduce((sum, i) => sum + (i.yield ?? 0), 0);
43
+ const perfFee = vaultConfig?.performanceFee ?? 0;
44
+ const mgmtFee = vaultConfig?.managementFee ?? 0;
45
+ const perfFeeAmount = nativeYieldValue === 0 ? 0 : (nativeYieldValue * perfFee) / 100;
46
+
47
+ return {
48
+ performanceFeeAmount: perfFeeAmount,
49
+ managementFeeAmount: mgmtFee,
50
+ };
51
+ }, [sortedIncentives, vaultConfig]);
52
+
53
+ // Use shared hook for net APR calculation
54
+ const netAPR = useNetAPR(incentives, vaultConfig);
55
+
56
+ const hasFeeConfig = vaultConfig !== undefined;
57
+ const performanceFee = vaultConfig?.performanceFee ?? 0;
58
+ const depositFee = vaultConfig?.depositFee ?? 0;
59
+ const withdrawalFee = vaultConfig?.withdrawalFee ?? 0;
60
+ const textSize = size === "sm" ? "text-sm" : "text-xs";
61
+ const headerTextSize = size === "sm" ? "text-xs" : "text-xs";
62
+ const descriptionTextSize = size === "sm" ? "text-xs" : "text-[10px]";
63
+
64
+ return (
65
+ <div className="space-y-3">
66
+ <div
67
+ className={cn(
68
+ "text-muted-foreground grid gap-4",
69
+ headerTextSize,
70
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
71
+ )}
72
+ >
73
+ <span>Source</span>
74
+ {showDescriptions && <span className="hidden md:block">Description</span>}
75
+ <span className="text-right">Est. APR</span>
76
+ </div>
77
+
78
+ {sortedIncentives.map((incentive) => (
79
+ <div
80
+ key={incentive.id}
81
+ className={cn(
82
+ "grid items-center gap-4",
83
+ textSize,
84
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
85
+ )}
86
+ >
87
+ <div className="flex items-center gap-2">
88
+ {showIcons && (
89
+ <div className="ring-border rounded-full ring">
90
+ <img src={incentive.iconUrl} alt={incentive.name} className="size-4 rounded-full" loading="lazy" />
91
+ </div>
92
+ )}
93
+ <span className="text-secondary-foreground/80 truncate">{incentive.name}</span>
94
+ </div>
95
+
96
+ {showDescriptions && (
97
+ <div className={cn("text-secondary-foreground/80 wrap-break-word hidden md:block", descriptionTextSize)}>
98
+ <span className="block">{incentive.description || "—"}</span>
99
+ </div>
100
+ )}
101
+
102
+ <div
103
+ className={
104
+ incentive.rewardType === "points" ? "text-muted-foreground text-right" : "text-success text-right"
105
+ }
106
+ >
107
+ {incentive.rewardType === "points" ? "-" : `+${formatNumber(incentive.yield ?? 0, 2, false, true)}%`}
108
+ </div>
109
+ </div>
110
+ ))}
111
+
112
+ {hasFeeConfig && (
113
+ <>
114
+ <Separator />
115
+ <div
116
+ className={cn(
117
+ "grid items-center gap-4",
118
+ textSize,
119
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
120
+ )}
121
+ >
122
+ <div className="flex items-center gap-2">
123
+ <span className="text-secondary-foreground/80">
124
+ Performance Fee
125
+ {!showNestedTooltips && performanceFee > 0 && (
126
+ <span className="text-muted-foreground ml-1 text-[10px]">
127
+ ({formatNumber(performanceFee, 2, false, true)}% on native yield)
128
+ </span>
129
+ )}
130
+ </span>
131
+ {showNestedTooltips && performanceFee > 0 && (
132
+ <TurtleTooltip
133
+ trigger={<InfoIcon className="size-3 shrink-0" />}
134
+ content={
135
+ <div className="text-xs">
136
+ {formatNumber(performanceFee, 0, false, true)}% performance fee on native yield.
137
+ </div>
138
+ }
139
+ />
140
+ )}
141
+ </div>
142
+ {showDescriptions && <div className="hidden md:block" />}
143
+ <div
144
+ className={performanceFeeAmount > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}
145
+ >
146
+ {performanceFeeAmount > 0 ? "-" : ""}
147
+ {formatNumber(performanceFeeAmount, 2, false, true)}%
148
+ </div>
149
+ </div>
150
+ <div
151
+ className={cn(
152
+ "grid items-center gap-4",
153
+ textSize,
154
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
155
+ )}
156
+ >
157
+ <div className="flex items-center gap-2">
158
+ <span className="text-secondary-foreground/80">
159
+ Management Fee
160
+ {!showNestedTooltips && managementFeeAmount > 0 && (
161
+ <span className="text-muted-foreground ml-1 text-[10px]">
162
+ ({formatNumber(managementFeeAmount, 2, false, true)}% annual fee)
163
+ </span>
164
+ )}
165
+ </span>
166
+ {showNestedTooltips && managementFeeAmount > 0 && (
167
+ <TurtleTooltip
168
+ trigger={<InfoIcon className="size-3 shrink-0" />}
169
+ content={
170
+ <div className="text-xs">
171
+ {formatNumber(managementFeeAmount, 0, false, true)}% annual management fee on vault TVL.
172
+ </div>
173
+ }
174
+ />
175
+ )}
176
+ </div>
177
+ {showDescriptions && <div className="hidden md:block" />}
178
+ <div
179
+ className={managementFeeAmount > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}
180
+ >
181
+ {managementFeeAmount > 0 ? "-" : ""}
182
+ {formatNumber(managementFeeAmount, 2, false, true)}%
183
+ </div>
184
+ </div>
185
+ <div
186
+ className={cn(
187
+ "grid items-center gap-4",
188
+ textSize,
189
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
190
+ )}
191
+ >
192
+ <div className="flex items-center gap-2">
193
+ <span className="text-secondary-foreground/80">Deposit Fee</span>
194
+ {!showNestedTooltips && depositFee > 0 && (
195
+ <span className="text-muted-foreground ml-1 text-[10px]">
196
+ {formatNumber(depositFee, 2, false, true)}% on deposit)
197
+ </span>
198
+ )}
199
+ {showNestedTooltips && depositFee > 0 && (
200
+ <TurtleTooltip
201
+ trigger={<InfoIcon className="size-3 shrink-0" />}
202
+ content={
203
+ <div className="text-xs">
204
+ {formatNumber(depositFee, 2, false, true)}% fee on every deposit into the vault.
205
+ </div>
206
+ }
207
+ />
208
+ )}
209
+ </div>
210
+ {showDescriptions && <div className="hidden md:block" />}
211
+ <div className={depositFee > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}>
212
+ {formatNumber(depositFee, 2, false, true)}%
213
+ </div>
214
+ </div>
215
+ <div
216
+ className={cn(
217
+ "grid items-center gap-4",
218
+ textSize,
219
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
220
+ )}
221
+ >
222
+ <div className="flex items-center gap-2">
223
+ <span className="text-secondary-foreground/80">Withdrawal Fee</span>
224
+ {!showNestedTooltips && withdrawalFee > 0 && (
225
+ <TurtleTooltip
226
+ trigger={<InfoIcon className="size-3 shrink-0" />}
227
+ content={<div className="text-xs">{formatNumber(withdrawalFee, 2, false, true)}% on withdrawal</div>}
228
+ />
229
+ )}
230
+ {showNestedTooltips && withdrawalFee > 0 && (
231
+ <TurtleTooltip
232
+ trigger={<InfoIcon className="size-3 shrink-0" />}
233
+ content={
234
+ <div className="text-xs">
235
+ {formatNumber(withdrawalFee, 2, false, true)}% fee on every withdrawal from the vault.
236
+ </div>
237
+ }
238
+ />
239
+ )}
240
+ </div>
241
+ {showDescriptions && <div className="hidden md:block" />}
242
+ <div className={withdrawalFee > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}>
243
+ {withdrawalFee > 0 ? "-" : ""}
244
+ {formatNumber(withdrawalFee, 2, false, true)}%
245
+ </div>
246
+ </div>
247
+ <Separator />
248
+ <div
249
+ className={cn(
250
+ "grid items-center gap-4 font-semibold",
251
+ textSize,
252
+ showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
253
+ )}
254
+ >
255
+ <span>Est. Net APR</span>
256
+ {showDescriptions && <div className="hidden md:block" />}
257
+ <div className="text-primary text-right">{formatNumber(netAPR, 2, false, true)}%</div>
258
+ </div>
259
+ </>
260
+ )}
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,4 @@
1
+ export { APRBreakdownTooltip } from "./apr-breakdown-tooltip";
2
+ export { ChainBadge } from "./chain-list";
3
+ export { OpportunitiesTable } from "./opportunities-table";
4
+ export { IncentivesBreakdown } from "./incentives-breakdown";
@@ -0,0 +1,218 @@
1
+ import type { Opportunity, TokenBalance } from "@turtleclub/hooks";
2
+ import { APIStatus, DataTable } from "@turtleclub/ui";
3
+ import type { ColumnDef } from "@tanstack/react-table";
4
+ import { formatCurrency } from "@turtleclub/utils";
5
+ import { getTotalYield } from "../hooks/useTotalYield";
6
+ import { calculateNetAPR } from "../utils/calculateNetAPR";
7
+ import { APRBreakdownTooltip } from "./apr-breakdown-tooltip";
8
+ import { ChainBadge } from "./chain-list";
9
+
10
+ interface OpportunitiesTableProps {
11
+ opportunities: Opportunity[];
12
+ currentNetwork?: number;
13
+ onSelectOpportunity: (opportunity: Opportunity) => void;
14
+ onChangeNetwork?: (chainId: number) => void;
15
+ onResetToken?: () => void;
16
+ onClose?: () => void;
17
+ userTvlByOpportunity?: Record<string, number>;
18
+ turtleTvl?: Record<string, number>;
19
+ isWidget?: boolean;
20
+ }
21
+
22
+ export function OpportunitiesTable({
23
+ opportunities,
24
+ currentNetwork,
25
+ onSelectOpportunity,
26
+ onChangeNetwork,
27
+ onResetToken,
28
+ onClose,
29
+ userTvlByOpportunity = {},
30
+ turtleTvl = {},
31
+ isWidget = false,
32
+ }: OpportunitiesTableProps) {
33
+ const handleSelectOpportunity = (opportunity: Opportunity) => {
34
+ onSelectOpportunity(opportunity);
35
+ const opportunityChainId = Number(opportunity.receiptToken.chain.chainId);
36
+ if (currentNetwork && opportunityChainId !== currentNetwork && onChangeNetwork) {
37
+ onChangeNetwork(opportunityChainId);
38
+ // Reset token to trigger auto-selection of first token in new chain
39
+ onResetToken?.();
40
+ }
41
+ onClose?.();
42
+ };
43
+
44
+ const columns: ColumnDef<Opportunity>[] = [
45
+ {
46
+ accessorKey: "name",
47
+ header: () => <div>Opportunity</div>,
48
+ cell: ({ row }) => (
49
+ <div className="flex items-center gap-2">
50
+ <div className="relative shrink-0">
51
+ <img
52
+ src={row.original.receiptToken.logoUrl}
53
+ className="size-6 rounded-full"
54
+ loading="lazy"
55
+ alt={row.original.receiptToken.name}
56
+ />
57
+ {row.original.receiptToken.chain.logoUrl && (
58
+ <img
59
+ src={row.original.receiptToken.chain.logoUrl}
60
+ alt={row.original.receiptToken.chain.name}
61
+ className="absolute right-[-5px] top-0 size-3 rounded-full"
62
+ loading="lazy"
63
+ />
64
+ )}
65
+ </div>
66
+ {row.original.name || row.original.receiptToken.name}
67
+ </div>
68
+ ),
69
+ enableSorting: true,
70
+ },
71
+ {
72
+ id: "type",
73
+ accessorFn: (row) => row.type ?? "-",
74
+ header: () => <div>Type</div>,
75
+ cell: ({ row }) =>
76
+ row.original.type ? (
77
+ <div className="text-sm capitalize">{row.original.type.toLowerCase()}</div>
78
+ ) : (
79
+ "-"
80
+ ),
81
+ enableSorting: true,
82
+ },
83
+ {
84
+ id: "organization",
85
+ accessorFn: (row) => (row.products.length ? row.products[0].organization.name : "-"),
86
+ header: () => <div>Partner</div>,
87
+ cell: ({ row }) =>
88
+ row.original.products.length ? (
89
+ <div className="flex h-[30px] items-center gap-2">
90
+ <img
91
+ src={row.original.products[0].organization.iconUrl || ""}
92
+ className="size-6 rounded-full"
93
+ loading="lazy"
94
+ />
95
+ {row.original.products[0].organization.name}
96
+ </div>
97
+ ) : (
98
+ "-"
99
+ ),
100
+ enableSorting: true,
101
+ },
102
+ {
103
+ id: "curator",
104
+ accessorFn: (row) => row.vaultConfig?.curator?.name ?? "-",
105
+ header: () => <div>Curator</div>,
106
+ cell: ({ row }) =>
107
+ row.original.vaultConfig?.curator ? (
108
+ <div className="flex h-[30px] items-center gap-2">
109
+ {row.original.vaultConfig.curator.iconUrl && (
110
+ <img
111
+ src={row.original.vaultConfig.curator.iconUrl}
112
+ className="size-6 rounded-full"
113
+ loading="lazy"
114
+ alt={row.original.vaultConfig.curator.name}
115
+ />
116
+ )}
117
+ {row.original.vaultConfig.curator.name}
118
+ </div>
119
+ ) : (
120
+ "-"
121
+ ),
122
+ enableSorting: true,
123
+ },
124
+ {
125
+ id: "provider",
126
+ accessorFn: (row) => row.vaultConfig?.infraProvider?.name ?? "-",
127
+ header: () => <div>Provider</div>,
128
+ cell: ({ row }) =>
129
+ row.original.vaultConfig?.infraProvider ? (
130
+ <div className="flex h-[30px] items-center gap-2">
131
+ {row.original.vaultConfig.infraProvider.iconUrl && (
132
+ <img
133
+ src={row.original.vaultConfig.infraProvider.iconUrl}
134
+ className="size-6 rounded-full"
135
+ loading="lazy"
136
+ alt={row.original.vaultConfig.infraProvider.name}
137
+ />
138
+ )}
139
+ {row.original.vaultConfig.infraProvider.name}
140
+ </div>
141
+ ) : (
142
+ "-"
143
+ ),
144
+ enableSorting: true,
145
+ },
146
+ {
147
+ id: "yourTvl",
148
+ accessorKey: "yourTvl",
149
+ header: () => <div className="flex w-full justify-end">Your TVL</div>,
150
+ cell: ({ row }) => {
151
+ const yourTvl = userTvlByOpportunity[row.original.id || ""] || 0;
152
+ return (
153
+ <div className="flex h-[30px] items-center justify-end text-end">
154
+ {formatCurrency(yourTvl, 0, true)}
155
+ </div>
156
+ );
157
+ },
158
+ enableSorting: true,
159
+ sortingFn: (rowA, rowB) => {
160
+ const a = userTvlByOpportunity[rowA.original.id || ""] || 0;
161
+ const b = userTvlByOpportunity[rowB.original.id || ""] || 0;
162
+ return a - b;
163
+ },
164
+ },
165
+ {
166
+ accessorKey: "turtleTvl",
167
+ header: () => <div className="flex w-full justify-end">Turtle TVL</div>,
168
+ cell: ({ row }) => {
169
+ const turtle = turtleTvl[row.original.id || ""] || 0;
170
+ return <div className="text-end">{formatCurrency(turtle, 0, true)}</div>;
171
+ },
172
+ enableSorting: true,
173
+ },
174
+ {
175
+ accessorKey: "tvl",
176
+ header: () => <div className="flex w-full justify-end">Total TVL</div>,
177
+ cell: ({ row }) => (
178
+ <div className="text-end">{formatCurrency(row.original.tvl, 0, true)}</div>
179
+ ),
180
+ enableSorting: true,
181
+ },
182
+ {
183
+ accessorKey: "totalYield",
184
+ header: () => <div className="flex w-full justify-end">Est. APR</div>,
185
+ cell: ({ row }) => (
186
+ <div className="flex flex-row-reverse">
187
+ <APRBreakdownTooltip
188
+ incentives={row.original.incentives}
189
+ vaultConfig={row.original.vaultConfig ?? undefined}
190
+ isWidget={isWidget}
191
+ />
192
+ </div>
193
+ ),
194
+ enableSorting: true,
195
+ sortingFn: (rowA, rowB) => {
196
+ const a = calculateNetAPR(rowA.original.incentives, rowA.original.vaultConfig ?? undefined);
197
+ const b = calculateNetAPR(rowB.original.incentives, rowB.original.vaultConfig ?? undefined);
198
+ return a - b;
199
+ },
200
+ },
201
+ ];
202
+
203
+ return (
204
+ <DataTable
205
+ columns={columns}
206
+ onRowClick={handleSelectOpportunity}
207
+ data={opportunities}
208
+ enableSearch
209
+ grid={{
210
+ displayAsGrid: true,
211
+ headerSlot: "name",
212
+ rightSlot: "totalYield",
213
+ excludeColumns: ["turtleTvl", "type", "yourTvl"],
214
+ className: "grid-cols-1 gap-2.5",
215
+ }}
216
+ />
217
+ );
218
+ }
@@ -0,0 +1,2 @@
1
+ export { useNetAPR } from "./useNetAPR";
2
+ export { useTotalYield } from "./useTotalYield";
@@ -0,0 +1,15 @@
1
+ import { useMemo } from "react";
2
+ import type { Incentive, VaultConfig } from "@turtleclub/hooks";
3
+ import { calculateNetAPR } from "../utils/calculateNetAPR";
4
+
5
+ /**
6
+ * React hook that calculates the net APR after fees for an opportunity
7
+ * @param incentives - Array of incentive objects
8
+ * @param vaultConfig - Optional vault configuration with fee information
9
+ * @returns Memoized net APR after performance and management fees
10
+ */
11
+ export function useNetAPR(incentives: Incentive[], vaultConfig?: VaultConfig) {
12
+ return useMemo(() => {
13
+ return calculateNetAPR(incentives, vaultConfig);
14
+ }, [incentives, vaultConfig]);
15
+ }
@@ -0,0 +1,12 @@
1
+ import { useMemo } from "react";
2
+ import type { Incentive } from "@turtleclub/hooks";
3
+
4
+ export const getTotalYield = (incentives: Incentive[]) =>
5
+ incentives
6
+ .filter((i) => i.rewardType !== "points")
7
+ .reduce((acc, incentive) => acc + (incentive.yield ?? 0), 0);
8
+
9
+ export const useTotalYield = (incentives: Incentive[]) => {
10
+ const totalYield = useMemo(() => getTotalYield(incentives), [incentives]);
11
+ return { totalYield };
12
+ };
@@ -0,0 +1,37 @@
1
+ import type { Incentive, VaultConfig } from "@turtleclub/hooks";
2
+
3
+ /**
4
+ * Calculates the net APR after fees for an opportunity
5
+ * @param incentives - Array of incentive objects
6
+ * @param vaultConfig - Optional vault configuration with fee information
7
+ * @returns Net APR after performance and management fees
8
+ */
9
+ export function calculateNetAPR(incentives: Incentive[], vaultConfig?: VaultConfig): number {
10
+ // Calculate total yield from all non-points incentives
11
+ const totalYield = incentives
12
+ .filter(
13
+ (incentive) => incentive.rewardType !== "points" && incentive.yield !== null && incentive.yield !== undefined,
14
+ )
15
+ .reduce((sum, incentive) => sum + (incentive.yield ?? 0), 0);
16
+
17
+ // If no vault config, return total yield
18
+ if (!vaultConfig) {
19
+ return totalYield;
20
+ }
21
+
22
+ // Identify native vault yield (base yield from the vault strategy itself)
23
+ const nativeYield = incentives
24
+ .filter(
25
+ (incentive) => incentive.name === "Native Yield" && incentive.yield !== null && incentive.yield !== undefined,
26
+ )
27
+ .reduce((sum, incentive) => sum + (incentive.yield ?? 0), 0);
28
+
29
+ const performanceFee = vaultConfig.performanceFee ?? 0;
30
+ const managementFee = vaultConfig.managementFee ?? 0;
31
+
32
+ // Performance fee applies only to native vault yield
33
+ const performanceFeeAmount = nativeYield === 0 ? 0 : (nativeYield * performanceFee) / 100;
34
+
35
+ // Net APR = total yield - performance fee on native - management fee on total
36
+ return totalYield - performanceFeeAmount - managementFee;
37
+ }
@@ -0,0 +1 @@
1
+ export { calculateNetAPR } from "./calculateNetAPR";
@@ -0,0 +1,2 @@
1
+ export { RouteDetails } from "./route-details";
2
+ export type { TokenStep } from "./route-details";
@@ -0,0 +1,100 @@
1
+ import * as React from "react";
2
+ import { Card, CardContent, LabelWithIcon, ScrollArea } from "@turtleclub/ui";
3
+ import { ChevronsRight } from "lucide-react";
4
+ import ensoLogo from "../images/enso.png";
5
+ import { useMemo } from "react";
6
+ import type { TokenStep } from "@turtleclub/hooks";
7
+
8
+ export type { TokenStep } from "@turtleclub/hooks";
9
+
10
+ interface RouteDetailsProps {
11
+ value: {
12
+ steps: TokenStep[];
13
+ };
14
+ className?: string;
15
+ }
16
+
17
+ export const RouteDetails = ({ value, className }: RouteDetailsProps) => {
18
+ const { steps } = value;
19
+
20
+ const isLongRoute = useMemo(() => {
21
+ return steps.length > 2;
22
+ }, [steps]);
23
+
24
+ const defaultIcon = (symbol: string) => (
25
+ <div className="bg-primary/20 flex size-3 items-center justify-center rounded-full">
26
+ <span className="text-primary text-xs font-bold">{symbol.charAt(0)}</span>
27
+ </div>
28
+ );
29
+
30
+ const capitalize = (str: string) => {
31
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
32
+ };
33
+
34
+ if (steps.length === 0) return null;
35
+
36
+ return (
37
+ <div className="text-foreground px-3 pb-3">
38
+ <div className="text-sm font-medium">Swap Route</div>
39
+ <div className="mb-1 flex items-center gap-1 text-[10px]">
40
+ <span className="text-sm opacity-50">Powered by</span>
41
+ <img src={ensoLogo} className="w-10" alt="Enso" />
42
+ </div>
43
+ {/* Route visualization */}
44
+ <ScrollArea className="mt-2 rounded-md whitespace-nowrap">
45
+ <div className="grid w-full auto-cols-fr grid-flow-col items-center gap-2 py-2">
46
+ {steps.map((step, index) => (
47
+ <React.Fragment key={`${step.in.symbol}-${step.type}-${index}`}>
48
+ <Card variant="border" className="border-muted-foreground/60">
49
+ <CardContent className="space-y-2 pt-4">
50
+ {/* Type */}
51
+ <div className="text-secondary-foreground text-sm">
52
+ {capitalize(step.type)}
53
+ </div>
54
+
55
+ <div className="flex flex-row items-center gap-2">
56
+ {/* Token in */}
57
+ <LabelWithIcon
58
+ icon={step.in.icon || defaultIcon(step.in.symbol)}
59
+ textSize="xs"
60
+ iconSize="xs"
61
+ >
62
+ {!isLongRoute && (
63
+ <span className="hidden gap-2 sm:block">
64
+ {step.in.symbol}
65
+ </span>
66
+ )}
67
+ {step.type === "approve" && (
68
+ <span className="gap-2"> {step.amount}</span>
69
+ )}
70
+ </LabelWithIcon>
71
+
72
+ {step.type !== "approve" && (
73
+ <>
74
+ {/* Arrow between tokens */}
75
+ <ChevronsRight className="text-primary size-4 shrink-0" />
76
+
77
+ {/* Token out */}
78
+ <LabelWithIcon
79
+ icon={step.out.icon || defaultIcon(step.out.symbol)}
80
+ textSize="xs"
81
+ iconSize="xs"
82
+ >
83
+ {!isLongRoute && (
84
+ <span className="hidden gap-2 sm:block">
85
+ {step.in.symbol}
86
+ </span>
87
+ )}
88
+ </LabelWithIcon>
89
+ </>
90
+ )}
91
+ </div>
92
+ </CardContent>
93
+ </Card>
94
+ </React.Fragment>
95
+ ))}
96
+ </div>
97
+ </ScrollArea>
98
+ </div>
99
+ );
100
+ };