diff --git a/package.json b/package.json index 56ce434..f97a682 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.199", + "version": "1.0.202", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/src/App.tsx b/src/App.tsx index 52d66e4..4f9183d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,9 @@ const WalletSection = lazy(() => const CitizenPage = lazy(() => import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage })) ); +const ExplorerPage = lazy(() => + import('@/pages/ExplorerPage').then((m) => ({ default: m.ExplorerPage })) +); // Loading fallback component function SectionLoader() { @@ -57,6 +60,7 @@ const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p'; // Check for standalone pages via URL query params or path (evaluated once at module level) const PAGE_PARAM = new URLSearchParams(window.location.search).get('page'); const IS_CITIZEN_PAGE = PAGE_PARAM === 'citizen' || window.location.pathname === '/citizens'; +const IS_EXPLORER_PAGE = window.location.pathname.replace(/\/$/, '') === '/explorer'; export default function App() { if (IS_CITIZEN_PAGE) { @@ -67,6 +71,14 @@ export default function App() { ); } + if (IS_EXPLORER_PAGE) { + return ( + }> + + + ); + } + return ; } diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index d9581ec..479e957 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -143,7 +143,7 @@ export function LanguageProvider({ children }: LanguageProviderProps) { const pathSegments = window.location.pathname.split('/').filter(Boolean); const firstSegment = pathSegments[0]; // Don't rewrite URL for standalone pages like /citizens - const STANDALONE_PATHS = ['citizens']; + const STANDALONE_PATHS = ['citizens', 'explorer']; if (firstSegment && STANDALONE_PATHS.includes(firstSegment)) { // Keep standalone path as-is } else if (!firstSegment || !VALID_LANGS.includes(firstSegment as LanguageCode)) { diff --git a/src/i18n/translations/ar.ts b/src/i18n/translations/ar.ts index b90dcfe..1b81862 100644 --- a/src/i18n/translations/ar.ts +++ b/src/i18n/translations/ar.ts @@ -575,6 +575,31 @@ const ar: Translations = { walletSyncFailed: 'فشل مزامنة عنوان المحفظة مع قاعدة البيانات', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'مستكشف البلوكتشين', + search: 'ابحث برقم الكتلة أو العنوان...', + chainStats: 'إحصائيات السلسلة', + latestBlocks: 'أحدث الكتل', + recentTransfers: 'أحدث المعاملات', + block: 'كتلة', + validators: 'المدققون', + era: 'الحقبة', + blockTime: 'وقت الكتلة', + extrinsics: 'معاملة', + noResults: 'لم يتم العثور على نتائج', + connecting: 'جاري الاتصال بالسلسلة...', + hash: 'هاش', + from: 'من', + to: 'إلى', + amount: 'المبلغ', + time: 'الوقت', + balance: 'الرصيد', + seconds: 'ث مضت', + finalized: 'الكتلة النهائية', + searchResult: 'نتيجة البحث', + }, + citizen: { pageTitle: 'كن مواطناً', fullName: 'الاسم الكامل', diff --git a/src/i18n/translations/ckb.ts b/src/i18n/translations/ckb.ts index fb5d37c..207253f 100644 --- a/src/i18n/translations/ckb.ts +++ b/src/i18n/translations/ckb.ts @@ -577,6 +577,31 @@ const ckb: Translations = { walletSyncFailed: 'هاوکاتکردنی ناونیشانی جزدان لەگەڵ DB سەرنەکەوت', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'گەڕانی بلۆکچەین', + search: 'ژمارەی بلۆک یان ناونیشان بگەڕێ...', + chainStats: 'ئامارەکانی زنجیرە', + latestBlocks: 'دوایین بلۆکەکان', + recentTransfers: 'دوایین مامەڵەکان', + block: 'بلۆک', + validators: 'ڤالیدەیتەر', + era: 'سەردەم', + blockTime: 'کاتی بلۆک', + extrinsics: 'مامەڵە', + noResults: 'ئەنجام نەدۆزرایەوە', + connecting: 'پەیوەندی بە زنجیرەوە دەکرێت...', + hash: 'هاش', + from: 'لە', + to: 'بۆ', + amount: 'بڕ', + time: 'کات', + balance: 'باڵانس', + seconds: 'چ پێش', + finalized: 'بلۆکی کۆتایی', + searchResult: 'ئەنجامی گەڕان', + }, + citizen: { pageTitle: 'ببە هاوڵاتی', fullName: 'ناوی تەواو', diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 1e7fb5b..90c322d 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -576,6 +576,31 @@ const en: Translations = { walletSyncFailed: 'Wallet address sync to DB failed', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'Blockchain Explorer', + search: 'Search block number or address...', + chainStats: 'Chain Stats', + latestBlocks: 'Latest Blocks', + recentTransfers: 'Recent Extrinsics', + block: 'Block', + validators: 'Validators', + era: 'Era', + blockTime: 'Block Time', + extrinsics: 'exts', + noResults: 'No results found', + connecting: 'Connecting to chain...', + hash: 'Hash', + from: 'From', + to: 'To', + amount: 'Amount', + time: 'Time', + balance: 'Balance', + seconds: 's ago', + finalized: 'Finalized Block', + searchResult: 'Search Result', + }, + citizen: { pageTitle: 'Be Citizen', fullName: 'Full Name', diff --git a/src/i18n/translations/fa.ts b/src/i18n/translations/fa.ts index 73af3ec..80b6ede 100644 --- a/src/i18n/translations/fa.ts +++ b/src/i18n/translations/fa.ts @@ -576,6 +576,31 @@ const fa: Translations = { walletSyncFailed: 'همگام‌سازی آدرس کیف پول با DB ناموفق', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'مرورگر بلاکچین', + search: 'شماره بلاک یا آدرس جستجو کنید...', + chainStats: 'آمار زنجیره', + latestBlocks: 'آخرین بلاک‌ها', + recentTransfers: 'آخرین تراکنش‌ها', + block: 'بلاک', + validators: 'اعتبارسنج', + era: 'دوره', + blockTime: 'زمان بلاک', + extrinsics: 'تراکنش', + noResults: 'نتیجه‌ای یافت نشد', + connecting: 'در حال اتصال به زنجیره...', + hash: 'هش', + from: 'از', + to: 'به', + amount: 'مقدار', + time: 'زمان', + balance: 'موجودی', + seconds: 'ث پیش', + finalized: 'بلاک نهایی', + searchResult: 'نتیجه جستجو', + }, + citizen: { pageTitle: 'شهروند شوید', fullName: 'نام کامل', diff --git a/src/i18n/translations/krd.ts b/src/i18n/translations/krd.ts index d210453..11759f4 100644 --- a/src/i18n/translations/krd.ts +++ b/src/i18n/translations/krd.ts @@ -601,6 +601,31 @@ const krd: Translations = { walletSyncFailed: 'Wallet adresa DB-\u00ea re senkron\u00eeze neb\u00fb', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'Gerîneya Blockchain', + search: 'Li hejmara blokê an navnîşanê bigere...', + chainStats: 'Statistîkên Zincîrê', + latestBlocks: 'Blokên Dawîn', + recentTransfers: 'Extrinsîkên Dawîn', + block: 'Blok', + validators: 'Validator', + era: 'Era', + blockTime: 'Dema Blokê', + extrinsics: 'ext', + noResults: 'Encam nehat dîtin', + connecting: 'Bi zincîrê ve tê girêdan...', + hash: 'Hash', + from: 'Ji', + to: 'Bo', + amount: 'Mîqdar', + time: 'Dem', + balance: 'Balance', + seconds: 's berê', + finalized: 'Bloka Dawî', + searchResult: 'Encama Lêgerînê', + }, + citizen: { pageTitle: 'Bibe Welatî', fullName: 'Navê Te', diff --git a/src/i18n/translations/tr.ts b/src/i18n/translations/tr.ts index 832f659..6b2d355 100644 --- a/src/i18n/translations/tr.ts +++ b/src/i18n/translations/tr.ts @@ -577,6 +577,31 @@ const tr: Translations = { walletSyncFailed: 'Cüzdan adresi DB ile senkronize edilemedi', }, + explorer: { + title: 'Pezkuwi Explorer', + subtitle: 'Blockchain Gezgini', + search: 'Blok numarası veya adres ara...', + chainStats: 'Zincir İstatistikleri', + latestBlocks: 'Son Bloklar', + recentTransfers: 'Son İşlemler', + block: 'Blok', + validators: 'Validatör', + era: 'Era', + blockTime: 'Blok Süresi', + extrinsics: 'işlem', + noResults: 'Sonuç bulunamadı', + connecting: 'Zincire bağlanılıyor...', + hash: 'Hash', + from: 'Gönderen', + to: 'Alıcı', + amount: 'Miktar', + time: 'Zaman', + balance: 'Bakiye', + seconds: 'sn önce', + finalized: 'Kesinleşen Blok', + searchResult: 'Arama Sonucu', + }, + citizen: { pageTitle: 'Vatandaş Ol', fullName: 'Tam İsim', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 5d4fe7c..96908c1 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -581,6 +581,32 @@ export interface Translations { walletSyncFailed: string; }; + // Explorer page + explorer: { + title: string; + subtitle: string; + search: string; + chainStats: string; + latestBlocks: string; + recentTransfers: string; + block: string; + validators: string; + era: string; + blockTime: string; + extrinsics: string; + noResults: string; + connecting: string; + hash: string; + from: string; + to: string; + amount: string; + time: string; + balance: string; + seconds: string; + finalized: string; + searchResult: string; + }; + // Citizen page citizen: { pageTitle: string; diff --git a/src/pages/ExplorerPage.tsx b/src/pages/ExplorerPage.tsx new file mode 100644 index 0000000..6b12771 --- /dev/null +++ b/src/pages/ExplorerPage.tsx @@ -0,0 +1,511 @@ +/** + * Explorer Page + * Standalone blockchain explorer for PezkuwiChain + * Accessed via /explorer URL - no bottom navigation + */ + +import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; +import { Search, Loader2, Globe, Blocks, Users, Clock, Hash, ArrowRight } from 'lucide-react'; +import { useTranslation, LANGUAGE_NAMES, VALID_LANGS } from '@/i18n'; +import type { LanguageCode } from '@/i18n'; +import { initRPCConnection } from '@/lib/rpc-manager'; +import { formatAddress, cn } from '@/lib/utils'; +import type { ApiPromise } from '@pezkuwi/api'; + +interface BlockInfo { + number: number; + hash: string; + extrinsicCount: number; + timestamp: number; +} + +interface ExtrinsicInfo { + blockNumber: number; + section: string; + method: string; + signer: string; + args: { dest?: string; value?: string }; +} + +interface SearchResult { + type: 'block' | 'address'; + blockInfo?: BlockInfo; + balance?: string; + address?: string; +} + +// Format balance from planck to HEZ (12 decimals) +function formatBalance(planck: string): string { + const num = BigInt(planck); + const whole = num / BigInt(10 ** 12); + const frac = num % BigInt(10 ** 12); + const fracStr = frac.toString().padStart(12, '0').slice(0, 4); + return `${whole.toLocaleString()}.${fracStr}`; +} + +// Format seconds ago +function formatSecondsAgo( + ts: number, + t: (key: string, params?: Record) => string +): string { + const diff = Math.floor((Date.now() - ts) / 1000); + if (diff < 1) return t('time.now'); + return `${diff}${t('explorer.seconds')}`; +} + +export function ExplorerPage() { + const { t, lang, setLang } = useTranslation(); + const [showLangMenu, setShowLangMenu] = useState(false); + + // Connection state + const [isConnecting, setIsConnecting] = useState(true); + const [connectionError, setConnectionError] = useState(null); + + // Chain stats + const [chainName, setChainName] = useState(''); + const [finalizedBlock, setFinalizedBlock] = useState(0); + const [validatorCount, setValidatorCount] = useState(0); + const [currentEra, setCurrentEra] = useState(0); + const [blockTime, setBlockTime] = useState(6); + + // Blocks & extrinsics + const [blocks, setBlocks] = useState([]); + const [extrinsics, setExtrinsics] = useState([]); + + // Search + const [searchQuery, setSearchQuery] = useState(''); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + + const apiRef = useRef(null); + const unsubRef = useRef<(() => void) | null>(null); + + // Initialize connection and subscribe to new heads + useEffect(() => { + let cancelled = false; + + async function init() { + try { + setIsConnecting(true); + setConnectionError(null); + + const api = await initRPCConnection(); + if (cancelled) return; + apiRef.current = api; + + // Fetch chain info + const [chain, validators, era, finalizedHash] = await Promise.all([ + api.rpc.system.chain(), + api.query.session.validators(), + api.query.staking.activeEra(), + api.rpc.chain.getFinalizedHead(), + ]); + + if (cancelled) return; + + setChainName(chain.toString()); + setValidatorCount((validators as unknown as unknown[]).length); + + const eraInfo = era.toJSON() as { index: number } | null; + if (eraInfo) setCurrentEra(eraInfo.index); + + const finalizedHeader = await api.rpc.chain.getHeader(finalizedHash); + setFinalizedBlock(finalizedHeader.number.toNumber()); + + setIsConnecting(false); + + // Subscribe to new heads + const unsub = await api.rpc.chain.subscribeNewHeads(async (header) => { + if (cancelled) return; + + const blockHash = await api.rpc.chain.getBlockHash(header.number.toNumber()); + const signedBlock = await api.rpc.chain.getBlock(blockHash); + const now = Date.now(); + + const blockInfo: BlockInfo = { + number: header.number.toNumber(), + hash: blockHash.toString(), + extrinsicCount: signedBlock.block.extrinsics.length, + timestamp: now, + }; + + setBlocks((prev) => [blockInfo, ...prev].slice(0, 20)); + + // Calculate block time from last two blocks + setBlocks((prev) => { + if (prev.length >= 2) { + const diff = (prev[0].timestamp - prev[1].timestamp) / 1000; + if (diff > 0 && diff < 30) setBlockTime(Math.round(diff)); + } + return prev; + }); + + // Update finalized block periodically + try { + const fHash = await api.rpc.chain.getFinalizedHead(); + const fHeader = await api.rpc.chain.getHeader(fHash); + setFinalizedBlock(fHeader.number.toNumber()); + } catch { + // ignore finalized update errors + } + + // Extract signed extrinsics (skip inherent ones) + const blockExts = signedBlock.block.extrinsics; + for (const ext of blockExts) { + if (ext.isSigned) { + const section = ext.method.section; + const method = ext.method.method; + const signer = ext.signer.toString(); + + let dest = ''; + let value = ''; + + // Try to extract transfer details + if ( + section === 'balances' && + (method === 'transferKeepAlive' || + method === 'transfer' || + method === 'transferAllowDeath') + ) { + try { + const args = ext.method.args; + if (args.length >= 2) { + dest = args[0].toString(); + value = args[1].toString(); + } + } catch { + // ignore parse errors + } + } + + const extInfo: ExtrinsicInfo = { + blockNumber: header.number.toNumber(), + section, + method, + signer, + args: { dest, value }, + }; + + setExtrinsics((prev) => [extInfo, ...prev].slice(0, 30)); + } + } + }); + + unsubRef.current = unsub as unknown as () => void; + } catch (err) { + if (cancelled) return; + setConnectionError(err instanceof Error ? err.message : String(err)); + setIsConnecting(false); + } + } + + init(); + + return () => { + cancelled = true; + if (unsubRef.current) { + unsubRef.current(); + unsubRef.current = null; + } + }; + }, []); + + // Search handler + const handleSearch = useCallback(async () => { + const query = searchQuery.trim(); + if (!query || !apiRef.current) return; + + setIsSearching(true); + setSearchResult(null); + + try { + const api = apiRef.current; + + // Check if it's a block number + if (/^\d+$/.test(query)) { + const blockNum = parseInt(query); + const blockHash = await api.rpc.chain.getBlockHash(blockNum); + const signedBlock = await api.rpc.chain.getBlock(blockHash); + + setSearchResult({ + type: 'block', + blockInfo: { + number: blockNum, + hash: blockHash.toString(), + extrinsicCount: signedBlock.block.extrinsics.length, + timestamp: Date.now(), + }, + }); + } else if (query.length >= 40) { + // Assume it's an address + const accountInfo = await api.query.system.account(query); + const data = (accountInfo as unknown as { data: { free: { toString(): string } } }).data; + + setSearchResult({ + type: 'address', + balance: data.free.toString(), + address: query, + }); + } + } catch { + setSearchResult(null); + } finally { + setIsSearching(false); + } + }, [searchQuery]); + + // Loading state + if (isConnecting) { + return ( +
+ Pezkuwi + +

