@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # 0.1.0-beta.0 (2025-11-25)
7
+
8
+ ### Features
9
+
10
+ - balances ([#183](https://github.com/turtle-dao/turtle-tools/issues/183)) ([139df7d](https://github.com/turtle-dao/turtle-tools/commit/139df7d27af9cb6f5d06c12650ef756b55b3d58a))
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@turtleclub/opportunities",
3
+ "version": "0.1.0-beta.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@turtleclub/hooks": "0.5.0-beta.0",
13
+ "@turtleclub/ui": "0.7.0-beta.1",
14
+ "@turtleclub/utils": "0.4.0-beta.0",
15
+ "jotai": "^2.10.3",
16
+ "viem": "^2.21.54"
17
+ },
18
+ "peerDependencies": {
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "^18.3.18",
24
+ "@types/react-dom": "^18.3.5",
25
+ "typescript": "^5.7.2"
26
+ },
27
+ "gitHead": "d896f0404e2bcb958a52def686e57893b4c68f6b"
28
+ }
@@ -0,0 +1,85 @@
1
+ import { useMemo } from "react";
2
+ import type { ColumnDef } from "@tanstack/react-table";
3
+ import { DataTable } from "@turtleclub/ui";
4
+ import { type TokenBalance } from "@turtleclub/hooks";
5
+ import { formatToken } from "@turtleclub/utils";
6
+
7
+ export interface BalancesDataTableProps {
8
+ balances: TokenBalance[];
9
+ isLoading?: boolean;
10
+ onBalanceSelect?: (balance: TokenBalance) => void;
11
+ emptyStateMessage?: string;
12
+ }
13
+
14
+ export function BalancesDataTable({
15
+ balances,
16
+ isLoading = false,
17
+ onBalanceSelect,
18
+ emptyStateMessage = "No balances found",
19
+ }: BalancesDataTableProps) {
20
+ const columns = useMemo<ColumnDef<TokenBalance>[]>(
21
+ () => [
22
+ {
23
+ accessorKey: "token.symbol",
24
+ header: "Token",
25
+ cell: ({ row }) => {
26
+ const token = row.original.token;
27
+ const logo = token.logoUrl;
28
+ return (
29
+ <div className="flex items-center gap-2">
30
+ {logo ? (
31
+ <img src={logo} alt={token.symbol} className="size-8 rounded-full" />
32
+ ) : (
33
+ <div className="bg-muted flex size-8 items-center justify-center rounded-full text-xs">
34
+ {token.symbol?.charAt(0) || "?"}
35
+ </div>
36
+ )}
37
+ <div>
38
+ <div>{token.symbol}</div>
39
+ <div className="text-muted-foreground text-xs">{token.name}</div>
40
+ </div>
41
+ </div>
42
+ );
43
+ },
44
+ },
45
+ {
46
+ accessorKey: "amount",
47
+ header: () => <div className="text-right pr-4">Balance</div>,
48
+ cell: ({ row }) => {
49
+ const { token, amount } = row.original;
50
+ const balance = Number(amount) / 10 ** token.decimals;
51
+ const value = token.priceUsd ? balance * token.priceUsd : null;
52
+
53
+ return (
54
+ <div className="pr-4 text-right">
55
+ <div>{formatToken(amount, token, true, false, 5)}</div>
56
+ {value !== null && (
57
+ <div className="text-muted-foreground text-xs">${value.toFixed(2)}</div>
58
+ )}
59
+ </div>
60
+ );
61
+ },
62
+ },
63
+ ],
64
+ []
65
+ );
66
+
67
+ const emptyState = (
68
+ <div className="flex flex-col items-center justify-center py-12 text-center">
69
+ <p className="text-muted-foreground">{emptyStateMessage}</p>
70
+ </div>
71
+ );
72
+
73
+ return (
74
+ <DataTable
75
+ columns={columns}
76
+ data={balances}
77
+ isLoading={isLoading}
78
+ enableSearch
79
+ enableSorting
80
+ onRowClick={onBalanceSelect}
81
+ emptyState={emptyState}
82
+ className="w-full"
83
+ />
84
+ );
85
+ }
@@ -0,0 +1 @@
1
+ export * from "./balances-data-table";
@@ -0,0 +1 @@
1
+ export const SLIPPAGE_DEFAULT = 0.001; // Represents 0.1%
@@ -0,0 +1,137 @@
1
+ import { Button } from "@turtleclub/ui";
2
+ import { useCallback, useMemo } from "react";
3
+ import type { EarnRouteResponse } from "@turtleclub/hooks";
4
+ import { GeoCheckBlocker } from "./geo-check-blocker";
5
+
6
+ interface ConfirmButtonProps {
7
+ fetchedRoute: EarnRouteResponse | null;
8
+ routeError: Error | null;
9
+ isLoadingRoute: boolean;
10
+ canExecute: boolean;
11
+ isExecuting: boolean;
12
+ hasError: boolean;
13
+ cancelled: boolean;
14
+ executeCurrentTransaction: () => Promise<void>;
15
+ totalSteps: number;
16
+ completedSteps: number;
17
+ showingTransactionStatus: boolean;
18
+ hasInsufficientBalance: boolean;
19
+ }
20
+
21
+ interface ButtonState {
22
+ text: string;
23
+ disabled: boolean;
24
+ onClick?: () => Promise<void>;
25
+ className?: string;
26
+ }
27
+
28
+ // Simple Confirm Button - Only handles button logic, no TxStatus
29
+ export function ConfirmButton({
30
+ fetchedRoute,
31
+ routeError,
32
+ isLoadingRoute,
33
+ canExecute,
34
+ isExecuting,
35
+ hasError,
36
+ cancelled,
37
+ executeCurrentTransaction,
38
+ totalSteps,
39
+ completedSteps,
40
+ showingTransactionStatus,
41
+ hasInsufficientBalance,
42
+ }: ConfirmButtonProps) {
43
+ const handleConfirm = useCallback(async (): Promise<void> => {
44
+ if (!canExecute) return;
45
+ await executeCurrentTransaction();
46
+ }, [canExecute, executeCurrentTransaction]);
47
+
48
+ // Calculate button state based on all conditions
49
+ const buttonState = useMemo<ButtonState | null>(() => {
50
+ // Don't render button if showing transaction status
51
+ if (showingTransactionStatus) {
52
+ return null;
53
+ }
54
+
55
+ // Loading state
56
+ if (isLoadingRoute) {
57
+ return { text: "Loading route...", disabled: true, className: "cursor-not-allowed" };
58
+ }
59
+
60
+ // Error state
61
+ if (routeError) {
62
+ return { text: "Route Error", disabled: true, className: "cursor-not-allowed" };
63
+ }
64
+
65
+ // No route available
66
+ if (!fetchedRoute || !fetchedRoute.steps || fetchedRoute.steps.length === 0) {
67
+ return { text: "Enter amount to continue", disabled: true, className: "cursor-not-allowed" };
68
+ }
69
+
70
+ // Insufficient balance
71
+ if (hasInsufficientBalance) {
72
+ return { text: "Insufficient Balance", disabled: true, className: "cursor-not-allowed" };
73
+ }
74
+
75
+ // Execution states
76
+ if (isExecuting) {
77
+ return { text: "Executing...", disabled: true, className: "cursor-not-allowed" };
78
+ }
79
+
80
+ if (hasError) {
81
+ return {
82
+ text: "Retry Transaction",
83
+ disabled: !canExecute,
84
+ onClick: handleConfirm,
85
+ className: "cursor-pointer",
86
+ };
87
+ }
88
+
89
+ if (cancelled) {
90
+ return {
91
+ text: "Try Again",
92
+ disabled: !canExecute,
93
+ onClick: handleConfirm,
94
+ className: "cursor-pointer",
95
+ };
96
+ }
97
+
98
+ // Default confirm state
99
+ const text =
100
+ totalSteps > 1 && completedSteps !== totalSteps
101
+ ? `Confirm Transaction (${completedSteps}/${totalSteps})`
102
+ : "Confirm Transaction";
103
+
104
+ return { text, disabled: false, onClick: handleConfirm, className: "cursor-pointer" };
105
+ }, [
106
+ showingTransactionStatus,
107
+ isLoadingRoute,
108
+ routeError,
109
+ fetchedRoute,
110
+ hasInsufficientBalance,
111
+ isExecuting,
112
+ hasError,
113
+ cancelled,
114
+ canExecute,
115
+ totalSteps,
116
+ completedSteps,
117
+ handleConfirm,
118
+ ]);
119
+
120
+ // Don't render if buttonState is null
121
+ if (!buttonState) {
122
+ return null;
123
+ }
124
+
125
+ return (
126
+ <GeoCheckBlocker>
127
+ <Button
128
+ border="plain"
129
+ onClick={() => buttonState.onClick?.()}
130
+ disabled={buttonState.disabled}
131
+ className={buttonState.className + " w-full"}
132
+ >
133
+ {buttonState.text}
134
+ </Button>
135
+ </GeoCheckBlocker>
136
+ );
137
+ }
@@ -0,0 +1,40 @@
1
+ import { useGeocheck } from "@turtleclub/hooks";
2
+ import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@turtleclub/ui";
3
+ import type { ReactNode } from "react";
4
+
5
+ interface GeoCheckButtonProps {
6
+ children: ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ export function GeoCheckBlocker({ children, className }: GeoCheckButtonProps) {
11
+ const { data: geoCheckData, isLoading: geoCheckLoading } = useGeocheck();
12
+
13
+ // Geo restriction check - loading state
14
+ if (geoCheckLoading) {
15
+ return (
16
+ <Button disabled className={className} size="lg" fullWidth>
17
+ Checking availability...
18
+ </Button>
19
+ );
20
+ }
21
+
22
+ // Geo restriction check - blocked state
23
+ if (geoCheckData && !geoCheckData.canInteract) {
24
+ return (
25
+ <Tooltip>
26
+ <TooltipTrigger className="w-full cursor-default space-y-2">
27
+ <Button disabled variant="default" className={className} size="lg" fullWidth>
28
+ Geo Restricted
29
+ </Button>
30
+ </TooltipTrigger>
31
+ <TooltipContent side="top" sideOffset={6} className="text-xs">
32
+ Due to geographical restrictions you can't use this vault.
33
+ </TooltipContent>
34
+ </Tooltip>
35
+ );
36
+ }
37
+
38
+ // Geo check passed - render children
39
+ return <>{children}</>;
40
+ }
@@ -0,0 +1,2 @@
1
+ export { ConfirmButton } from "./confirm-button";
2
+ export { GeoCheckBlocker } from "./geo-check-blocker";
@@ -0,0 +1 @@
1
+ export * from "./components";
Binary file
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Balances
2
+ export * from "./components";
3
+
4
+ // Deposit Components
5
+ export * from "./deposit";
6
+
7
+ // Transaction Status
8
+ export * from "./transaction-status";
9
+
10
+ // Opportunity Table
11
+ export * from "./opportunity-table/components";
12
+
13
+ // Route Details
14
+ export * from "./route-details";
15
+
16
+ // Constants
17
+ export * from "./constants";
@@ -0,0 +1,103 @@
1
+ import { TurtleTooltip, cn } from "@turtleclub/ui";
2
+ import { InfoIcon } from "lucide-react";
3
+ import { useMemo } from "react";
4
+ import type { Incentive, VaultConfig } from "@turtleclub/hooks";
5
+ import { formatNumber } from "@turtleclub/utils";
6
+ import { useTotalYield } from "../hooks/useTotalYield";
7
+ import { IncentivesBreakdown } from "./incentives-breakdown";
8
+
9
+ export function APRBreakdownTooltip({
10
+ incentives,
11
+ vaultConfig,
12
+ isPill,
13
+ isWidget,
14
+ className,
15
+ }: {
16
+ incentives: Incentive[];
17
+ vaultConfig?: VaultConfig;
18
+ isPill?: boolean;
19
+ isWidget?: boolean;
20
+ className?: string;
21
+ }) {
22
+ const { totalYield } = useTotalYield(incentives);
23
+
24
+ // Widget version: Simple list of incentives without fees
25
+ const sortedIncentives = useMemo(() => {
26
+ return incentives
27
+ .filter((incentive) => {
28
+ const hasValidYield =
29
+ incentive.yield !== null && incentive.yield !== undefined && incentive.yield > 0;
30
+ return hasValidYield || (incentive.rewardType === "points" && incentive.yield !== null);
31
+ })
32
+ .sort((a, b) => {
33
+ if (a.rewardType === "points" && b.rewardType !== "points") return 1;
34
+ if (a.rewardType !== "points" && b.rewardType === "points") return -1;
35
+ return (b.yield ?? 0) - (a.yield ?? 0);
36
+ });
37
+ }, [incentives]);
38
+
39
+ const widgetContent = (
40
+ <div>
41
+ <div className="text-sm">APR Breakdown</div>
42
+ <div>
43
+ {sortedIncentives.map((incentive) => (
44
+ <div key={incentive.id} className="my-1 flex justify-between gap-2 text-xs">
45
+ <div className="flex items-center gap-1">
46
+ {incentive.iconUrl && (
47
+ <img
48
+ src={incentive.iconUrl}
49
+ alt={incentive.name || incentive.id}
50
+ className="size-4 rounded-full"
51
+ loading="lazy"
52
+ />
53
+ )}
54
+ <div>{incentive.name}</div>
55
+ </div>
56
+ <div>
57
+ {incentive.rewardType === "points"
58
+ ? "-"
59
+ : `+${formatNumber(incentive.yield ?? 0, 2, false, true)}%`}
60
+ </div>
61
+ </div>
62
+ ))}
63
+ </div>
64
+ <div className="text-primary flex justify-between gap-2 text-sm mt-2">
65
+ <span>Total</span>
66
+ <span>{formatNumber(totalYield, 2, false, true)}%</span>
67
+ </div>
68
+ </div>
69
+ );
70
+
71
+ const fullContent = (
72
+ <div className="max-w-[300px] p-3">
73
+ <div className="mb-3 text-sm font-medium">Est. APR Breakdown</div>
74
+ <IncentivesBreakdown
75
+ incentives={incentives}
76
+ vaultConfig={vaultConfig}
77
+ showIcons
78
+ showNestedTooltips={false}
79
+ />
80
+ </div>
81
+ );
82
+
83
+ return (
84
+ <TurtleTooltip
85
+ trigger={
86
+ <div
87
+ className={cn(
88
+ "group flex items-center gap-1",
89
+ className,
90
+ isPill &&
91
+ "hover:bg-neutral-alpha-5 border-border bg-neutral-alpha-2 hover:border-gradient-white rounded-full border px-3 py-1.5 text-xs"
92
+ )}
93
+ >
94
+ {formatNumber(totalYield, 1, false, false)}%{" "}
95
+ <InfoIcon size={12} className="text-primary/70 group-hover:text-primary" />
96
+ </div>
97
+ }
98
+ content={isWidget ? widgetContent : fullContent}
99
+ side="bottom"
100
+ contentClassName={cn("backdrop-blur-xl", isWidget ? "p-5" : "")}
101
+ />
102
+ );
103
+ }
@@ -0,0 +1,28 @@
1
+ import type { Chain } from "@turtleclub/hooks";
2
+ import { cn } from "@turtleclub/ui";
3
+ import { getNetworkBackgroundColor, getNetworkById } from "@turtleclub/utils";
4
+
5
+ interface ChainBadgeProps {
6
+ chain: Chain;
7
+ className?: string;
8
+ triggerClassName?: string;
9
+ }
10
+
11
+ export function ChainBadge({ chain, triggerClassName }: ChainBadgeProps) {
12
+ return (
13
+ <div className="flex justify-end">
14
+ <div
15
+ className={cn(
16
+ "flex size-6 items-center justify-center overflow-hidden rounded-full",
17
+ triggerClassName
18
+ )}
19
+ >
20
+ <img
21
+ src={chain.logoUrl}
22
+ className="size-6 rounded-full object-cover"
23
+ alt={chain.name}
24
+ />
25
+ </div>
26
+ </div>
27
+ );
28
+ }