diff --git a/Config.ts b/Config.ts index 6445c9a73a288141adcd9c1c6622103741da5142..119e9d4574c2cb3c9dcbd6a66d42cf659423fd4d 100644 --- a/Config.ts +++ b/Config.ts @@ -14,6 +14,10 @@ export const config = { gitHash: process.env.NEXT_PUBLIC_COMMIT_HASH, get lastCommitHashRepoUrl() { return `https://gitlab.syncad.com/hive/block_explorer_ui/-/commit/${this.gitHash}`; + }, + topHolders: { + totalCount: 500, // Total number of top holders + pageSize: 100, // Page size for pagination }, opsBodyLimit: 100000, commentOperationsTypeIds: [0, 1, 17, 19, 51, 53, 61, 63, 72, 73], diff --git a/components/ExploreMenu.tsx b/components/ExploreMenu.tsx index 11e778b355263d19a40ba983b3b3e8481d4ece08..4d69abcd68366d6435d3f43a800d5d3ebb23baec 100644 --- a/components/ExploreMenu.tsx +++ b/components/ExploreMenu.tsx @@ -1,9 +1,10 @@ // src/components/layout/ExploreMenu.tsx +// At the top with imports import React, { useState, useEffect, useRef } from "react"; import Link from "next/link"; import { useI18n } from "@/i18n/i18n"; -import { Users, Vote, Menu, UserCheck, SettingsIcon } from "lucide-react"; +import { Users, Vote, Menu, UserCheck, SettingsIcon,Award } from "lucide-react"; import { Popover, PopoverContent, @@ -55,6 +56,7 @@ export function ExploreMenu() { setIsOpen(false)} /> setIsOpen(false)} /> setIsOpen(false)} /> + setIsOpen(false)}/> setIsOpen(false)} /> diff --git a/components/footer.tsx b/components/footer.tsx index 125d15fc5d69a311482039a2037582c0583c76d9..4c6bf7b23fc95b3fe055deee8e12f7ae9d50d78e 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -200,6 +200,10 @@ const Footer = () => { {t("footer.witnessSchedule")} +
  • + + {t("pageTitle.topHolders")} +
  • diff --git a/components/navbar.tsx b/components/navbar.tsx index a8264e30916d5be23bb57174d3903173dec4a8d5..f57ca5e60d672fb837bb1fb3fd1e7254b81a00ab 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -95,10 +95,15 @@ export default function Navbar() { {t("navbar.proposalsTitle")} - setMenuOpen(false)}> + setMenuOpen(false)}> {t("navbar.witnessesTitle")} + setMenuOpen(false)}> + + {t("pageTitle.topHolders")} + + setMenuOpen(false)}> {t("navbar.additionalSettingsTitle")} diff --git a/components/top-holders/BalanceHistoryModal.tsx b/components/top-holders/BalanceHistoryModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b6f0368e2693d209d7339e0299cb9e6ec84892d9 --- /dev/null +++ b/components/top-holders/BalanceHistoryModal.tsx @@ -0,0 +1,84 @@ +import React, { useState, useMemo } from "react"; +import BalanceHistoryChart from "@/components/balanceHistory/BalanceHistoryChart"; +import useBalanceHistory from "@/hooks/api/balanceHistory/useBalanceHistory"; + +interface BalanceHistoryModalProps { + username: string; + coinType?: "HIVE" | "VESTS" | "HBD"; + onClose: () => void; +} + +const BalanceHistoryModal: React.FC = ({ + username, + coinType = "HIVE", + onClose, +}) => { + const [selectedCoinType, setSelectedCoinType] = useState<"HIVE" | "VESTS" | "HBD">(coinType); + + const { accountBalanceHistory, isAccountBalanceHistoryLoading, isAccountBalanceHistoryError } = + useBalanceHistory(username, selectedCoinType, 1, 100, "asc"); + + // Map API result to chart-compatible format + const parsedOperations = useMemo(() => { + return accountBalanceHistory?.operations_result?.map((op: any) => ({ + timestamp: op.timestamp, + balance: Number(op.balance || 0), + balance_change: Number(op.balance_change || 0), + savings_balance: op.savings_balance !== undefined ? Number(op.savings_balance) : undefined, + savings_balance_change: + op.savings_balance_change !== undefined ? Number(op.savings_balance_change) : undefined, + hivePrice: op.hivePrice || "0", + dollarValue: op.dollarValue !== undefined ? Number(op.dollarValue) : undefined, + convertedHive: op.convertedHive !== undefined ? Number(op.convertedHive) : undefined, + })); + }, [accountBalanceHistory]); + + return ( +
    +
    +
    +

    {username} Balance History

    + +
    + + {/* Coin selection */} +
    + {["HIVE", "VESTS", "HBD"].map((coin) => ( + + ))} +
    + + {/* Content */} + {isAccountBalanceHistoryLoading ? ( +

    Loading balance history...

    + ) : isAccountBalanceHistoryError ? ( +

    Error loading balance history.

    + ) : !parsedOperations?.length ? ( +

    No balance history available.

    + ) : ( +
    + +
    + )} +
    +
    + ); +}; + +export default BalanceHistoryModal; diff --git a/components/top-holders/TopHoldersSearchBar.tsx b/components/top-holders/TopHoldersSearchBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed8dcaff3b3afea56045824437064bb3a5e29d82 --- /dev/null +++ b/components/top-holders/TopHoldersSearchBar.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Search } from "lucide-react"; +import { useI18n } from "@/i18n/i18n"; + +interface TableSearchBarProps { + value: string; + onChange: (value: string) => void; +} + +const TableSearchBar: React.FC = ({ value, onChange }) => { + const { t } = useI18n(); + + return ( +
    + onChange(e.target.value)} + placeholder={t("filters.searchUser")} + className="w-full border rounded pl-10 pr-3 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + +
    + ); +}; + +export default TableSearchBar; diff --git a/hooks/common/useTopHolders.ts b/hooks/common/useTopHolders.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe80e7f51e4fccdca882e2fd2aa8c88324784989 --- /dev/null +++ b/hooks/common/useTopHolders.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import fetchingService from "@/services/FetchingService"; +import Hive from "@/types/Hive"; + +export type CoinType = "HIVE" | "HBD" | "VESTS"; +export type BalanceType = "balance" | "savings_balance"; + +const useTopHolders = (coinType: CoinType, balanceType: BalanceType, page: number) => { + const { + data: holdersData, + isLoading: isTopHoldersLoading, + error: isTopHoldersError, + } = useQuery({ + queryKey: ["topHolders", coinType, balanceType, page], + queryFn: () => fetchingService.getTopHolders(coinType, balanceType, page), + enabled: !(coinType === "VESTS" && balanceType !== "balance"), + refetchOnWindowFocus: false, + }); + + return { + holdersData: holdersData ?? [], + isTopHoldersLoading, + isTopHoldersError: isTopHoldersError as Error | null, + }; +}; + +export default useTopHolders; diff --git a/i18n/ar.json b/i18n/ar.json index fd3e40bdc17e94e6f0bbb83d8dbf55cc6130b060..b204f0efe36f8c9f7675cc815d441f7e0862f0a6 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -912,4 +912,21 @@ "blockDetails.blockDetails": "تفاصيل_الكتلة", "commentsSearch.commentSearchResults": "نتائج_البحث_عن_التعليقات", "transactionPage.transactionDetailsRefferer": "تفاصيل_المعاملة" + ,"pageTitle.topHolders": "أعلى الحائزين", + "filters.filtersAndSearch": "المرشحات والبحث", + "filters.balance": "الرصيد", + "filters.savings": "المدخرات", + "table.rank": "الترتيب", + "table.account": "الحساب", + "table.balance": "الرصيد", + "table.savings": "المدخرات", + "modal.loading": "جار التحميل...", + "modal.noHistory": "لا يوجد تاريخ متاح.", + "modal.error": "خطأ في تحميل تاريخ الرصيد.", + "modal.close": "إغلاق", + "topHoldersPage.infoDescription": "تُظهر هذه الصفحة أكبر الحائزين لكل عملة. استخدم الفلاتر للبحث، والفرز، وتحديد الأرصدة.", +"filters.ascending": "تصاعدي", +"filters.descending": "تنازلي", +"filters.searchUser":"البحث عن مستخدم" + } \ No newline at end of file diff --git a/i18n/de.json b/i18n/de.json index 3be0514c2133370b3502618d693e40f6e219bc5e..92c0401c4c3bc605265638b9af6e6e953d201b69 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -912,4 +912,21 @@ "blockDetails.blockDetails": "block_details", "commentsSearch.commentSearchResults": "kommentar_suchergebnisse", "transactionPage.transactionDetailsRefferer": "transaktionsdetails" + , "pageTitle.topHolders": "Top-Inhaber", + "filters.filtersAndSearch": "Filter & Suche", + "filters.balance": "Kontostand", + "filters.savings": "Ersparnisse", + "table.rank": "Rang", + "table.account": "Konto", + "table.balance": "Kontostand", + "table.savings": "Ersparnisse", + "modal.loading": "Lädt...", + "modal.noHistory": "Keine Historie verfügbar.", + "modal.error": "Fehler beim Laden des Kontostandverlaufs.", + "modal.close": "Schließen", + "topHoldersPage.infoDescription": "Diese Seite zeigt die Top-Inhaber jeder Münze. Verwenden Sie Filter, um zu suchen, zu sortieren und Guthaben auszuwählen.", +"filters.ascending": "Aufsteigend", +"filters.descending": "Absteigend", +"filters.searchUser": "Nach Benutzer suchen" + } \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index d0ed10cbdf8443ab125b2a789cfa3606f923ddf8..d52a6431526a6c50559210dc1b0d33f4302240c9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -911,5 +911,25 @@ "transactionPage.transactionDetailsRefferer": "transaction_details", "accountSearch.accountReactResults": "account_search_results", "commentsSearch.commentSearchResults": "comment_search_results", - "blockDetails.blockDetails": "block_details" + "blockDetails.blockDetails": "block_details", + "pageTitle.topHolders": "Top Holders", + "filters.ascending":"Ascending", + "filters.descending":"Descending", + "topHoldersPage.infoDescription": "This page shows the top holders for each coin. Use filters to search, sort, and select balances.", + + "filters.filtersAndSearch": "Filters & Search", + "filters.balance": "Balance", + "filters.savings": "Savings", + "table.rank": "Rank", + "table.account": "Account", + "table.balance": "Balance", + "table.savings": "Savings", + "modal.loading": "Loading...", + "modal.noHistory": "No history available.", + "modal.error": "Error loading balance history.", + "modal.close": "Close", + "filters.searchUser": "Search by user" + + + } \ No newline at end of file diff --git a/i18n/es.json b/i18n/es.json index 3b77dde13399feb10f1b33bd98b11f5828a661e1..cea823578c40d784a98ed8ef2fe284897388a473 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -911,4 +911,20 @@ "blockDetails.blockDetails": "detalles_bloque", "commentsSearch.commentSearchResults": "resultados_busqueda_comentarios", "transactionPage.transactionDetailsRefferer": "detalles_transaccion" + ,"pageTitle.topHolders": "Principales Titulares", + "filters.filtersAndSearch": "Filtros y Búsqueda", + "filters.balance": "Saldo", + "filters.savings": "Ahorros", + "table.rank": "Rango", + "table.account": "Cuenta", + "table.balance": "Saldo", + "table.savings": "Ahorros", + "modal.loading": "Cargando...", + "modal.noHistory": "No hay historial disponible.", + "modal.error": "Error al cargar el historial de saldo.", + "modal.close": "Cerrar", + "topHoldersPage.infoDescription": "Esta página muestra los principales poseedores de cada moneda. Usa los filtros para buscar, ordenar y seleccionar saldos.", +"filters.ascending": "Ascendente", +"filters.descending": "Descendente", +"filters.searchUser": "Buscar por usuario" } \ No newline at end of file diff --git a/i18n/fr.json b/i18n/fr.json index 31d4262832fe5c22577b263e69cd76cc3da5fe84..8b16910cfb75363c53bc4cd077bed0b788cd4758 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -911,4 +911,21 @@ "blockDetails.blockDetails": "details_bloc", "commentsSearch.commentSearchResults": "resultats_recherche_commentaires", "transactionPage.transactionDetailsRefferer": "details_transaction" +, "pageTitle.topHolders": "Principaux Détenteurs", + "filters.filtersAndSearch": "Filtres & Recherche", + "filters.balance": "Solde", + "filters.savings": "Économies", + "table.rank": "Rang", + "table.account": "Compte", + "table.balance": "Solde", + "table.savings": "Économies", + "modal.loading": "Chargement...", + "modal.noHistory": "Aucun historique disponible.", + "modal.error": "Erreur lors du chargement de l'historique du solde.", + "modal.close": "Fermer", + "topHoldersPage.infoDescription": "Cette page affiche les principaux détenteurs de chaque crypto-monnaie. Utilisez les filtres pour rechercher, trier et sélectionner les soldes.", +"filters.ascending": "Croissant", +"filters.descending": "Décroissant", +"filters.searchUser": "Rechercher un utilisateur" + } \ No newline at end of file diff --git a/i18n/it.json b/i18n/it.json index 0e5470f2ebe9ad62889ff18d9d71d7fc5ace7ded..2e2349347397037e8b8928eacadd762a1284de78 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -903,8 +903,27 @@ "settingsPage.radialViewDescription": "Mostra risorse come il potere di voto usando indicatori circolari. (Predefinito)", "settingsPage.linearViewLabel": "Vista lineare", "settingsPage.linearViewDescription": "Mostra risorse come il potere di voto usando le tradizionali barre orizzontali.", - "accountSearch.accountReactResults": "risultati_ricerca_account", + "pageTitle.topHolders": "Principali Detentori", + "filters.filtersAndSearch": "Filtri e Ricerca", + "filters.balance": "Saldo", + "filters.savings": "Risparmi", + "table.rank": "Posizione", + "table.account": "Account", + "table.balance": "Saldo", + "table.savings": "Risparmi", + "modal.loading": "Caricamento...", + "modal.noHistory": "Nessuna cronologia disponibile.", + "modal.error": "Errore durante il caricamento dello storico saldo.", + "modal.close": "Chiudi" + + ,"accountSearch.accountReactResults": "risultati_ricerca_account", "blockDetails.blockDetails": "dettagli_blocco", "commentsSearch.commentSearchResults": "risultati_ricerca_commenti", - "transactionPage.transactionDetailsRefferer": "dettagli_transazione" + "transactionPage.transactionDetailsRefferer": "dettagli_transazione", + "topHoldersPage.infoDescription": "Questa pagina mostra i principali detentori di ogni moneta. Usa i filtri per cercare, ordinare e selezionare i saldi.", +"filters.ascending": "Crescente", +"filters.descending": "Decrescente", +"filters.searchUser": "Cerca per utente" + + } \ No newline at end of file diff --git a/i18n/ja.json b/i18n/ja.json index 93cb325bc965961c4fca93fa6dc4510cd3db94c0..17f08101d74d2af9630d1b996cc93b80f4fbdc91 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -912,4 +912,22 @@ "blockDetails.blockDetails": "ブロック詳細", "commentsSearch.commentSearchResults": "コメント検索結果", "transactionPage.transactionDetailsRefferer": "取引詳細" +, "pageTitle.topHolders": "トップ保有者", + "filters.filtersAndSearch": "フィルターと検索", + "filters.balance": "残高", + "filters.savings": "貯蓄", + "table.rank": "ランク", + "table.account": "アカウント", + "table.balance": "残高", + "table.savings": "貯蓄", + "modal.loading": "読み込み中...", + "modal.noHistory": "履歴はありません。", + "modal.error": "残高履歴の読み込み中にエラーが発生しました。", + "modal.close": "閉じる", + "topHoldersPage.infoDescription": "このページでは、各コインの上位保有者を表示します。フィルターを使用して検索、並べ替え、残高を選択してください。", +"filters.ascending": "昇順", +"filters.descending": "降順", +"filters.searchUser": "ユーザーで検索" + + } \ No newline at end of file diff --git a/i18n/ko.json b/i18n/ko.json index 4158fc01d05a101d1c64db2719f753cc3d46ac7d..31491a0aab86d6484181e6f52d1f0bb42830eb72 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -912,4 +912,18 @@ "blockDetails.blockDetails": "블록_세부정보", "commentsSearch.commentSearchResults": "댓글_검색_결과", "transactionPage.transactionDetailsRefferer": "거래_세부정보" + , + "pageTitle.topHolders": "상위 보유자", + "filters.filtersAndSearch": "필터 및 검색", + "filters.balance": "잔액", + "filters.savings": "저축", + "table.rank": "순위", + "table.account": "계정", + "table.balance": "잔액", + "table.savings": "저축", + "modal.loading": "로딩 중...", + "modal.noHistory": "사용 가능한 기록이 없습니다.", + "modal.error": "잔액 기록을 불러오는 중 오류가 발생했습니다.", + "modal.close": "닫기" + } \ No newline at end of file diff --git a/i18n/pl.json b/i18n/pl.json index 657ea73666dc439afce4a206b738e54a259ed02d..1be96d30af8d2304ab3c8a370b479389ac81bb9b 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -912,4 +912,22 @@ "blockDetails.blockDetails": "szczegoly_bloku", "commentsSearch.commentSearchResults": "wyniki_wyszukiwania_komentarzy", "transactionPage.transactionDetailsRefferer": "szczegoly_transakcji" +, "pageTitle.topHolders": "Najwięksi Posiadacze", + "filters.filtersAndSearch": "Filtry i Wyszukiwanie", + "filters.balance": "Saldo", + "filters.savings": "Oszczędności", + "table.rank": "Pozycja", + "table.account": "Konto", + "table.balance": "Saldo", + "table.savings": "Oszczędności", + "modal.loading": "Ładowanie...", + "modal.noHistory": "Brak dostępnej historii.", + "modal.error": "Błąd podczas ładowania historii salda.", + "modal.close": "Zamknij", + "topHoldersPage.infoDescription": "Ta strona pokazuje największych posiadaczy każdej monety. Użyj filtrów, aby wyszukiwać, sortować i wybierać salda.", +"filters.ascending": "Rosnąco", +"filters.descending": "Malejąco", +"filters.searchUser": "Szukaj użytkownika" + + } \ No newline at end of file diff --git a/i18n/pt.json b/i18n/pt.json index ca8c9f881c8dbcd2585f23ad32bd95bb64c58bc4..7faf9a55957b9b86f29459b419f28a3edf86c2d6 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -908,8 +908,27 @@ "settingsPage.radialViewDescription": "Mostrar recursos como poder de voto usando indicadores circulares. (Padrão)", "settingsPage.linearViewLabel": "Visualização Linear", "settingsPage.linearViewDescription": "Mostrar recursos como poder de voto usando barras horizontais tradicionais.", - "accountSearch.accountReactResults": "resultados_pesquisa_conta", + "pageTitle.topHolders": "Principais Detentores", + "filters.filtersAndSearch": "Filtros e Pesquisa", + "filters.balance": "Saldo", + "filters.savings": "Poupança", + "table.rank": "Classificação", + "table.account": "Conta", + "table.balance": "Saldo", + "table.savings": "Poupança", + "modal.loading": "Carregando...", + "modal.noHistory": "Nenhum histórico disponível.", + "modal.error": "Erro ao carregar histórico de saldo.", + "modal.close": "Fechar" + + ,"accountSearch.accountReactResults": "resultados_pesquisa_conta", "blockDetails.blockDetails": "detalhes_bloco", "commentsSearch.commentSearchResults": "resultados_pesquisa_comentarios", - "transactionPage.transactionDetailsRefferer": "detalhes_transacao" + "transactionPage.transactionDetailsRefferer": "detalhes_transacao", + "topHoldersPage.infoDescription": "Esta página mostra os principais detentores de cada moeda. Use os filtros para pesquisar, classificar e selecionar saldos.", +"filters.ascending": "Ascendente", +"filters.descending": "Descendente", +"filters.searchUser": "Pesquisar por usuário" + + } \ No newline at end of file diff --git a/i18n/ro.json b/i18n/ro.json index 7dccfe0abf852da0d3dec4e9e7d82e95fa11a1ff..8113beed06880e7ee226f6e613ae3074b753ebcc 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -911,4 +911,22 @@ "blockDetails.blockDetails": "detalii_bloc", "commentsSearch.commentSearchResults": "rezultate_cautare_comentarii", "transactionPage.transactionDetailsRefferer": "detalii_tranzactie" +, "pageTitle.topHolders": "Cei mai mari deținători", + "filters.filtersAndSearch": "Filtre și Căutare", + "filters.balance": "Sold", + "filters.savings": "Economii", + "table.rank": "Rang", + "table.account": "Cont", + "table.balance": "Sold", + "table.savings": "Economii", + "modal.loading": "Se încarcă...", + "modal.noHistory": "Nu există istoric disponibil.", + "modal.error": "Eroare la încărcarea istoricului soldului.", + "modal.close": "Închide", + "topHoldersPage.infoDescription": "Această pagină arată cei mai importanți deținători pentru fiecare monedă. Folosiți filtrele pentru a căuta, sorta și selecta soldurile.", +"filters.ascending": "Ascendent", +"filters.descending": "Descendent", +"filters.searchUser": "Căutați după utilizator" + + } \ No newline at end of file diff --git a/i18n/zh.json b/i18n/zh.json index 8944827dc8e847b0188bb81c7312dabbbec27c3c..796f2a59d3359eb445b10de8a6b32eb7816e602c 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -911,5 +911,23 @@ "accountSearch.accountReactResults": "账户搜索结果", "blockDetails.blockDetails": "区块详情", "commentsSearch.commentSearchResults": "评论搜索结果", - "transactionPage.transactionDetailsRefferer": "交易详情" + "transactionPage.transactionDetailsRefferer": "交易详情" ,"pageTitle.topHolders": "持币最多者", + "filters.filtersAndSearch": "筛选与搜索", + "filters.balance": "余额", + "filters.savings": "储蓄", + "table.rank": "排名", + "table.account": "账户", + "table.balance": "余额", + "table.savings": "储蓄", + "modal.loading": "加载中...", + "modal.noHistory": "没有可用历史记录。", + "modal.error": "加载余额历史出错。", + "modal.close": "关闭", + "topHoldersPage.infoDescription": "此页面显示每种货币的顶级持有者。使用筛选器进行搜索、排序和选择余额。", +"filters.ascending": "升序", +"filters.descending": "降序", +"filters.searchUser": "按用户搜索" + + + } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 84d1a2ac3dffb077ed6a462793df66472e41a212..42be30eed0ef5599b4d191a7d7d93a693c27a8a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "autoprefixer": "10.4.14", + "chart.js": "^4.5.0", "child_process": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -43,6 +44,7 @@ "playwright-merge-html-reports": "^0.2.8", "postcss": "^8.4.31", "react": "18.3.1", + "react-chartjs-2": "^5.3.0", "react-country-flag": "^3.1.0", "react-day-picker": "^8.10.0", "react-dom": "18.3.1", @@ -706,6 +708,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@next/env": { "version": "15.3.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz", @@ -3075,6 +3082,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/child_process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", @@ -6605,6 +6623,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-country-flag": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz", @@ -8485,6 +8512,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "@next/env": { "version": "15.3.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz", @@ -9809,6 +9841,14 @@ "supports-color": "^7.1.0" } }, + "chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, "child_process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", @@ -12253,6 +12293,12 @@ "loose-envify": "^1.1.0" } }, + "react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "requires": {} + }, "react-country-flag": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz", diff --git a/package.json b/package.json index 675a2354a4ae98ed441e2b3dd40076a1f3f1bdee..81f6015ac686969f44774af91e2e909457e02cb2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "autoprefixer": "10.4.14", + "chart.js": "^4.5.0", "child_process": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -53,6 +54,7 @@ "playwright-merge-html-reports": "^0.2.8", "postcss": "^8.4.31", "react": "18.3.1", + "react-chartjs-2": "^5.3.0", "react-country-flag": "^3.1.0", "react-day-picker": "^8.10.0", "react-dom": "18.3.1", diff --git a/pages/top-holders.tsx b/pages/top-holders.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b44bb4309ae9ef35ef9a18f04821f822c8db197 --- /dev/null +++ b/pages/top-holders.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { Card } from "@/components/ui/card"; +import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from "@/components/ui/table"; +import PageTitle from "@/components/PageTitle"; +import ErrorMessage from "@/components/ErrorMessage"; +import NoResult from "@/components/NoResult"; +import FilterSectionToggle from "@/components/account/FilterSectionToggle"; +import { useI18n } from "@/i18n/i18n"; +import DataExport from "@/components/DataExport"; +import CustomPagination from "@/components/CustomPagination"; +import useTopHolders, { CoinType, BalanceType } from "@/hooks/common/useTopHolders"; +import { config } from "@/Config"; +import { formatNumber } from "@/lib/utils"; +import { ChevronDown, ChevronUp, ChevronsUpDown, Loader2 } from "lucide-react"; +import { getHiveAvatarUrl } from "@/utils/HiveBlogUtils"; +import Image from "next/image"; +import Link from "next/link"; +import useDynamicGlobal from "@/hooks/api/homePage/useDynamicGlobal"; +import { convertVestsToHP } from "@/utils/Calculations"; +import { useHiveChainContext } from "@/contexts/HiveChainContext"; +import Hive from "@/types/Hive"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export default function TopHoldersPage() { + const { t } = useI18n(); + const router = useRouter(); + + const [page, setPage] = useState(1); + const [coinType, setCoinType] = useState("HIVE"); + const [balanceType, setBalanceType] = useState("balance"); + const [isFiltersVisible, setIsFiltersVisible] = useState(false); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [unit, setUnit] = useState<"vests" | "hp">("vests"); + + const totalCount = config.topHolders.totalCount; + + const { holdersData, isTopHoldersLoading, isTopHoldersError } = useTopHolders( + coinType, + balanceType, + page + ); + + const defaultCoinType: CoinType = "HIVE"; + const defaultBalanceType: BalanceType = "balance"; + const defaultSortOrder: "asc" | "desc" = "desc"; + + const filtersChanged = + coinType !== defaultCoinType || + balanceType !== defaultBalanceType || + sortOrder !== defaultSortOrder; + + const filteredHolders = holdersData.sort((a, b) => { + const valA = parseFloat(a.value); + const valB = parseFloat(b.value); + if (isNaN(valA) || isNaN(valB)) return 0; + return sortOrder === "asc" ? valA - valB : valB - valA; + }); + + const { dynamicGlobalData } = useDynamicGlobal() as any; + const [totalVestingShares, setTotalVestingShares] = useState( + dynamicGlobalData?.headBlockDetails.rawTotalVestingShares + ); + const [totalVestingFundHive, setTotalVestingFundHive] = useState( + dynamicGlobalData?.headBlockDetails.rawTotalVestingFundHive + ); + const { hiveChain } = useHiveChainContext(); + + useEffect(() => { + if (dynamicGlobalData?.headBlockDetails) { + setTotalVestingShares(dynamicGlobalData.headBlockDetails.rawTotalVestingShares); + setTotalVestingFundHive(dynamicGlobalData.headBlockDetails.rawTotalVestingFundHive); + } + }, [dynamicGlobalData]); + + const fetchHivePower = (value: string, isHP: boolean): string => { + if (isHP) { + if (!hiveChain) return ""; + return convertVestsToHP( + hiveChain, + value, + totalVestingFundHive, + totalVestingShares + ); + } + return `${formatNumber(value, true)} VESTS`; + }; + + const formatValueForDisplay = (value: string, coinType: CoinType) => { + if (coinType === "VESTS" && unit === "hp") { + return fetchHivePower(value, true); + } + return formatNumber(value, coinType === "VESTS") + (coinType === "VESTS" ? " VESTS" : ""); + }; + + // Fixed: export respects the toggle (HP / VESTS) + const prepareExportData = () => + filteredHolders.map((holder) => { + let displayValue = holder.value; + + if (coinType === "VESTS") { + displayValue = unit === "hp" + ? fetchHivePower(holder.value, true) + : `${formatNumber(holder.value, true)} VESTS`; + } + + return { + [t("table.rank")]: holder.rank + (page - 1) * 100, + [t("table.account")]: holder.account, + [balanceType === "savings_balance" ? t("table.savings") : t("table.balance")]: displayValue, + }; + }); + + const exportFileName = `top_holders_${coinType.toLowerCase()}.csv`; + + const HolderRow = ({ rank, account, value }: { rank: number; account: string; value: string }) => ( + + {rank} + +
    + {`${account}'s + {account} +
    +
    + {formatValueForDisplay(value, coinType)} +
    + ); + + const TableHeaderRow = () => ( + + + setSortOrder(sortOrder === "asc" ? "desc" : "asc")}> +
    + {t("table.rank")} + {sortOrder === "asc" ? : } +
    +
    + {t("table.account")} + setSortOrder(sortOrder === "asc" ? "desc" : "asc")}> +
    + {balanceType === "savings_balance" ? t("table.savings") : t("table.balance")} + +
    +
    +
    +
    + ); + + return ( +
    + {/* Top Section */} + +
    +
    + +
    +
    + setIsFiltersVisible(!isFiltersVisible)} + /> +
    +
    +
    + + {/* Filter Section */} + {isFiltersVisible && ( + +
    + + + +
    +
    + )} + + {/* Pagination */} +
    + +
    + + {/* Export + Unit Toggle */} +
    +
    + {coinType === "VESTS" && ( +
    + + setUnit(checked ? "hp" : "vests")} /> + +
    + )} + +
    +
    + + {/* Table */} + + {isTopHoldersLoading && ( +
    + +
    + )} + {!isTopHoldersLoading && isTopHoldersError && ( +

    + +

    + )} + {!isTopHoldersLoading && !isTopHoldersError && filteredHolders.length === 0 && } + {!isTopHoldersLoading && !isTopHoldersError && filteredHolders.length > 0 && ( + + + + {filteredHolders.map((holder) => { + const displayRank = holder.rank + (page - 1) * 100; + return ; + })} + +
    + )} +
    +
    + ); +} diff --git a/services/FetchingService.ts b/services/FetchingService.ts index 463c52b602e80dfaec55adae91e8f1846903263c..2bde9a8c94a7252f26bd7492fdf00de1e8b187e0 100644 --- a/services/FetchingService.ts +++ b/services/FetchingService.ts @@ -769,6 +769,20 @@ class FetchingService { async getBlockChainProps(): Promise { return await this.extendedHiveChain!.api.condenser_api.get_chain_properties([]); } + async getTopHolders( + coinType: "HIVE" | "HBD" | "VESTS", + balanceType: "balance" | "savings_balance", + page: number +): Promise { + return await this.extendedHiveChain!.restApi["balance-api"].topHolders({ + "coin-type": coinType, + "balance-type": balanceType, + page, + + }); +} + + } const fetchingService = new FetchingService(); diff --git a/types/Hive.ts b/types/Hive.ts index 95b2b45603c8eb60eebdccebcfc692c84747d873..bad4c437928000327b2b1e4da263e3f4fdeb6556 100644 --- a/types/Hive.ts +++ b/types/Hive.ts @@ -1094,6 +1094,18 @@ namespace Hive { account_subsidy_limit!: number; } + export class GetTopHoldersParams { + "coin-type"!: string; + "balance-type"!: string; + page!: number; + } + + export class TopHolder { + rank!: number; + account!: string; + value!: string; + } + } export default Hive; diff --git a/types/Rest.ts b/types/Rest.ts index 8bda3399d9c71ddec632f6f17ef9f6a4e67fe562..abb7e89a568d2a0b249d78ebea4140524c26d5f9 100644 --- a/types/Rest.ts +++ b/types/Rest.ts @@ -171,5 +171,14 @@ export const extendedRest = { result: Hive.AccountRecurrentBalanceTransfersResponse, urlPath: "accounts/{accountName}/recurrent-transfers", }, + topHolders: { + params: Hive.GetTopHoldersParams, + result: Hive.TopHolder, + urlPath: "top-holders", + responseArray: true, + }, + + }, + }; diff --git a/utils/PageTitlesInfo.tsx b/utils/PageTitlesInfo.tsx index 23eea1b1af23fd3cb6ab8ca46d408149d0af04ea..84614ac188646b5d874bb0aac3b13e8f512f6559 100644 --- a/utils/PageTitlesInfo.tsx +++ b/utils/PageTitlesInfo.tsx @@ -68,6 +68,84 @@ const TransactionDetailsInfoEn: React.FC = () => (
  • You can change the setting from Data View in the main menu to view data in other formats
  • ); +//top holders section +// --- English (en) --- +const TopHoldersInfoEn: React.FC = () => ( +
      +
    • This page shows the top holders for each coin. Use filters to search, sort, and select balances.
    • +
    +); + +// --- Spanish (es) --- +const TopHoldersInfoEs: React.FC = () => ( +
      +
    • Esta página muestra los principales poseedores de cada moneda. Usa filtros para buscar, ordenar y seleccionar saldos.
    • +
    +); + +// --- Italian (it) --- +const TopHoldersInfoIt: React.FC = () => ( +
      +
    • Questa pagina mostra i principali detentori per ogni moneta. Usa i filtri per cercare, ordinare e selezionare i saldi.
    • +
    +); + +// --- German (de) --- +const TopHoldersInfoDe: React.FC = () => ( +
      +
    • Diese Seite zeigt die größten Inhaber jeder Münze. Verwenden Sie Filter, um Salden zu suchen, zu sortieren und auszuwählen.
    • +
    +); + +// --- Portuguese (pt) --- +const TopHoldersInfoPt: React.FC = () => ( +
      +
    • Esta página mostra os principais detentores de cada moeda. Use filtros para pesquisar, classificar e selecionar saldos.
    • +
    +); + +// --- French (fr) --- +const TopHoldersInfoFr: React.FC = () => ( +
      +
    • Cette page affiche les principaux détenteurs de chaque monnaie. Utilisez des filtres pour rechercher, trier et sélectionner les soldes.
    • +
    +); + +// --- Polish (pl) --- +const TopHoldersInfoPl: React.FC = () => ( +
      +
    • Ta strona pokazuje największych posiadaczy każdej monety. Użyj filtrów, aby wyszukać, posortować i wybrać salda.
    • +
    +); + +// --- Chinese (Simplified) (zh) --- +const TopHoldersInfoZh: React.FC = () => ( +
      +
    • 此页面显示每种代币的最大持有者。使用筛选器来搜索、排序和选择余额。
    • +
    +); + +// --- Japanese (ja) --- +const TopHoldersInfoJa: React.FC = () => ( +
      +
    • このページでは、各コインの最大保有者が表示されます。フィルターを使って検索、並べ替え、残高の選択ができます。
    • +
    +); + +// --- Romanian (ro) --- +const TopHoldersInfoRo: React.FC = () => ( +
      +
    • Această pagină afișează principalii deținători pentru fiecare monedă. Folosiți filtre pentru a căuta, sorta și selecta soldurile.
    • +
    +); + +// --- Arabic (ar) --- +const TopHoldersInfoAr: React.FC = () => ( +
      +
    • تعرض هذه الصفحة كبار الحائزين لكل عملة. استخدم الفلاتر للبحث والفرز وتحديد الأرصدة.
    • +
    +); + // src/components/info/ProposalsInfo.tsx const ProposalsInfoEn: React.FC = () => ( @@ -804,8 +882,20 @@ const pageTitlesInfo: InfoContent = { ro: , ar: }, + "pageTitle.topHolders": { + en: , + es: , + it: , + de: , + pt: , + fr: , + pl: , + zh: , + ja: , + ro: , + ar: +}, - }; export default pageTitlesInfo; \ No newline at end of file