@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.
- package/CHANGELOG.md +10 -0
- package/package.json +28 -0
- package/src/components/balances-data-table.tsx +85 -0
- package/src/components/index.ts +1 -0
- package/src/constants.ts +1 -0
- package/src/deposit/components/confirm-button.tsx +137 -0
- package/src/deposit/components/geo-check-blocker.tsx +40 -0
- package/src/deposit/components/index.ts +2 -0
- package/src/deposit/index.ts +1 -0
- package/src/images/enso.png +0 -0
- package/src/index.ts +17 -0
- package/src/opportunity-table/components/apr-breakdown-tooltip.tsx +103 -0
- package/src/opportunity-table/components/chain-list.tsx +28 -0
- package/src/opportunity-table/components/incentives-breakdown.tsx +263 -0
- package/src/opportunity-table/components/index.ts +4 -0
- package/src/opportunity-table/components/opportunities-table.tsx +218 -0
- package/src/opportunity-table/hooks/index.ts +2 -0
- package/src/opportunity-table/hooks/useNetAPR.ts +15 -0
- package/src/opportunity-table/hooks/useTotalYield.ts +12 -0
- package/src/opportunity-table/utils/calculateNetAPR.ts +37 -0
- package/src/opportunity-table/utils/index.ts +1 -0
- package/src/route-details/index.ts +2 -0
- package/src/route-details/route-details.tsx +100 -0
- package/src/transaction-status/components/TransactionStatusSection.tsx +81 -0
- package/src/transaction-status/hooks/useTransactionQueue.ts +322 -0
- package/src/transaction-status/index.ts +8 -0
- package/src/transaction-status/types/index.ts +22 -0
- package/src/transaction-status/utils/index.ts +80 -0
- package/src/transaction-status/utils/selectors.ts +66 -0
- 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";
|
package/src/constants.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|