diff --git a/.github/workflows/basic-checks.yml b/.github/workflows/basic-checks.yml index 9ae980a81..5cbe7d5d6 100644 --- a/.github/workflows/basic-checks.yml +++ b/.github/workflows/basic-checks.yml @@ -21,7 +21,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@v3 with: - node-version: "18.15.0" + node-version: "22.12.0" cache: "yarn" cache-dependency-path: | yarn.lock diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 162fbd44d..283110869 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -55,7 +55,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "20.11.0" + node-version: "22.12.0" cache: "yarn" cache-dependency-path: | yarn.lock @@ -94,7 +94,7 @@ jobs: - name: deploy uses: kefranabg/s3-sync-action@master # https://github.com/kefranabg/s3-sync-action with: - args: --acl public-read --follow-symlinks --delete + args: --follow-symlinks --delete env: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOYER__AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOYER__SECRET_ACCESS_KEY }} diff --git a/.github/workflows/deploy-epsilon.yml b/.github/workflows/deploy-epsilon.yml index 1631bbcff..b965f9453 100644 --- a/.github/workflows/deploy-epsilon.yml +++ b/.github/workflows/deploy-epsilon.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "18.15.0" + node-version: "22.12.0" cache: "yarn" cache-dependency-path: | yarn.lock @@ -83,7 +83,7 @@ jobs: - name: deploy uses: kefranabg/s3-sync-action@master # https://github.com/kefranabg/s3-sync-action with: - args: --acl public-read --follow-symlinks + args: --follow-symlinks env: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOYER__AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOYER__SECRET_ACCESS_KEY }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 4294dad76..2ba2591ec 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -59,7 +59,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "20.11.0" + node-version: "22.12.0" cache: "yarn" cache-dependency-path: | yarn.lock @@ -98,7 +98,7 @@ jobs: - name: deploy uses: kefranabg/s3-sync-action@master # https://github.com/kefranabg/s3-sync-action with: - args: --acl public-read --follow-symlinks --delete + args: --follow-symlinks --delete env: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOYER__AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOYER__SECRET_ACCESS_KEY }} diff --git a/package.json b/package.json index 826f6779e..d424144ea 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@types/cypress-cucumber-preprocessor": "4.0.5", "@types/react": "18.2.25", "@types/react-dom": "18.2.6", - "@typescript-eslint/eslint-plugin": "5.54.0", + "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", "@vitejs/plugin-react": "6.0.1", "autoprefixer": "10.4.21", @@ -47,7 +47,7 @@ "husky": "8.0.3", "prettier": "3.5.3", "typescript": "5.9.3", - "vite": "8.0.12", + "vite": "8.0.16", "vite-tsconfig-paths": "6.1.1", "vitest": "4.1.5" }, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c74b59ab0..ce5455a99 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@alphaday/frontend", "private": true, - "version": "4.1.0", + "version": "4.1.1", "type": "module", "scripts": { "prepare": "export VITE_COMMIT=$(git rev-parse --short HEAD)", @@ -25,7 +25,7 @@ "@web3modal/ethereum": "2.7.1", "@web3modal/react": "2.7.0", "chroma-js": "3.1.2", - "dompurify": "3.2.4", + "dompurify": "3.4.13", "es6-error": "4.1.1", "firebase": "10.0.0", "html2canvas": "1.4.1", @@ -53,8 +53,8 @@ "redux-persist": "6.0.0", "remark-breaks": "4.0.0", "sharp": "0.32.6", - "unist-util-visit": "5.0.0", - "uuid": "9.0.0", + "unist-util-visit": "5.1.0", + "uuid": "14.0.0", "viem": "1.2.5", "wagmi": "1.3.9", "web3": "1.9.0", @@ -76,10 +76,10 @@ "@vitejs/plugin-legacy": "8.0.1", "cypress": "12.17.4", "dotenv": "16.4.5", - "happy-dom": "20.0.11", + "happy-dom": "20.8.9", "react-test-renderer": "18.2.0", "sharp": "0.32.6", - "svgo": "3.2.0", + "svgo": "3.3.4", "terser": "5.39.2", "vite-plugin-image-optimizer": "2.0.3", "vite-plugin-optimize-css-modules": "1.4.0", diff --git a/packages/frontend/src/api/hooks/useCustomAnalytics.ts b/packages/frontend/src/api/hooks/useCustomAnalytics.ts index 60746aa02..f8ca8239b 100644 --- a/packages/frontend/src/api/hooks/useCustomAnalytics.ts +++ b/packages/frontend/src/api/hooks/useCustomAnalytics.ts @@ -23,7 +23,9 @@ export const useCustomAnalytics = () => { const logButtonClicked = (payload: TButtonClickEventPayload) => { const key = EActivityLogEventTypes.ButtonClicked; - logEvent(analytics, key, payload); + if (analytics) { + logEvent(analytics, key, payload); + } logButtonClickedActivity(payload.buttonName, payload.data); Logger.debug("logButtonClicked", key, payload); }; diff --git a/packages/frontend/src/api/hooks/useGATracker.ts b/packages/frontend/src/api/hooks/useGATracker.ts index e61ead85e..0fdc931ab 100644 --- a/packages/frontend/src/api/hooks/useGATracker.ts +++ b/packages/frontend/src/api/hooks/useGATracker.ts @@ -1,10 +1,21 @@ import { useEffect } from "react"; -import ReactGA from "react-ga4"; +import ReactGADefault from "react-ga4"; import { useLocation } from "react-router-dom"; import { useCookieChoice } from "src/api/hooks"; import { Logger } from "src/api/utils/logging"; import CONFIG from "src/config"; +// react-ga4 ships as CommonJS. Vite's dev pre-bundler (esbuild) resolves the +// default-import interop correctly, but the production Rollup build double-wraps +// the default export, leaving `ReactGA.initialize` undefined. Unwrap the extra +// `default` layer when present so it works under both bundlers. +const ReactGA = + typeof (ReactGADefault as { initialize?: unknown }).initialize === + "function" + ? ReactGADefault + : (ReactGADefault as unknown as { default: typeof ReactGADefault }) + .default; + let isInitialized = false; export const useGaTracker = (): void => { diff --git a/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts b/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts new file mode 100644 index 000000000..1b49a69cf --- /dev/null +++ b/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts @@ -0,0 +1,68 @@ +import queryString from "query-string"; +import { TDeveloperActivitySnapshot } from "src/api/types"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetDeveloperActivityRequest, + TGetDeveloperActivityRawResponse, + TGetDeveloperActivityResponse, +} from "./types"; + +const { COINS } = CONFIG.API.DEFAULT.ROUTES; + +const developerActivityApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getDeveloperActivity: builder.query< + TGetDeveloperActivityResponse, + TGetDeveloperActivityRequest + >({ + query: ({ coin, limit }) => { + const params = queryString.stringify( + { limit }, + { skipNull: true, skipEmptyString: true } + ); + const path = `${String(COINS.BASE)}${String( + COINS.DEVELOPER_ACTIVITY(coin) + )}${params ? `?${params}` : ""}`; + Logger.debug("getDeveloperActivity: querying", path); + return path; + }, + transformResponse: ( + r: TGetDeveloperActivityRawResponse + ): TGetDeveloperActivityResponse => { + try { + const snapshots: TDeveloperActivitySnapshot[] = r + .map((s) => ({ + id: s.id, + coin: s.coin, + commitsLast4Weeks: s.commits_last_4_weeks, + contributorsCount: s.contributors_count, + openIssues: s.open_issues, + stars: s.stars, + forks: s.forks, + date: s.date, + })) + // The endpoint returns snapshots newest-first; sort + // oldest-first so the data is ready to plot on a time + // axis and the latest snapshot is the last element. + .sort( + (a, b) => + new Date(a.date).getTime() - + new Date(b.date).getTime() + ); + return { snapshots }; + } catch (error) { + Logger.error( + "getDeveloperActivity::transformResponse: error while parsing response:", + error + ); + return { snapshots: [] }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetDeveloperActivityQuery } = developerActivityApi; diff --git a/packages/frontend/src/api/services/developerActivity/types.ts b/packages/frontend/src/api/services/developerActivity/types.ts new file mode 100644 index 000000000..1bb126140 --- /dev/null +++ b/packages/frontend/src/api/services/developerActivity/types.ts @@ -0,0 +1,31 @@ +import { TDeveloperActivityData } from "src/api/types"; + +/** + * Primitive types + */ + +export type TRemoteDeveloperActivitySnapshot = { + id: number; + coin: string; + commits_last_4_weeks: number; + contributors_count: number; + open_issues: number; + stars: number; + forks: number; + date: string; +}; + +/** + * Queries + */ + +export type TGetDeveloperActivityRequest = { + // coin slug (case insensitive) — required path param + coin: string; + limit?: number | undefined; +}; + +export type TGetDeveloperActivityRawResponse = + TRemoteDeveloperActivitySnapshot[]; + +export type TGetDeveloperActivityResponse = TDeveloperActivityData; diff --git a/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts b/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts new file mode 100644 index 000000000..e8836409a --- /dev/null +++ b/packages/frontend/src/api/services/exploits/exploitsEndpoints.ts @@ -0,0 +1,101 @@ +import queryString from "query-string"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetExploitsRequest, + TGetExploitsRawResponse, + TGetExploitsResponse, +} from "./types"; + +const { EXPLOITS } = CONFIG.API.DEFAULT.ROUTES; + +const parseAmount = (amount: string | null): number | null => { + if (amount === null) return null; + const parsed = parseFloat(amount); + return Number.isNaN(parsed) ? null : parsed; +}; + +const exploitsApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getExploits: builder.query({ + query: (req) => { + const params: string = queryString.stringify({ + ...req, + limit: req.limit ?? 30, + }); + const path = `${String(EXPLOITS.BASE)}${String( + EXPLOITS.DEFAULT + )}?${params}`; + Logger.debug("getExploits: querying", path); + return path; + }, + transformResponse: ( + r: TGetExploitsRawResponse, + _meta, + _arg + ): TGetExploitsResponse => { + Logger.debug("[EXPLOITS] transformResponse: raw response =", r); + Logger.debug( + "[EXPLOITS] transformResponse: typeof r =", + typeof r, + "| keys =", + r && typeof r === "object" ? Object.keys(r) : "n/a", + "| results isArray =", + Array.isArray(r?.results), + "| results length =", + r?.results?.length + ); + try { + const facadeData = r.results + .filter( + (item, index, self) => + self.findIndex( + (innerItem) => innerItem.id === item.id + ) === index + ) + .map((data) => ({ + id: data.id, + hash: data.hash, + title: data.title, + url: data.url, + image: data.image, + shortDescription: data.short_description, + description: data.description, + protocol: data.protocol, + amountUsd: parseAmount(data.amount_usd), + chain: data.chain, + attackType: data.attack_type?.name ?? null, + date: data.date, + source: data.source, + tags: data.tags ?? [], + })); + Logger.debug( + "[EXPLOITS] transformResponse: mapped facade length =", + facadeData.length, + "| first item =", + facadeData[0] + ); + return { + ...r, + results: facadeData, + }; + } catch (error) { + Logger.error( + "[EXPLOITS] transformResponse: FAILED to parse response — returning empty list. error =", + error, + "| raw response was =", + r + ); + return { + ...r, + results: [], + }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetExploitsQuery } = exploitsApi; diff --git a/packages/frontend/src/api/services/exploits/types.ts b/packages/frontend/src/api/services/exploits/types.ts new file mode 100644 index 000000000..98daf52cb --- /dev/null +++ b/packages/frontend/src/api/services/exploits/types.ts @@ -0,0 +1,69 @@ +import { TExploit } from "src/api/types"; +import { TPagination } from "../baseTypes"; + +/** + * Primitive types + */ + +export type TRemoteExploitSource = { + name: string; + slug: string; + icon: string | null; +}; + +export type TRemoteExploitTag = { + id: number; + name: string; + slug: string; +}; + +export type TRemoteExploitAttackType = { + id: number; + name: string; + slug: string; + description: string | null; +}; + +export type TRemoteExploit = { + id: number; + hash: string; + title: string; + url: string; + image: string | null; + short_description: string | null; + description: string | null; + protocol: string | null; + // amount lost, expressed in millions of USD + amount_usd: string | null; + chain: string | null; + attack_type: TRemoteExploitAttackType | null; + date: string; + source: TRemoteExploitSource | null; + tags: TRemoteExploitTag[]; +}; + +/** + * Queries + */ + +export type TGetExploitsRequest = { + page?: number | undefined; + limit?: number | undefined; + attack_type?: string | undefined; + chain?: string | undefined; + date?: string | undefined; + min_amount?: number | undefined; + period?: string | undefined; + protocol?: string | undefined; + sort_by?: string | undefined; + sources?: string | undefined; + tags?: string | undefined; +}; + +export type TGetExploitsRawResponse = TPagination & { + results: TRemoteExploit[]; +}; + +export type TGetExploitsResponse = TPagination & { + results: TExploit[]; +}; diff --git a/packages/frontend/src/api/services/fees/feesEndpoints.ts b/packages/frontend/src/api/services/fees/feesEndpoints.ts new file mode 100644 index 000000000..c88b0c883 --- /dev/null +++ b/packages/frontend/src/api/services/fees/feesEndpoints.ts @@ -0,0 +1,63 @@ +import queryString from "query-string"; +import { TFeesItem } from "src/api/types"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetTvlFeesRequest, + TGetTvlFeesRawResponse, + TGetTvlFeesResponse, +} from "./types"; + +const { TVL } = CONFIG.API.DEFAULT.ROUTES; + +const feesApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getTvlFees: builder.query({ + query: (req) => { + const params: string = queryString.stringify({ ...req }); + const path = `${String(TVL.BASE)}${String(TVL.FEES)}?${params}`; + Logger.debug("getTvlFees: querying", path); + return path; + }, + transformResponse: ( + r: TGetTvlFeesRawResponse, + _meta, + arg + ): TGetTvlFeesResponse => { + try { + // Each protocol carries every metric/timeframe combination + // (eg. `revenue_24h`, `total_7d`), so we pick the field that + // matches the requested view. + const metric = arg.metric ?? "revenue"; + const timeframe = arg.timeframe ?? "24h"; + const valueKey = `${metric}_${timeframe}` as const; + const items: TFeesItem[] = r.results + .map((result) => ({ + id: result.id, + name: result.project.name, + icon: result.project.icon, + value: result[valueKey], + })) + // NOTE: the backend currently orders results by + // `total_24h` regardless of `metric`, so this only sorts + // the fetched page by the selected metric. A correct + // ranking requires the backend to order by `metric`. + .sort((a, b) => b.value - a.value); + return { items }; + } catch (error) { + Logger.error( + "getTvlFees::transformResponse: error while parsing response:", + error + ); + return { + items: [], + }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetTvlFeesQuery } = feesApi; diff --git a/packages/frontend/src/api/services/fees/types.ts b/packages/frontend/src/api/services/fees/types.ts new file mode 100644 index 000000000..d1791c178 --- /dev/null +++ b/packages/frontend/src/api/services/fees/types.ts @@ -0,0 +1,47 @@ +import { TFeesData, TFeesMetric, TFeesTimeframe } from "src/api/types"; + +/** + * Primitive types + */ + +export type TRemoteFeesProject = { + name: string; + slug: string; + project_type: string; + network_id: number | null; + icon: string; + url: string; +}; + +export type TRemoteFeesItem = { + id: number; + project: TRemoteFeesProject; + currency: string; + total_24h: number; + total_7d: number; + total_30d: number; + revenue_24h: number; + revenue_7d: number; + revenue_30d: number; + date: string; +}; + +/** + * Queries + */ + +export type TGetTvlFeesRequest = { + metric?: TFeesMetric | undefined; + timeframe?: TFeesTimeframe | undefined; +}; + +export type TGetTvlFeesRawResponse = { + links: { + next: string | null; + previous: string | null; + }; + total: number; + results: TRemoteFeesItem[]; +}; + +export type TGetTvlFeesResponse = TFeesData; diff --git a/packages/frontend/src/api/services/index.ts b/packages/frontend/src/api/services/index.ts index 0f6ded9a4..342c8577c 100644 --- a/packages/frontend/src/api/services/index.ts +++ b/packages/frontend/src/api/services/index.ts @@ -38,6 +38,18 @@ export * from "./event/types"; export * from "./tvl/tvlEndpoints"; export * from "./tvl/types"; +export * from "./yields/yieldsEndpoints"; +export * from "./yields/types"; + +export * from "./fees/feesEndpoints"; +export * from "./fees/types"; + +export * from "./exploits/exploitsEndpoints"; +export * from "./exploits/types"; + +export * from "./developerActivity/developerActivityEndpoints"; +export * from "./developerActivity/types"; + export * from "./projects/projectsEndpoints"; export * from "./projects/types"; diff --git a/packages/frontend/src/api/services/video/types.ts b/packages/frontend/src/api/services/video/types.ts index e6f2fd1dc..54bb539a1 100644 --- a/packages/frontend/src/api/services/video/types.ts +++ b/packages/frontend/src/api/services/video/types.ts @@ -5,6 +5,7 @@ export type TRemoteVideoItem = TRemoteItem & { image: string; published_at: string; short_description: string; + aspect_ratio?: number | string; }; export type TGetVideoItemsRequest = { diff --git a/packages/frontend/src/api/services/video/videoEndpoints.ts b/packages/frontend/src/api/services/video/videoEndpoints.ts index ee2314877..24c39d026 100644 --- a/packages/frontend/src/api/services/video/videoEndpoints.ts +++ b/packages/frontend/src/api/services/video/videoEndpoints.ts @@ -52,6 +52,7 @@ export const videoApi = alphadayApi.injectEndpoints({ bookmarked: r.is_bookmarked, image: r.image, shortDescription: r.short_description, + aspectRatio: r.aspect_ratio, }), keepUnusedDataFor: 0, }), @@ -84,6 +85,7 @@ export const videoApi = alphadayApi.injectEndpoints({ bookmarked: i.is_bookmarked, image: i.image, shortDescription: i.short_description, + aspectRatio: i.aspect_ratio, })), }), keepUnusedDataFor: 0, diff --git a/packages/frontend/src/api/services/yields/types.ts b/packages/frontend/src/api/services/yields/types.ts new file mode 100644 index 000000000..1ebdeb3ce --- /dev/null +++ b/packages/frontend/src/api/services/yields/types.ts @@ -0,0 +1,35 @@ +import { TYieldPool } from "src/api/types"; +import { TPagination } from "../baseTypes"; + +/** + * Primitive types + */ + +export type TRemoteYieldPool = { + id: number; + pool_id: string; + project: string; + chain: string; + symbol: string; + apy: number; + apy_base: number | null; + apy_reward: number | null; + tvl_usd: number; + il_risk: string; + date: string; +}; + +/** + * Queries + */ + +export type TGetYieldsRequest = { + page?: number | undefined; + limit?: number | undefined; +}; +export type TGetYieldsRawResponse = TPagination & { + results: TRemoteYieldPool[]; +}; +export type TGetYieldsResponse = TPagination & { + results: TYieldPool[]; +}; diff --git a/packages/frontend/src/api/services/yields/yieldsEndpoints.ts b/packages/frontend/src/api/services/yields/yieldsEndpoints.ts new file mode 100644 index 000000000..d0f0d2d5e --- /dev/null +++ b/packages/frontend/src/api/services/yields/yieldsEndpoints.ts @@ -0,0 +1,77 @@ +import queryString from "query-string"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetYieldsRequest, + TGetYieldsRawResponse, + TGetYieldsResponse, +} from "./types"; + +const { YIELDS } = CONFIG.API.DEFAULT.ROUTES; + +const yieldsApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getYields: builder.query({ + query: (req) => { + const params: string = queryString.stringify({ + ...req, + limit: req.limit ?? 30, + }); + const path = `${String(YIELDS.BASE)}${String( + YIELDS.DEFAULT + )}?${params}`; + Logger.debug("getYields: querying", path); + return path; + }, + transformResponse: ( + r: TGetYieldsRawResponse, + _meta, + _arg + ): TGetYieldsResponse => { + try { + // The backend already returns pools ordered by tvl_usd + // descending, so we preserve that order here — a client-side + // re-sort would only apply per-page and break the global + // ordering once pages are appended. + const facadeData = r.results + .filter( + (item, index, self) => + self.findIndex( + (innerItem) => innerItem.id === item.id + ) === index + ) + .map((data) => ({ + id: data.id, + poolId: data.pool_id, + project: data.project, + chain: data.chain, + symbol: data.symbol, + apy: data.apy, + apyBase: data.apy_base, + apyReward: data.apy_reward, + tvlUsd: data.tvl_usd, + ilRisk: data.il_risk, + date: data.date, + })); + return { + ...r, + results: facadeData, + }; + } catch (error) { + Logger.error( + "getYields::transformResponse: error while parsing response:", + error + ); + return { + ...r, + results: [], + }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetYieldsQuery } = yieldsApi; diff --git a/packages/frontend/src/api/store/migrations.ts b/packages/frontend/src/api/store/migrations.ts index fe0b60098..4e1a35761 100644 --- a/packages/frontend/src/api/store/migrations.ts +++ b/packages/frontend/src/api/store/migrations.ts @@ -40,7 +40,13 @@ type PersistedRootState = (PersistedState & RootState) | undefined; * 102: (s: RootStateV101) => PersistedRootState */ -type RootStateV110 = PersistedRootState; +type RootStateV111 = PersistedRootState; +type RootStateV110 = + | (PersistedState & + Omit & { + widgets: Omit; + }) + | undefined; type RootStateV109 = | (PersistedState & Omit & { @@ -144,6 +150,7 @@ type TMigrations = MigrationManifest & { 108: (s: RootStateV107) => RootStateV108; 109: (s: RootStateV108) => RootStateV109; 110: (s: RootStateV109) => RootStateV110; + 111: (s: RootStateV110) => RootStateV111; }; /** @@ -355,6 +362,16 @@ const migrations: TMigrations = { }, }; }, + 111: (s: RootStateV110): RootStateV111 => { + if (!s) return undefined; + return { + ...s, + widgets: { + ...s.widgets, + tvlFees: {}, + }, + }; + }, }; export default migrations; diff --git a/packages/frontend/src/api/store/slices/widgets.test.ts b/packages/frontend/src/api/store/slices/widgets.test.ts index 29c9ac0a8..38eb666ff 100644 --- a/packages/frontend/src/api/store/slices/widgets.test.ts +++ b/packages/frontend/src/api/store/slices/widgets.test.ts @@ -75,6 +75,7 @@ beforeEach(() => { selectedProjectType: "chain", }, }, + tvlFees: {}, }; rootState = { diff --git a/packages/frontend/src/api/store/slices/widgets.ts b/packages/frontend/src/api/store/slices/widgets.ts index 8347e69a8..36a13bf8f 100644 --- a/packages/frontend/src/api/store/slices/widgets.ts +++ b/packages/frontend/src/api/store/slices/widgets.ts @@ -3,6 +3,7 @@ import { TChartRange, EItemFeedPreference, TProjectType, + TFeesMetric, TKasandraCase, } from "src/api/types"; import { ECalendarType } from "src/components/calendar/types"; @@ -63,6 +64,10 @@ export interface ITvlWidgetState { selectedProjectType: TProjectType; } +export interface ITvlFeesWidgetState { + selectedMetric: TFeesMetric; +} + export interface IWidgetsState { common: Record; market: Record; @@ -73,6 +78,7 @@ export interface IWidgetsState { podcast: Record; video: Record; tvl: Record; + tvlFees: Record; kasandra: Record; polymarket: Record; } @@ -87,6 +93,7 @@ const initialState: IWidgetsState = { video: {}, blog: {}, tvl: {}, + tvlFees: {}, kasandra: {}, polymarket: {}, }; @@ -405,6 +412,22 @@ const widgetsSlice = createSlice({ selectedProjectType: projectType, }; }, + setSelectedFeesMetric( + draft, + action: PayloadAction<{ + widgetHash: string; + metric: TFeesMetric; + }> + ) { + const { + payload: { widgetHash, metric }, + } = action; + + draft.tvlFees[widgetHash] = { + ...draft.tvlFees[widgetHash], + selectedMetric: metric, + }; + }, setPolymarketFilter( draft, action: PayloadAction<{ @@ -442,6 +465,7 @@ export const { removeWidgetStateFromCache, setWidgetHeight, setSelectedTvlProjectType, + setSelectedFeesMetric, setPolymarketFilter, } = widgetsSlice.actions; export default widgetsSlice.reducer; @@ -524,3 +548,8 @@ export const selectTvlProjectType = (widgetHash: string) => (state: RootState): TProjectType | undefined => state.widgets.tvl[widgetHash]?.selectedProjectType; + +export const selectFeesMetric = + (widgetHash: string) => + (state: RootState): TFeesMetric | undefined => + state.widgets.tvlFees[widgetHash]?.selectedMetric; diff --git a/packages/frontend/src/api/types/developerActivity.ts b/packages/frontend/src/api/types/developerActivity.ts new file mode 100644 index 000000000..20fdaef92 --- /dev/null +++ b/packages/frontend/src/api/types/developerActivity.ts @@ -0,0 +1,20 @@ +/** + * The `/coins/{coin}/developer-activity/` endpoint returns GitHub + * developer-activity snapshots for a coin, newest first. Each snapshot is a + * point-in-time reading of the coin's main repository. + */ +export type TDeveloperActivitySnapshot = { + id: number; + coin: string; + commitsLast4Weeks: number; + contributorsCount: number; + openIssues: number; + stars: number; + forks: number; + date: string; +}; + +export type TDeveloperActivityData = { + // snapshots ordered oldest -> newest (ready to plot on a time axis) + snapshots: TDeveloperActivitySnapshot[]; +}; diff --git a/packages/frontend/src/api/types/exploits.ts b/packages/frontend/src/api/types/exploits.ts new file mode 100644 index 000000000..a42c21c82 --- /dev/null +++ b/packages/frontend/src/api/types/exploits.ts @@ -0,0 +1,41 @@ +export type TExploitSource = { + name: string; + slug: string; + icon: string | null; +}; + +export type TExploitTag = { + id: number; + name: string; + slug: string; +}; + +export type TExploit = { + id: number; + hash: string; + title: string; + url: string; + image: string | null; + shortDescription: string | null; + description: string | null; + protocol: string | null; + // amount lost, expressed in millions of USD (null when unknown) + amountUsd: number | null; + chain: string | null; + attackType: string | null; + date: string; + source: TExploitSource | null; + tags: TExploitTag[]; +}; + +export enum EExploitPeriod { + Last24 = "LAST_24", + LastWeek = "LAST_WEEK", + LastMonth = "LAST_MONTH", + LastQuarter = "LAST_QUARTER", +} + +export enum EExploitSortBy { + Date = "-date", + Amount = "-amount_usd", +} diff --git a/packages/frontend/src/api/types/fees.ts b/packages/frontend/src/api/types/fees.ts new file mode 100644 index 000000000..e111144e8 --- /dev/null +++ b/packages/frontend/src/api/types/fees.ts @@ -0,0 +1,18 @@ +/** + * The `/tvl/fees/` endpoint ranks protocols by either the revenue they keep + * (`revenue`) or the total fees paid by their users (`total`), over a given + * timeframe. Data is powered by DefiLlama. + */ +export type TFeesMetric = "revenue" | "total"; +export type TFeesTimeframe = "24h" | "7d" | "30d"; + +export type TFeesItem = { + id: number; + name: string; + value: number; + icon: string; +}; + +export type TFeesData = { + items: TFeesItem[]; +}; diff --git a/packages/frontend/src/api/types/index.ts b/packages/frontend/src/api/types/index.ts index 750fa388a..6109d86ff 100644 --- a/packages/frontend/src/api/types/index.ts +++ b/packages/frontend/src/api/types/index.ts @@ -3,9 +3,12 @@ export * from "./blog"; export * from "./chat"; export * from "./custom"; export * from "./dao"; +export * from "./developerActivity"; export * from "./discord"; export * from "./event"; +export * from "./exploits"; export * from "./features"; +export * from "./fees"; export * from "./forum"; export * from "./gas"; export * from "./lens"; @@ -21,6 +24,7 @@ export * from "./qna"; export * from "./recipe"; export * from "./reddit"; export * from "./tvl"; +export * from "./yields"; export * from "./tweets"; export * from "./tutorial"; export * from "./user"; diff --git a/packages/frontend/src/api/types/video.ts b/packages/frontend/src/api/types/video.ts index e74fe5bb8..428c44ee7 100644 --- a/packages/frontend/src/api/types/video.ts +++ b/packages/frontend/src/api/types/video.ts @@ -5,6 +5,12 @@ export type TVideoItem = TBaseItem & { image: string; publishedAt: string; shortDescription: string; + /** + * Aspect ratio of the video, when known by the backend. + * May be a float (width / height, e.g. 0.5625) or a "W:H" string + * (e.g. "9:16"). Not all videos provide this. + */ + aspectRatio?: number | string; }; export type TVideoChannel = { diff --git a/packages/frontend/src/api/types/yields.ts b/packages/frontend/src/api/types/yields.ts new file mode 100644 index 000000000..4d8d76172 --- /dev/null +++ b/packages/frontend/src/api/types/yields.ts @@ -0,0 +1,13 @@ +export type TYieldPool = { + id: number; + poolId: string; + project: string; + chain: string; + symbol: string; + apy: number; + apyBase: number | null; + apyReward: number | null; + tvlUsd: number; + ilRisk: string; + date: string; +}; diff --git a/packages/frontend/src/api/utils/__snapshots__/textUtils.test.ts.snap b/packages/frontend/src/api/utils/__snapshots__/textUtils.test.ts.snap index c61a8dc9b..70184f6d6 100644 --- a/packages/frontend/src/api/utils/__snapshots__/textUtils.test.ts.snap +++ b/packages/frontend/src/api/utils/__snapshots__/textUtils.test.ts.snap @@ -12,16 +12,3 @@ exports[`Test for text Utilities > remarkRegex() >