{t('explorer.connecting')}

+
+ ); + } + + // Error state + if (connectionError) { + return ( +
+ Pezkuwi +

{connectionError}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ Pezkuwi +
+

{t('explorer.title')}

+ + {chainName || 'Pezkuwi'} + +
+
+ + {/* Language selector */} +
+ + {showLangMenu && ( +
+ {VALID_LANGS.map((l) => ( + + ))} +
+ )} +
+
+
+ +
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder={t('explorer.search')} + className="w-full bg-gray-900 border border-gray-700 rounded-xl pl-10 pr-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-[#70c639]/50" + /> + {isSearching && ( + + )} +
+ + {/* Search Result */} + {searchResult && ( +
+

{t('explorer.searchResult')}

+ {searchResult.type === 'block' && searchResult.blockInfo && ( +
+

+ {t('explorer.block')}: # + {searchResult.blockInfo.number.toLocaleString()} +

+

+ {t('explorer.hash')}:{' '} + {searchResult.blockInfo.hash} +

+

+ {t('explorer.extrinsics')}:{' '} + {searchResult.blockInfo.extrinsicCount} +

+
+ )} + {searchResult.type === 'address' && ( +
+

{searchResult.address}

+

+ {t('explorer.balance')}:{' '} + {formatBalance(searchResult.balance || '0')} HEZ +

+
+ )} + +
+ )} + + {/* Stats Grid */} +
+ } + label={t('explorer.finalized')} + value={`#${finalizedBlock.toLocaleString()}`} + /> + } + label={t('explorer.validators')} + value={validatorCount.toString()} + /> + } + label={t('explorer.era')} + value={currentEra.toString()} + /> + } + label={t('explorer.blockTime')} + value={`${blockTime}s`} + /> +
+ + {/* Latest Blocks */} +
+

