Skip to content

Commit 2c25437

Browse files
authored
feat: added dynamic assets categories using Coingecko API (#2593)
1 parent 9148648 commit 2c25437

15 files changed

Lines changed: 658 additions & 33 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ NEXT_PUBLIC_AMPLITUDE_API_KEY=6b28cb736c53d59f0951a50f59597aae
2121
NEXT_PUBLIC_PRIVATE_RPC_ENABLED=false
2222

2323

24+
2425
# Set to 'true' to allow all domains for CORS (use only in development)
2526
CORS_DOMAINS_ALLOWED=false
2627

@@ -41,4 +42,5 @@ SONIC_RPC_API_KEY=
4142
CELO_RPC_API_KEY=
4243
FAMILY_API_KEY=
4344
FAMILY_API_URL=
45+
COINGECKO_API_KEY=
4446
PLAIN_API_KEY=

pages/api/coingecko-categories.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
const CG_ENDPOINT = 'https://pro-api.coingecko.com/api/v3/coins/markets';
4+
const HEADERS: HeadersInit = {
5+
accept: 'application/json',
6+
'x-cg-pro-api-key': process.env.COINGECKO_API_KEY ?? '',
7+
};
8+
interface CoinGeckoCoin {
9+
id: string;
10+
symbol: string;
11+
}
12+
13+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
14+
const allowedOrigins = ['https://app.aave.com', 'https://aave.com'];
15+
const origin = req.headers.origin;
16+
17+
const isOriginAllowed = (origin: string | undefined): boolean => {
18+
if (!origin) return false;
19+
20+
if (allowedOrigins.includes(origin)) return true;
21+
22+
// Match any subdomain ending with avaraxyz.vercel.app for deployment urls
23+
const allowedPatterns = [/^https:\/\/.*avaraxyz\.vercel\.app$/];
24+
25+
return allowedPatterns.some((pattern) => pattern.test(origin));
26+
};
27+
28+
if (process.env.CORS_DOMAINS_ALLOWED === 'true') {
29+
res.setHeader('Access-Control-Allow-Origin', '*');
30+
} else if (origin && isOriginAllowed(origin)) {
31+
res.setHeader('Access-Control-Allow-Origin', origin);
32+
}
33+
34+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
35+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
36+
37+
if (req.method === 'OPTIONS') {
38+
return res.status(200).end();
39+
}
40+
41+
if (req.method !== 'GET') {
42+
return res.status(405).json({ error: 'Method not allowed' });
43+
}
44+
45+
try {
46+
const coingeckoApiKey = process.env.COINGECKO_API_KEY;
47+
if (!coingeckoApiKey) {
48+
return res.status(500).json({ error: 'CoinGecko API key is not configured' });
49+
}
50+
51+
// Fetch for Stablecoins Category and Eth Correlated Categories
52+
const [resStable1, resStable2, resEth1, resEth2, resEth3] = await Promise.all([
53+
fetch(`${CG_ENDPOINT}?vs_currency=usd&category=stablecoins&per_page=250&page=1`, {
54+
method: 'GET',
55+
headers: HEADERS,
56+
}),
57+
fetch(`${CG_ENDPOINT}?vs_currency=usd&category=stablecoins&per_page=250&page=2`, {
58+
method: 'GET',
59+
headers: HEADERS,
60+
}),
61+
fetch(`${CG_ENDPOINT}?vs_currency=usd&category=liquid-staked-eth&per_page=250&page=1`, {
62+
method: 'GET',
63+
headers: HEADERS,
64+
}),
65+
fetch(`${CG_ENDPOINT}?vs_currency=usd&category=ether-fi-ecosystem&per_page=250&page=1`, {
66+
method: 'GET',
67+
headers: HEADERS,
68+
}),
69+
fetch(`${CG_ENDPOINT}?vs_currency=usd&category=liquid-staking-tokens&per_page=250&page=1`, {
70+
method: 'GET',
71+
headers: HEADERS,
72+
}),
73+
]);
74+
75+
if (!resStable1.ok) {
76+
return res.status(resStable1.status).json({
77+
error: `Error fetching stablecoins page 1`,
78+
details: resStable1.statusText,
79+
});
80+
}
81+
if (!resStable2.ok) {
82+
return res.status(resStable2.status).json({
83+
error: `Error fetching stablecoins page 2`,
84+
details: resStable2.statusText,
85+
});
86+
}
87+
if (!resEth1.ok) {
88+
return res.status(resEth1.status).json({
89+
error: `Error fetching liquid-staked-eth`,
90+
details: resEth1.statusText,
91+
});
92+
}
93+
if (!resEth2.ok) {
94+
return res.status(resEth2.status).json({
95+
error: `Error fetching ether-fi-ecosystem`,
96+
details: resEth2.statusText,
97+
});
98+
}
99+
if (!resEth3.ok) {
100+
return res.status(resEth3.status).json({
101+
error: `Error fetching liquid-staking-tokens`,
102+
details: resEth3.statusText,
103+
});
104+
}
105+
106+
const [dataStable1, dataStable2, dataEth1, dataEth2, dataEth3] = await Promise.all([
107+
resStable1.json(),
108+
resStable2.json(),
109+
resEth1.json(),
110+
resEth2.json(),
111+
resEth3.json(),
112+
]);
113+
const combinedData = [...dataStable1, ...dataStable2];
114+
115+
const processedSymbols = combinedData
116+
.map((coin: CoinGeckoCoin) => coin.symbol?.toUpperCase())
117+
.filter((symbol: string) => symbol);
118+
119+
const uniqueSymbolsStablecoins = [...new Set([...processedSymbols])];
120+
121+
// Filter category 'liquid-staking-tokens' to only include coins correlated to ETH
122+
const filteredData3 = dataEth3.filter((coin: CoinGeckoCoin) => {
123+
const symbol = coin.symbol?.toUpperCase();
124+
return symbol?.includes('ETH');
125+
});
126+
127+
const combinedDataEth = [...dataEth1, ...dataEth2, ...filteredData3];
128+
129+
const symbols = combinedDataEth
130+
.map((coin: CoinGeckoCoin) => coin.symbol?.toUpperCase())
131+
.filter((symbol: string) => symbol);
132+
133+
const uniqueSymbolsEth = [...new Set([...symbols, 'WETH'])];
134+
135+
return res.status(200).json({ uniqueSymbolsStablecoins, uniqueSymbolsEth });
136+
} catch (error) {
137+
console.error('Coingecko categories proxy error:', error);
138+
return res.status(500).json({ error: 'Internal server error', details: String(error) });
139+
}
140+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Trans } from '@lingui/macro';
2+
import { SxProps, Theme, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
3+
import { AssetCategory } from 'src/modules/markets/utils/assetCategories';
4+
5+
interface MarketAssetCategoryFiltersProps {
6+
selectedCategory: AssetCategory;
7+
onCategoryChange: (category: AssetCategory) => void;
8+
disabled?: boolean;
9+
sx?: {
10+
buttonGroup?: SxProps<Theme>;
11+
button?: SxProps<Theme>;
12+
};
13+
}
14+
const categoryLabels = {
15+
[AssetCategory.ALL]: <Trans>All</Trans>,
16+
[AssetCategory.STABLECOINS]: <Trans>Stablecoins</Trans>,
17+
[AssetCategory.ETH_CORRELATED]: <Trans>ETH Correlated</Trans>,
18+
} as const;
19+
const categories = [
20+
AssetCategory.ALL,
21+
AssetCategory.STABLECOINS,
22+
AssetCategory.ETH_CORRELATED,
23+
] as const;
24+
25+
export const MarketAssetCategoryFilter = ({
26+
selectedCategory,
27+
onCategoryChange,
28+
disabled = false,
29+
...props
30+
}: MarketAssetCategoryFiltersProps) => {
31+
const handleChange = (_event: React.MouseEvent<HTMLElement>, newCategory: AssetCategory) => {
32+
if (newCategory !== null) {
33+
onCategoryChange(newCategory);
34+
}
35+
};
36+
37+
return (
38+
<ToggleButtonGroup
39+
value={selectedCategory}
40+
exclusive
41+
onChange={handleChange}
42+
aria-label="Asset category"
43+
disabled={disabled}
44+
sx={{
45+
width: '100%',
46+
height: '36px',
47+
'&.MuiToggleButtonGroup-grouped': {
48+
borderRadius: 'unset',
49+
},
50+
...props.sx?.buttonGroup,
51+
}}
52+
>
53+
{categories.map((category) => (
54+
<ToggleButton
55+
key={category}
56+
value={category}
57+
disableRipple
58+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
59+
// @ts-ignore
60+
sx={(theme): SxProps<Theme> | undefined => ({
61+
flex: { xs: 1, xsm: 1, sm: 'auto' },
62+
'&.MuiToggleButtonGroup-grouped:not(.Mui-selected), &.MuiToggleButtonGroup-grouped&.Mui-disabled':
63+
{
64+
border: '1px solid transparent',
65+
backgroundColor: 'background.surface',
66+
color: 'action.disabled',
67+
},
68+
'&.MuiToggleButtonGroup-grouped&.Mui-selected': {
69+
borderRadius: '4px',
70+
border: `1px solid ${theme.palette.divider}`,
71+
boxShadow: '0px 2px 1px rgba(0, 0, 0, 0.05), 0px 0px 1px rgba(0, 0, 0, 0.25)',
72+
backgroundColor: 'background.paper',
73+
},
74+
...props.sx?.button,
75+
})}
76+
>
77+
<Typography
78+
variant="buttonM"
79+
sx={{
80+
fontSize: '0.875rem',
81+
whiteSpace: 'nowrap',
82+
}}
83+
>
84+
{categoryLabels[category]}
85+
</Typography>
86+
</ToggleButton>
87+
))}
88+
</ToggleButtonGroup>
89+
);
90+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { SearchIcon } from '@heroicons/react/solid';
2+
import { Trans } from '@lingui/macro';
3+
import {
4+
Box,
5+
Button,
6+
IconButton,
7+
SvgIcon,
8+
Typography,
9+
TypographyProps,
10+
useMediaQuery,
11+
useTheme,
12+
} from '@mui/material';
13+
import { ReactNode, useState } from 'react';
14+
import { AssetCategory } from 'src/modules/markets/utils/assetCategories';
15+
16+
import { MarketAssetCategoryFilter } from './MarketAssetCategoryFilter';
17+
import { SearchInput } from './SearchInput';
18+
19+
interface TitleWithFiltersAndSearchBarProps<C extends React.ElementType> {
20+
onSearchTermChange: (value: string) => void;
21+
searchPlaceholder: string;
22+
titleProps?: TypographyProps<C, { component?: C }>;
23+
title: ReactNode;
24+
selectedCategory: AssetCategory;
25+
onCategoryChange: (category: AssetCategory) => void;
26+
disabled?: boolean;
27+
}
28+
29+
export const TitleWithFiltersAndSearchBar = <T extends React.ElementType>({
30+
onSearchTermChange,
31+
searchPlaceholder,
32+
titleProps,
33+
title,
34+
selectedCategory,
35+
onCategoryChange,
36+
disabled = false,
37+
}: TitleWithFiltersAndSearchBarProps<T>) => {
38+
const [showSearchBar, setShowSearchBar] = useState(false);
39+
40+
const { breakpoints } = useTheme();
41+
const sm = useMediaQuery(breakpoints.down('sm'));
42+
43+
const showSearchIcon = sm && !showSearchBar;
44+
const showMarketTitle = !sm || !showSearchBar;
45+
46+
const handleCancelClick = () => {
47+
setShowSearchBar(false);
48+
onSearchTermChange('');
49+
};
50+
51+
return (
52+
<Box
53+
sx={{
54+
width: '100%',
55+
display: 'flex',
56+
alignItems: 'center',
57+
justifyContent: 'space-between',
58+
}}
59+
>
60+
{showMarketTitle && (
61+
<Typography component="div" variant="h2" sx={{ mr: 4 }} {...titleProps}>
62+
{title}
63+
</Typography>
64+
)}
65+
66+
<Box
67+
sx={{
68+
height: '40px',
69+
width: showSearchBar && sm ? '100%' : 'unset',
70+
position: 'relative',
71+
display: 'flex',
72+
alignItems: 'center',
73+
gap: 4,
74+
justifyContent: 'space-between',
75+
}}
76+
>
77+
<MarketAssetCategoryFilter
78+
selectedCategory={selectedCategory}
79+
onCategoryChange={onCategoryChange}
80+
disabled={disabled}
81+
/>
82+
{showSearchIcon && (
83+
<IconButton onClick={() => setShowSearchBar(true)}>
84+
<SvgIcon>
85+
<SearchIcon />
86+
</SvgIcon>
87+
</IconButton>
88+
)}
89+
{(showSearchBar || !sm) && (
90+
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-between' }}>
91+
<SearchInput
92+
wrapperSx={{
93+
width: {
94+
xs: '100%',
95+
sm: '340px',
96+
},
97+
}}
98+
placeholder={searchPlaceholder}
99+
onSearchTermChange={onSearchTermChange}
100+
/>
101+
{sm && (
102+
<Button sx={{ ml: 2 }} onClick={() => handleCancelClick()}>
103+
<Typography variant="buttonM">
104+
<Trans>Cancel</Trans>
105+
</Typography>
106+
</Button>
107+
)}
108+
</Box>
109+
)}
110+
</Box>
111+
</Box>
112+
);
113+
};

src/components/lists/ListWrapper.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface ListWrapperProps {
1919
tooltipOpen?: boolean;
2020
paperSx?: PaperProps['sx'];
2121
topInfoSx?: BoxProps['sx'];
22+
onCollapseChange?: (collapsed: boolean) => void;
2223
}
2324

2425
export const ListWrapper = ({
@@ -34,6 +35,7 @@ export const ListWrapper = ({
3435
tooltipOpen,
3536
paperSx,
3637
topInfoSx,
38+
onCollapseChange,
3739
}: ListWrapperProps) => {
3840
const [isCollapse, setIsCollapse] = useState(
3941
localStorageName ? localStorage.getItem(localStorageName) === 'true' : false
@@ -151,9 +153,11 @@ export const ListWrapper = ({
151153
onClick={() => {
152154
handleTrackingEvents();
153155

154-
!!localStorageName && !noData
155-
? toggleLocalStorageClick(isCollapse, setIsCollapse, localStorageName)
156-
: undefined;
156+
if (localStorageName && !noData) {
157+
const nextIsCollapse = !isCollapse;
158+
toggleLocalStorageClick(isCollapse, setIsCollapse, localStorageName);
159+
onCollapseChange?.(nextIsCollapse);
160+
}
157161
}}
158162
>
159163
<Typography variant="buttonM" color="text.secondary">

0 commit comments

Comments
 (0)