Skip to content

Commit 16869f6

Browse files
v3.6.0 - Crypto HeatMap + Coin URL Selector (#649)
Co-authored-by: elcharitas <[email protected]>
2 parents af60c54 + 3022897 commit 16869f6

File tree

31 files changed

+1451
-157
lines changed

31 files changed

+1451
-157
lines changed

packages/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@alphaday/frontend",
33
"private": true,
4-
"version": "3.5.6",
4+
"version": "3.6.0",
55
"type": "module",
66
"scripts": {
77
"prepare": "export VITE_COMMIT=$(git rev-parse --short HEAD)",
5.27 KB
Binary file not shown.

packages/frontend/src/api/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export * from "./useValueWatcher";
3333
export * from "./useOnScreen";
3434
export * from "./usePullToRefresh";
3535
export * from "./useHistory";
36+
export * from "./useSelectedCoin";
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { useMemo, useEffect, useCallback } from "react";
2+
import { useHistory } from "react-router-dom";
3+
import { useGlobalSearch, useQuery } from "src/api/hooks";
4+
import { TBaseTag } from "src/api/services";
5+
import { setSelectedMarket } from "src/api/store";
6+
import { useAppDispatch, useAppSelector } from "src/api/store/hooks";
7+
import { TCoin } from "src/api/types";
8+
import { TMarketMeta } from "src/components/market/types";
9+
10+
interface UseSelectedCoinReturn {
11+
selectedCoin: TCoin | undefined;
12+
handleSelectedCoin: (market: TMarketMeta) => void;
13+
}
14+
15+
export const useSelectedCoin = (
16+
widgetHash: string,
17+
coinsData: TCoin[],
18+
pinnedCoins: TCoin[],
19+
tags?: TBaseTag[]
20+
): UseSelectedCoinReturn => {
21+
const dispatch = useAppDispatch();
22+
const history = useHistory();
23+
const { lastSelectedKeyword } = useGlobalSearch();
24+
const query = useQuery();
25+
const coinSlugFromUrl = query.get("coin");
26+
27+
const prevSelectedMarketData = useAppSelector(
28+
(state) => state.widgets.market?.[widgetHash]
29+
);
30+
31+
// Helper function to remove coin parameter from URL
32+
const removeCoinFromUrl = useCallback(() => {
33+
const currentParams = new URLSearchParams(window.location.search);
34+
currentParams.delete("coin");
35+
const newSearch = currentParams.toString();
36+
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}`;
37+
history.replace(newUrl);
38+
}, [history]);
39+
40+
const selectedCoin: TCoin | undefined = useMemo(() => {
41+
// Priority 1: URL parameter - find coin by slug
42+
if (coinSlugFromUrl) {
43+
const coinFromUrl = [...pinnedCoins, ...coinsData].find(
44+
(c) => c.slug === coinSlugFromUrl
45+
);
46+
if (coinFromUrl) {
47+
return coinFromUrl;
48+
}
49+
// Clean up URL if coin slug is not found in available coins
50+
if (coinsData.length > 0 || pinnedCoins.length > 0) {
51+
removeCoinFromUrl();
52+
}
53+
}
54+
55+
// Priority 2: Redux stored selection
56+
const storedMarket = [...pinnedCoins, ...coinsData].find(
57+
(c) => c.id === prevSelectedMarketData?.selectedMarket?.id
58+
);
59+
60+
// Priority 3: Fallback to first pinned coin or first available coin
61+
return storedMarket ?? pinnedCoins[0] ?? coinsData[0] ?? undefined;
62+
}, [
63+
coinSlugFromUrl,
64+
prevSelectedMarketData?.selectedMarket,
65+
coinsData,
66+
pinnedCoins,
67+
removeCoinFromUrl,
68+
]);
69+
70+
const handleSelectedCoin = useCallback(
71+
(market: TMarketMeta) => {
72+
dispatch(setSelectedMarket({ widgetHash, market }));
73+
74+
// Remove coin URL parameter when user manually selects a coin
75+
if (coinSlugFromUrl) {
76+
removeCoinFromUrl();
77+
}
78+
},
79+
[dispatch, widgetHash, coinSlugFromUrl, removeCoinFromUrl]
80+
);
81+
82+
// Sync URL parameter to Redux when coin from URL is found
83+
useEffect(() => {
84+
// Add guard for data availability
85+
if (!coinsData.length && !pinnedCoins.length) {
86+
return; // Wait for data to load
87+
}
88+
if (
89+
coinSlugFromUrl &&
90+
selectedCoin &&
91+
selectedCoin.slug === coinSlugFromUrl
92+
) {
93+
// Only update Redux if the current Redux state doesn't match the URL selection
94+
if (
95+
selectedCoin.id !== prevSelectedMarketData?.selectedMarket?.id
96+
) {
97+
handleSelectedCoin(selectedCoin);
98+
}
99+
}
100+
}, [
101+
coinSlugFromUrl,
102+
selectedCoin,
103+
prevSelectedMarketData?.selectedMarket?.id,
104+
handleSelectedCoin,
105+
coinsData.length,
106+
pinnedCoins.length,
107+
]);
108+
109+
// Existing useEffect for global search
110+
useEffect(() => {
111+
if (
112+
lastSelectedKeyword &&
113+
tags?.find((t) => t.id === lastSelectedKeyword.tag.id)
114+
) {
115+
const newMarketFromSearch = coinsData.find((marketMeta) => {
116+
return marketMeta.tags?.find(
117+
(t) => t.id === lastSelectedKeyword.tag.id
118+
);
119+
});
120+
if (newMarketFromSearch) {
121+
handleSelectedCoin(newMarketFromSearch);
122+
}
123+
}
124+
}, [lastSelectedKeyword, coinsData, tags, handleSelectedCoin]);
125+
126+
return {
127+
selectedCoin,
128+
handleSelectedCoin,
129+
};
130+
};

packages/frontend/src/api/services/baseTypes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export type TBaseCoin = TProtoItem & {
112112
market_cap: number;
113113
volume: number;
114114
price_percent_change_24h: number;
115+
price_percent_change_7d: number;
116+
price_percent_change_14d: number;
117+
price_percent_change_30d: number;
118+
price_percent_change_60d: number;
119+
tags: TRemoteTagReadOnly[];
115120
};
116121

117122
export type TRemoteWriteComment = {

packages/frontend/src/api/services/coins/coinsEndpoints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const transformRemoteCoin = (coin: TRemoteCoin): TCoin => {
3636
volume: coin.volume ?? 0,
3737
marketCap: coin.market_cap ?? 0,
3838
percentChange24h: coin.price_percent_change_24h ?? 0,
39+
percentChange7d: coin.price_percent_change_7d ?? 0,
40+
percentChange14d: coin.price_percent_change_14d ?? 0,
41+
percentChange30d: coin.price_percent_change_30d ?? 0,
42+
percentChange60d: coin.price_percent_change_60d ?? 0,
3943
};
4044
};
4145

packages/frontend/src/api/services/coins/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type TGetCoinsRequest = {
2323
tags?: string;
2424
limit?: number;
2525
page?: number;
26+
kasandra_supported?: boolean;
2627
} | void;
2728
export type TGetCoinsRawResponse = TPagination & {
2829
results: TRemoteCoin[];

packages/frontend/src/api/services/market/marketEndpoints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const mapRemoteMarketCoin = (coin: TRemoteMarketCoin) => {
3131
volume: coin.volume,
3232
marketCap: coin.market_cap,
3333
percentChange24h: coin.price_percent_change_24h,
34+
percentChange7d: coin.price_percent_change_7d,
35+
percentChange14d: coin.price_percent_change_14d,
36+
percentChange30d: coin.price_percent_change_30d,
37+
percentChange60d: coin.price_percent_change_60d,
3438
};
3539
};
3640

packages/frontend/src/api/types/primitives.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export type TCoin = {
5252
tags?: TTag[];
5353
price: number;
5454
percentChange24h: number;
55+
percentChange7d: number;
56+
percentChange14d: number;
57+
percentChange30d: number;
58+
percentChange60d: number;
5559
marketCap: number;
5660
volume?: number;
5761
};

packages/frontend/src/api/utils/layoutUtils.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
1+
import { useMemo } from "react";
12
import { DraggingStyle, NotDraggingStyle } from "react-beautiful-dnd";
2-
import { TUserViewWidget } from "src/api/types";
3+
import { TCachedView, TUserViewWidget } from "src/api/types";
34
import { Logger } from "src/api/utils/logging";
5+
import KasandraContainer from "src/containers/kasandra/KasandraContainer";
6+
import MarketHeatmapContainer from "src/containers/market-heatmap/MarketHeatmapContainer";
47
import { deviceBreakpoints } from "src/globalStyles/breakpoints";
58
import CONFIG from "src/config";
9+
import { ETemplateNameRegistry } from "src/constants";
610

711
const { Z_INDEX_REGISTRY } = CONFIG.UI;
12+
const { WIDGETS } = CONFIG;
813

914
const { singleCol, twoCol, threeCol, fourCol } = deviceBreakpoints;
1015

16+
export const TWO_COL_WIDGETS_CONFIG = {
17+
kasandra: {
18+
templateName: ETemplateNameRegistry.Kasandra,
19+
Container: KasandraContainer,
20+
widgetConfig: WIDGETS.KASANDRA,
21+
},
22+
marketHeatmap: {
23+
templateName: ETemplateNameRegistry.MarketHeatmap,
24+
Container: MarketHeatmapContainer,
25+
widgetConfig: WIDGETS.MARKET_HEATMAP,
26+
},
27+
} as const;
28+
1129
/**
1230
* Heads up: layout is in the form (col #, row #) or (x, y), starting from the
1331
* top left. That is, if:
@@ -243,3 +261,51 @@ export const recomputeWidgetsPos = (
243261

244262
return widgets;
245263
};
264+
265+
export const getTwoColWidgetTemplateSlugs = () => {
266+
return Object.values(TWO_COL_WIDGETS_CONFIG).map(
267+
(config) => `${config.templateName.toLowerCase()}_template`
268+
);
269+
};
270+
271+
export const useTwoColWidgets = (selectedView: TCachedView | undefined) => {
272+
return useMemo(() => {
273+
if (!selectedView) return { widgets: {}, twoColWidgetSlugs: [] };
274+
const twoColWidgetSlugs = getTwoColWidgetTemplateSlugs();
275+
const widgets: Record<string, TUserViewWidget> = {};
276+
277+
Object.entries(TWO_COL_WIDGETS_CONFIG).forEach(([key, config]) => {
278+
const templateSlug = `${config.templateName.toLowerCase()}_template`;
279+
const widgetData = selectedView?.data.widgets.find(
280+
(w: TUserViewWidget) => w.widget.template.slug === templateSlug
281+
);
282+
widgets[key] = widgetData as TUserViewWidget;
283+
});
284+
285+
return { widgets, twoColWidgetSlugs };
286+
}, [selectedView]);
287+
};
288+
289+
export const calculateTwoColWidgetsHeight = (
290+
widgets: Record<string, TUserViewWidget>,
291+
collapsedStates: Record<string, boolean>
292+
) => {
293+
let totalHeight = 0;
294+
const defaultMarginBottom = 14;
295+
296+
Object.entries(TWO_COL_WIDGETS_CONFIG).forEach(([key, config]) => {
297+
if (widgets[key]) {
298+
if (collapsedStates[key]) {
299+
totalHeight +=
300+
(config.widgetConfig.COLLAPSED_WIDGET_HEIGHT as number) ||
301+
0;
302+
} else {
303+
totalHeight +=
304+
(config.widgetConfig.WIDGET_HEIGHT as number) || 0;
305+
}
306+
totalHeight += defaultMarginBottom;
307+
}
308+
});
309+
310+
return totalHeight;
311+
};

0 commit comments

Comments
 (0)