import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import './landing.css'; // ─── Types ─────────────────────────────────────────────────────────────────── interface ChainStats { latestBlock: number; finalizedBlock: number; blockHash: string; peers: number; validators: number; nominators: number; collators: number; collatorsAH: number; collatorsPeople: number; activeProposals: number; totalVoters: number; citizenCount: number; tokensStakedPct: string; } // ─── SVG Sprite (pallet icons) ─────────────────────────────────────────────── const SPRITE_STYLES = ` .stk { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } .fill { fill: currentColor; stroke: none; } .soft { fill: currentColor; stroke: none; opacity: 0.18; } .softer{ fill: currentColor; stroke: none; opacity: 0.10; } .pop { fill: #fbbf24; stroke: none; } .pop2 { fill: #f97316; stroke: none; } .pop3 { fill: #ef4444; stroke: none; } .pop4 { fill: #22c55e; stroke: none; } .pop5 { fill: #3b82f6; stroke: none; } .pop6 { fill: #a855f7; stroke: none; } .ink { fill: #ffffff; stroke: none; } `; const Sprite: React.FC = () => ( ); // ─── Pallet item ───────────────────────────────────────────────────────────── const PalletItem: React.FC<{ icon?: string; imgSrc?: string; label: string; to?: string; external?: string; locked?: boolean; requiresLogin?: boolean; onClick?: () => void; }> = ({ icon, imgSrc, label, to, external, locked, requiresLogin, onClick }) => { const navigate = useNavigate(); const inner = ( <> {imgSrc ? : } {label} ); if (locked) { return ( {inner} ); } if (requiresLogin) { return ( ); } if (external) { return ( {inner} ); } if (onClick) { return ; } return ( {inner} ); }; // ─── Main component ─────────────────────────────────────────────────────────── const LandingPageDesktop: React.FC = () => { const navigate = useNavigate(); const { t } = useTranslation(); const { api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount, disconnectWallet, connectWallet } = usePezkuwi(); const [tokTab, setTokTab] = useState<'hez'|'pez'>('hez'); const [walletConnectError, setWalletConnectError] = useState(null); const [stats, setStats] = useState({ latestBlock: 0, finalizedBlock: 0, blockHash: '', peers: 0, validators: 0, nominators: 0, collators: 0, collatorsAH: 0, collatorsPeople: 0, activeProposals: 0, totalVoters: 0, citizenCount: 0, tokensStakedPct: '—', }); // Animate counter on scroll const counterRefs = useRef>(new Map()); const animated = useRef>(new Set()); const animateNum = useCallback((el: HTMLElement, target: number) => { const dur = 1600; const start = performance.now(); const tick = (now: number) => { const elapsed = Math.min(1, (now - start) / dur); const eased = 1 - Math.pow(1 - elapsed, 3); el.textContent = Math.round(target * eased).toLocaleString(); if (elapsed < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }, []); // Scroll reveal useEffect(() => { const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('lp-in'); io.unobserve(e.target); } }); }, { threshold: 0.1 }); document.querySelectorAll('.lp-reveal').forEach(el => io.observe(el)); return () => io.disconnect(); }, []); // Counter animation on scroll useEffect(() => { const io = new IntersectionObserver((entries) => { entries.forEach(e => { const el = e.target as HTMLElement; const key = el.dataset.counterKey ?? ''; if (e.isIntersecting && !animated.current.has(key)) { animated.current.add(key); const target = parseInt(el.dataset.counter ?? '0', 10); animateNum(el, target); io.unobserve(el); } }); }, { threshold: 0.5 }); document.querySelectorAll('[data-counter]').forEach(el => io.observe(el)); return () => io.disconnect(); }, [animateNum, stats]); // Live block subscription useEffect(() => { if (!api || !isApiReady) return; let unsub: (() => void) | null = null; api.rpc.chain.subscribeNewHeads((header) => { const blockNum = header.number.toNumber(); setStats(prev => ({ ...prev, latestBlock: blockNum, blockHash: header.hash.toHex().slice(0, 10) + '…' + header.hash.toHex().slice(-8), finalizedBlock: Math.max(prev.finalizedBlock, blockNum - 2), })); }).then(fn => { unsub = fn; }).catch(() => {}); api.rpc.system.peers().then(peers => { setStats(prev => ({ ...prev, peers: peers.length })); }).catch(() => {}); return () => { unsub?.(); }; }, [api, isApiReady]); // Governance + validators + nominators from relay useEffect(() => { if (!api || !isApiReady) return; (async () => { try { const [entries, votingKeys, sessionVals] = await Promise.all([ api.query.referenda.referendumInfoFor.entries(), api.query.convictionVoting.votingFor.keys(), api.query.session.validators(), ]); const activeProposals = entries.filter(([, info]) => { const d = info.toJSON(); return d && typeof d === 'object' && 'ongoing' in d; }).length; const totalVoters = new Set(votingKeys.map(k => k.args[0].toString())).size; const validators = sessionVals.length; setStats(prev => ({ ...prev, activeProposals, totalVoters, validators })); } catch {} // Nominators/staking migrated to Asset Hub — counted in the Asset Hub effect below. })(); }, [api, isApiReady]); // Staking % from Asset Hub useEffect(() => { if (!assetHubApi || !isAssetHubReady) return; (async () => { try { const ledgers = await assetHubApi.query.staking.ledger.entries(); let total = BigInt(0); for (const [, l] of ledgers) { if (!l.isEmpty) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = (l as any).unwrap?.()?.toJSON() ?? (l as any).toJSON(); total += BigInt(d?.active ?? d?.total ?? '0'); } } const totalIssuance = await assetHubApi.query.balances.totalIssuance(); const issuance = BigInt(totalIssuance.toString()); if (issuance > 0n) { const pct = (Number(total) / Number(issuance) * 100).toFixed(1); setStats(prev => ({ ...prev, tokensStakedPct: pct + '%' })); } } catch {} // Nominators live on Asset Hub after the staking migration (AHM). try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const nomCount = await (assetHubApi.query.staking as any)?.counterForNominators?.(); if (nomCount != null) setStats(prev => ({ ...prev, nominators: nomCount.toNumber() })); } catch {} // Collators are the invulnerable set (not staking candidates, which are empty). try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const inv = await (assetHubApi.query.collatorSelection as any)?.invulnerables?.(); if (inv != null) setStats(prev => ({ ...prev, collatorsAH: inv.length, collators: inv.length + prev.collatorsPeople })); } catch {} })(); }, [assetHubApi, isAssetHubReady]); // Citizens from People Chain useEffect(() => { if (!peopleApi || !isPeopleReady) return; (async () => { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const entries = await (peopleApi.query as any).tiki?.citizenNft?.entries?.(); if (entries) setStats(prev => ({ ...prev, citizenCount: entries.length })); } catch {} // People Chain also runs invulnerable collators — add them to the total. try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const inv = await (peopleApi.query.collatorSelection as any)?.invulnerables?.(); if (inv != null) setStats(prev => ({ ...prev, collatorsPeople: inv.length, collators: prev.collatorsAH + inv.length })); } catch {} })(); }, [peopleApi, isPeopleReady]); const shortHash = (h: string) => h || '0x——————…————'; const handleConnectWallet = async () => { setWalletConnectError(null); try { await connectWallet(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setWalletConnectError(msg || 'Wallet extension not found. Please install Pezkuwi.js or Polkadot.js.'); } }; // ─── Ticker content ─────────────────────────────────────────────────────── const tickerItems = [ { label: t('landing.ticker.block'), val: stats.latestBlock ? `#${stats.latestBlock.toLocaleString()}` : '—' }, { label: t('landing.ticker.validators'), val: stats.validators || '—' }, { label: t('landing.ticker.nominators'), val: stats.nominators ? stats.nominators.toLocaleString() : '—' }, { label: t('landing.ticker.citizens'), val: stats.citizenCount ? stats.citizenCount.toLocaleString() : '—' }, { label: t('landing.ticker.staked'), val: stats.tokensStakedPct }, { label: t('landing.ticker.proposals'), val: stats.activeProposals || '—' }, { label: t('landing.ticker.peers'), val: stats.peers || '—' }, ]; return (