{t('explorer.latestBlocks')}

+
+ {blocks.length === 0 ? ( +
+ + {t('explorer.connecting')} +
+ ) : ( + blocks.slice(0, 10).map((block) => ( +
+
+
+ +
+
+

+ #{block.number.toLocaleString()} +

+

+ {block.hash.slice(0, 10)}...{block.hash.slice(-6)} +

+
+
+
+

+ {block.extrinsicCount} {t('explorer.extrinsics')} +

+

{formatSecondsAgo(block.timestamp, t)}

+
+
+ )) + )} +
+
+ + {/* Recent Extrinsics (transfers) */} + {extrinsics.length > 0 && ( +
+

+ {t('explorer.recentTransfers')} +

+
+ {extrinsics.slice(0, 10).map((ext, i) => ( +
+
+ + {ext.section}.{ext.method} + + + #{ext.blockNumber.toLocaleString()} + +
+
+ {formatAddress(ext.signer, 6)} + {ext.args.dest && ( + <> + + {formatAddress(ext.args.dest, 6)} + + )} + {ext.args.value && ( + + {formatBalance(ext.args.value)} HEZ + + )} +
+
+ ))} +
+
+ )} + + {/* Footer spacing */} +
+
+
+ ); +} + +// Stat card component +function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+
+ ); +} diff --git a/src/version.json b/src/version.json index 32e9ffb..7949627 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.199", - "buildTime": "2026-02-15T01:12:21.769Z", - "buildNumber": 1771117941770 + "version": "1.0.202", + "buildTime": "2026-02-15T22:27:40.363Z", + "buildNumber": 1771194460364 }