diff --git a/apps/web/public/images/circle_hive_red.png b/apps/web/public/images/circle_hive_red.png new file mode 100644 index 0000000000000000000000000000000000000000..818e3f3a7e36ef587a91265bd5e63e534d37dfa7 Binary files /dev/null and b/apps/web/public/images/circle_hive_red.png differ diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 856cfdb673c8b4f8f8fccf7a6367c7942a38fda6..9fcd81bf7c16bcb01e0557c6e2a87a67cd0dba93 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -5,15 +5,21 @@ import { useRouter } from '@/i18n/routing'; import { Button } from '@/components/ui/button'; import { Logo } from '@/components/logo/Logo'; import { ScrollIndicator } from '@/components/ScrollIndicator'; +import { RootDPoS, RootDPoSHandle } from '@/components/root/RootDPoS'; import { RootEco } from '@/components/root/RootEco'; +import { RootHistory } from '@/components/root/RootHistory'; +import { LogoMarquee } from '@/components/root/LogoMarquee'; +import { TokenCard } from '@/components/cards/TokenCard'; import { useAssets } from '@/hooks/useAssets'; +import { useTVL } from '@/hooks/useTVL'; import { EXCHANGES } from '@/lib/data/var'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { DynamicGlobalProperties } from '@hiveio/hive-lib'; // Live Activity Components import { DynamicHero } from '@/components/hero/DynamicHero'; -interface MoneyParticle { +interface TokenParticle { id: number; x: number; y: number; @@ -21,18 +27,57 @@ interface MoneyParticle { vy: number; rotation: number; rotationSpeed: number; + type: 'hive' | 'hbd'; } export default function HomePage() { const router = useRouter(); const t = useTranslations(); const { getImage } = useAssets(); - const [particles, setParticles] = useState([]); - const defiCardRef = useRef(null); + const [particles, setParticles] = useState([]); + const hiveCardRef = useRef(null); + const hbdCardRef = useRef(null); const particleIdRef = useRef(0); const animationFrameRef = useRef(null); - const isHoveringRef = useRef(false); - const lastParticleTimeRef = useRef(0); + const dposRef = useRef(null); + const hasTriggeredRef = useRef<{ hive: boolean; hbd: boolean }>({ hive: false, hbd: false }); + const [globalProps, setGlobalProps] = useState(null); + + // Fetch TVL data for locked amounts + const { tvl } = useTVL({ updateInterval: 60000 }); + + // Callback to pass new block info from DynamicHero to RootDPoS + const handleNewBlock = useCallback((blockNum: number, witness: string) => { + dposRef.current?.addBlock(blockNum, witness); + }, []); + + // Callback to receive global props from DynamicHero + const handleGlobalProps = useCallback((props: DynamicGlobalProperties) => { + setGlobalProps(props); + }, []); + + // Parse supply values from global props (format: "123.456 HIVE" or {amount, nai, precision}) + const parseSupply = (supply: unknown): number => { + if (!supply) return 0; + // Handle string format: "123.456 HIVE" + if (typeof supply === 'string') { + return parseFloat(supply.split(' ')[0]) || 0; + } + // Handle object format: { amount: "123456", nai: "@@000000021", precision: 3 } + if (typeof supply === 'object' && supply !== null && 'amount' in supply) { + const obj = supply as { amount: string; precision?: number }; + const amount = parseInt(obj.amount, 10); + const precision = obj.precision ?? 3; + return amount / Math.pow(10, precision); + } + return 0; + }; + + // Calculate token supply data + const hiveSupply = parseSupply(globalProps?.current_supply); + const hbdSupply = parseSupply(globalProps?.current_hbd_supply); + const hiveLocked = tvl ? tvl.hpAmount + tvl.hiveSavings : 0; + const hbdLocked = tvl?.hbdSavings || 0; const go = (link: string) => { window.open(link, '_blank'); @@ -42,22 +87,20 @@ export default function HomePage() { return getImage(`exchanges/${image}`); }; - // Particle animation loop for DeFi card + // Particle animation loop useEffect(() => { const animate = () => { - const updateParticles = (prev: MoneyParticle[]) => { - return prev + setParticles((prev) => + prev .map((p) => ({ ...p, x: p.x + p.vx, y: p.y + p.vy, - vy: p.vy + 0.5, // gravity + vy: p.vy + 0.4, rotation: p.rotation + p.rotationSpeed, })) - .filter((p) => p.y < window.innerHeight + 100); // remove off-screen particles - }; - - setParticles(updateParticles); + .filter((p) => p.y < window.innerHeight + 100) + ); animationFrameRef.current = requestAnimationFrame(animate); }; @@ -70,105 +113,184 @@ export default function HomePage() { }; }, []); - // Mouse move handler for DeFi card (HBD particles) - const handleMouseMove = (e: React.MouseEvent) => { - if (!isHoveringRef.current || !defiCardRef.current) return; - - // Throttle particle creation - only create one every 50ms - const now = Date.now(); - if (now - lastParticleTimeRef.current < 50) return; - lastParticleTimeRef.current = now; - - // Create particle at cursor position - const newParticle: MoneyParticle = { - id: particleIdRef.current++, - x: e.clientX, - y: e.clientY, - vx: (Math.random() - 0.5) * 4, - vy: -Math.random() * 3 - 2, - rotation: Math.random() * 360, - rotationSpeed: (Math.random() - 0.5) * 10, - }; + // Spawn particles from random positions along the edges of the card (once per page view) + const spawnParticleBurst = ( + cardRef: React.RefObject, + type: 'hive' | 'hbd' + ) => { + if (!cardRef.current || hasTriggeredRef.current[type]) return; + hasTriggeredRef.current[type] = true; + + const rect = cardRef.current.getBoundingClientRect(); + const newParticles: TokenParticle[] = []; + const count = 5 + Math.floor(Math.random() * 3); // 5-7 particles - setParticles((prev) => [...prev, newParticle]); + for (let i = 0; i < count; i++) { + // Pick a random edge (0: top, 1: right, 2: bottom, 3: left) + const edge = Math.floor(Math.random() * 4); + let x: number, y: number, vx: number, vy: number; - // Limit particles - if (particles.length > 40) { - setParticles((prev) => prev.slice(-40)); + switch (edge) { + case 0: // top edge + x = rect.left + Math.random() * rect.width; + y = rect.top; + vx = (Math.random() - 0.5) * 3; + vy = -2 - Math.random() * 2; + break; + case 1: // right edge + x = rect.right; + y = rect.top + Math.random() * rect.height; + vx = 2 + Math.random() * 2; + vy = (Math.random() - 0.5) * 3; + break; + case 2: // bottom edge + x = rect.left + Math.random() * rect.width; + y = rect.bottom; + vx = (Math.random() - 0.5) * 3; + vy = 1 + Math.random() * 2; + break; + default: // left edge + x = rect.left; + y = rect.top + Math.random() * rect.height; + vx = -2 - Math.random() * 2; + vy = (Math.random() - 0.5) * 3; + break; + } + + newParticles.push({ + id: particleIdRef.current++, + x, + y, + vx, + vy, + rotation: Math.random() * 360, + rotationSpeed: (Math.random() - 0.5) * 12, + type, + }); } + + setParticles((prev) => [...prev, ...newParticles].slice(-30)); }; return (
{/* Dynamic Hero with Live Block Number and Activities */} - +
- {/* Ecosystem */} - - - {/* Core Features Section */} -
-
-

- Why Hive? + {/* Token Showcase Section */} +
+
+

+ Our Coins.

- {/* Features Grid */} -
- {/* Feature 1: Fast & Free */} -
-
- No fees -
-

{t('root.feeTitle')}

-

- {t('root.feeText')} -

-

- {t('root.feeText2')} -

-
- - {/* Feature 2: Decentralized */} -
-
- Decentralized -
-

{t('root.decTitle')}

-

- {t('root.decText')} -

-

- {t('root.decText2')} -

