diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 2c37c3fd8dabf66360907327a0a525ea1c5849cd..9fa96fd29f318446feba4ee7717f5b3829bf4daf 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -8,14 +8,31 @@ import { ScrollIndicator } from '@/components/ScrollIndicator'; import { RootEco } from '@/components/root/RootEco'; import { useAssets } from '@/hooks/useAssets'; import { EXCHANGES } from '@/lib/data/var'; +import { useState, useRef, useEffect } from 'react'; // Live Activity Components import { DynamicHero } from '@/components/hero/DynamicHero'; +interface MoneyParticle { + id: number; + x: number; + y: number; + vx: number; + vy: number; + rotation: number; + rotationSpeed: number; +} + export default function HomePage() { const router = useRouter(); const t = useTranslations(); const { getImage } = useAssets(); + const [particles, setParticles] = useState([]); + const defiCardRef = useRef(null); + const particleIdRef = useRef(0); + const animationFrameRef = useRef(null); + const isHoveringRef = useRef(false); + const lastParticleTimeRef = useRef(0); const go = (link: string) => { window.open(link, '_blank'); @@ -25,6 +42,62 @@ export default function HomePage() { return getImage(`exchanges/${image}`); }; + // Particle animation loop for DeFi card + useEffect(() => { + const animate = () => { + const updateParticles = (prev: MoneyParticle[]) => { + return prev + .map((p) => ({ + ...p, + x: p.x + p.vx, + y: p.y + p.vy, + vy: p.vy + 0.5, // gravity + rotation: p.rotation + p.rotationSpeed, + })) + .filter((p) => p.y < window.innerHeight + 100); // remove off-screen particles + }; + + setParticles(updateParticles); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animate(); + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + // 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, + }; + + setParticles((prev) => [...prev, newParticle]); + + // Limit particles + if (particles.length > 40) { + setParticles((prev) => prev.slice(-40)); + } + }; + return (
{/* Feature 3: DeFi Made Simple */} -
+
{ isHoveringRef.current = true; }} + onMouseLeave={() => { isHoveringRef.current = false; }} + onMouseMove={handleMouseMove} + >
HBD DeFi
@@ -121,6 +200,29 @@ export default function HomePage() {
+ + {/* HBD Money Fountain Particles */} + {particles.map((particle) => ( +
+ HBD +
+ ))}
); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2977d86f25c1b57ee41b41ef28aa9cac9e2eb42a..9b056652aae43e357f18fa0e570781b525b4670a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -189,9 +189,11 @@ @keyframes fadeIn { from { opacity: 0; + transform: translateY(var(--y-pos, 0px)) translateX(50px); } to { opacity: 1; + transform: translateY(var(--y-pos, 0px)) translateX(0); } } @@ -205,7 +207,7 @@ } .animate-fade-in { - animation: fadeIn 0.3s ease-out forwards; + animation: fadeIn 0.5s ease-out; } .animate-scroll-left { diff --git a/apps/web/src/components/hero/DynamicHero.tsx b/apps/web/src/components/hero/DynamicHero.tsx index c534fc67eeaa433f74b09f5bf25bd8cc48855a76..5309803c86b20540b23ee863fd60cf6d705511bf 100644 --- a/apps/web/src/components/hero/DynamicHero.tsx +++ b/apps/web/src/components/hero/DynamicHero.tsx @@ -1,27 +1,117 @@ 'use client'; import { useBlockchainActivity } from '@/hooks/useBlockchainActivity'; -import { formatTimeAgo } from '@hiveio/hive-lib'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; export function DynamicHero() { const [isVisible, setIsVisible] = useState(true); const containerRef = useRef(null); - const { activities, currentBlock, transactionCount } = useBlockchainActivity({ - maxActivities: 4, - enabled: isVisible + const maxActivities = 4; // Change this to limit number of activities fetched + const LIMIT_TOTAL_ACTIVITIES = 0; // Set to 0 to disable - stops accepting new activities after this many + + const seenActivitiesRef = useRef>(new Set()); + const [shouldStopPolling, setShouldStopPolling] = useState(false); + + const { activities: hookActivities, currentBlock, transactionCount } = useBlockchainActivity({ + maxActivities, + enabled: true }); + const activities = LIMIT_TOTAL_ACTIVITIES > 0 + ? hookActivities.filter(activity => { + if (seenActivitiesRef.current.has(activity.id)) { + return true; // Keep already seen activities + } + if (seenActivitiesRef.current.size < LIMIT_TOTAL_ACTIVITIES) { + seenActivitiesRef.current.add(activity.id); + // Stop polling once we've reached the limit + if (seenActivitiesRef.current.size === LIMIT_TOTAL_ACTIVITIES) { + setShouldStopPolling(true); + } + return true; + } + return false; // Reject new activities once limit reached + }) + : hookActivities; + const [animatingIds, setAnimatingIds] = useState>(new Set()); + const [finishedAnimatingIds, setFinishedAnimatingIds] = useState>(new Set()); + const [queuedIds, setQueuedIds] = useState([]); + const [exitingActivities, setExitingActivities] = useState([]); + const [fadingOutIds, setFadingOutIds] = useState>(new Set()); const prevActivitiesRef = useRef>(new Set()); - const timeoutRef = useRef(null); + const prevActivitiesArrayRef = useRef([]); + const prevPositionsRef = useRef>(new Map()); + + // Handle animation completion + const handleAnimationEnd = useCallback((activityId: string, event: React.AnimationEvent) => { + if (event.animationName === 'fadeIn') { + console.log('✅ Animation complete:', activityId.substring(0, 10)); + + // Clear animating state + setAnimatingIds(new Set()); + + // Add to finished set (keep only last 5) + setFinishedAnimatingIds((prev) => { + const arr = [...prev, activityId]; + const updated = new Set(arr.slice(-5)); // Keep only last 5 + console.log(' Updated finishedAnimatingIds:', Array.from(updated).map(id => id.substring(0, 10))); + return updated; + }); + + // Remove from queue (next animation will be started by the queue effect) + setQueuedIds((prev) => { + const remaining = prev.slice(1); + console.log(' Queue remaining:', remaining.length); + return remaining; + }); + } + }, []); + + // Handle transition end for exit animations + const handleTransitionEnd = useCallback((activityId: string, event: React.TransitionEvent) => { + if (event.propertyName === 'opacity') { + // Check if this is an exiting activity + setExitingActivities((prev) => { + const isExiting = prev.some((a) => a.id === activityId); + if (isExiting) { + console.log('🚪 Exit complete:', activityId); + // Remove from exiting, fading, and finished + setFadingOutIds((prevFading) => { + const next = new Set(prevFading); + next.delete(activityId); + return next; + }); + setFinishedAnimatingIds((prevFinished) => { + const next = new Set(prevFinished); + next.delete(activityId); + return next; + }); + return prev.filter((a) => a.id !== activityId); + } + return prev; + }); + } + }, []); // Observe visibility of the container useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { - setIsVisible(entry.isIntersecting); + const wasVisible = isVisible; + const nowVisible = entry.isIntersecting; + setIsVisible(nowVisible); + + // If becoming visible after being hidden, reset animation state + if (!wasVisible && nowVisible) { + console.log('🔄 Visibility restored, clearing queue'); + setQueuedIds([]); + setAnimatingIds(new Set()); + setExitingActivities([]); + // Mark all current activities as finished + setFinishedAnimatingIds(new Set(activities.map((a) => a.id))); + } }, { threshold: 0.1 } // Trigger when at least 10% is visible ); @@ -35,7 +125,23 @@ export function DynamicHero() { observer.unobserve(containerRef.current); } }; - }, []); + }, [isVisible, activities]); + + // Handle tab visibility changes + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + console.log('🔄 Tab visible, clearing queue'); + setQueuedIds([]); + setAnimatingIds(new Set()); + setExitingActivities([]); + setFinishedAnimatingIds(new Set(activities.map((a) => a.id))); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [activities]); // Detect new activities and mark them for animation useEffect(() => { @@ -45,25 +151,42 @@ export function DynamicHero() { // Find truly new IDs that weren't in the previous set const newIds = Array.from(currentIds).filter((id) => !prevIds.has(id)); - // Update previous activities reference first - prevActivitiesRef.current = currentIds; + // Find IDs that are leaving (were in prev but not in current) + const exitIds = Array.from(prevIds).filter((id) => !currentIds.has(id)); - if (newIds.length > 0) { - // Clear any existing timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + if (exitIds.length > 0) { + console.log('🚪 Exiting:', exitIds); + // Get exiting activities from previous activities array (not current!) + const exitingActsList = prevActivitiesArrayRef.current.filter((a) => exitIds.includes(a.id)); + setExitingActivities(exitingActsList); - // Add new IDs to animating set - setAnimatingIds(new Set(newIds)); + // Trigger fade out after a tick to ensure transition happens + requestAnimationFrame(() => { + setFadingOutIds(new Set(exitIds)); + }); + } - // Remove animation class after animation completes (300ms) - timeoutRef.current = setTimeout(() => { - setAnimatingIds(new Set()); - }, 300); + if (newIds.length > 0) { + console.log('➕ Adding to queue:', newIds); + setQueuedIds((prev) => [...prev, ...newIds]); } + + // Update refs with current state + prevActivitiesRef.current = currentIds; + prevActivitiesArrayRef.current = activities; }, [activities]); + // Start first animation when queue goes from empty to having items + useEffect(() => { + if (queuedIds.length > 0 && animatingIds.size === 0) { + const firstId = queuedIds[0]; + console.log('🎬 Starting animation:', firstId); + console.log(' Current activities:', activities.map(a => a.id.substring(0, 10))); + console.log(' finishedAnimatingIds:', Array.from(finishedAnimatingIds).map(id => id.substring(0, 10))); + setAnimatingIds(new Set([firstId])); + } + }, [queuedIds, animatingIds, activities, finishedAnimatingIds]); + return (
{/* Main Headlines */} @@ -110,17 +233,59 @@ export function DynamicHero() {
{/* Live Activities Feed */} -
+
-
- {[0, 1, 2, 3].map((index) => { - const activity = activities[index]; - if (!activity) return null; +
+ {[...exitingActivities, ...activities].map((activity, index) => { const isAnimating = animatingIds.has(activity.id); + const hasFinishedAnimating = finishedAnimatingIds.has(activity.id); + const isExiting = exitingActivities.some((a) => a.id === activity.id); + + // Calculate position - animating items stay at top, exiting items keep their last position + const actualIndex = isAnimating + ? 0 // Always position animating items at top + : isExiting + ? prevPositionsRef.current.get(activity.id) !== undefined + ? prevPositionsRef.current.get(activity.id)! / 90 + : activities.length + : activities.findIndex((a) => a.id === activity.id); + const yPosition = actualIndex * 90; // 80px height + 10px gap + + // Set opacity and transform for animations + const baseTransform = `translateY(${yPosition}px)`; + const isFadingOut = fadingOutIds.has(activity.id); + + const style = isAnimating + ? { '--y-pos': `${yPosition}px`, top: 0, left: 0, right: 0 } as React.CSSProperties + : isExiting + ? { opacity: isFadingOut ? 0 : 1, transform: baseTransform, top: 0, left: 0, right: 0 } + : hasFinishedAnimating + ? { opacity: 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, + isExiting, + activitiesArray: activities.map(a => a.id.substring(0, 10)), + transform: isAnimating ? `var(--y-pos: ${yPosition}px)` : style.transform + }); + prevPositionsRef.current.set(activity.id, yPosition); + } + return (
handleAnimationEnd(activity.id, e)} + onTransitionEnd={(e) => handleTransitionEnd(activity.id, e)} >
{activity.avatarUrl ? ( @@ -136,9 +301,6 @@ export function DynamicHero() {

{activity.message}

-

- {formatTimeAgo(activity.timestamp)} -

{activity.txId && ( = ({ return (