Check out https://www.alphaday.com.

1`] = ` -

- Check out - - https://www.alphaday.com. - -

-`; diff --git a/packages/frontend/src/api/utils/firebaseUtils.ts b/packages/frontend/src/api/utils/firebaseUtils.ts index 41d578860..c11ebf087 100644 --- a/packages/frontend/src/api/utils/firebaseUtils.ts +++ b/packages/frontend/src/api/utils/firebaseUtils.ts @@ -6,4 +6,4 @@ import CONFIG from "src/config/config"; const { APP_CONFIG } = CONFIG.FIREBASE; export const isConfigured = !!APP_CONFIG.apiKey; export const firebaseApp = isConfigured ? initializeApp(APP_CONFIG) : undefined; -export const analytics = getAnalytics(firebaseApp); +export const analytics = firebaseApp ? getAnalytics(firebaseApp) : undefined; diff --git a/packages/frontend/src/api/utils/mediaUtils.test.ts b/packages/frontend/src/api/utils/mediaUtils.test.ts new file mode 100644 index 000000000..811e5b0c5 --- /dev/null +++ b/packages/frontend/src/api/utils/mediaUtils.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + getYoutubeVideoId, + getVideoEmbedUrl, + isYoutubeShortUrl, + parseAspectRatio, + resolveVideoAspect, +} from "./mediaUtils"; + +describe("getYoutubeVideoId", () => { + it("extracts the id from a watch URL", () => { + expect( + getYoutubeVideoId("https://www.youtube.com/watch?v=abc123") + ).toBe("abc123"); + }); + it("extracts the id from a shorts URL", () => { + expect(getYoutubeVideoId("https://www.youtube.com/shorts/abc123")).toBe( + "abc123" + ); + }); + it("extracts the id from a youtu.be URL", () => { + expect(getYoutubeVideoId("https://youtu.be/abc123")).toBe("abc123"); + }); + it("extracts the id from an embed URL", () => { + expect(getYoutubeVideoId("https://www.youtube.com/embed/abc123")).toBe( + "abc123" + ); + }); + it("ignores extra query params on a shorts URL", () => { + expect( + getYoutubeVideoId("https://www.youtube.com/shorts/abc123?feature=x") + ).toBe("abc123"); + }); + it("returns null for an empty url", () => { + expect(getYoutubeVideoId("")).toBeNull(); + }); + it("returns null for a non-YouTube host", () => { + expect(getYoutubeVideoId("https://vimeo.com/12345")).toBeNull(); + }); + it("returns null for a watch URL without a `v` param", () => { + expect(getYoutubeVideoId("https://www.youtube.com/watch")).toBeNull(); + }); +}); + +describe("getVideoEmbedUrl", () => { + it("builds an embed URL for a shorts link", () => { + expect( + getVideoEmbedUrl("https://www.youtube.com/shorts/abc123", { + autoplay: 1, + }) + ).toBe("//www.youtube.com/embed/abc123?autoplay=1"); + }); + it("builds an embed URL for a watch link", () => { + expect( + getVideoEmbedUrl("https://www.youtube.com/watch?v=abc123", { + autoplay: 1, + }) + ).toBe("//www.youtube.com/embed/abc123?autoplay=1"); + }); + it("passes through a non-YouTube URL unchanged", () => { + const url = "https://vimeo.com/12345"; + expect(getVideoEmbedUrl(url, { autoplay: 1 })).toBe(url); + }); +}); + +describe("isYoutubeShortUrl", () => { + it("detects shorts URLs", () => { + expect(isYoutubeShortUrl("https://www.youtube.com/shorts/abc123")).toBe( + true + ); + }); + it("returns false for regular watch URLs", () => { + expect( + isYoutubeShortUrl("https://www.youtube.com/watch?v=abc123") + ).toBe(false); + }); +}); + +describe("parseAspectRatio", () => { + it("returns undefined when absent", () => { + expect(parseAspectRatio(undefined)).toBeUndefined(); + }); + it("passes through a positive float", () => { + expect(parseAspectRatio(0.5625)).toBe(0.5625); + }); + it("rejects a non-positive float", () => { + expect(parseAspectRatio(0)).toBeUndefined(); + }); + it('parses a "W:H" string', () => { + expect(parseAspectRatio("9:16")).toBeCloseTo(9 / 16); + }); + it("parses a numeric string", () => { + expect(parseAspectRatio("1.7778")).toBeCloseTo(1.7778); + }); + it("returns undefined for garbage", () => { + expect(parseAspectRatio("not-a-ratio")).toBeUndefined(); + }); +}); + +describe("resolveVideoAspect", () => { + it("prefers the backend aspect ratio over the URL", () => { + const result = resolveVideoAspect({ + url: "https://www.youtube.com/watch?v=abc123", + aspectRatio: "9:16", + }); + expect(result.isVertical).toBe(true); + expect(result.ratio).toBeCloseTo(9 / 16); + }); + it("falls back to the shorts URL heuristic", () => { + const result = resolveVideoAspect({ + url: "https://www.youtube.com/shorts/abc123", + }); + expect(result.isVertical).toBe(true); + }); + it("defaults to landscape", () => { + const result = resolveVideoAspect({ + url: "https://www.youtube.com/watch?v=abc123", + }); + expect(result.isVertical).toBe(false); + expect(result.ratio).toBeCloseTo(16 / 9); + }); +}); diff --git a/packages/frontend/src/api/utils/mediaUtils.ts b/packages/frontend/src/api/utils/mediaUtils.ts index 19f3a5e98..6ce7a6d60 100644 --- a/packages/frontend/src/api/utils/mediaUtils.ts +++ b/packages/frontend/src/api/utils/mediaUtils.ts @@ -1,5 +1,6 @@ import html2canvas from "html2canvas"; import querystring from "query-string"; +import { TVideoItem } from "src/api/types"; import CONFIG from "src/config"; import { Logger } from "./logging"; @@ -27,6 +28,110 @@ export const entryEmbedUrl = ( return `${String(MEDIA.YOUTUBE_EMBED_BASE_URL)}${entryId}?${params}`; }; +/** + * Extracts the YouTube video id from any of the common URL shapes: + * - https://www.youtube.com/watch?v=ID + * - https://www.youtube.com/shorts/ID + * - https://youtu.be/ID + * - https://www.youtube.com/embed/ID + * + * @returns the video id, or null when it can't be determined. + */ +export const getYoutubeVideoId = (url: string): string | null => { + if (!url) return null; + try { + const parsed = new URL(url, "https://www.youtube.com"); + const { hostname } = parsed; + // youtu.be short links carry the id as the first (and only) path segment + if (hostname === "youtu.be") { + return parsed.pathname.match(/^\/([^/?#]+)/)?.[1] ?? null; + } + // Only treat youtube.com hosts (www., m., music., …) as YouTube; any + // other host is left for the caller to pass through unchanged. + if (hostname.endsWith("youtube.com")) { + const fromQuery = parsed.searchParams.get("v"); + if (fromQuery) return fromQuery; + // /shorts/ID and /embed/ID — note a bare /watch with no `v` is not + // a valid id source, so it correctly falls through to null. + return ( + parsed.pathname.match(/\/(?:shorts|embed)\/([^/?#]+)/)?.[1] ?? + null + ); + } + return null; + } catch (e) { + Logger.error("getYoutubeVideoId::failed to parse url", url, e); + return null; + } +}; + +/** + * Builds a playable YouTube embed URL from any YouTube URL shape. + * Falls back to the original URL when the id can't be extracted, so + * non-YouTube sources keep working. + */ +export const getVideoEmbedUrl = ( + url: string, + options: TEmbedOptions = {} +): string => { + const id = getYoutubeVideoId(url); + if (!id) return url; + return entryEmbedUrl(id, options); +}; + +/** + * `true` when a YouTube URL points to a Short (vertical 9:16 content). + */ +export const isYoutubeShortUrl = (url: string): boolean => + /\/shorts\//.test(url ?? ""); + +/** + * Normalizes a backend aspect ratio into a numeric width / height ratio. + * Accepts a float (e.g. 0.5625) or a "W:H" string (e.g. "9:16"). + * + * @returns the ratio, or undefined when it can't be parsed. + */ +export const parseAspectRatio = ( + aspectRatio: number | string | undefined +): number | undefined => { + if (aspectRatio === undefined || aspectRatio === null) return undefined; + if (typeof aspectRatio === "number") { + return aspectRatio > 0 ? aspectRatio : undefined; + } + const colonMatch = aspectRatio.match( + /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/ + ); + if (colonMatch) { + const w = Number(colonMatch[1]); + const h = Number(colonMatch[2]); + return h > 0 ? w / h : undefined; + } + const numeric = Number(aspectRatio); + return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined; +}; + +const LANDSCAPE_RATIO = 16 / 9; +const VERTICAL_RATIO = 9 / 16; + +/** + * Resolves the orientation of a video using, in order of precedence: + * 1. the backend `aspectRatio` (when present and parseable) + * 2. the `/shorts/` URL heuristic (YouTube Shorts are vertical) + * 3. a landscape (16:9) default + */ +export const resolveVideoAspect = ( + video: Pick +): { ratio: number; isVertical: boolean } => { + const fromBackend = parseAspectRatio(video.aspectRatio); + if (fromBackend !== undefined) { + return { ratio: fromBackend, isVertical: fromBackend < 1 }; + } + if (isYoutubeShortUrl(video.url)) { + return { ratio: VERTICAL_RATIO, isVertical: true }; + } + return { ratio: LANDSCAPE_RATIO, isVertical: false }; +}; + /** * Creates a Screenshot of an HTML element in the app. * diff --git a/packages/frontend/src/api/utils/textUtils.tsx b/packages/frontend/src/api/utils/textUtils.tsx index 51f755182..4c0145b5b 100644 --- a/packages/frontend/src/api/utils/textUtils.tsx +++ b/packages/frontend/src/api/utils/textUtils.tsx @@ -108,8 +108,9 @@ export const REMARK_URL_REGEX = export const REMARK_MENTION_REGEX = /(@[a-zA-Z\d-_]{1,31})/g; export const REMARK_HASHTAG_REGEX = /(#\w*[A-Za-z]\w*)/g; -interface IParentWithLiteral extends Literal { +interface IParentWithLiteral extends Omit { url?: string; + value?: Literal["value"]; children: Literal[]; } @@ -161,7 +162,7 @@ export const remarkRegex = ( newChildren.push({ type: "link", url, - value: { + data: { hProperties: { target: "_blank", rel: "noopener noreferrer", diff --git a/packages/frontend/src/api/utils/translationUtils.ts b/packages/frontend/src/api/utils/translationUtils.ts index 9be30d185..c3ad712ec 100644 --- a/packages/frontend/src/api/utils/translationUtils.ts +++ b/packages/frontend/src/api/utils/translationUtils.ts @@ -20,6 +20,8 @@ export enum ETranslationValues { trading = "Trading", chains = "Chains", protocols = "Protocols", + revenue = "Revenue", + fees = "Fees", timeline = "Timeline", Active = "Active", Resolved = "Resolved", diff --git a/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx b/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx new file mode 100644 index 000000000..88430843a --- /dev/null +++ b/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx @@ -0,0 +1,123 @@ +import { FC, useMemo } from "react"; +import { ApexAreaChart, themeColors } from "@alphaday/ui-kit"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; + +interface IProps { + // [unix timestamp (seconds), value] pairs, oldest first + series: Array<[number, number]>; + height: number; +} + +const DeveloperActivityChart: FC = ({ series, height }) => { + const chartSeries = useMemo( + () => [ + { + name: "value", + data: + series.length > 0 + ? series + : ([[0, 0]] as Array<[number, number]>), + }, + ], + [series] + ); + + const isUptrend = + series.length > 1 && series[series.length - 1][1] >= series[0][1]; + const lineColor = isUptrend + ? themeColors.success + : themeColors.secondaryOrangeSoda; + + const chartOptions = useMemo( + () => ({ + chart: { + sparkline: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + animations: { enabled: false }, + }, + colors: [lineColor], + dataLabels: { enabled: false }, + stroke: { + curve: "smooth", + width: 1.7, + }, + fill: { + type: "gradient", + gradient: { + shade: "light", + type: "vertical", + shadeIntensity: 1, + gradientToColors: [lineColor], + inverseColors: false, + opacityFrom: 0.4, + opacityTo: 0.05, + }, + }, + legend: { show: false }, + xaxis: { + type: "datetime", + axisBorder: { show: false }, + axisTicks: { show: false }, + tooltip: { enabled: false }, + labels: { + style: { + colors: themeColors.primaryVariant100, + fontSize: "10px", + fontFamily: "Arial, sans-serif", + fontWeight: 400, + }, + }, + }, + yaxis: { + show: true, + tickAmount: 3, + decimalsInFloat: false, + labels: { + style: { + colors: [themeColors.primaryVariant100], + fontSize: "10px", + fontFamily: "Arial, sans-serif", + fontWeight: 400, + }, + formatter: (value: number) => + formatNumber({ + value, + style: ENumberStyle.Decimal, + }).value, + }, + }, + grid: { + borderColor: themeColors.borderLine, + xaxis: { lines: { show: false } }, + yaxis: { lines: { show: true } }, + padding: { left: 4, right: 8, top: 0, bottom: 0 }, + }, + tooltip: { + enabled: true, + theme: "dark", + x: { format: "dd MMM yyyy" }, + y: { + formatter: (value: number) => + formatNumber({ + value, + style: ENumberStyle.Decimal, + }).value, + title: { formatter: () => "" }, + }, + }, + }), + [lineColor] + ); + + return ( + + ); +}; + +export default DeveloperActivityChart; diff --git a/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx b/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx new file mode 100644 index 000000000..1771c1bd0 --- /dev/null +++ b/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx @@ -0,0 +1,177 @@ +import { FC, memo, useMemo, useState } from "react"; +import { ModuleLoader } from "@alphaday/ui-kit"; +import moment from "moment-with-locales-es6"; +import { TDeveloperActivitySnapshot } from "src/api/types"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; +import globalMessages from "src/globalMessages"; +import DeveloperActivityChart from "./DeveloperActivityChart"; + +export enum EDeveloperActivityMetric { + Stars = "stars", + Forks = "forks", + Commits = "commitsLast4Weeks", + Contributors = "contributorsCount", + OpenIssues = "openIssues", +} + +type TMetricKey = keyof Pick< + TDeveloperActivitySnapshot, + "stars" | "forks" | "commitsLast4Weeks" | "contributorsCount" | "openIssues" +>; + +const METRICS: Array<{ + key: TMetricKey; + value: EDeveloperActivityMetric; + label: string; +}> = [ + { + key: "stars", + value: EDeveloperActivityMetric.Stars, + label: "Stars", + }, + { + key: "forks", + value: EDeveloperActivityMetric.Forks, + label: "Forks", + }, + { + key: "commitsLast4Weeks", + value: EDeveloperActivityMetric.Commits, + label: "Commits (4w)", + }, + { + key: "contributorsCount", + value: EDeveloperActivityMetric.Contributors, + label: "Contributors", + }, + { + key: "openIssues", + value: EDeveloperActivityMetric.OpenIssues, + label: "Open Issues", + }, +]; + +const formatCount = (value: number): string => + formatNumber({ value, style: ENumberStyle.Decimal }).value; + +const StatTile: FC<{ + label: string; + value: number; + active: boolean; + onClick: () => void; +}> = ({ label, value, active, onClick }) => ( + +); + +interface IDeveloperActivity { + isLoading: boolean; + snapshots: TDeveloperActivitySnapshot[] | undefined; + coin: string | undefined; + widgetHeight: number; +} + +const DeveloperActivityModule: FC = memo( + function DeveloperActivityModule({ + isLoading, + snapshots, + coin, + widgetHeight, + }) { + const [selectedMetric, setSelectedMetric] = + useState(EDeveloperActivityMetric.Stars); + + const latest = snapshots?.[snapshots.length - 1]; + + const selectedMetricConfig = + METRICS.find((m) => m.value === selectedMetric) ?? METRICS[0]; + + const chartSeries = useMemo>(() => { + if (!snapshots) return []; + return snapshots.map((s) => [ + moment(s.date).unix(), + Number(s[selectedMetricConfig.key]), + ]); + }, [snapshots, selectedMetricConfig.key]); + + const TILES_HEIGHT = 60; + const chartHeight = Math.max(widgetHeight - TILES_HEIGHT, 120); + + if (isLoading) { + return ; + } + + if (coin === undefined) { + return ( +
+

+ Pin a coin to this widget to see its GitHub developer + activity. +

+
+ ); + } + + if (latest === undefined || snapshots?.length === 0) { + return ( +
+

+ {globalMessages.queries.noMatchFound( + "developer activity" + )} +

+
+ ); + } + + return ( +
+
+ {METRICS.map((metric) => ( + setSelectedMetric(metric.value)} + /> + ))} +
+
+ + {selectedMetricConfig.label} + + + {moment(latest.date).format("DD MMM YYYY")} + +
+ +
+ ); + } +); + +export default DeveloperActivityModule; diff --git a/packages/frontend/src/components/exploits/ExploitItem.tsx b/packages/frontend/src/components/exploits/ExploitItem.tsx new file mode 100644 index 000000000..c3961dfdb --- /dev/null +++ b/packages/frontend/src/components/exploits/ExploitItem.tsx @@ -0,0 +1,107 @@ +import { FC, useState, KeyboardEvent } from "react"; +import moment from "moment-with-locales-es6"; +import { TExploit } from "src/api/types"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; +import ChevronSVG from "src/assets/icons/chevron-down2.svg?react"; + +interface IExploitItemProps { + exploit: TExploit; +} + +const formatAmount = (amountUsd: number | null): string => { + if (amountUsd === null) return "—"; + // amount_usd is expressed in millions of USD + return formatNumber({ + value: amountUsd * 1_000_000, + style: ENumberStyle.Currency, + currency: "USD", + }).value; +}; + +export const ExploitItem: FC = ({ exploit }) => { + const [isExpanded, setIsExpanded] = useState(false); + const description = exploit.description ?? exploit.shortDescription; + + const toggle = () => setIsExpanded((prev) => !prev); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }; + + return ( +
+
+
+ + {exploit.protocol ?? exploit.title} + + + {exploit.title} + +
+ {exploit.chain && ( + + {exploit.chain} + + )} + {exploit.attackType && ( + + {exploit.attackType} + + )} + + {moment(exploit.date).format("MMM D, YYYY")} + +
+
+
+
+ + {formatAmount(exploit.amountUsd)} + + + lost + +
+ +
+
+ + {isExpanded && description && ( + + )} +
+ ); +}; diff --git a/packages/frontend/src/components/exploits/ExploitsModule.tsx b/packages/frontend/src/components/exploits/ExploitsModule.tsx new file mode 100644 index 000000000..864d4215b --- /dev/null +++ b/packages/frontend/src/components/exploits/ExploitsModule.tsx @@ -0,0 +1,98 @@ +import { FC, memo, FormEvent } from "react"; +import { ModuleLoader, ScrollBar, TabsBar } from "@alphaday/ui-kit"; +import { TExploit, EExploitSortBy } from "src/api/types"; +import { shouldFetchMoreItems } from "src/api/utils/itemUtils"; +import { Logger } from "src/api/utils/logging"; +import globalMessages from "src/globalMessages"; +import { ExploitItem } from "./ExploitItem"; + +interface IExploits { + isLoading: boolean; + exploits: TExploit[] | undefined; + widgetHeight: number; + selectedSort: EExploitSortBy; + onChangeSort: (sort: EExploitSortBy) => void; + handlePaginate: (type: "next" | "previous") => void; +} + +const SORT_HEIGHT = 38; + +const sortNavItems = [ + { label: "Latest", value: EExploitSortBy.Date }, + { label: "Biggest", value: EExploitSortBy.Amount }, +]; + +const ExploitsModule: FC = memo(function ExploitsModule({ + isLoading, + exploits, + widgetHeight, + selectedSort, + onChangeSort, + handlePaginate, +}) { + const LIST_HEIGHT = widgetHeight - SORT_HEIGHT; + + const selectedSortOption = + sortNavItems.find((item) => item.value === selectedSort) || + sortNavItems[0]; + + const onTabOptionChange = (value: string) => { + const optionItem = sortNavItems.find((item) => item.value === value); + if (optionItem === undefined) { + Logger.debug("ExploitsModule::onTabOptionChange: option not found"); + return; + } + onChangeSort(optionItem.value); + }; + + const handleListScroll = ({ currentTarget }: FormEvent) => { + if (shouldFetchMoreItems(currentTarget)) { + handlePaginate("next"); + } + }; + + if (isLoading) { + return ; + } + + return ( +
+
+
+ +
+
    + {exploits !== undefined && exploits?.length !== 0 ? ( + + {exploits?.map((exploit) => ( +
  • + +
  • + ))} +
    + ) : ( +
    +

    + {globalMessages.queries.noMatchFound( + "security alerts" + )} +

    +
    + )} +
+
+
+ ); +}); + +export default ExploitsModule; diff --git a/packages/frontend/src/components/fees/FeesItem.tsx b/packages/frontend/src/components/fees/FeesItem.tsx new file mode 100644 index 000000000..471cdece5 --- /dev/null +++ b/packages/frontend/src/components/fees/FeesItem.tsx @@ -0,0 +1,104 @@ +import { FC } from "react"; +import { TFeesItem, TFeesMetric } from "src/api/types"; +import { imgOnError } from "src/api/utils/errorHandling"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; +import logoDay from "src/assets/png/logo-white.png"; + +const COLUMN_WIDTHS = { + INDEX: 0.4, + NAME: 4, + DATUM: 2.5, +}; + +export const FeesItemsHeader: FC<{ metric: TFeesMetric }> = ({ metric }) => { + return ( +
+
+
+
+ Name +
+
+
+
+ {metric === "total" ? "Fees (24h)" : "Revenue (24h)"} +
+
+
+ ); +}; + +interface IFeesItemProps { + item: TFeesItem; + index: number; +} + +export const FeesItem: FC = ({ item, index }) => { + return ( +
+
+
+ {index + 1} +
+
+
+
+
+ +
+
+ {item.name} +
+
+
+
+ { + formatNumber({ + value: item.value, + style: ENumberStyle.Currency, + currency: "USD", + }).value + } +
+
+
+ ); +}; diff --git a/packages/frontend/src/components/fees/FeesModule.tsx b/packages/frontend/src/components/fees/FeesModule.tsx new file mode 100644 index 000000000..f1c0e43d6 --- /dev/null +++ b/packages/frontend/src/components/fees/FeesModule.tsx @@ -0,0 +1,112 @@ +import { FC, useMemo, memo } from "react"; +import { ModuleLoader, ScrollBar, TabsBar } from "@alphaday/ui-kit"; +import useElementSize from "src/api/hooks/useElementSize"; +import { TFeesItem, TFeesMetric } from "src/api/types"; +import { Logger } from "src/api/utils/logging"; +import { translateLabels } from "src/api/utils/translationUtils"; +import globalMessages from "src/globalMessages"; +import { FeesItem, FeesItemsHeader } from "./FeesItem"; + +export enum EFeesItemPreference { + Revenue = "revenue", + Fees = "total", +} +interface IFees { + isLoading: boolean; + items: TFeesItem[] | undefined; + widgetHeight: number; + selectedMetric: TFeesMetric; + onChangeMetric: (metric: EFeesItemPreference) => void; +} + +const translateNavItems = () => [ + { + label: translateLabels("Revenue"), + value: EFeesItemPreference.Revenue, + }, + { + label: translateLabels("Fees"), + value: EFeesItemPreference.Fees, + }, +]; + +const FeesModule: FC = memo(function FeesModule({ + isLoading, + items, + widgetHeight, + selectedMetric, + onChangeMetric, +}) { + const feesNavItems = translateNavItems(); + + const [squareRef, { width }] = useElementSize(); + + const THRESHOLD = 475; + const SWITCH_HEIGHT = 38; + const LIST_HEADER_HEIGHT = 28; + const LIST_HEIGHT = useMemo( + () => + width >= THRESHOLD + ? widgetHeight - (SWITCH_HEIGHT + LIST_HEADER_HEIGHT) + : widgetHeight - SWITCH_HEIGHT, + [widgetHeight, width] + ); + + const selectedMetricOption = + feesNavItems.find((item) => item.value === selectedMetric) || + feesNavItems[0]; + + const onTabOptionChange = (value: string) => { + const optionItem = feesNavItems.find((item) => item.value === value); + if (optionItem === undefined) { + Logger.debug( + "FeesModule::onTabOptionsChange: Nav option item not found" + ); + return; + } + onChangeMetric(optionItem.value); + }; + + if (isLoading) { + return ; + } + + return ( +
+
+
+ +
+ {items?.length !== 0 && width >= THRESHOLD && ( + + )} +
    + {items !== undefined && items?.length !== 0 ? ( + + {items?.map((item, i) => ( +
  • + +
  • + ))} +
    + ) : ( +
    +

    + {globalMessages.queries.noMatchFound("fees")} +

    +
    + )} +
+
+
+ ); +}); + +export default FeesModule; diff --git a/packages/frontend/src/components/media/MediaModule.tsx b/packages/frontend/src/components/media/MediaModule.tsx index 8f5ed32e4..0d20524d9 100644 --- a/packages/frontend/src/components/media/MediaModule.tsx +++ b/packages/frontend/src/components/media/MediaModule.tsx @@ -1,17 +1,23 @@ import { FC, memo, useEffect, useRef } from "react"; -import { CenteredBlock, ModuleLoader } from "@alphaday/ui-kit"; +import { CenteredBlock, ModuleLoader, twMerge } from "@alphaday/ui-kit"; import globalMessages from "src/globalMessages"; interface IMedia { title: string; entryUrl: string; isLoading: boolean; + isVertical?: boolean; } +const FRAME_HEIGHT = 410; +// width that preserves a 9:16 ratio at the fixed frame height +const VERTICAL_FRAME_WIDTH = Math.round((FRAME_HEIGHT * 9) / 16); + const MediaModule: FC = memo(function MediaModule({ title, entryUrl, isLoading, + isVertical = false, }) { /** * Before the iframe loads the browser displays a white page. @@ -30,19 +36,33 @@ const MediaModule: FC = memo(function MediaModule({ }, []); if (isLoading) { - return ; + return ; } if (entryUrl) { return ( -
+