-
- - {/* Feature 3: DeFi Made Simple */} -
{ isHoveringRef.current = true; }} - onMouseLeave={() => { isHoveringRef.current = false; }} - onMouseMove={handleMouseMove} - > -
- HBD DeFi -
-

DeFi Made Simple

-

- Earn up to 15% APR on HBD, our decentralized stablecoin pegged to USD. -

-
+
+ {/* HIVE Token Card */} + + + + ), + text: 'Powers all transactions', + }, + { + icon: ( + + + + ), + text: 'Stake to vote for witnesses', + }, + { + icon: ( + + + + ), + text: 'Earn curation rewards', + }, + ]} + totalSupply={hiveSupply} + lockedAmount={hiveLocked} + chartLabel="Staked" + onMouseEnter={() => spawnParticleBurst(hiveCardRef, 'hive')} + /> + + {/* HBD Token Card */} + + + + ), + text: 'Algorithmic peg to USD', + }, + { + icon: ( + + + + ), + text: 'Backed by HIVE', + }, + { + icon: ( + + + + ), + text: ( + + Earn 15% APR + + ), + }, + ]} + totalSupply={hbdSupply} + lockedAmount={hbdLocked} + chartLabel="In Savings" + onMouseEnter={() => spawnParticleBurst(hbdCardRef, 'hbd')} + buyUrl="https://hivedex.io/" + />
+ {/* Ecosystem */} + + + {/* DPoS Visualization */} + + + {/* History Section */} + + + {/* Community Section */} + + {/* Exchanges */} -
-
+
+

{t('root.exchanges.title')} @@ -200,7 +322,7 @@ export default function HomePage() {

- {/* HBD Money Fountain Particles */} + {/* Token Particles */} {particles.map((particle) => (
HBD
))} -
+
); } diff --git a/apps/web/src/components/LayoutContent.tsx b/apps/web/src/components/LayoutContent.tsx index c37c987d08f34c4c2da5a39017924b4b0e8c0c45..8a0a23f9caa88c4a0a0d98984c02cc0e5554b862 100644 --- a/apps/web/src/components/LayoutContent.tsx +++ b/apps/web/src/components/LayoutContent.tsx @@ -4,8 +4,7 @@ import { useEffect } from 'react'; import '@/lib/fontawesome'; import { Header } from '@/components/header/Header'; import { Footer } from '@/components/footer/Footer'; -import { Icon } from '@/components/Icon'; -import { NAVIGATION_HEADER_DROPDOWN, NAVIGATION_FOOTER, SOCIAL_MEDIAS } from '@/lib/data/var'; +import { NAVIGATION_HEADER_DROPDOWN, NAVIGATION_FOOTER } from '@/lib/data/var'; import { useMainStore } from '@/store/useMainStore'; export function LayoutContent({ children }: { children: React.ReactNode }) { @@ -21,18 +20,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
{children}
-
- {SOCIAL_MEDIAS.map(({ icon, link }) => ( - - ))} -
); diff --git a/apps/web/src/components/cards/TokenCard.tsx b/apps/web/src/components/cards/TokenCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2205dcec5af0225702bfe2946c0217b08b50244 --- /dev/null +++ b/apps/web/src/components/cards/TokenCard.tsx @@ -0,0 +1,153 @@ +'use client'; + +import React, { forwardRef } from 'react'; +import { TokenDonutChart } from '@/components/charts/TokenDonutChart'; + +interface TokenFeature { + icon: React.ReactNode; + text: React.ReactNode; +} + +interface TokenCardProps { + name: string; + subtitle: string; + iconSrc: string; + color: string; + colorRgb: string; // For shadow: "227,19,55" or "16,185,129" + features: TokenFeature[]; + totalSupply: number; + lockedAmount: number; + chartLabel: string; + onMouseEnter?: () => void; + buyUrl?: string; // If provided, opens this URL in new tab instead of scrolling to exchanges +} + +export const TokenCard = forwardRef( + ( + { + name, + subtitle, + iconSrc, + color, + colorRgb, + features, + totalSupply, + lockedAmount, + chartLabel, + onMouseEnter, + buyUrl, + }, + ref + ) => { + return ( +
{ + const target = e.currentTarget; + target.style.borderColor = `${color}80`; + target.style.boxShadow = `0 0 60px -15px rgba(${colorRgb},0.3)`; + }} + onMouseLeave={(e) => { + const target = e.currentTarget; + target.style.borderColor = `${color}4d`; + target.style.boxShadow = 'none'; + }} + > + {/* Animated background glow */} +
+ +
+ {/* Left side - Token info */} +
+ {/* Token icon and name */} +
+
+
+ {name} +
+
+
+

{name}

+ +
+
+ + {subtitle} + +
+
+
+ + {/* Visual attributes */} +
+ {features.map((feature, index) => ( +
+
+
{feature.icon}
+
+ {feature.text} +
+ ))} +
+
+ + {/* Right side - Donut chart */} + {totalSupply > 0 && ( +
+ +
+ )} +
+
+ ); + } +); + +TokenCard.displayName = 'TokenCard'; diff --git a/apps/web/src/components/charts/TokenDonutChart.tsx b/apps/web/src/components/charts/TokenDonutChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e00630b45911262f38f5d6ec6f6079bb48c9813 --- /dev/null +++ b/apps/web/src/components/charts/TokenDonutChart.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React from 'react'; + +interface TokenDonutChartProps { + total: number; + locked: number; + size?: number; + primaryColor: string; + secondaryColor: string; + label?: string; +} + +export const TokenDonutChart: React.FC = ({ + total, + locked, + size = 160, + primaryColor, + secondaryColor, + label, +}) => { + const strokeWidth = 16; + const radius = (size - strokeWidth) / 2; + const innerRadius = radius - strokeWidth / 2 - 8; + const circumference = 2 * Math.PI * radius; + const lockedPercentage = total > 0 ? (locked / total) * 100 : 0; + const lockedOffset = circumference - (lockedPercentage / 100) * circumference; + + const formatNumber = (num: number) => { + if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`; + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toFixed(0); + }; + + return ( +
+
+ + + + + + + + + {/* Filled center circle */} + + + {/* Background track */} + + + {/* Progress arc (locked portion) */} + + + + {/* Center content */} +
+ + {lockedPercentage.toFixed(0)}% + + {label || 'Locked'} +
+
+ + {/* Legend */} +
+
+
+
+ {label || 'Locked'} + {formatNumber(locked)} +
+
+
+
+
+ Liquid + {formatNumber(total - locked)} +
+
+
+
+ ); +}; diff --git a/apps/web/src/components/footer/Footer.tsx b/apps/web/src/components/footer/Footer.tsx index bad2128b6083f190e1b2ed81f750bd619cb36ca3..79d2c706e5b7be087e03d082bb51de7dd71c790e 100644 --- a/apps/web/src/components/footer/Footer.tsx +++ b/apps/web/src/components/footer/Footer.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Logo } from '@/components/logo/Logo'; import { FooterNavigation } from './FooterNavigation'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { SOCIAL_MEDIAS } from '@/lib/data/socialmedias'; interface FooterProps extends React.HTMLAttributes { items?: any[][]; @@ -8,9 +10,31 @@ interface FooterProps extends React.HTMLAttributes { export const Footer: React.FC = ({ items = [], className, ...props }) => { return ( -
+
- +
+ +
+ {SOCIAL_MEDIAS.map((social, index) => ( + + + + + {index < SOCIAL_MEDIAS.length - 1 && ( +
+ )} + + ))} +
+
{items.map((subItems, index) => ( = ({ items = [], return (
diff --git a/apps/web/src/components/hero/DynamicHero.tsx b/apps/web/src/components/hero/DynamicHero.tsx index 011efb4cc8e7a3e53a2a8aaef8c182ef2e983a1f..b03a29b2ba6ff387595d5b3e162a71b0401945bc 100644 --- a/apps/web/src/components/hero/DynamicHero.tsx +++ b/apps/web/src/components/hero/DynamicHero.tsx @@ -1,8 +1,17 @@ 'use client'; import { useBlockchainActivity } from '@/hooks/useBlockchainActivity'; +import { useTotalAccounts } from '@/hooks/useTotalAccounts'; +import { useTransactionStats } from '@/hooks/useTransactionStats'; +import { useTVL } from '@/hooks/useTVL'; import { Link } from '@/i18n/routing'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { DynamicGlobalProperties } from '@hiveio/hive-lib'; + +interface DynamicHeroProps { + onNewBlock?: (blockNum: number, witness: string) => void; + onGlobalProps?: (props: DynamicGlobalProperties) => void; +} // Calculate max activities based on screen dimensions function calculateMaxActivities(): number { @@ -15,10 +24,10 @@ function calculateMaxActivities(): number { const availableHeight = height - headerSpace; const calculated = Math.floor(availableHeight / 90); - return Math.max(4, Math.min(calculated, 6)); + return Math.max(4, Math.min(calculated, 4)); } -export function DynamicHero() { +export function DynamicHero({ onNewBlock, onGlobalProps }: DynamicHeroProps) { const [isVisible, setIsVisible] = useState(true); const containerRef = useRef(null); const [maxActivities, setMaxActivities] = useState(() => calculateMaxActivities()); @@ -36,13 +45,33 @@ export function DynamicHero() { const [shouldStopPolling, setShouldStopPolling] = useState(false); const [isHoveringFeed, setIsHoveringFeed] = useState(false); - const { activities: hookActivities, currentBlock } = useBlockchainActivity({ + const { activities: hookActivities, currentBlock, globalProps } = useBlockchainActivity({ maxActivities, updateInterval: 3000, enabled: true, paused: isHoveringFeed, + onNewBlock, }); + // Pass global props to parent when updated + useEffect(() => { + if (globalProps && onGlobalProps) { + onGlobalProps(globalProps); + } + }, [globalProps, onGlobalProps]); + + // Fetch transaction statistics + const { displayedTransactions } = useTransactionStats({ + updateInterval: 3000, + enabled: true, + }); + + // Fetch total accounts (runs once on mount) + const { totalAccounts } = useTotalAccounts(); + + // Fetch TVL data with live prices + const { tvl } = useTVL({ updateInterval: 60000 }); + const activities = LIMIT_TOTAL_ACTIVITIES > 0 ? hookActivities.filter(activity => { if (seenActivitiesRef.current.has(activity.id)) { @@ -259,204 +288,272 @@ export function DynamicHero() { }, [queuedIds, animatingIds, displayedActivities, finishedAnimatingIds, activities, isHoveringFeed]); return ( -
- {/* Main Headlines */} -
-

- Fast & Scalable. -

-

- Web3 Becomes Reality -

-

- Built by the community, for the community. Experience the power of Hive, the - decentralized blockchain that puts you in control. -

- - {/* CTA Buttons */} - -
- - {/* Live Block Number - Subtle */} -
-
- {/* Live Indicator - Left side */} -
-
- - -
- Live -
- - {/* Block Number - Right side */} -
+
+ {/* Two column layout */} +
+ {/* Main Headlines - Left side on desktop */} +
+

+ Fast & Scalable. +

+

+ The Blockchain For You. +

+

+ Battle-tested since 2016. Zero Downtime. Zero Gas Fees. Experience the power of Hive, the + decentralized blockchain that puts you in control. +

+ + {/* CTA Buttons */} +
-
- {/* Live Activities Feed */} - {/* Height: min 4 activities (360px) on mobile, up to 6 (540px) on desktop based on viewport */} -
setIsHoveringFeed(false)} - > - -
- {displayedActivities.map((activity) => { - const isAnimating = animatingIds.has(activity.id); - const hasFinishedAnimating = finishedAnimatingIds.has(activity.id); - const isFadingOut = fadingOutIds.has(activity.id); - - // Calculate position based on index in displayedActivities - // Fading out items move to the bottom (maxActivities position) - const currentIndex = displayedActivities.findIndex((a) => a.id === activity.id); - const actualIndex = isAnimating ? 0 : currentIndex; - const yPosition = actualIndex * 90; // 80px height + 10px gap - - // Set opacity and transform for animations - const baseTransform = `translateY(${yPosition}px)`; - - // Determine style based on state - // - Animating: use CSS animation (no inline transition) - // - Fading out: opacity 0, same position animation - // - Finished animating: opacity 1, position animation - // - Not yet animated: hidden (opacity 0, offset) - const style = isAnimating - ? { '--y-pos': `${yPosition}px`, top: 0, left: 0, right: 0, transition: 'none' } as React.CSSProperties - : hasFinishedAnimating || isFadingOut - ? { opacity: isFadingOut ? 0 : 1, transform: baseTransform, top: 0, left: 0, right: 0 } - : { opacity: 0, transform: `${baseTransform} translateX(50px)`, top: 0, left: 0, right: 0 }; - - // Log position changes - const prevPosition = prevPositionsRef.current.get(activity.id); - if (prevPosition !== yPosition) { - console.log(`📐 ${activity.id.substring(0, 10)}:`, { - prevPos: prevPosition ?? 'new', - newPos: yPosition, - actualIndex, - isAnimating, - hasFinishedAnimating, - isFadingOut, - displayedArray: displayedActivities.map(a => a.id.substring(0, 10)), - transform: isAnimating ? `var(--y-pos: ${yPosition}px)` : style.transform - }); - prevPositionsRef.current.set(activity.id, yPosition); - } - - // Activities are clickable if they have a txId AND are not hardcoded defaults - // Hardcoded defaults have IDs starting with "default-" - const isClickable = !!activity.txId && !activity.id.startsWith('default-'); - - // For posts, comments, and votes, link to peakd; otherwise link to hivehub transaction - let activityUrl: string | undefined; - if (isClickable) { - if ((activity.type === 'post' || activity.type === 'comment' || activity.type === 'vote') && activity.author && activity.permlink) { - activityUrl = `https://peakd.com/@${activity.author}/${activity.permlink}`; - } else { - activityUrl = `https://hivehub.dev/tx/${activity.txId}`; - } - } - - const cardContent = ( -
- {activity.user { - e.currentTarget.src = 'https://images.hive.blog/u/null/avatar/small'; - }} - /> -
-

- {activity.message} -

-
- {activity.txId && ( - - {activity.txId.substring(0, 4)}...{activity.txId.substring(activity.txId.length - 4)} - + {/* Live Feed - Right side on desktop */} +
+ + {/* Live Activities Feed */} + {/* Height: min 4 activities (360px) on mobile, up to 6 (540px) on desktop based on viewport */} +
setIsHoveringFeed(false)} + > + {/* Title and Live Indicator */} +
+
+

Activity

+ {currentBlock > 0 ? ( + + #{currentBlock.toLocaleString()} + + ) : ( + --- )}
- ); - - const animationClass = isAnimating && !isHoveringFeed ? 'animate-fade-in' : ''; - const pausedClass = isHoveringFeed ? 'animation-paused' : ''; - - // Track hovered activity - pause immediately if not animating, - // otherwise pause will trigger when animation completes - const handleCardMouseEnter = () => { - // Don't allow pausing on fading out activities - if (isFadingOut) return; - - setHoveredActivityId(activity.id); - if (!isAnimating) { - setIsHoveringFeed(true); - } - // If animating, pause will be triggered in handleAnimationEnd - }; - - const handleCardMouseLeave = () => { - setHoveredActivityId(null); - }; - - return isClickable ? ( - handleAnimationEnd(activity.id, e)} - onTransitionEnd={(e) => handleTransitionEnd(activity.id, e)} - onMouseEnter={handleCardMouseEnter} - onMouseLeave={handleCardMouseLeave} - > - {cardContent} - - ) : ( -
handleAnimationEnd(activity.id, e)} - onTransitionEnd={(e) => handleTransitionEnd(activity.id, e)} - onMouseEnter={handleCardMouseEnter} - onMouseLeave={handleCardMouseLeave} - > - {cardContent} +
+
+ + +
+ Live
- ); - })} +
+ +
+ {displayedActivities.map((activity) => { + const isAnimating = animatingIds.has(activity.id); + const hasFinishedAnimating = finishedAnimatingIds.has(activity.id); + const isFadingOut = fadingOutIds.has(activity.id); + + // Calculate position based on index in displayedActivities + // Fading out items move to the bottom (maxActivities position) + const currentIndex = displayedActivities.findIndex((a) => a.id === activity.id); + const actualIndex = isAnimating ? 0 : currentIndex; + const yPosition = actualIndex * 90; // 80px height + 10px gap + + // Set opacity and transform for animations + const baseTransform = `translateY(${yPosition}px)`; + + // Determine style based on state + // - Animating: use CSS animation (no inline transition) + // - Fading out: opacity 0, same position animation + // - Finished animating: opacity 1, position animation + // - Not yet animated: hidden (opacity 0, offset) + const style = isAnimating + ? { '--y-pos': `${yPosition}px`, top: 0, left: 0, right: 0, transition: 'none' } as React.CSSProperties + : hasFinishedAnimating || isFadingOut + ? { opacity: isFadingOut ? 0 : 1, transform: baseTransform, top: 0, left: 0, right: 0 } + : { opacity: 0, transform: `${baseTransform} translateX(50px)`, top: 0, left: 0, right: 0 }; + + // Log position changes + const prevPosition = prevPositionsRef.current.get(activity.id); + if (prevPosition !== yPosition) { + console.log(`📐 ${activity.id.substring(0, 10)}:`, { + prevPos: prevPosition ?? 'new', + newPos: yPosition, + actualIndex, + isAnimating, + hasFinishedAnimating, + isFadingOut, + displayedArray: displayedActivities.map(a => a.id.substring(0, 10)), + transform: isAnimating ? `var(--y-pos: ${yPosition}px)` : style.transform + }); + prevPositionsRef.current.set(activity.id, yPosition); + } + + // Activities are clickable if they have a txId AND are not hardcoded defaults + // Hardcoded defaults have IDs starting with "default-" + const isClickable = !!activity.txId && !activity.id.startsWith('default-'); + + // For posts, comments, and votes, link to peakd; otherwise link to hivehub transaction + let activityUrl: string | undefined; + if (isClickable) { + if ((activity.type === 'post' || activity.type === 'comment' || activity.type === 'vote') && activity.author && activity.permlink) { + activityUrl = `https://peakd.com/@${activity.author}/${activity.permlink}`; + } else { + activityUrl = `https://hivehub.dev/tx/${activity.txId}`; + } + } + + // Extract action text by removing username prefix from message + const actionText = activity.user && activity.message.startsWith(activity.user) + ? activity.message.slice(activity.user.length).trim() + : activity.message; + + const cardContent = ( +
+ {activity.user { + e.currentTarget.src = 'https://images.hive.blog/u/null/avatar/small'; + }} + /> +
+ + {activity.user || 'Unknown'} + + + {actionText} + +
+
+ + ↗ + + {activity.txId && ( + + {activity.txId.substring(0, 4)}...{activity.txId.substring(activity.txId.length - 4)} + + )} +
+
+ ); + + const animationClass = isAnimating && !isHoveringFeed ? 'animate-fade-in' : ''; + const pausedClass = isHoveringFeed ? 'animation-paused' : ''; + + // Track hovered activity - pause immediately if not animating, + // otherwise pause will trigger when animation completes + const handleCardMouseEnter = () => { + // Don't allow pausing on fading out activities + if (isFadingOut) return; + + setHoveredActivityId(activity.id); + if (!isAnimating) { + setIsHoveringFeed(true); + } + // If animating, pause will be triggered in handleAnimationEnd + }; + + const handleCardMouseLeave = () => { + setHoveredActivityId(null); + }; + + return isClickable ? ( + handleAnimationEnd(activity.id, e)} + onTransitionEnd={(e) => handleTransitionEnd(activity.id, e)} + onMouseEnter={handleCardMouseEnter} + onMouseLeave={handleCardMouseLeave} + > + {cardContent} + + ) : ( +
handleAnimationEnd(activity.id, e)} + onTransitionEnd={(e) => handleTransitionEnd(activity.id, e)} + onMouseEnter={handleCardMouseEnter} + onMouseLeave={handleCardMouseLeave} + > + {cardContent} +
+ ); + })} +
+
+
+
+ + {/* Stats Bar - Full width below both columns */} +
+
+
+ + + + Uptime (Years) +
+
8+
+
+ +
+
+ + + + Total Value Locked +
+
+ {tvl ? `$${(tvl.totalUSD / 1_000_000).toFixed(2)}M` : '---'} +
+
+ +
+
+ + + + Accounts +
+
0 ? 'text-gray-900' : 'text-gray-300'}`}> + {totalAccounts > 0 ? `${(totalAccounts / 1_000_000).toFixed(1)}M` : '---'} +
+
+ +
+
+ + + + Transactions +
+
0 ? 'text-gray-900' : 'text-gray-300'}`}> + {displayedTransactions > 0 ? displayedTransactions.toLocaleString() : '---'} +
diff --git a/apps/web/src/components/root/LogoMarquee.tsx b/apps/web/src/components/root/LogoMarquee.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d58a5e6f66eafb90423398947fc84b8f3ecd4dbf --- /dev/null +++ b/apps/web/src/components/root/LogoMarquee.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; +import { useTotalAccounts } from '@/hooks/useTotalAccounts'; + +interface LogoMarqueeProps { + className?: string; +} + +// Placeholder community images - replace with actual Hive community photos +const COMMUNITY_IMAGES = [ + '/images/community/hive-meetup-1.jpg', + '/images/community/hive-meetup-2.jpg', + '/images/community/hive-meetup-3.jpg', + '/images/community/hive-meetup-4.jpg', + '/images/community/hive-meetup-5.jpg', + '/images/community/hive-meetup-6.jpg', +]; + +interface MarqueeItemProps { + src: string; + alt: string; + tall?: boolean; +} + +const MarqueeImage: React.FC = ({ src, alt, tall }) => ( +
+ {alt} +
+); + +interface StatCardProps { + value: string; + label: string; +} + +const StatCard: React.FC = ({ value, label }) => ( +
+ + {value} + + {label} +
+); + +// A column that contains either 1 tall item or 2 stacked small items +interface MarqueeColumnProps { + children: React.ReactNode; +} + +const MarqueeColumn: React.FC = ({ children }) => ( +
+ {children} +
+); + +export const LogoMarquee: React.FC = ({ className }) => { + const { totalAccounts } = useTotalAccounts(); + + const formatNumber = (num: number) => { + if (num === 0) return '---'; + if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`; + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return num.toLocaleString(); + return num.toString(); + }; + + // Tetris-like columns: either 1 tall image OR 2 stacked small items + const renderColumns = (keyPrefix: string) => ( + <> + {/* Column 1: Tall image */} + + + + + {/* Column 2: Two stacked small images */} + + + + + + {/* Column 3: Tall image */} + + + + + {/* Column 4: Stat + small image stacked */} + + + + + + {/* Column 5: Tall image */} + + + + + {/* Column 6: Two stacked small images */} + + + + + + ); + + return ( +
+
+

+ Join a Thriving Community. +

+
+ + {/* Animated Marquee */} +
+
+ {renderColumns('first')} + {renderColumns('second')} +
+
+ + +
+ ); +}; diff --git a/apps/web/src/components/root/RootDPoS.tsx b/apps/web/src/components/root/RootDPoS.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cf120196295937ccaf7b86d78d842372ffc2c45 --- /dev/null +++ b/apps/web/src/components/root/RootDPoS.tsx @@ -0,0 +1,235 @@ +'use client'; + +import React, { useImperativeHandle, forwardRef, useState, useEffect, useRef } from 'react'; +import { useBlockProducers } from '@/hooks/useBlockProducers'; + +const WITNESS_COUNT = 10; + +interface RootDPoSProps extends React.HTMLAttributes { } + +export interface RootDPoSHandle { + addBlock: (blockNum: number, producer: string) => void; +} + +export const RootDPoS = forwardRef(({ className, ...props }, ref) => { + // Fetch initial block producers once - no polling needed + const { producers, latestProducer, isLoading, addBlock } = useBlockProducers({ + initialCount: WITNESS_COUNT, + enabled: true, + }); + + // Track both current and outgoing producer for crossfade + const [currentProducer, setCurrentProducer] = useState(latestProducer); + const [outgoingProducer, setOutgoingProducer] = useState(null); + const [isFadingOut, setIsFadingOut] = useState(false); + const prevProducerRef = useRef(latestProducer?.producer); + + // Crossfade when producer changes + useEffect(() => { + if (latestProducer && latestProducer.producer !== prevProducerRef.current) { + // Set the old producer as outgoing (starts visible) + if (currentProducer) { + setOutgoingProducer(currentProducer); + setIsFadingOut(false); + // Trigger fade-out on next frame + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setIsFadingOut(true); + }); + }); + } + // Set new producer as current + setCurrentProducer(latestProducer); + prevProducerRef.current = latestProducer.producer; + + // Clear outgoing after transition completes + const timeout = setTimeout(() => { + setOutgoingProducer(null); + setIsFadingOut(false); + }, 400); + + return () => clearTimeout(timeout); + } else if (latestProducer && !currentProducer) { + // Initial load + setCurrentProducer(latestProducer); + prevProducerRef.current = latestProducer.producer; + } + }, [latestProducer, currentProducer]); + + // Expose addBlock to parent via ref + useImperativeHandle(ref, () => ({ + addBlock, + }), [addBlock]); + + // Get recent producers, excluding the latest (shown separately at top) + const recentProducers = React.useMemo(() => { + return producers + .filter(p => !(latestProducer && p.producer === latestProducer.producer && p.block_num === latestProducer.block_num)) + .slice(0, WITNESS_COUNT); + }, [producers, latestProducer]); + + return ( +
+
+ {/* Title */} +
+

+ Governance. +

+

+ Hive is decentralized by design. Secured through Delegated Proof of Stake. Over 100 stakeholder-elected witnesses produce blocks every 3s. +

+
+ + {/* Main visualization */} +
+ {/* Central block indicator */} +
+ {/* Latest block producer highlight */} + {currentProducer ? ( +
+ {/* Pulsing glow */} +
+ + {/* Container for crossfade */} +
+ {/* Outgoing producer (fading out) */} + {outgoingProducer && ( +
+
+
+ {outgoingProducer.producer} +
+
+ + +
+ LIVE +
+
+ @{outgoingProducer.producer} +
+ Block + #{outgoingProducer.block_num.toLocaleString()} +
+
+ )} + + {/* Current producer (fading in) */} +
+
+
+ {currentProducer.producer} { + e.currentTarget.src = 'https://images.hive.blog/u/null/avatar'; + }} + /> + {/* Live indicator */} +
+
+ + +
+ LIVE +
+
+ + {/* Producer info */} + + @{currentProducer.producer} + + +
+
+
+ ) : ( + // Loading placeholder +
+
+
+ {/* Live indicator placeholder */} +
+
+ + +
+ LIVE +
+
+ --- +
+ Block + --- +
+
+ )} +
+ + {/* Witness grid */} +
+ {/* Recent block producers */} +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: WITNESS_COUNT }).map((_, i) => ( + +
+
+
+ ); +}); + +RootDPoS.displayName = 'RootDPoS'; diff --git a/apps/web/src/components/root/RootEco.tsx b/apps/web/src/components/root/RootEco.tsx index 66ef2ae43c2259f7920d1132be223f53f67a7209..587e1cd77523519b2ea285c8d64853cb5c4b4481 100644 --- a/apps/web/src/components/root/RootEco.tsx +++ b/apps/web/src/components/root/RootEco.tsx @@ -30,53 +30,89 @@ export const RootEco: React.FC = ({ full = true, className, ...pro }, []); return ( -
+
-
-
-

{t('root.ecoTitle')}

-

- {t('root.ecoText')} -

- -
-
- {favs.map((app, index) => { - const getSizeForIndex = (idx: number) => { - const sizes = [110, 80, 85, 65, 80, 55, 60, 77]; - return sizes[idx] || 110; - }; +
+
+ {/* Left side - Title and Text */} +
+

+ {t('root.ecoTitle')}. +

+ +

+ {t('root.ecoText')} +

- return ( -
+ + + {/* Stats - styled like hero */} +
+
+
+ + + + Apps +
+
158
+
+
+
+ + + + Communities +
+
248
+
- ); - })} +
+
+ + {/* Right side - Icons */} +
+
+ {favs.map((app, index) => { + const getSizeForIndex = (idx: number) => { + const sizes = [110, 80, 85, 65, 80, 55, 60, 77]; + return sizes[idx] || 110; + }; + + return ( +
+ +
+ ); + })} +
+
diff --git a/apps/web/src/components/root/RootHistory.tsx b/apps/web/src/components/root/RootHistory.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ab927cb542c0e3664b8159113359a115305308d7 --- /dev/null +++ b/apps/web/src/components/root/RootHistory.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React from 'react'; + +interface RootHistoryProps extends React.HTMLAttributes { } + +export const RootHistory: React.FC = ({ className, ...props }) => { + return ( +
+
+
+ {/* Left side - Bold statement */} +
+

+ We forked. +

+

+ In March 2020, a hostile takeover threatened to centralize a blockchain. + The community refused. They forked, taking their code, their content, + and their principles with them. +

+

+ Hive was born—a + chain owned by no one and governed by everyone. +

+
+ + {/* Right side - Articles */} + +
+
+
+ ); +}; diff --git a/apps/web/src/hooks/defaultActivities.ts b/apps/web/src/hooks/defaultActivities.ts index 45c83902f293eb8ae1aa5c8033995e6eb9cd8f7b..b17b47abce281f0ab762d25252aec41992e05daf 100644 --- a/apps/web/src/hooks/defaultActivities.ts +++ b/apps/web/src/hooks/defaultActivities.ts @@ -87,6 +87,6 @@ export function getDefaultActivities(count: number = 2): ActivityItem[] { return generateRandomActivities(count) } -// Default block data -export const DEFAULT_BLOCK = 100000000 -export const DEFAULT_TX_COUNT = 24 +// Default block data - set to 0 so UI shows loading state +export const DEFAULT_BLOCK = 0 +export const DEFAULT_TX_COUNT = 0 diff --git a/apps/web/src/hooks/useBlockProducers.ts b/apps/web/src/hooks/useBlockProducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..01ace53f91b519870b613b09855a67cf10b53628 --- /dev/null +++ b/apps/web/src/hooks/useBlockProducers.ts @@ -0,0 +1,121 @@ +'use client' + +import {useState, useEffect, useCallback, useRef} from 'react' + +export interface BlockProducer { + block_num: number + producer: string + timestamp: string +} + +interface UseBlockProducersOptions { + initialCount?: number + enabled?: boolean +} + +interface UseBlockProducersResult { + producers: BlockProducer[] + latestProducer: BlockProducer | null + isLoading: boolean + error: string | null + addBlock: (blockNum: number, producer: string) => void +} + +/** + * Hook to fetch and track block producers for DPoS visualization + * Fetches initial producers once, then use addBlock to add new ones + */ +export function useBlockProducers( + options: UseBlockProducersOptions = {}, +): UseBlockProducersResult { + const {initialCount = 20, enabled = true} = options + + const [producers, setProducers] = useState([]) + const [latestProducer, setLatestProducer] = useState( + null, + ) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const lastBlockRef = useRef(0) + const hasInitializedRef = useRef(false) + const initialCountRef = useRef(initialCount) + + /** + * Fetch initial block producers (runs once) + */ + const fetchInitialProducers = useCallback(async () => { + try { + const response = await fetch( + `https://api.hive.blog/hafbe-api/operation-type-counts?result-limit=${initialCountRef.current}`, + ) + + if (!response.ok) { + throw new Error('Failed to fetch block producers') + } + + const data = await response.json() + + if (Array.isArray(data)) { + const blockProducers: BlockProducer[] = data.map( + (block: {block_num: number; witness: string}) => ({ + block_num: block.block_num, + producer: block.witness, + timestamp: new Date().toISOString(), + }), + ) + + setProducers(blockProducers) + + if (blockProducers.length > 0) { + setLatestProducer(blockProducers[0]) + lastBlockRef.current = blockProducers[0].block_num + } + } + + setIsLoading(false) + setError(null) + } catch (err) { + console.error('Failed to fetch block producers:', err) + setError(err instanceof Error ? err.message : 'Unknown error') + setIsLoading(false) + } + }, []) + + /** + * Add a new block from external source + * Call this when a new block is produced + */ + const addBlock = useCallback((blockNum: number, producer: string) => { + // Only add if it's a newer block + if (blockNum <= lastBlockRef.current) return + + const newProducer: BlockProducer = { + block_num: blockNum, + producer, + timestamp: new Date().toISOString(), + } + + lastBlockRef.current = blockNum + setLatestProducer(newProducer) + setProducers((prev) => + [newProducer, ...prev].slice(0, initialCountRef.current), + ) + }, []) + + // Initial fetch only - runs once + useEffect(() => { + if (!enabled || hasInitializedRef.current) return + + hasInitializedRef.current = true + void fetchInitialProducers() + }, [enabled, fetchInitialProducers]) + + return { + producers, + latestProducer, + isLoading, + error, + addBlock, + } +} diff --git a/apps/web/src/hooks/useBlockchainActivity.ts b/apps/web/src/hooks/useBlockchainActivity.ts index 612467b41cf4d67d7d86ec374b58af21bc9c58ec..cba79a3f2c9a81596bd197f71ccb5fc77a56e0b2 100644 --- a/apps/web/src/hooks/useBlockchainActivity.ts +++ b/apps/web/src/hooks/useBlockchainActivity.ts @@ -5,6 +5,8 @@ import { fetchBlockchainActivity, filterOptimalActivities, type ActivityItem, + type BlockWitness, + type DynamicGlobalProperties, } from '@hiveio/hive-lib' import { getDefaultActivities, @@ -17,6 +19,7 @@ interface UseBlockchainActivityOptions { updateInterval: number enabled: boolean paused?: boolean + onNewBlock?: (blockNum: number, witness: string) => void } interface UseBlockchainActivityResult { @@ -26,6 +29,7 @@ interface UseBlockchainActivityResult { currentBlock: number blockTimestamp: string transactionCount: number + globalProps: DynamicGlobalProperties | null reset: () => void } @@ -41,6 +45,7 @@ export function useBlockchainActivity( updateInterval, enabled, paused = false, + onNewBlock, } = options const [activities, setActivities] = useState([]) @@ -49,6 +54,7 @@ export function useBlockchainActivity( const [currentBlock, setCurrentBlock] = useState(DEFAULT_BLOCK) const [blockTimestamp, setBlockTimestamp] = useState('') const [transactionCount, setTransactionCount] = useState(DEFAULT_TX_COUNT) + const [globalProps, setGlobalProps] = useState(null) const lastBlockRef = useRef(0) const activitiesPoolRef = useRef([]) @@ -58,10 +64,13 @@ export function useBlockchainActivity( const animationIntervalRef = useRef(null) const hasInitializedRef = useRef(false) const hasLoadedDefaultsRef = useRef(false) + const isFirstFetchRef = useRef(true) const pausedRef = useRef(paused) + const onNewBlockRef = useRef(onNewBlock) - // Keep pausedRef in sync + // Keep refs in sync pausedRef.current = paused + onNewBlockRef.current = onNewBlock /** * Preload an image to ensure it's cached before display @@ -156,20 +165,35 @@ export function useBlockchainActivity( latestBlock, shouldSpeedUp, transactionCount, + witnesses, + globalProps: fetchedGlobalProps, } = await fetchBlockchainActivity( lastBlockRef.current, 3, // max 3 blocks per fetch ) + // Update global props if we received new ones + if (fetchedGlobalProps) { + setGlobalProps(fetchedGlobalProps) + } + // On first fetch from default block, show latestBlock - 1 for display const displayBlock = - currentBlock === DEFAULT_BLOCK ? latestBlock - 1 : latestBlock + isFirstFetchRef.current ? latestBlock - 1 : latestBlock + isFirstFetchRef.current = false setCurrentBlock(displayBlock) setBlockTimestamp(new Date().toLocaleTimeString()) setTransactionCount(transactionCount) lastBlockRef.current = latestBlock + // Notify about new blocks/witnesses + if (onNewBlockRef.current && witnesses.length > 0) { + witnesses.forEach((w) => { + onNewBlockRef.current?.(w.blockNum, w.witness) + }) + } + // Update activities pool if (newActivities.length > 0) { // Add new activities to pool @@ -209,7 +233,7 @@ export function useBlockchainActivity( setError(err instanceof Error ? err.message : 'Unknown error') setIsLoading(false) } - }, [enabled, maxActivities, updateInterval, queueActivities]) + }, [enabled, maxActivities, queueActivities]) /** * Fetch initial block number immediately on mount @@ -219,13 +243,16 @@ export function useBlockchainActivity( hasInitializedRef.current = true - // Fetch block number immediately to avoid showing default block for too long + // Fetch block number and global props immediately const fetchInitialBlock = async () => { try { - const {latestBlock} = await fetchBlockchainActivity(0, 1) + const {latestBlock, globalProps: initialGlobalProps} = await fetchBlockchainActivity(0, 1) setCurrentBlock(latestBlock - 1) setBlockTimestamp(new Date().toLocaleTimeString()) lastBlockRef.current = latestBlock + if (initialGlobalProps) { + setGlobalProps(initialGlobalProps) + } } catch (err) { console.error('Failed to fetch initial block:', err) } @@ -361,6 +388,7 @@ export function useBlockchainActivity( currentBlock, blockTimestamp, transactionCount, + globalProps, reset, } } diff --git a/apps/web/src/hooks/useTVL.ts b/apps/web/src/hooks/useTVL.ts new file mode 100644 index 0000000000000000000000000000000000000000..10cef7f9f10a1dd0989537bbb5a03b217ed42368 --- /dev/null +++ b/apps/web/src/hooks/useTVL.ts @@ -0,0 +1,168 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; + +// Placeholder data - TODO: Replace with actual API call +const PLACEHOLDER_LOCKED = { + hive_savings: 2320515.702, + hbd_savings: 8383879.033, + hp: 197821486.215, +}; + +interface PriceData { + hive: { + usd: number; + usd_24h_change: number; + }; + hive_dollar: { + usd: number; + usd_24h_change: number; + }; +} + +interface TVLData { + totalUSD: number; + hiveSavingsUSD: number; + hbdSavingsUSD: number; + hpUSD: number; + hivePrice: number; + hbdPrice: number; + hive24hChange: number; + hbd24hChange: number; + // Raw token amounts + hiveSavings: number; + hbdSavings: number; + hpAmount: number; +} + +interface UseTVLOptions { + updateInterval?: number; + enabled?: boolean; +} + +interface UseTVLResult { + tvl: TVLData | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +const COINGECKO_API = 'https://api.coingecko.com/api/v3/simple/price?ids=hive,hive_dollar&vs_currencies=usd&include_24hr_change=true'; + +// Fallback prices when API fails +const FALLBACK_PRICES = { + hive: 0.1, + hbd: 1.0, +}; + +/** + * Hook to fetch Total Value Locked (TVL) data + * Uses placeholder locked amounts and fetches prices from CoinGecko + */ +export function useTVL(options: UseTVLOptions = {}): UseTVLResult { + const { updateInterval = 60000, enabled = true } = options; + + const [tvl, setTVL] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const intervalIdRef = useRef(null); + + const fetchTVL = useCallback(async () => { + if (!enabled) return; + + try { + const response = await fetch(COINGECKO_API); + + if (!response.ok) { + throw new Error('Failed to fetch price data from CoinGecko'); + } + + const priceData: PriceData = await response.json(); + + const hivePrice = priceData.hive.usd; + const hbdPrice = priceData.hive_dollar.usd; + + // Calculate USD values + const hiveSavingsUSD = PLACEHOLDER_LOCKED.hive_savings * hivePrice; + const hbdSavingsUSD = PLACEHOLDER_LOCKED.hbd_savings * hbdPrice; + const hpUSD = PLACEHOLDER_LOCKED.hp * hivePrice; // HP uses HIVE price + + const totalUSD = hiveSavingsUSD + hbdSavingsUSD + hpUSD; + + setTVL({ + totalUSD, + hiveSavingsUSD, + hbdSavingsUSD, + hpUSD, + hivePrice, + hbdPrice, + hive24hChange: priceData.hive.usd_24h_change, + hbd24hChange: priceData.hive_dollar.usd_24h_change, + // Raw token amounts + hiveSavings: PLACEHOLDER_LOCKED.hive_savings, + hbdSavings: PLACEHOLDER_LOCKED.hbd_savings, + hpAmount: PLACEHOLDER_LOCKED.hp, + }); + setIsLoading(false); + setError(null); + } catch (err) { + console.error('Failed to fetch TVL data, using fallback prices:', err); + + // Use fallback prices + const hivePrice = FALLBACK_PRICES.hive; + const hbdPrice = FALLBACK_PRICES.hbd; + + const hiveSavingsUSD = PLACEHOLDER_LOCKED.hive_savings * hivePrice; + const hbdSavingsUSD = PLACEHOLDER_LOCKED.hbd_savings * hbdPrice; + const hpUSD = PLACEHOLDER_LOCKED.hp * hivePrice; + + const totalUSD = hiveSavingsUSD + hbdSavingsUSD + hpUSD; + + setTVL({ + totalUSD, + hiveSavingsUSD, + hbdSavingsUSD, + hpUSD, + hivePrice, + hbdPrice, + hive24hChange: 0, + hbd24hChange: 0, + hiveSavings: PLACEHOLDER_LOCKED.hive_savings, + hbdSavings: PLACEHOLDER_LOCKED.hbd_savings, + hpAmount: PLACEHOLDER_LOCKED.hp, + }); + setError(null); + setIsLoading(false); + } + }, [enabled]); + + // Fetch immediately on mount + useEffect(() => { + if (!enabled) return; + + void fetchTVL(); + }, [enabled, fetchTVL]); + + // Set up polling interval + useEffect(() => { + if (!enabled) return; + + intervalIdRef.current = setInterval(() => { + void fetchTVL(); + }, updateInterval); + + return () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + }; + }, [enabled, updateInterval, fetchTVL]); + + return { + tvl, + isLoading, + error, + refetch: fetchTVL, + }; +} diff --git a/apps/web/src/hooks/useTotalAccounts.ts b/apps/web/src/hooks/useTotalAccounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b14413f60bf9bd8a4e090c6a65bcf8b8f3e7f1e --- /dev/null +++ b/apps/web/src/hooks/useTotalAccounts.ts @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface AccountDetails { + id: number; + name: string; + recovery_account: string; + created: string; + post_count: number; + reputation: number; + json_metadata: string; + posting_json_metadata: string; +} + +interface UseTotalAccountsResult { + totalAccounts: number; + isLoading: boolean; + error: string | null; +} + +export function useTotalAccounts(): UseTotalAccountsResult { + const [totalAccounts, setTotalAccounts] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchTotalAccounts = async () => { + try { + // Step 1: Get the last account name + const accountsResponse = await fetch('https://rpc.mahdiyari.info/hafsql/accounts?limit=-1'); + if (!accountsResponse.ok) { + throw new Error('Failed to fetch accounts list'); + } + const accountNames: string[] = await accountsResponse.json(); + + if (!accountNames || accountNames.length === 0) { + throw new Error('No accounts returned'); + } + + // The last account name in the list + const lastAccountName = accountNames[accountNames.length - 1]; + + // Step 2: Get the account details to retrieve the ID (which is the total count) + const detailsResponse = await fetch( + `https://rpc.mahdiyari.info/hafsql/accounts/by-names?names=${lastAccountName}` + ); + if (!detailsResponse.ok) { + throw new Error('Failed to fetch account details'); + } + const accountDetails: AccountDetails[] = await detailsResponse.json(); + + if (!accountDetails || accountDetails.length === 0) { + throw new Error('No account details returned'); + } + + // The ID of the last account represents the total number of accounts + setTotalAccounts(accountDetails[0].id); + setIsLoading(false); + } catch (err) { + console.error('Error fetching total accounts:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + setIsLoading(false); + } + }; + + fetchTotalAccounts(); + }, []); + + return { totalAccounts, isLoading, error }; +} diff --git a/apps/web/src/hooks/useTransactionStats.ts b/apps/web/src/hooks/useTransactionStats.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e612146708972aa736531f10cd6407ad55e8cc3 --- /dev/null +++ b/apps/web/src/hooks/useTransactionStats.ts @@ -0,0 +1,132 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; + +interface TransactionStatsOptions { + updateInterval: number; + enabled: boolean; +} + +interface TransactionStatsResult { + displayedTransactions: number; + isAnimating: boolean; +} + +/** + * Hook to fetch total transaction count from Hive API + * Updates at the same interval as blockchain activity + * Animates the count up smoothly when new data arrives + */ +export function useTransactionStats( + options: TransactionStatsOptions, +): TransactionStatsResult { + const { updateInterval, enabled } = options; + + const [displayedTransactions, setDisplayedTransactions] = useState(0); + const [targetTransactions, setTargetTransactions] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const intervalIdRef = useRef(null); + const animationFrameRef = useRef(null); + + const fetchTransactionStats = async () => { + if (!enabled) return; + + try { + const response = await fetch( + 'https://api.hive.blog/hafbe-api/transaction-statistics?granularity=yearly' + ); + + if (!response.ok) { + throw new Error('Failed to fetch transaction statistics'); + } + + const data = await response.json(); + + // Sum up all transaction counts from all years + const total = data.reduce((sum: number, year: any) => sum + year.trx_count, 0); + + // Set new target (will trigger animation in useEffect) + setTargetTransactions(total); + } catch (err) { + console.error('Failed to fetch transaction statistics:', err); + } + }; + + // Animate counting up to target + useEffect(() => { + if (targetTransactions === 0 || displayedTransactions === targetTransactions) { + return; + } + + // If this is the first load, set immediately + if (displayedTransactions === 0) { + setDisplayedTransactions(targetTransactions); + return; + } + + // Start animation + setIsAnimating(true); + + const startValue = displayedTransactions; + const endValue = targetTransactions; + const duration = 2500; // 2.5 seconds for smooth counting + const startTime = Date.now(); + + const animate = () => { + const now = Date.now(); + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Easing function for smooth animation (ease-out) + const easeOut = 1 - Math.pow(1 - progress, 3); + + const currentValue = Math.floor(startValue + (endValue - startValue) * easeOut); + setDisplayedTransactions(currentValue); + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + setDisplayedTransactions(endValue); + setIsAnimating(false); + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }, [targetTransactions, displayedTransactions]); + + // Fetch immediately on mount + useEffect(() => { + if (!enabled) return; + + void fetchTransactionStats(); + }, [enabled]); + + // Set up polling interval + useEffect(() => { + if (!enabled) return; + + intervalIdRef.current = setInterval(() => { + void fetchTransactionStats(); + }, updateInterval); + + return () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + }; + }, [enabled, updateInterval]); + + return { + displayedTransactions, + isAnimating, + }; +} diff --git a/apps/web/src/lib/data/socialmedias.ts b/apps/web/src/lib/data/socialmedias.ts index 6f7853f72b97fc5af8ebc71650d3726b2c58cf80..92bab2a796e1c715cb30cae5a984dda836e0f021 100644 --- a/apps/web/src/lib/data/socialmedias.ts +++ b/apps/web/src/lib/data/socialmedias.ts @@ -6,6 +6,14 @@ export const SOCIAL_MEDIAS = [ icon: 'hive', link: 'https://hive.blog/@hiveio', }, + { + icon: 'x-twitter', + link: 'https://x.com/hiveblocks', + }, + { + icon: 'discord', + link: 'https://myhive.li/discord', + }, { icon: 'github', link: 'https://github.com/openhive-network/hive', @@ -14,39 +22,19 @@ export const SOCIAL_MEDIAS = [ icon: 'gitlab', link: 'https://gitlab.hive.io', }, - { - icon: 'twitter', - link: 'https://twitter.com/hiveblocks', - }, { icon: 'youtube', link: 'https://www.youtube.com/channel/UCwM89V7NzVIHizgWT3GxhwA', }, - { - icon: 'medium', - link: 'https://medium.com/@hiveblocks', - }, { icon: 'telegram', link: 'https://t.me/hiveblockchain', }, - { - icon: 'reddit', - link: 'https://reddit.com/r/hivenetwork', - }, - { - icon: 'discord', - link: 'https://myhive.li/discord', - }, { icon: 'facebook', link: 'https://www.facebook.com/hiveblocks/', }, { - icon: 'quora', - link: 'https://www.quora.com/q/hive', - }, - { icon: 'instagram', link: 'https://www.instagram.com/hiveblocks', }, diff --git a/apps/web/src/lib/fontawesome.ts b/apps/web/src/lib/fontawesome.ts index 03421006313ea1bba4f1297f379096b4127e5057..514d989b70aa5969e71752b525246bf679d0859e 100644 --- a/apps/web/src/lib/fontawesome.ts +++ b/apps/web/src/lib/fontawesome.ts @@ -10,18 +10,15 @@ import { import { faHive, faYoutube, - faTwitter, + faXTwitter, faFacebook, - faQuora, faGitlab, faGithub, - faMedium, faChrome, faSafari, faFirefox, faTelegram, faDiscord, - faReddit, faAndroid, faLinux, faAppStoreIos, @@ -58,18 +55,15 @@ library.add( faWallet, faHive, faYoutube, - faTwitter, + faXTwitter, faFacebook, - faQuora, faGitlab, faGithub, - faMedium, faChrome, faSafari, faFirefox, faTelegram, faDiscord, - faReddit, faAndroid, faLinux, faAppStoreIos, diff --git a/packages/hive-lib/src/activity.ts b/packages/hive-lib/src/activity.ts index 8a128b34b06e74996a732449aff78a34d17aeae7..705d35e7eee9c54376cf3e0f6a34432f7a27cdc6 100644 --- a/packages/hive-lib/src/activity.ts +++ b/packages/hive-lib/src/activity.ts @@ -434,79 +434,68 @@ export function filterOptimalActivities( // Track block sync state let blockCheckCounter = 0 let assumedHeadBlock = 0 - -/** - * Fetch initial activities for default display - * Fetches from multiple recent blocks and randomizes selection - */ -export async function fetchInitialActivities(count: number = 4): Promise<{ - activities: ActivityItem[] - currentBlock: number - transactionCount: number -}> { - const hive = await getHiveChain() - - // Get current head block - const props = await hive.api.database_api.get_dynamic_global_properties({}) - const headBlock = props.head_block_number - - // Fetch last 10 blocks to get a good pool of activities - const allActivities: ActivityItem[] = [] - let latestTransactionCount = 0 - - for (let i = 0; i < 10; i++) { - const blockNum = headBlock - i - - try { - const result = await hive.api.block_api.get_block({block_num: blockNum}) - - if (!result?.block?.transactions) continue - - const block = result.block - - // Store transaction count from the latest block - if (i === 0) { - latestTransactionCount = block.transactions.length - } - - // Parse all transactions in the block - block.transactions.forEach((tx, txIndex: number) => { - const operations = tx.operations || [] - // Get transaction ID from block.transaction_ids array (same order as transactions) - const txId = (block as any).transaction_ids?.[txIndex] - - const interestingOps = filterInterestingOperations(operations) - - interestingOps.forEach((op) => { - const activity = parseOperation(hive, op, blockNum, txIndex, txId) - if (activity) { - allActivities.push(activity) - } - }) - }) - } catch (error) { - console.error(`Failed to fetch block ${blockNum}:`, error) - } - } - - // Filter and get optimal activities - const optimalActivities = filterOptimalActivities(allActivities, count * 3) - - // Shuffle and take the requested count - const shuffled = shuffleArray(optimalActivities) - const selected = shuffled.slice(0, count) - - return { - activities: selected, - currentBlock: headBlock, - transactionCount: latestTransactionCount, - } +let cachedGlobalProps: DynamicGlobalProperties | null = null + +// Asset can be either string format "123.456 HIVE" or object format +export type HiveAsset = string | { amount: string; nai: string; precision: number } + +export interface DynamicGlobalProperties { + head_block_number: number + head_block_id: string + time: string + current_witness: string + total_pow: number + num_pow_witnesses: number + virtual_supply: HiveAsset + current_supply: HiveAsset + init_hbd_supply: HiveAsset + current_hbd_supply: HiveAsset + total_vesting_fund_hive: HiveAsset + total_vesting_shares: HiveAsset + total_reward_fund_hive: HiveAsset + total_reward_shares2: string + pending_rewarded_vesting_shares: HiveAsset + pending_rewarded_vesting_hive: HiveAsset + hbd_interest_rate: number + hbd_print_rate: number + maximum_block_size: number + required_actions_partition_percent: number + current_aslot: number + recent_slots_filled: string + participation_count: number + last_irreversible_block_num: number + vote_power_reserve_rate: number + delegation_return_period: number + reverse_auction_seconds: number + available_account_subsidies: number + hbd_stop_percent: number + hbd_start_percent: number + next_maintenance_time: string + last_budget_time: string + next_daily_maintenance_time: string + content_reward_percent: number + vesting_reward_percent: number + proposal_fund_percent: number + dhf_interval_ledger: string + downvote_pool_percent: number + current_remove_threshold: number + early_voting_seconds: number + mid_voting_seconds: number + max_consecutive_recurrent_transfer_failures: number + max_recurrent_transfer_end_date: number + min_recurrent_transfers_recurrence: number + max_open_recurrent_transfers: number } /** * Fetch blockchain activities from recent blocks * Optimized to avoid unnecessary dynamic_global_properties calls */ +export interface BlockWitness { + blockNum: number + witness: string +} + export async function fetchBlockchainActivity( lastBlock: number, maxBlocks: number = 3, @@ -515,12 +504,15 @@ export async function fetchBlockchainActivity( latestBlock: number shouldSpeedUp: boolean transactionCount: number + witnesses: BlockWitness[] + globalProps: DynamicGlobalProperties | null }> { const hive = await getHiveChain() // Only check dynamic props on first call or every 10 blocks if (lastBlock === 0 || blockCheckCounter % 10 === 0) { const props = await hive.api.database_api.get_dynamic_global_properties({}) + cachedGlobalProps = props as unknown as DynamicGlobalProperties assumedHeadBlock = props.head_block_number // If this is the first fetch, set lastBlock to fetch recent blocks @@ -548,6 +540,7 @@ export async function fetchBlockchainActivity( } const newActivities: ActivityItem[] = [] + const witnesses: BlockWitness[] = [] let actualLatestBlock = lastBlock let latestTransactionCount = 0 @@ -568,14 +561,17 @@ export async function fetchBlockchainActivity( const block = result.block - // console.log( - // `Fetched block ${blockNum} with ${block.transactions.length} transactions.`, - // block, - // ) - actualLatestBlock = blockNum latestTransactionCount = block.transactions.length + // Track the witness/producer for this block + if (block.witness) { + witnesses.push({ + blockNum, + witness: block.witness, + }) + } + // Parse all transactions in the block block.transactions.forEach((tx, txIndex: number) => { const operations = tx.operations || [] @@ -608,5 +604,7 @@ export async function fetchBlockchainActivity( latestBlock: actualLatestBlock, shouldSpeedUp, transactionCount: latestTransactionCount, + witnesses, + globalProps: cachedGlobalProps, } }