From 0e49f0054f0b2578d91f2e68a00cf1a9a95a1423 Mon Sep 17 00:00:00 2001 From: acktarius Date: Tue, 17 Mar 2026 19:43:14 -0400 Subject: [PATCH 1/3] under the hood --- biome.json | 3 +- src/App.tsx | 109 +-- src/components/Footer.tsx | 71 +- src/components/Header.tsx | 645 ++++++++------- src/components/SplashScreen.tsx | 225 ++--- .../sections/CryptoWidgetSection.tsx | 307 ++++--- src/components/sections/FeaturesSection.tsx | 777 ++++++++---------- src/components/sections/LabsSection.tsx | 114 +-- src/components/sections/ManifestoSection.tsx | 19 +- src/components/sections/MarketsSection.tsx | 272 +++--- src/components/sections/MiningSection.tsx | 552 ++++++------- src/components/sections/RoadmapSection.tsx | 325 ++++---- src/components/sections/WalletsSection.tsx | 344 ++++---- src/components/ui/Button.tsx | 286 ++++--- src/components/ui/Carousel.tsx | 64 +- src/components/ui/LanguageSelector.tsx | 234 ++---- src/components/ui/MajorLinks.tsx | 212 ++--- src/components/ui/SocialMenu.tsx | 135 ++- src/theme.ts | 184 ++--- src/utils/scrollAnimations.ts | 121 ++- 20 files changed, 2243 insertions(+), 2756 deletions(-) diff --git a/biome.json b/biome.json index a1b0c23..6ea7aae 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,8 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": "warn" } }, "javascript": { diff --git a/src/App.tsx b/src/App.tsx index de6c58f..da2c50e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,102 +19,61 @@ interface AppProps { onReady?: () => void; } -function App({ onReady }: AppProps) { - const [isScrolledPastHero, setIsScrolledPastHero] = useState(false); - const heroSectionRef = useRef(null); - const location = useLocation(); +const doubleRAF = (cb: () => void) => requestAnimationFrame(() => requestAnimationFrame(cb)); - useEffect(() => { - const handleScroll = () => { - if (heroSectionRef.current) { - const heroBottom = heroSectionRef.current.offsetTop + heroSectionRef.current.offsetHeight; - const scrollY = window.scrollY; - setIsScrolledPastHero(scrollY > heroBottom); - } - }; - - window.addEventListener('scroll', handleScroll); - handleScroll(); // Check initial state +function scrollToWithOffset(element: Element) { + const top = element.getBoundingClientRect().top + window.pageYOffset - 100; + window.scrollTo({ top, behavior: 'smooth' }); +} - return () => { - window.removeEventListener('scroll', handleScroll); +function useHeroScroll(heroRef: React.RefObject) { + const [isPast, setIsPast] = useState(false); + useEffect(() => { + const check = () => { + if (!heroRef.current) return; + setIsPast(window.scrollY > heroRef.current.offsetTop + heroRef.current.offsetHeight); }; - }, []); + window.addEventListener('scroll', check); + check(); + return () => window.removeEventListener('scroll', check); + }, [heroRef]); + return isPast; +} - // Handle hash scrolling from navigation state and URL hash +function useHashScroll(location: ReturnType) { useEffect(() => { - // Only handle on main page if (location.pathname !== '/') return; - - // Check both navigation state and URL hash const state = location.state as { scrollToHash?: string } | null; - const hashFromState = state?.scrollToHash; - const hashFromUrl = location.hash; - - // Also check window.location.hash as fallback (for direct navigation) - const hashFromWindow = window.location.hash; - - const hash = hashFromState || hashFromUrl || hashFromWindow; + const hash = state?.scrollToHash || location.hash || window.location.hash; if (!hash) return; - - // Remove # if present and ensure it starts with # const cleanHash = hash.startsWith('#') ? hash : `#${hash}`; - - // Helper function to scroll to element with offset - const scrollToElement = (element: Element) => { - const elementTop = element.getBoundingClientRect().top + window.pageYOffset; - const offset = 100; // Offset for header - window.scrollTo({ - top: elementTop - offset, - behavior: 'smooth', - }); - }; - - // Helper function to execute after double RAF (ensures DOM is painted) - const executeAfterDoubleRAF = (callback: () => void) => { - requestAnimationFrame(() => { - requestAnimationFrame(callback); - }); - }; - - // Robust scrolling: retry until element exists or timeout - const maxAttempts = 50; // Increased attempts for slower renders let attempts = 0; - const tryScroll = () => { attempts++; - const element = document.querySelector(cleanHash); - - if (element) { - // Element found, scroll to it with slight offset for header - executeAfterDoubleRAF(() => scrollToElement(element)); + const el = document.querySelector(cleanHash); + if (el) { + doubleRAF(() => scrollToWithOffset(el)); return; } - - // Element not found yet, retry after a short delay - if (attempts < maxAttempts) { - setTimeout(tryScroll, appConfig.animations.scrollRetryDelayCrossPage); - } + if (attempts < 50) setTimeout(tryScroll, appConfig.animations.scrollRetryDelayCrossPage); }; - - // Start trying after a delay to allow DOM to render (longer delay for cross-page navigation) setTimeout(tryScroll, appConfig.animations.scrollInitialDelayCrossPage); }, [location.state, location.hash, location.pathname]); +} - // Signal that App is ready (after initial render) +function useAppReady(onReady?: () => void) { useEffect(() => { if (!onReady) return; - - // Helper function to execute after double RAF (ensures DOM is painted) - const executeAfterDoubleRAF = (callback: () => void) => { - requestAnimationFrame(() => { - requestAnimationFrame(callback); - }); - }; - - // Use requestAnimationFrame to ensure DOM is painted - executeAfterDoubleRAF(onReady); + doubleRAF(onReady); }, [onReady]); +} + +function App({ onReady }: AppProps) { + const heroSectionRef = useRef(null); + const location = useLocation(); + const isScrolledPastHero = useHeroScroll(heroSectionRef); + useHashScroll(location); + useAppReady(onReady); return (
= scrollHeight - margin; +} + +function useFooterState() { const [isExpanded, setIsExpanded] = useState(false); const [isPulling, setIsPulling] = useState(false); const [pullDistance, setPullDistance] = useState(0); const footerRef = useRef(null); - const startYRef = useRef(0); - const lastScrollTopRef = useRef(0); + const startYRef = useRef(0); + const lastScrollTopRef = useRef(0); useEffect(() => { const handleScroll = () => { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const scrollHeight = document.documentElement.scrollHeight; - const clientHeight = document.documentElement.clientHeight; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10; - - // Auto-expand when scrolled to bottom - if (isAtBottom && !isExpanded) { - setIsExpanded(true); - } - // Auto-collapse when scrolling up (if not at bottom) - else if (!isAtBottom && scrollTop < lastScrollTopRef.current && isExpanded) { + if (isPageAtBottom() && !isExpanded) setIsExpanded(true); + else if (!isPageAtBottom() && scrollTop < lastScrollTopRef.current && isExpanded) setIsExpanded(false); - } - lastScrollTopRef.current = scrollTop; }; - window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [isExpanded]); @@ -199,51 +192,45 @@ export function Footer() { useEffect(() => { const footer = footerRef.current; if (!footer) return; - - const handleTouchStart = (e: TouchEvent) => { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const scrollHeight = document.documentElement.scrollHeight; - const clientHeight = document.documentElement.clientHeight; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; - - if (isAtBottom) { + const onTouchStart = (e: TouchEvent) => { + if (isPageAtBottom(50)) { startYRef.current = e.touches[0].clientY; setIsPulling(true); } }; - - const handleTouchMove = (e: TouchEvent) => { + const onTouchMove = (e: TouchEvent) => { if (!isPulling) return; - - const currentY = e.touches[0].clientY; - const distance = Math.max(0, currentY - startYRef.current); - setPullDistance(Math.min(distance, 150)); // Max pull distance - + const distance = Math.max(0, e.touches[0].clientY - startYRef.current); + setPullDistance(Math.min(distance, 150)); if (distance > 50 && !isExpanded) { setIsExpanded(true); setIsPulling(false); setPullDistance(0); } }; - - const handleTouchEnd = () => { + const onTouchEnd = () => { if (isPulling) { setIsPulling(false); setPullDistance(0); } }; - - footer.addEventListener('touchstart', handleTouchStart); - footer.addEventListener('touchmove', handleTouchMove); - footer.addEventListener('touchend', handleTouchEnd); - + footer.addEventListener('touchstart', onTouchStart); + footer.addEventListener('touchmove', onTouchMove); + footer.addEventListener('touchend', onTouchEnd); return () => { - footer.removeEventListener('touchstart', handleTouchStart); - footer.removeEventListener('touchmove', handleTouchMove); - footer.removeEventListener('touchend', handleTouchEnd); + footer.removeEventListener('touchstart', onTouchStart); + footer.removeEventListener('touchmove', onTouchMove); + footer.removeEventListener('touchend', onTouchEnd); }; }, [isPulling, isExpanded]); + return { isExpanded, isPulling, pullDistance, footerRef }; +} + +export function Footer() { + const currentYear = new Date().getFullYear(); + const { isExpanded, isPulling, pullDistance, footerRef } = useFooterState(); + return (
(null); - const [dropdownWider, setDropdownWider] = useState<{ [key: string]: boolean }>({}); - const navRef = useRef(null); - const buttonRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}); - const dropdownRefs = useRef<{ [key: string]: HTMLUListElement | null }>({}); - - const handleLinkClick = (e: React.MouseEvent, href: string) => { - // Handle hash links (like /#mining, /#wallets) - if (href.startsWith('/#')) { - e.preventDefault(); - const hash = href.substring(1); // Get #mining, #wallets, etc. - - // If we're not on the main page, navigate to root first, then scroll - if (location.pathname !== '/') { - // Navigate to root with hash in state - navigate('/', { - state: { scrollToHash: hash }, - replace: false, - }); - } else { - // Already on main page, just scroll - const element = document.querySelector(hash); - if (element) { - requestAnimationFrame(() => { - const elementTop = element.getBoundingClientRect().top + window.pageYOffset; - const offset = 100; // Offset for header - window.scrollTo({ - top: elementTop - offset, - behavior: 'smooth', - }); - }); - } - } - } - // For regular links, let default behavior handle it - }; - - const handleNavItemClick = (label: string) => { - const newState = openDropdown === label ? null : label; - setOpenDropdown(newState); - - // Check widths after state update - if (newState) { - setTimeout(() => { - const button = buttonRefs.current[label]; - const dropdown = dropdownRefs.current[label]; - if (button && dropdown) { - const buttonWidth = button.offsetWidth; - const dropdownWidth = dropdown.scrollWidth; - setDropdownWider((prev) => ({ - ...prev, - [label]: dropdownWidth > buttonWidth, - })); - } - }, 10); - } - }; - - useEffect(() => { - // Close dropdowns when clicking outside - const handleClickOutside = (event: MouseEvent) => { - if (navRef.current && !navRef.current.contains(event.target as Node)) { - setOpenDropdown(null); - } - }; +type LinkClickHandler = (e: React.MouseEvent, href: string) => void; - if (openDropdown) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [openDropdown]); +const MOBILE_LINK_CLASS = + 'block px-4 py-2 text-white hover:text-[var(--color1)] rounded transition-colors duration-200'; +const MOBILE_LINK_STYLE = { '--hover-bg': 'var(--color1-bg-glow)' } as React.CSSProperties; +const onHoverEnter = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = 'var(--color1-bg-glow)'; +}; +const onHoverLeave = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = 'transparent'; +}; +function HamburgerButton({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { return ( -
{ + e.currentTarget.style.backgroundColor = 'var(--color1-bg-glow)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.7)'; + }} > - {/* Logo */} -
- - Conceal Network - -
+ + + + + ); +} - {/* Desktop Navigation */} - - - {/* Mobile Menu Toggle - Hamburger Button */} - + {child.label} + + + ))} + + )} +
+ ))} +
+ +
+ + + ); +} - {/* Mobile Menu - Slide-in Navigation */} +function MobileMenu({ + isOpen, + onClose, + handleLinkClick, +}: { + isOpen: boolean; + onClose: () => void; + handleLinkClick: LinkClickHandler; +}) { + return ( + <>
- {/* Close Button */} - - {/* Logo in Mobile Menu */}
Conceal Network
- {/* Navigation Items */} - - - {/* Language Selector in Mobile Menu */} +
- - {/* Mobile Menu Overlay */} - {isMobileMenuOpen && ( + {isOpen && ( )} + + ); +} + +const NAV_LINK_CLASS = + 'px-4 py-2 uppercase tracking-[0.1em] text-[var(--color1)] rounded-[0.75em] transition-all duration-500 hover:text-white'; + +interface DesktopNavProps { + openDropdown: string | null; + dropdownWider: Record; + buttonRefs: React.MutableRefObject>; + dropdownRefs: React.MutableRefObject>; + handleNavItemClick: (label: string) => void; + handleLinkClick: LinkClickHandler; +} + +function DesktopNav({ + openDropdown, + dropdownWider, + buttonRefs, + dropdownRefs, + handleNavItemClick, + handleLinkClick, +}: DesktopNavProps) { + return ( + + ); +} + +function useHeaderState( + navigate: ReturnType, + location: ReturnType +) { + const [openDropdown, setOpenDropdown] = useState(null); + const [dropdownWider, setDropdownWider] = useState>({}); + const navRef = useRef(null); + const buttonRefs = useRef>({}); + const dropdownRefs = useRef>({}); + + const handleLinkClick: LinkClickHandler = (e, href) => { + if (!href.startsWith('/#')) return; + e.preventDefault(); + const hash = href.substring(1); + if (location.pathname !== '/') { + navigate('/', { state: { scrollToHash: hash }, replace: false }); + return; + } + const el = document.querySelector(hash); + if (el) + requestAnimationFrame(() => + window.scrollTo({ + top: el.getBoundingClientRect().top + window.pageYOffset - 100, + behavior: 'smooth', + }) + ); + }; + + const handleNavItemClick = (label: string) => { + const next = openDropdown === label ? null : label; + setOpenDropdown(next); + if (next) + setTimeout(() => { + const btn = buttonRefs.current[label]; + const dd = dropdownRefs.current[label]; + if (btn && dd) + setDropdownWider((p) => ({ ...p, [label]: dd.scrollWidth > btn.offsetWidth })); + }, 10); + }; - {/* Language Selector - Far Right (Desktop Only) */} + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (navRef.current && !navRef.current.contains(e.target as Node)) setOpenDropdown(null); + }; + if (openDropdown) { + document.addEventListener('mousedown', onClick); + return () => document.removeEventListener('mousedown', onClick); + } + }, [openDropdown]); + + return { + openDropdown, + dropdownWider, + navRef, + buttonRefs, + dropdownRefs, + handleLinkClick, + handleNavItemClick, + }; +} + +export function Header({ isScrolledPastHero = false, forceBackground = null }: HeaderProps) { + const navigate = useNavigate(); + const location = useLocation(); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { + openDropdown, + dropdownWider, + navRef, + buttonRefs, + dropdownRefs, + handleLinkClick, + handleNavItemClick, + } = useHeaderState(navigate, location); + + const bgClass = + forceBackground === 'black' + ? 'bg-black' + : forceBackground === 'transparent' + ? 'bg-transparent' + : isScrolledPastHero + ? 'bg-[rgba(0,0,0,0.6)]' + : 'bg-transparent'; + + return ( +
+
+ + Conceal Network + +
+ + setIsMobileMenuOpen(!isMobileMenuOpen)} + /> + setIsMobileMenuOpen(false)} + handleLinkClick={handleLinkClick} + />
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 414d975..c7976ab 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -8,185 +8,122 @@ interface SplashScreenProps { waitForAppReady?: boolean; } -export function SplashScreen({ - onComplete, - showOnlyOnce = false, - waitForAppReady = false, -}: SplashScreenProps) { +function useSplashVisibility({ onComplete, showOnlyOnce, waitForAppReady }: SplashScreenProps) { const [isLoaded, setIsLoaded] = useState(false); const [isVisible, setIsVisible] = useState(true); useEffect(() => { - // Check if we should skip the splash screen if (showOnlyOnce && hasCookie('splash-shown')) { onComplete(); return; } - - // Minimum display time from config const minTime = appConfig.splash.minDisplayTime; const startTime = Date.now(); - - // Helper function to set cookie if needed - const handleCookieSetting = () => { - if (showOnlyOnce) { + const finish = () => { + if (showOnlyOnce) setCookie('splash-shown', 'true', hoursToMinutes(appConfig.cookies.splashScreenExpiration)); - } - }; - - // Helper function to complete splash screen (remove from DOM) - const completeSplash = () => { - handleCookieSetting(); onComplete(); }; - - // Helper function to trigger fade out animation - const triggerFadeOut = () => { + const fadeOut = () => { setIsVisible(false); - setTimeout(completeSplash, appConfig.splash.fadeOutDuration); + setTimeout(finish, appConfig.splash.fadeOutDuration); }; - - // Wait for window load AND minimum time AND app ready - const checkComplete = () => { - const elapsed = Date.now() - startTime; - const windowLoaded = document.readyState === 'complete'; - const isReady = windowLoaded && elapsed >= minTime && waitForAppReady; - - if (!isReady) { - // Check again at configured interval - setTimeout(checkComplete, appConfig.splash.checkInterval); - return; + const check = () => { + if ( + document.readyState === 'complete' && + Date.now() - startTime >= minTime && + waitForAppReady + ) { + setIsLoaded(true); + setTimeout(fadeOut, appConfig.splash.checkInterval); + } else { + setTimeout(check, appConfig.splash.checkInterval); } - - // All conditions met, start fade out - setIsLoaded(true); - setTimeout(triggerFadeOut, appConfig.splash.checkInterval); - }; - - // Start checking - setTimeout(checkComplete, appConfig.splash.checkInterval); - - // Also listen for window load event - const handleLoad = () => { - checkComplete(); }; - + setTimeout(check, appConfig.splash.checkInterval); if (document.readyState === 'complete') { - checkComplete(); - } else { - window.addEventListener('load', handleLoad); - return () => window.removeEventListener('load', handleLoad); + check(); + return; } + window.addEventListener('load', check); + return () => window.removeEventListener('load', check); }, [onComplete, showOnlyOnce, waitForAppReady]); - if (!isVisible) { - return null; + return { isLoaded, isVisible }; +} + +function SpinnerRings() { + return ( +
+
+
+
+
+ ); +} + +const SPLASH_STYLES = ` + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + @keyframes glowPulse1 { + 0%, 100% { filter: drop-shadow(0 0 8px var(--color1)) drop-shadow(0 0 12px var(--color1)); opacity: 1; } + 50% { filter: drop-shadow(0 0 16px var(--color1)) drop-shadow(0 0 24px var(--color1)) drop-shadow(0 0 32px var(--color1)); opacity: 0.9; } } + @keyframes glowPulse2 { + 0%, 100% { filter: drop-shadow(0 0 8px var(--color2)) drop-shadow(0 0 12px var(--color2)); opacity: 1; } + 50% { filter: drop-shadow(0 0 16px var(--color2)) drop-shadow(0 0 24px var(--color2)) drop-shadow(0 0 32px var(--color2)); opacity: 0.9; } + } + @keyframes glowPulse3 { + 0%, 100% { filter: drop-shadow(0 0 8px var(--color1)) drop-shadow(0 0 12px var(--color1)); opacity: 1; } + 50% { filter: drop-shadow(0 0 16px var(--color1)) drop-shadow(0 0 24px var(--color1)) drop-shadow(0 0 32px var(--color1)); opacity: 0.9; } + } +`; +export function SplashScreen(props: SplashScreenProps) { + const { isLoaded, isVisible } = useSplashVisibility(props); + if (!isVisible) return null; return (
- {/* Background pattern layer (b element equivalent) */}
- - {/* Black transparent overlay layer */} -
- - {/* Spinner loader */} -
- {/* Outer circle */} -
- - {/* Middle circle */} -
- - {/* Inner circle */} -
-
- - {/* Spin and glow animation keyframes */} - +
+ +
); } diff --git a/src/components/sections/CryptoWidgetSection.tsx b/src/components/sections/CryptoWidgetSection.tsx index 87f7d60..59e8923 100644 --- a/src/components/sections/CryptoWidgetSection.tsx +++ b/src/components/sections/CryptoWidgetSection.tsx @@ -7,194 +7,163 @@ interface PriceData { source: 'coingecko' | 'coinpaprika'; } +const JSON_HEADERS = { mode: 'cors' as const, headers: { Accept: 'application/json' } }; + +async function fetchFromCoinGecko(): Promise { + for (const coinId of ['conceal', 'conceal-network']) { + try { + const response = await fetch( + `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd&include_24hr_change=true`, + JSON_HEADERS + ); + if (response.status === 429) { + console.warn('CoinGecko rate limited, trying fallback...'); + break; + } + if (response.ok) { + const data = await response.json(); + if (data[coinId]) { + return { + usd: data[coinId].usd, + usd_24h_change: data[coinId].usd_24h_change || 0, + source: 'coingecko', + }; + } + } + } catch (err) { + console.warn(`CoinGecko failed for ${coinId}:`, err); + } + } + return null; +} + +async function fetchFromCoinPaprika(): Promise { + try { + const response = await fetch( + 'https://api.coinpaprika.com/v1/tickers/ccx-conceal', + JSON_HEADERS + ); + if (response.ok) { + const data = await response.json(); + if (data.quotes?.USD) { + return { + usd: data.quotes.USD.price, + usd_24h_change: data.quotes.USD.percent_change_24h || 0, + source: 'coinpaprika', + }; + } + } + } catch (err) { + console.error('CoinPaprika API failed:', err); + } + return null; +} + +async function fetchCCXPrice(): Promise { + return (await fetchFromCoinGecko()) ?? (await fetchFromCoinPaprika()); +} + +const SECTION_STYLE = { + background: + 'linear-gradient(to bottom, rgba(34,34,34,1) 0%, rgba(34,34,34,0) 30%, rgba(10,10,10,0.3) 30%, rgba(10,10,10,1) 100%)', +}; +const SECTION_CLASS = + 'py-16 px-4 bg-[var(--color-bg-primary)] border-b border-[rgba(255,255,255,0.2)]'; + +function WidgetSection({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function PriceDisplay({ price }: { price: PriceData }) { + const isPositive = price.usd_24h_change >= 0; + return ( +
+
+

Conceal Network (CCX)

+
+ $ + {price.usd.toLocaleString(undefined, { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + })}{' '} + USD +
+
+
+ {isPositive ? '+' : ''} + {price.usd_24h_change.toFixed(2)}% (24h) +
+
+ Data provided by + {price.source === 'coingecko' ? ( + + CoinGecko + + ) : ( + + CoinPaprika{' '} + CoinPaprika + + )} +
+
+ ); +} + export function CryptoWidgetSection() { const [price, setPrice] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); useEffect(() => { - // Fetch price from CoinGecko API (primary) or CoinPaprika (fallback) - const fetchPrice = async () => { + const load = async () => { try { - // Try CoinGecko first - const coinIds = ['conceal', 'conceal-network']; - let coingeckoSuccess = false; - - for (const coinId of coinIds) { - try { - const response = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd&include_24hr_change=true`, - { - mode: 'cors', - headers: { - Accept: 'application/json', - }, - } - ); - - if (response.ok) { - const data = await response.json(); - if (data[coinId]) { - setPrice({ - usd: data[coinId].usd, - usd_24h_change: data[coinId].usd_24h_change || 0, - source: 'coingecko', - }); - setLoading(false); - setError(false); - coingeckoSuccess = true; - return; - } - } else if (response.status === 429) { - // Rate limited - will try fallback - console.warn('CoinGecko API rate limited, trying fallback...'); - break; - } - } catch (err) { - // CORS or network error - will try fallback - console.warn(`CoinGecko failed for ${coinId}, trying fallback...`, err); - } - } - - // If CoinGecko failed, try CoinPaprika as fallback - if (!coingeckoSuccess) { - try { - // CoinPaprika API - ticker ID is ccx-conceal - const response = await fetch('https://api.coinpaprika.com/v1/tickers/ccx-conceal', { - mode: 'cors', - headers: { - Accept: 'application/json', - }, - }); - - if (response.ok) { - const data = await response.json(); - if (data.quotes?.USD) { - setPrice({ - usd: data.quotes.USD.price, - usd_24h_change: data.quotes.USD.percent_change_24h || 0, - source: 'coinpaprika', - }); - setLoading(false); - setError(false); - return; - } - } - } catch (err) { - console.error('CoinPaprika API failed:', err); - } - } - - // If both APIs failed, set error - if (!coingeckoSuccess) { - setError(true); - setLoading(false); - } + const data = await fetchCCXPrice(); + if (data) { + setPrice(data); + setError(false); + } else setError(true); } catch (err) { - console.error('Error in fetchPrice:', err); + console.error('Error fetching price:', err); setError(true); + } finally { setLoading(false); } }; - - fetchPrice(); - - // Update price at configured interval - const interval = setInterval(fetchPrice, appConfig.refresh.cryptoPriceInterval); - + load(); + const interval = setInterval(load, appConfig.refresh.cryptoPriceInterval); return () => clearInterval(interval); }, []); - if (loading) { + if (loading) return ( -
-
-
Loading price...
-
-
+ +
Loading price...
+
); - } - - if (error || !price) { + if (error || !price) return ( -
-
-
Price data unavailable
-
-
+ +
Price data unavailable
+
); - } - - const isPositive = price.usd_24h_change >= 0; - const changeColor = isPositive ? 'text-green-500' : 'text-red-500'; - return ( -
-
-
-
-

- Conceal Network (CCX) -

-
- $ - {price.usd.toLocaleString(undefined, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - })}{' '} - USD -
-
-
- {isPositive ? '+' : ''} - {price.usd_24h_change.toFixed(2)}% (24h) -
-
- Data provided by - {price.source === 'coingecko' ? ( - - CoinGecko - - ) : ( - - CoinPaprika{' '} - CoinPaprika - - )} -
-
-
-
+ + + ); } diff --git a/src/components/sections/FeaturesSection.tsx b/src/components/sections/FeaturesSection.tsx index cbd6cfd..291fdd4 100644 --- a/src/components/sections/FeaturesSection.tsx +++ b/src/components/sections/FeaturesSection.tsx @@ -31,459 +31,374 @@ interface TableRow { tier3Total: number; } -export function FeaturesSection() { - const [principal, setPrincipal] = useState(20000); - const [months, setMonths] = useState(12); - const [tableRows, setTableRows] = useState([]); - - const nTEA = getTEA(principal, months); - const nProfit = nTEA - principal; +const TIERS = [ + { + label: 'Tier 1', + principal: 'Under 10,000 CCX', + apr: '2.90%', + maxInterest: '4.00%', + example: '5,000 CCX', + }, + { + label: 'Tier 2', + principal: '10,000 - 19,999 CCX', + apr: '3.90%', + maxInterest: '5.00%', + example: '10,000 CCX', + }, + { + label: 'Tier 3', + principal: 'Over 20,000 CCX', + apr: '4.90%', + maxInterest: '6.00%', + example: '20,000 CCX', + }, +]; +function useInterestTable() { + const [tableRows, setTableRows] = useState([]); useEffect(() => { - // Populate table const rows: TableRow[] = []; - const nTier1Mid = 5000; - const nTier2 = 10000; - const nTier3 = 20000; - - for (let nCurrentMonth = 1; nCurrentMonth < 13; nCurrentMonth++) { + for (let m = 1; m < 13; m++) { rows.push({ - month: nCurrentMonth, - tier1Interest: getEIR(nTier1Mid, nCurrentMonth) * 100, - tier1Total: getTEA(nTier1Mid, nCurrentMonth), - tier2Interest: getEIR(nTier2, nCurrentMonth) * 100, - tier2Total: getTEA(nTier2, nCurrentMonth), - tier3Interest: getEIR(nTier3, nCurrentMonth) * 100, - tier3Total: getTEA(nTier3, nCurrentMonth), + month: m, + tier1Interest: getEIR(5000, m) * 100, + tier1Total: getTEA(5000, m), + tier2Interest: getEIR(10000, m) * 100, + tier2Total: getTEA(10000, m), + tier3Interest: getEIR(20000, m) * 100, + tier3Total: getTEA(20000, m), }); } setTableRows(rows); }, []); + return tableRows; +} - const handlePrincipalChange = (e: React.ChangeEvent) => { - let value = parseInt(e.target.value, 10); - if (value > 9999999) { - value = 9999999; - } else if (value < 1 || Number.isNaN(value)) { - value = 1; - } - setPrincipal(value); - }; +function clampInt(value: number, min: number, max: number) { + if (Number.isNaN(value) || value < min) return min; + return value > max ? max : value; +} - const handleMonthsChange = (e: React.ChangeEvent) => { - let value = parseInt(e.target.value, 10); - if (value > 12) { - value = 12; - } else if (value < 1 || Number.isNaN(value)) { - value = 1; - } - setMonths(value); - }; +const FMT = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; +const CELL_CLASS = 'border border-[#444] p-2 text-center text-[white]'; - return ( -
-
- We are about} title={PRIVACY!} /> -
-
- - Conceal Network chameleon mascot - -
+const TIER_DATA = [ + { + label: 'tier1', + mult: 2.9, + rate: (r: TableRow) => r.tier1Interest, + total: (r: TableRow) => r.tier1Total, + }, + { + label: 'tier2', + mult: 3.9, + rate: (r: TableRow) => r.tier2Interest, + total: (r: TableRow) => r.tier2Total, + }, + { + label: 'tier3', + mult: 4.9, + rate: (r: TableRow) => r.tier3Interest, + total: (r: TableRow) => r.tier3Total, + }, +]; -
-

- Conceal Network is a secure peer-to-peer privacy framework empowering individuals and - organizations to anonymously communicate and interact financially in a decentralized - and censorship resistant environment. -

-

- Conceal Network powers the $CCX cryptocurrency which is an open source, privacy - protected digital cash system that mimics physical cash; nobody knows where you store - or spend your $CCX. All transactions, deposits and messages on Conceal Network are - untraceable, tamperproof and operate with no central authority through the use of - cryptographic protocols, which makes the chameleon a mascot of choice. -

-

- Conceal Network is a community driven, truly decentralized blockchain bank accessible - to everyone regardless of social or financial status and geographic location. No one - owns Conceal Network and everyone can participate for free. -

-
-
+function InterestTableDesktop({ rows }: { rows: TableRow[] }) { + return ( +
+ + + + + {TIERS.map((t) => ( + + ))} + + + + {TIERS.map((t) => ( + + ))} + + + + {TIERS.map((t) => ( + + ))} + + + + {TIERS.map((t) => ( + + ))} + + + + {TIERS.map((t) => ( + + ))} + + + + {TIER_DATA.map((t) => + ['Interest', 'Total'].map((h) => ( + + )) + )} + + + + {rows.map((row) => ( + + + {TIER_DATA.map(({ label, mult, rate, total }) => [ + , + , + ])} + + ))} + +
+ Compound Level + + {t.label} +
+ Principal + + {t.principal} +
+ Base/APR + + {t.apr} +
+ Example + + {t.example} +
+ Maximum Interest + + {t.maxInterest} +
+ Duration: Months + + {h} +
{row.month} + {rate(row).toFixed(2)}% + + {total(row).toLocaleString(undefined, FMT)} +
+
+ ); +} -
-
-
- -

- Banking: Conceal-Earn -

-
-

- Deposits form the backbone of the Conceal ecosystem, providing users with a - decentralized and egalitarian form of cold staking that earns interests on locked - deposits. -

+function InterestTableMobile({ rows }: { rows: TableRow[] }) { + return ( +
+ {TIERS.map((tier, i) => ( +
+

{tier.label}

+
+ {(['principal', 'apr', 'maxInterest', 'example'] as const).map((k) => ( +
+ + {k === 'maxInterest' ? 'Max Interest' : k.charAt(0).toUpperCase() + k.slice(1)}: + + {tier[k]} +
+ ))}
-
-
- -

- Encrypted Messages -

+
+
Sample Returns (First 6 months):
+
+ {rows.slice(0, 6).map((row) => { + const { rate, total } = TIER_DATA[i]; + return ( +
+ Month {row.month}: +
+ {rate(row).toFixed(2)}% + + {total(row).toLocaleString(undefined, FMT)} + +
+
+ ); + })}
-

- A truly private, decentralized, anonymous, untraceable, and end-to-end encrypted - messaging service that operates on the blockchain while allowing messages that - self-destruct. -

+ ))} +
+

Scroll horizontally on desktop to see full table with all months

+
+
+ ); +} -
-

+
+
+ + Conceal Network chameleon mascot + +
+
+

- Compound Interest Calculator -

- -
- - -
- -
- Total:{' '} - - {nTEA.toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - })} - {' '} - CCX Profit:{' '} - - {nProfit.toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - })} - {' '} - CCX + Conceal Network is a secure peer-to-peer privacy framework empowering individuals and + organizations to anonymously communicate and interact financially in a decentralized and + censorship resistant environment. +

+

+ Conceal Network powers the $CCX cryptocurrency which is an open source, privacy + protected digital cash system that mimics physical cash; nobody knows where you store or + spend your $CCX. All transactions, deposits and messages on Conceal Network are + untraceable, tamperproof and operate with no central authority through the use of + cryptographic protocols, which makes the chameleon a mascot of choice. +

+

+ Conceal Network is a community driven, truly decentralized blockchain bank accessible to + everyone regardless of social or financial status and geographic location. No one owns + Conceal Network and everyone can participate for free. +

+
+
+
+
+
+ +

+ Banking: Conceal-Earn +

+

+ Deposits form the backbone of the Conceal ecosystem, providing users with a + decentralized and egalitarian form of cold staking that earns interests on locked + deposits. +

+
+
+
+ +

+ Encrypted Messages +

+
+

+ A truly private, decentralized, anonymous, untraceable, and end-to-end encrypted + messaging service that operates on the blockchain while allowing messages that + self-destruct. +

+
+
+ + ); +} -
- {/* Desktop Table View */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {tableRows.map((row) => ( - - - - - - - - - - ))} - -
- Compound Level - - Tier 1 - - Tier 2 - - Tier 3 -
- Principal - - Under 10,000 CCX - - 10,000 - 19,999 CCX - - Over 20,000 CCX -
- Base/APR - - 2.90% - - 3.90% - - 4.90% -
- Example - - 5,000 CCX - - 10,000 CCX - - 20,000 CCX -
- Maximum Interest - - 4.00% - - 5.00% - - 6.00% -
- Duration: Months - - Interest - - Total - - Interest - - Total - - Interest - - Total -
{row.month} - {row.tier1Interest.toFixed(2)}% - - {row.tier1Total.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - {row.tier2Interest.toFixed(2)}% - - {row.tier2Total.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - {row.tier3Interest.toFixed(2)}% - - {row.tier3Total.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
-
+function InterestCalculator() { + const [principal, setPrincipal] = useState(20000); + const [months, setMonths] = useState(12); + const tableRows = useInterestTable(); + const nTEA = getTEA(principal, months); + const nProfit = nTEA - principal; + const FMT2 = { minimumFractionDigits: 0, maximumFractionDigits: 2 }; + return ( + +

+ Compound Interest Calculator +

+
+ + +
+
+ Total: {nTEA.toLocaleString(undefined, FMT2)} CCX{' '} + Profit: {nProfit.toLocaleString(undefined, FMT2)} CCX +
+
+ + +
+ + ); +} - {/* Mobile Card View - Simplified Tier Cards */} -
- {[ - { - tier: 'Tier 1', - principal: 'Under 10,000 CCX', - apr: '2.90%', - maxInterest: '4.00%', - example: '5,000 CCX', - }, - { - tier: 'Tier 2', - principal: '10,000 - 19,999 CCX', - apr: '3.90%', - maxInterest: '5.00%', - example: '10,000 CCX', - }, - { - tier: 'Tier 3', - principal: 'Over 20,000 CCX', - apr: '4.90%', - maxInterest: '6.00%', - example: '20,000 CCX', - }, - ].map((tierInfo, tierIndex) => ( -
-

- {tierInfo.tier} -

-
-
- Principal: - {tierInfo.principal} -
-
- Base/APR: - {tierInfo.apr} -
-
- Max Interest: - {tierInfo.maxInterest} -
-
- Example: - {tierInfo.example} -
-
-
-
- Sample Returns (First 6 months): -
-
- {tableRows.slice(0, 6).map((row) => { - const interest = - tierIndex === 0 - ? row.tier1Interest - : tierIndex === 1 - ? row.tier2Interest - : row.tier3Interest; - const total = - tierIndex === 0 - ? row.tier1Total - : tierIndex === 1 - ? row.tier2Total - : row.tier3Total; - return ( -
- Month {row.month}: -
- {interest.toFixed(2)}% - - {total.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - -
-
- ); - })} -
-
-
- ))} -
-

Scroll horizontally on desktop to see full table with all months

-
-
-
- +export function FeaturesSection() { + return ( +
+
+ We are about} title={PRIVACY!} /> + +
); diff --git a/src/components/sections/LabsSection.tsx b/src/components/sections/LabsSection.tsx index 21995ad..1f676b5 100644 --- a/src/components/sections/LabsSection.tsx +++ b/src/components/sections/LabsSection.tsx @@ -83,72 +83,31 @@ const labsProjects: LabProject[] = [ }, ]; -function ProjectCard({ project }: { project: LabProject }) { - // Generate image paths from folder - const imagePattern = project.imagePattern || 'assistant'; - const imageCount = project.imageCount || 5; - const imageExtension = project.imageExtension || 'png'; - const carouselImages = loadImagesWithPattern( - project.linktoscreenshotfolder, - imagePattern, - imageCount, - imageExtension - ); +const LINK_CLASS = 'text-[var(--color1)] hover:underline'; - // Split description by newlines - const descriptionParagraphs = project.description.split('\n').filter((p) => p.trim()); - - // Get appropriate icon for download type - const getDownloadIcon = (downloadType: DownloadType): string => { - switch (downloadType) { - case 'zip': - return 'fas fa-file-zipper'; - case 'apk': - return 'fab fa-android'; - case 'f-droid': - return 'fab fa-android'; - case 'deb': - return 'fab fa-ubuntu'; - case 'exe': - return 'fab fa-windows'; - case 'dmg': - case 'app': - case 'apple-store': - return 'fab fa-apple'; - default: - return 'fas fa-download'; - } - }; +function getDownloadIcon(type: DownloadType): string { + if (type === 'deb') return 'fab fa-ubuntu'; + if (type === 'exe') return 'fab fa-windows'; + if (type === 'dmg' || type === 'app' || type === 'apple-store') return 'fab fa-apple'; + if (type === 'apk' || type === 'f-droid') return 'fab fa-android'; + if (type === 'zip') return 'fas fa-file-zipper'; + return 'fas fa-download'; +} - // Get appropriate icon for document type - const getDocumentIcon = (documentType: LabProject['documenttype']): string => { - switch (documentType) { - case 'pdf': - return 'fas fa-file-pdf'; - case 'wiki': - return 'fab fa-wikipedia-w'; - default: - return 'fas fa-file'; - } - }; +function getDocumentIcon(type: LabProject['documenttype']): string { + if (type === 'pdf') return 'fas fa-file-pdf'; + if (type === 'wiki') return 'fab fa-wikipedia-w'; + return 'fas fa-file'; +} +function ProjectLinks({ project }: { project: LabProject }) { return ( -
- -

{project.title}

-
- - {descriptionParagraphs.map((paragraph) => ( - -

{paragraph}

-
- ))} - + <>

@@ -157,13 +116,12 @@ function ProjectCard({ project }: { project: LabProject }) {

- {project.documentlink && project.labeltodocument && (

@@ -177,15 +135,10 @@ function ProjectCard({ project }: { project: LabProject }) {

{project.labelToDownload}{' '} - {project.downloads.map(([url, type], index) => ( + {project.downloads.map(([url, type], i) => ( - {index > 0 && ' - '} - + {i > 0 && ' - '} + {type} @@ -194,11 +147,34 @@ function ProjectCard({ project }: { project: LabProject }) {

)} + + ); +} +function ProjectCard({ project }: { project: LabProject }) { + const carouselImages = loadImagesWithPattern( + project.linktoscreenshotfolder, + project.imagePattern || 'assistant', + project.imageCount || 5, + project.imageExtension || 'png' + ); + return ( +
+ +

{project.title}

+
+ {project.description + .split('\n') + .filter((p) => p.trim()) + .map((paragraph) => ( + +

{paragraph}

+
+ ))} +

How it looks...

- diff --git a/src/components/sections/ManifestoSection.tsx b/src/components/sections/ManifestoSection.tsx index 994fd6c..16a56f3 100644 --- a/src/components/sections/ManifestoSection.tsx +++ b/src/components/sections/ManifestoSection.tsx @@ -78,16 +78,15 @@ function ManifestoText({ content }: { content: string }) { const parts = line.split(/(\*\*[^*]+\*\*)/g); return (

- {parts.map((part) => { - if (part.startsWith('**') && part.endsWith('**')) { - return ( - - {part.slice(2, -2)} - - ); - } - return {part}; - })} + {parts.map((part) => + part.startsWith('**') && part.endsWith('**') ? ( + + {part.slice(2, -2)} + + ) : ( + part + ) + )}

); }) diff --git a/src/components/sections/MarketsSection.tsx b/src/components/sections/MarketsSection.tsx index 4edb65d..89d4c1a 100644 --- a/src/components/sections/MarketsSection.tsx +++ b/src/components/sections/MarketsSection.tsx @@ -5,169 +5,159 @@ const exchanges = [ { label: 'nonKYC BTC/CCX', href: 'https://nonkyc.io/market/CCX_BTC' }, { label: 'nonKYC USDT/CCX', href: 'https://nonkyc.io/market/CCX_USDT' }, { label: 'Nonlogs USDT/CCX', href: 'https://nonlogs.io/trade/CCX-USDT' }, - // { label: 'AnonEx USDT/CCX', href: 'https://anonex.io/market/CCX_USDT' }, ]; +const SECTION_CLASS = + 'py-16 px-4 bg-[var(--color-bg-primary)] border-b border-[rgba(255,255,255,0.2)]'; +const SECTION_STYLE = { + background: + 'linear-gradient(to bottom, rgba(34,34,34,1) 0%, rgba(34,34,34,0) 30%, rgba(10,10,10,0.3) 30%, rgba(10,10,10,1) 100%)', +}; +const MORE_LINK = 'https://conceal.network/community/#exchanges'; +const LINK_CLASS = + 'text-[var(--color1)] hover:text-[#fafafa] transition-colors inline-flex items-center gap-2'; + +function MarketSection({ + id, + subtitle, + title, + children, + showMore = false, +}: { + id: string; + subtitle: React.ReactNode; + title: string; + children: React.ReactNode; + showMore?: boolean; +}) { + return ( +
+
+ +
{children}
+ {showMore && ( + + + More + + )} +
+
+ ); +} + +function ExchangeButton({ href, label }: { href: string; label: string }) { + return ( + + ); +} + export function MarketsSection() { return ( <> -
Buy CCX} + title="Markets" > -
- Buy CCX} title="Markets" /> -
- {exchanges.map((exchange) => ( - - ))} -
-
-
+ {exchanges.map((e) => ( + + ))} + -
Buy wCCX} + title="Polygon" > -
- Buy wCCX} title="Polygon" /> - -
-
+ + + -
Buy wCCX} + title="Binance Smart Chain" + showMore > -
- Buy wCCX} - title="Binance Smart Chain" - /> - + + +
-
+ + + + -
Buy wCCX} + title="Ethereum" + showMore > -
- Buy wCCX} title="Ethereum" /> - + +
-
+ + ); } diff --git a/src/components/sections/MiningSection.tsx b/src/components/sections/MiningSection.tsx index 1a3e41c..f42840f 100644 --- a/src/components/sections/MiningSection.tsx +++ b/src/components/sections/MiningSection.tsx @@ -18,7 +18,7 @@ interface PoolData { }; } -const numberFormatter = new Intl.NumberFormat('en-US'); // US formatting, force commas +const numberFormatter = new Intl.NumberFormat('en-US'); function localizeNumber(number: number): string { return numberFormatter.format(number); @@ -40,12 +40,8 @@ function getPoolName(data: PoolData): string { return index < 0 ? host : host.slice(0, index); } -// Get the API URL based on environment -// On production (conceal.network), GitHub Pages, and Handshake domains, use direct API (CORS allowed) -// On Netlify staging, use proxy function getPoolsApiUrl(): string { const hostname = window.location.hostname; - // If on production domain, GitHub Pages, or Handshake domain, use direct API (CORS enabled) if ( hostname === 'conceal.network' || hostname === 'www.conceal.network' || @@ -55,128 +51,203 @@ function getPoolsApiUrl(): string { ) { return 'https://explorer.conceal.network/services/pools/data'; } - // Otherwise use proxy (Netlify staging) return '/api/pools'; } -export function MiningSection() { +async function fetchViaProxy(apiUrl: string): Promise { + const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(apiUrl)}`; + const proxyResponse = await fetch(proxyUrl); + if (!proxyResponse.ok) throw new Error(`Proxy error! status: ${proxyResponse.status}`); + const proxyData = await proxyResponse.json(); + return JSON.parse(proxyData.contents); +} + +async function fetchPoolData(apiUrl: string): Promise { + try { + const response = await fetch(apiUrl); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return await response.json(); + } catch (corsError) { + if (apiUrl.startsWith('https://')) return await fetchViaProxy(apiUrl); + throw corsError; + } +} + +function usePools(sectionRef: React.RefObject) { const [pools, setPools] = useState([]); const [isLoading, setIsLoading] = useState(false); - const sectionRef = useRef(null); - const hasLoadedPools = useRef(false); // Use a ref to prevent re-fetching + const hasLoadedPools = useRef(false); useEffect(() => { - const loadPools = async () => { - if (hasLoadedPools.current) return; // Already loaded, do nothing - - // Helper function to process and set pool data - const processPoolData = (data: PoolData[]) => { + const handleIntersection = async (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (!entry.isIntersecting || isLoading || hasLoadedPools.current) return; + setIsLoading(true); + try { + const data = await fetchPoolData(getPoolsApiUrl()); data.sort((a, b) => a.config.poolFee - b.config.poolFee); setPools(data); hasLoadedPools.current = true; - }; - - // Helper function to fetch via CORS proxy - const fetchViaProxy = async (apiUrl: string): Promise => { - const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(apiUrl)}`; - const proxyResponse = await fetch(proxyUrl); - - if (!proxyResponse.ok) { - throw new Error(`Proxy error! status: ${proxyResponse.status}`); - } - - const proxyData = await proxyResponse.json(); - return JSON.parse(proxyData.contents); - }; - - // Helper function to fetch pool data (direct or via proxy) - const fetchPoolData = async (apiUrl: string): Promise => { - try { - // Try direct API first - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return await response.json(); - } catch (corsError) { - // If CORS fails and URL is HTTPS, try proxy - if (apiUrl.startsWith('https://')) { - return await fetchViaProxy(apiUrl); - } - throw corsError; - } - }; - - // Helper function to load pools when section is visible - const handleIntersection = async (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - const shouldLoad = entry.isIntersecting && !isLoading && !hasLoadedPools.current; + } catch (error) { + console.error('Failed to fetch pool data:', error); + } finally { + setIsLoading(false); + observer.disconnect(); + } + }; - if (!shouldLoad) return; + const observer = new IntersectionObserver(handleIntersection, { threshold: 0.1 }); + if (sectionRef.current) observer.observe(sectionRef.current); + return () => observer.disconnect(); + }, [isLoading, sectionRef]); - setIsLoading(true); - try { - const apiUrl = getPoolsApiUrl(); - const data = await fetchPoolData(apiUrl); - processPoolData(data); - } catch (error) { - console.error('Failed to fetch pool data:', error); - } finally { - setIsLoading(false); - observer.disconnect(); - } - }; + return { pools, isLoading }; +} - // Check if the section is visible - const observer = new IntersectionObserver(handleIntersection, { - threshold: 0.1, // Trigger when 10% of the section is visible - }); +const CELL = 'border border-[#444] p-2 text-center text-[#757575]'; +const LINK = 'text-[var(--color1)] hover:text-[#fafafa] transition-colors'; - if (sectionRef.current) { - observer.observe(sectionRef.current); - } +function PoolRow({ pool }: { pool: PoolData }) { + const poolName = getPoolName(pool); + return ( + + + + {pool.info.name} + + + {pool.network.height.toLocaleString()} + {pool.config.poolFee}% + {getReadableHashRateString(pool.pool.hashrate)} + {pool.pool.miners.toLocaleString()} + + ); +} - return () => { - observer.disconnect(); - }; - }; +function PoolCard({ pool }: { pool: PoolData }) { + const poolName = getPoolName(pool); + return ( +
+ +
+
+ + Height: + + {pool.network.height.toLocaleString()} +
+
+ + Fee: + + {pool.config.poolFee}% +
+
+ + Hashrate: + + {getReadableHashRateString(pool.pool.hashrate)} +
+
+ + Miners: + + {pool.pool.miners.toLocaleString()} +
+
+
+ ); +} - loadPools(); - }, [isLoading]); +function PoolsTable({ pools, isLoading }: { pools: PoolData[]; isLoading: boolean }) { + const placeholder = (colSpan: number, tag: 'td' | 'div') => + isLoading ? ( + tag === 'td' ? ( + + + Loading pools... + + + ) : ( +
+ Loading pools... +
+ ) + ) : pools.length === 0 ? ( + tag === 'td' ? ( + + + No pools available + + + ) : ( +
+ No pools available +
+ ) + ) : null; return ( -
-
- Getting CCX} - title={Mining} - /> +
+

+ Mining Pools +

+
+ + + + {[ + ['fa-server', 'Pools'], + ['fa-th-large', 'Height'], + ['fa-coins', 'Fee'], + ['fa-tachometer-alt', 'Hashrate'], + ['fa-users-cog', 'Miners'], + ].map(([icon, label]) => ( + + ))} + + + + {placeholder(5, 'td')} + {pools.map((pool) => ( + + ))} + +
+ + {label} +
+
+
+ {placeholder(0, 'div')} + {pools.map((pool) => ( + + ))} +
+
+ ); +} -

- Quick Start -

-
-
-

- - XMRStak - -

-
-              {`"pool_list": [
+const MINERS = [
+  {
+    name: 'XMRStak',
+    url: 'https://github.com/fireice-uk/xmr-stak/releases',
+    code: `"pool_list": [
   {
       "pool_address": "pool.conceal.network:3333",
       "wallet_address": "YOUR_WALLET_ADDRESS",
@@ -188,65 +259,81 @@ export function MiningSection() {
       "pool_weight": 1
   },
 ],
-"currency": "cryptonight_gpu",`}
-            
-
-
-

- - CryptoDredge - -

-
-              {`c:/cryptodredge/CryptoDredge.exe -a cngpu 
+"currency": "cryptonight_gpu",`,
+  },
+  {
+    name: 'CryptoDredge',
+    url: 'https://github.com/CryptoDredge/miner/releases',
+    code: `c:/cryptodredge/CryptoDredge.exe -a cngpu 
 -o stratum+tcp://pool.conceal.network:3333 
--u wallet_address -p WorkerName \n --api-type ccminer-tcp -b`}
-            
-
-
-

- - XMRigCC - -

-
-              {`xmrigDaemon --no-cpu -a cn/gpu \n 
+-u wallet_address -p WorkerName \n --api-type ccminer-tcp -b`,
+  },
+  {
+    name: 'XMRigCC',
+    url: 'https://github.com/Bendr0id/xmrigCC',
+    code: `xmrigDaemon --no-cpu -a cn/gpu \n 
 -o pool:port -u wallet_address -p x -k \n
 --cc-url=127.0.0.1:3344 \n
 --cc-access-token=your_token \n
---cc-worker-id=worker_name pause`}
-            
-
-
+--cc-worker-id=worker_name pause`, + }, + { + name: 'SRBMiner', + url: 'https://www.srbminer.com/', + code: `SRBMiner-MULTI.exe --algorithm gpu \n +--pool pool.conceal.network:3333 \n +--wallet "YOUR_WALLET_ADDRESS" \n +--gpu-tweak-profile 5`, + }, +]; + +const PRE_CLASS = + 'font-[Consolas,Serif] text-[1.3rem] leading-[1.3rem] bg-[#111] border border-[#444] rounded p-4 overflow-x-auto'; + +function MinerQuickStart() { + return ( + <> +

+ Quick Start +

+
+ {MINERS.map(({ name, url, code }) => ( +

- - SRBMiner + + {name}

-
-              {`SRBMiner-MULTI.exe --algorithm gpu \n
---pool pool.conceal.network:3333 \n
---wallet "YOUR_WALLET_ADDRESS" \n
---gpu-tweak-profile 5`}
+            
+              {code}
             
-
+ ))} +
+ + ); +} +export function MiningSection() { + const sectionRef = useRef(null); + const { pools, isLoading } = usePools(sectionRef); + + return ( +
+
+ Getting CCX} + title={Mining} + /> +

The easiest way to get CCX is to mine with CPU or GPU using one of the miners that @@ -256,161 +343,14 @@ export function MiningSection() { href="https://conceal.network/wiki/doku.php?id=mining" target="_blank" rel="noopener" - className="text-[var(--color1)] hover:text-[#fafafa] transition-colors" + className={LINK} data-tkey="rDocumentation" > documentation {' '} for more detailed information about mining CCX.

- -
-

- Mining Pools -

- - {/* Desktop Table View */} -
- - - - - - - - - - - - {isLoading && ( - - - - )} - {!isLoading && pools.length === 0 && ( - - - - )} - {pools.map((pool) => { - const poolName = getPoolName(pool); - return ( - - - - - - - - ); - })} - -
- - Pools - - - Height - - - Fee - - - Hashrate - - - Miners -
- Loading pools... -
- No pools available -
- - {pool.info.name} - - - {pool.network.height.toLocaleString()} - - {pool.config.poolFee}% - - {getReadableHashRateString(pool.pool.hashrate)} - - {pool.pool.miners.toLocaleString()} -
-
- - {/* Mobile Card View */} -
- {isLoading && ( -
- Loading pools... -
- )} - {!isLoading && pools.length === 0 && ( -
- No pools available -
- )} - {pools.map((pool) => { - const poolName = getPoolName(pool); - return ( -
- -
-
- - - Height: - - {pool.network.height.toLocaleString()} -
-
- - - Fee: - - {pool.config.poolFee}% -
-
- - - Hashrate: - - - {getReadableHashRateString(pool.pool.hashrate)} - -
-
- - - Miners: - - {pool.pool.miners.toLocaleString()} -
-
-
- ); - })} -
-
+
); diff --git a/src/components/sections/RoadmapSection.tsx b/src/components/sections/RoadmapSection.tsx index 100b97e..f42bd39 100644 --- a/src/components/sections/RoadmapSection.tsx +++ b/src/components/sections/RoadmapSection.tsx @@ -254,63 +254,185 @@ const timelineItems: TimelineItem[] = [ }, ]; -export function RoadmapSection() { - const sectionRef = useRef(null); +const TEXT_SHADOW = + '[text-shadow:0_-0.1em_0.1em_#000,0_0.1em_0.1em_#000,-0.25em_0_0.25em_#000,0.25em_0_0.25em_#000]'; + +function useTimelineScale(sectionRef: React.RefObject) { const itemRefs = useRef>(new Map()); const [itemScales, setItemScales] = useState>(new Map()); useEffect(() => { const handleScroll = () => { if (!sectionRef.current) return; - const sectionRect = sectionRef.current.getBoundingClientRect(); - - // Only apply effect if roadmap section is in viewport const isSectionVisible = sectionRect.bottom > 0 && sectionRect.top < window.innerHeight; if (!isSectionVisible) { - // Reset all scales if section is not visible const resetScales = new Map(); - itemRefs.current.forEach((_, index) => { - resetScales.set(index, 1.0); - }); + for (const [index] of itemRefs.current) resetScales.set(index, 1.0); setItemScales(resetScales); return; } const newScales = new Map(); const centerY = window.innerHeight / 2; - const maxDistance = 400; // Maximum distance for scaling effect - - itemRefs.current.forEach((element, index) => { - if (!element) return; - + const maxDistance = 400; + for (const [index, element] of itemRefs.current) { const rect = element.getBoundingClientRect(); - const itemY = rect.top + rect.height / 2; - const distanceFromCenter = Math.abs(centerY - itemY); - - // Map distance to scale: 1.22 at center, 1.0 at maxDistance - const normalizedDistance = Math.min(1, distanceFromCenter / maxDistance); - const scale = 1.0 + 0.22 * (1 - normalizedDistance); // 1.6 at center, 1.0 at maxDistance - - newScales.set(index, scale); - }); - + const distanceFromCenter = Math.abs(centerY - (rect.top + rect.height / 2)); + newScales.set(index, 1.0 + 0.22 * (1 - Math.min(1, distanceFromCenter / maxDistance))); + } setItemScales(newScales); }; - // Initial check handleScroll(); - - // Listen to scroll events window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('resize', handleScroll, { passive: true }); - return () => { window.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleScroll); }; - }, []); + }, [sectionRef]); + + const setItemRef = (index: number) => (el: HTMLDivElement | null) => { + if (el) itemRefs.current.set(index, el); + else itemRefs.current.delete(index); + }; + + return { itemScales, setItemRef }; +} + +interface TimelineItemCardProps { + item: TimelineItem; + index: number; + scale: number; + itemRef: (el: HTMLDivElement | null) => void; +} + +type ItemStatus = TimelineItem['status']; + +function getStatusClasses(status: ItemStatus): { accentColor: string; squareColor: string } { + const isCompleted = status === 'completed'; + const isActive = status === 'inprog' || status === 'activ'; + return { + accentColor: isCompleted ? 'text-white' : isActive ? 'text-[var(--color1)]' : '', + squareColor: isCompleted ? 'bg-white' : isActive ? 'bg-[var(--color1)]' : '', + }; +} + +function ItemDescription({ description, url }: { description?: string; url?: string }) { + if (!description) return null; + return ( + + {' '} + —{' '} + {url ? ( + + {description} + + ) : ( + description + )} + + ); +} + +function TimelineItemCard({ item, index, scale, itemRef }: TimelineItemCardProps) { + const isEven = index % 2 === 1; + const isDone = item.status === 'done'; + const isActiv = item.status === 'activ'; + const { accentColor, squareColor } = getStatusClasses(item.status); + + return ( + +
+
+
+
+ +
+ {item.date ? `${item.date}, ${item.title}` : item.title} +
+ +
+
+
+
+ ); +} + +function RoadmapHeader() { + return ( +
+ + + CONCEAL ROADMAP + + + +

+ The Birth of something{' '} + Amazing. +

+
+
+ ); +} + +interface RoadmapTimelineProps { + itemScales: Map; + setItemRef: (index: number) => (el: HTMLDivElement | null) => void; +} + +function RoadmapTimeline({ itemScales, setItemRef }: RoadmapTimelineProps) { + return ( +
+
+
+ +

+ CONCEAL ROADMAP +

+
+
+
+
+ {timelineItems.map((item, index) => ( + + ))} +
+
+
+ ); +} + +export function RoadmapSection() { + const sectionRef = useRef(null); + const { itemScales, setItemRef } = useTimelineScale(sectionRef); return (
- {/* Background image */}
- - {/* Dark overlay */}
-
- {/* Hero Title Section */} -
- - - CONCEAL ROADMAP - - - -

- The Birth of something{' '} - Amazing. -

-
-
- - {/* Timeline Section */} -
-
-
- -

- CONCEAL ROADMAP -

-
-
-
- {/* Center Line */} -
- - {/* Timeline Items */} - {timelineItems.map((item, index) => { - const isEven = index % 2 === 1; - const isCompleted = item.status === 'completed'; - const isInProg = item.status === 'inprog'; - const isActiv = item.status === 'activ'; - const isDone = item.status === 'done'; - - const scale = itemScales.get(index) ?? 1.0; - - return ( - -
{ - if (el) { - itemRefs.current.set(index, el); - } else { - itemRefs.current.delete(index); - } - }} - className={`single-timeline flex items-center mb-[22px] transition-transform duration-300 ease-out ${isEven ? 'flex-row-reverse' : ''}`} - style={{ - transform: `scale(${scale})`, - transformOrigin: 'center center', - }} - > -
-
-
- - {item.date && ( -
- {item.date}, {item.title} -
- )} - {!item.date && ( -
- {item.title} -
- )} - {item.description && ( - - {' '} - —{' '} - {item.url ? ( - - {item.description} - - ) : ( - item.description - )} - - )} -
-
-
-
- ); - })} -
-
-
+ +
); diff --git a/src/components/sections/WalletsSection.tsx b/src/components/sections/WalletsSection.tsx index fd49660..8ca042b 100644 --- a/src/components/sections/WalletsSection.tsx +++ b/src/components/sections/WalletsSection.tsx @@ -2,6 +2,158 @@ import { Button } from '@/components/ui/Button'; import { SectionHeading } from '@/components/ui/SectionHeading'; import { AnimatedElement } from '../ui/AnimatedElement'; +const DESKTOP_URL = 'https://github.com/ConcealNetwork/conceal-desktop/releases/latest'; +const CORE_URL = 'https://github.com/ConcealNetwork/conceal-core/releases/latest'; + +function DownloadLink({ + href, + icon, + label, + gap = 4, +}: { + href: string; + icon: string; + label: string; + gap?: number; +}) { + return ( + + ); +} + +function ConcealDesktop() { + return ( + <> +

+ Conceal-Desktop | Full-Node Graphical Wallet +

+

+ Conceal Desktop is the central point of interaction for the primary features of Conceal and + is available for all major platforms. With Conceal Desktop you can send and receive CCX and + encrypted secure messages, and manage your deposits. +

+ + Conceal GUI + +
+ + + +
+ + ); +} + +function ConcealCore() { + return ( + <> +

+ Conceal-Core | Full-Node Command Line Wallet +

+

+ Conceal-Core is the heart of our peer-to-peer privacy-preserving network. It's a full local + node of our network. +

+ + Conceal CLI + +
+ + + +
+ + ); +} + +function WebAndPaper() { + return ( + <> +

+ Web Wallet & Paper Wallet +

+

+ Conceal Web Wallet runs in your Browser on any device, Mobile, PC or Mac! It is completely + Client-Side, stores your encrypted wallet keys on your device, and is the perfect + lightweight alternative to the Full Node Wallet. The Conceal Paper wallet is the easiest way + to create an offline wallet with simple Key generation tools. +

+ + Conceal Web Wallet + +
+ + +
+ + ); +} + +function ConcealMobile() { + return ( + <> +

+ Conceal-Mobile | Lite Wallet +

+

+ Conceal-Mobile is a wrapped version of our web wallet. +

+
+ + + +
+ + ); +} + export function WalletsSection() { return (
Using Conceal} title={Wallets} /> - - {/* Conceal-Desktop */} -

- Conceal-Desktop | Full-Node Graphical Wallet -

- -

- Conceal Desktop is the central point of interaction for the primary features of Conceal - and is available for all major platforms. With Conceal Desktop you can send and receive - CCX and encrypted secure messages, and manage your deposits. -

- - Conceal GUI - - - +
- {/* Conceal-Core */} -

- Conceal-Core | Full-Node Command Line Wallet -

-

- Conceal-Core is the heart of our peer-to-peer privacy-preserving network. It's a full - local node of our network. -

- - Conceal CLI - - - +
- {/* Web Wallet Types */} -

- Web Wallet & Paper Wallet -

-

- Conceal Web Wallet runs in your Browser on any device, Mobile, PC or Mac! It is completely - Client-Side, stores your encrypted wallet keys on your device, and is the perfect - lightweight alternative to the Full Node Wallet. The Conceal Paper wallet is the easiest - way to create an offline wallet with simple Key generation tools. -

- - Conceal Web Wallet - - +
- {/* Conceal-Mobile */} -

- Conceal-Mobile | Lite Wallet -

-

- Conceal-Mobile is a wrapped version of our web wallet. -

- +
); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index de5034d..f353bc3 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -10,12 +10,45 @@ import { useNavigate } from 'react-router-dom'; import { appConfig } from '@/config/app.config'; import { cn } from '@/lib/utils'; +function scrollToElement(targetId: string, scrollOffset: number) { + const maxAttempts = 10; + let attempts = 0; + const tryScroll = () => { + attempts++; + const el = document.getElementById(targetId); + if (el) { + requestAnimationFrame(() => { + window.scrollTo({ + top: el.getBoundingClientRect().top + window.pageYOffset - scrollOffset, + behavior: 'smooth', + }); + }); + } else if (attempts < maxAttempts) { + setTimeout(tryScroll, appConfig.animations.scrollRetryDelay); + } + }; + setTimeout(tryScroll, appConfig.animations.scrollRetryDelayButton); +} + +function mergeRefs( + outer: React.ForwardedRef, + inner: React.Ref | undefined +): (node: T | null) => void { + return (node) => { + if (typeof outer === 'function') outer(node); + else if (outer) (outer as React.RefObject).current = node; + if (typeof inner === 'function') inner(node); + else if (inner && typeof inner === 'object' && 'current' in inner) + (inner as React.RefObject).current = node; + }; +} + interface BaseButtonProps { variant?: 'primary' | 'stroke' | 'default' | 'download' | 'slide' | 'slideToId'; size?: 'default' | 'medium' | 'large'; fullWidth?: boolean; - targetId?: string; // For slideToId variant - the ID to scroll to - scrollOffset?: number; // Offset for scroll (default: 100 for header) + targetId?: string; + scrollOffset?: number; } interface ButtonAsButtonProps extends BaseButtonProps, ButtonHTMLAttributes { @@ -28,6 +61,95 @@ interface ButtonAsAnchorProps extends BaseButtonProps, AnchorHTMLAttributes = { + about: '/about', + labsHeading: '/labs', + labs: '/labs', +}; + +type ClickHandler = (e: React.MouseEvent) => void; + +interface RenderAsChildOptions { + anchorProps: Omit; + ref: React.ForwardedRef; + baseClasses: string; + handleClick: ClickHandler; + children: React.ReactNode; + variant: string; + targetId: string | undefined; +} + +function renderAsChild({ + anchorProps, + ref, + baseClasses, + handleClick, + children, + variant, + targetId, +}: RenderAsChildOptions) { + const child = isValidElement(children) + ? (children as ReactElement>) + : null; + const isMergeable = child && typeof child.type === 'string' && child.type === 'a'; + if (!(child && isMergeable)) { + return ( + } + className={baseClasses} + href={ + variant === 'slideToId' && targetId + ? `#${targetId}` + : (anchorProps as AnchorHTMLAttributes).href + } + onClick={handleClick} + {...anchorProps} + > + {children} + + ); + } + const childRef = (child as unknown as { ref?: React.Ref }).ref; + return cloneElement(child, { + ...anchorProps, + ...child.props, + className: cn(baseClasses, (child.props as { className?: string }).className), + onClick: (e: React.MouseEvent) => { + handleClick(e as React.MouseEvent); + if (typeof child.props.onClick === 'function') + (child.props.onClick as (e: React.MouseEvent) => void)(e); + }, + ref: mergeRefs(ref as React.ForwardedRef, childRef), + }); +} + const Button = forwardRef( ( { @@ -43,161 +165,33 @@ const Button = forwardRef( ref ) => { const navigate = useNavigate(); - const baseClasses = cn( - // Base styles from main.css - 'inline-flex font-sans text-sm uppercase tracking-wider', - 'h-[5.4rem] leading-[5rem] px-[3rem] cursor-pointer', - 'transition-all duration-300 ease-in-out', - 'border-2 border-[#c5c5c5]', - 'items-center', - - // Variants - variant === 'primary' && [ - 'bg-black text-white border border-[var(--color1)] rounded-[0.5rem] text-2xl', - 'hover:bg-[var(--color1)] hover:text-black hover:border-2 hover:border-white', - ], - variant === 'stroke' && [ - 'bg-transparent border-2 border-[var(--color1)] text-white rounded-[10px]', - 'hover:border-white hover:bg-[var(--color1)]', - ], - variant === 'default' && [ - 'bg-transparent text-white', - 'hover:bg-[#b8b8b8] hover:border-[#b8b8b8]', - ], - variant === 'download' && [ - 'bg-black text-white border border-[var(--color1)] rounded-[0.5rem] text-2xl', - 'hover:bg-[var(--color1)] hover:text-black hover:border-2 hover:border-white', - ], - variant === 'slide' && [ - 'button-slide bg-transparent text-white border border-[var(--color1)] rounded-[0.5rem] text-2xl', - ], - variant === 'slideToId' && [ - 'button-slide bg-transparent text-white border border-[var(--color1)] rounded-[0.5rem] text-2xl', - ], - - // Sizes - size === 'medium' && 'h-[5.7rem] leading-[5.3rem]', - size === 'large' && 'h-[6rem] leading-[5.6rem]', - - // Full width - fullWidth && 'w-full', - - className - ); + const baseClasses = buildBaseClasses(variant, size, fullWidth, className); - // Handle scroll-to-id functionality - const handleClick = (e: React.MouseEvent) => { + const handleClick: ClickHandler = (e) => { if (variant === 'slideToId' && targetId) { e.preventDefault(); - - // Check if element exists on current page - const element = document.getElementById(targetId); - - if (element) { - // Element exists on current page, scroll to it - const maxAttempts = 10; - let attempts = 0; - - const tryScroll = () => { - attempts++; - const el = document.getElementById(targetId); - if (el) { - requestAnimationFrame(() => { - const elementTop = el.getBoundingClientRect().top + window.pageYOffset; - window.scrollTo({ - top: elementTop - scrollOffset, - behavior: 'smooth', - }); - }); - } else if (attempts < maxAttempts) { - setTimeout(tryScroll, appConfig.animations.scrollRetryDelay); - } - }; - - setTimeout(tryScroll, appConfig.animations.scrollRetryDelayButton); - } else { - // Element not on current page - navigate to appropriate page - // Map of targetIds to their page routes - const targetIdToRoute: { [key: string]: string } = { - about: '/about', - labsHeading: '/labs', - labs: '/labs', - // Add more mappings as needed - }; - - const targetRoute = targetIdToRoute[targetId] || '/about'; // Default to /about for unknown IDs - - // Navigate to the page with hash in state - navigate(targetRoute, { + if (document.getElementById(targetId)) scrollToElement(targetId, scrollOffset); + else + navigate(SLIDE_TO_ID_ROUTES[targetId] ?? '/about', { state: { scrollToHash: `#${targetId}`, scrollOffset }, replace: false, }); - } - } - - // Call original onClick if provided - if (props.onClick) { - // Type assertion is safe: handleClick receives union event type compatible with both button and anchor handlers - if (props.asChild) { - (props.onClick as React.MouseEventHandler)( - e as React.MouseEvent - ); - } else { - (props.onClick as React.MouseEventHandler)( - e as React.MouseEvent - ); - } } + if (props.onClick) + (props.onClick as React.MouseEventHandler)(e as React.MouseEvent); }; if (props.asChild) { const { asChild: _asChild, ...anchorProps } = props as ButtonAsAnchorProps; - const child = isValidElement(children) - ? (children as ReactElement>) - : null; - // Only merge into the child if it's a real DOM element (e.g. ). Fragments and components cannot receive href. - const isMergeable = child && typeof child.type === 'string' && child.type === 'a'; - if (!child || !isMergeable) { - return ( - } - className={baseClasses} - href={ - variant === 'slideToId' && targetId - ? `#${targetId}` - : (anchorProps as AnchorHTMLAttributes).href - } - onClick={handleClick} - {...anchorProps} - > - {children} - - ); - } - const mergedProps = { - ...anchorProps, - ...child.props, - className: cn(baseClasses, (child.props as { className?: string }).className), - onClick: (e: React.MouseEvent) => { - handleClick(e as React.MouseEvent); - typeof child.props.onClick === 'function' && - (child.props.onClick as (e: React.MouseEvent) => void)(e); - }, - ref: (node: HTMLAnchorElement | null) => { - if (typeof ref === 'function') ref(node); - else if (ref && 'current' in ref) - (ref as React.MutableRefObject).current = node; - const childRef = ( - child as ReactElement<{ ref?: React.Ref }> & { - ref?: React.Ref; - } - ).ref; - if (typeof childRef === 'function') childRef(node); - else if (childRef && typeof childRef === 'object' && 'current' in childRef) - (childRef as React.MutableRefObject).current = node; - }, - }; - return cloneElement(child, mergedProps); + return renderAsChild({ + anchorProps, + ref, + baseClasses, + handleClick, + children, + variant, + targetId, + }); } const { asChild: _asChild, ...buttonProps } = props as ButtonAsButtonProps; diff --git a/src/components/ui/Carousel.tsx b/src/components/ui/Carousel.tsx index eec84a2..37c3c36 100644 --- a/src/components/ui/Carousel.tsx +++ b/src/components/ui/Carousel.tsx @@ -6,51 +6,45 @@ interface CarouselProps { className?: string; } +function CarouselSlide({ src, alt }: { src: string; alt: string }) { + return ( +
+ {alt} { + console.error('Failed to load image:', src); + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + if (target.parentElement) { + target.parentElement.innerHTML = `
Image failed to load: ${src}
`; + } + }} + onLoad={() => console.log('Image loaded successfully:', src)} + /> +
+ ); +} + export function Carousel({ images, altPrefix = 'Image', className = '' }: CarouselProps) { const [activeIndex, setActiveIndex] = useState(0); - - const goToPrevious = () => { - setActiveIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); - }; - - const goToNext = () => { - setActiveIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); - }; + const goToPrevious = () => setActiveIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); + const goToNext = () => setActiveIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); return (
- {/* Carousel Inner */}
{images.map((image, index) => ( -
- {`${altPrefix} { - console.error('Failed to load image:', image); - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - if (target.parentElement) { - target.parentElement.innerHTML = `
Image failed to load: ${image}
`; - } - }} - onLoad={() => { - console.log('Image loaded successfully:', image); - }} - /> -
+ ))}
diff --git a/src/components/ui/LanguageSelector.tsx b/src/components/ui/LanguageSelector.tsx index 1b0e8c7..6822013 100644 --- a/src/components/ui/LanguageSelector.tsx +++ b/src/components/ui/LanguageSelector.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; interface Language { @@ -10,184 +10,117 @@ interface LanguageSelection { name: string; } -export function LanguageSelector() { - const [isOpen, setIsOpen] = useState(false); +async function fetchJSON(url: string): Promise { + return (await fetch(url)).json(); +} + +function applyLangData(langData: Record) { + for (const element of document.querySelectorAll('[data-tkey]')) { + const key = (element as HTMLElement).dataset.tkey; + if (!(key && langData[key])) continue; + if (element.children.length === 0) { + element.textContent = langData[key]; + } else { + const first = element.firstChild; + if (first?.nodeType === Node.TEXT_NODE) first.textContent = langData[key]; + else element.textContent = langData[key]; + } + } +} + +async function autoTagElements() { + try { + const enData = await fetchJSON>('/lang/en.json'); + const all = document.body.getElementsByTagName('*'); + for (const key of Object.keys(enData)) { + const expected = enData[key].toUpperCase(); + for (let i = 0; i < all.length; i++) { + const el = all[i] as HTMLElement; + if (el.firstElementChild || el.dataset.tkey) continue; + if (el.textContent?.trim().toUpperCase() !== expected) continue; + el.dataset.tkey = key; + if (key.charAt(0) !== 'r') break; + } + } + } catch (err) { + console.error('Failed to auto-tag elements:', err); + } +} + +const ACCEPT_LANGS = ['en', 'fr', 'es', 'ar', 'zh', 'de', 'sl', 'cs', 'nl', 'tr', 'it', 'ru']; + +function useLanguageInit() { const [selectedLanguage, setSelectedLanguage] = useState({ code: 'en', name: 'English', }); const [languages, setLanguages] = useState([]); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); useEffect(() => { - // Helper function to check if element matches the translation text - const elementMatchesText = (element: Element, expectedText: string): boolean => { - if (element.firstElementChild) return false; - if ((element as HTMLElement).dataset.tkey) return false; - - const elementText = element.textContent?.trim().toUpperCase(); - return elementText === expectedText.toUpperCase(); - }; - - // Helper function to tag a single element - const tagElement = (element: Element, key: string): boolean => { - (element as HTMLElement).dataset.tkey = key; - return key.charAt(0) !== 'r'; - }; - - // Helper function to find and tag elements for a specific key - const tagElementsForKey = ( - key: string, - expectedText: string, - allElements: HTMLCollectionOf - ): void => { - for (let i = 0; i < allElements.length; i++) { - const element = allElements[i]; - if (elementMatchesText(element, expectedText)) { - const shouldContinue = tagElement(element, key); - if (!shouldContinue) break; - } - } - }; - - // Auto-tag elements with data-tkey based on English text (matching original language.js behavior) - const autoTagElements = async () => { - try { - const response = await fetch('/lang/en.json'); - const enLangData = await response.json(); - const allElements = document.body.getElementsByTagName('*'); - - for (const key of Object.keys(enLangData)) { - tagElementsForKey(key, enLangData[key], allElements); - } - } catch (err) { - console.error('Failed to auto-tag elements:', err); - } - }; - - // Load available languages - const loadLanguages = async () => { + autoTagElements(); + (async () => { try { - // Get initial language from cookie or browser language const cookieLang = document.cookie .split('; ') - .find((row) => row.startsWith('CCX_Language=')) + .find((r) => r.startsWith('CCX_Language=')) ?.split('=')[1]; - - let currentLang = cookieLang || navigator.language.substring(0, 2) || 'en'; - - // Validate language is in accepted list - const acceptLang = ['ru', 'en', 'ar', 'es', 'zh', 'de', 'sl', 'cs', 'nl', 'tr', 'fr', 'it']; - if (!acceptLang.includes(currentLang)) { - currentLang = 'en'; - } - - // Load language selection - const selectionRes = await fetch('/lang/selection.json'); - const selectionData = (await selectionRes.json()) as Record; - - const langList: Language[] = Object.entries(selectionData).map(([code, value]) => ({ - code, - name: value.name, + let code = cookieLang || navigator.language.substring(0, 2) || 'en'; + if (!ACCEPT_LANGS.includes(code)) code = 'en'; + + const selectionData = + await fetchJSON>('/lang/selection.json'); + const langList: Language[] = Object.entries(selectionData).map(([c, v]) => ({ + code: c, + name: v.name, })); setLanguages(langList); - // Set selected language - const current = langList.find((l) => l.code === currentLang) || langList[0]; + const current = langList.find((l) => l.code === code) ?? langList[0]; setSelectedLanguage(current); - // Load and apply translations for initial language - const langRes = await fetch(`/lang/${currentLang}.json`); - const langData = await langRes.json(); - - const elements = document.querySelectorAll('[data-tkey]'); - elements.forEach((element) => { - const key = (element as HTMLElement).dataset.tkey; - if (key && langData[key]) { - element.textContent = langData[key]; - } - }); - } catch (err) { - console.error('Failed to load languages:', err); - // Fallback + applyLangData(await fetchJSON>(`/lang/${current.code}.json`)); + } catch { setLanguages([{ code: 'en', name: 'English' }]); } - }; - - autoTagElements(); - loadLanguages(); + })(); }, []); useEffect(() => { - // Close dropdown when clicking outside - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - buttonRef.current && - !dropdownRef.current.contains(event.target as Node) && - !buttonRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); + if (!selectedLanguage.code) return; + const timer = setTimeout(() => { + fetchJSON>(`/lang/${selectedLanguage.code}.json`) + .then(applyLangData) + .catch(console.error); + }, 100); + return () => clearTimeout(timer); + }, [selectedLanguage.code]); + + return { selectedLanguage, languages, setSelectedLanguage }; +} - const applyTranslations = useCallback(async (langCode: string) => { - try { - const response = await fetch(`/lang/${langCode}.json`); - const langData = await response.json(); - - // Update all elements with data-tkey attributes - const elements = document.querySelectorAll('[data-tkey]'); - elements.forEach((element) => { - const key = (element as HTMLElement).dataset.tkey; - if (key && langData[key]) { - // Preserve HTML structure if it exists - if (element.children.length === 0) { - element.textContent = langData[key]; - } else { - // If element has children, only update if it's a simple text node replacement - const firstChild = element.firstChild; - if (firstChild && firstChild.nodeType === Node.TEXT_NODE) { - firstChild.textContent = langData[key]; - } else { - element.textContent = langData[key]; - } - } - } - }); - } catch (err) { - console.error('Failed to load language:', err); - } - }, []); +export function LanguageSelector() { + const { selectedLanguage, languages, setSelectedLanguage } = useLanguageInit(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); - // Re-apply translations when language changes, with a small delay to ensure DOM is ready useEffect(() => { - if (selectedLanguage.code) { - // Use setTimeout to ensure React has finished rendering - const timer = setTimeout(() => { - applyTranslations(selectedLanguage.code); - }, 100); - return () => clearTimeout(timer); - } - }, [selectedLanguage.code, applyTranslations]); + if (!isOpen) return; + const handle = (e: MouseEvent) => { + if (dropdownRef.current?.contains(e.target as Node)) return; + if (buttonRef.current?.contains(e.target as Node)) return; + setIsOpen(false); + }; + document.addEventListener('mousedown', handle); + return () => document.removeEventListener('mousedown', handle); + }, [isOpen]); const handleLanguageSelect = async (lang: Language) => { - // Set cookie - // biome-ignore lint/suspicious/noDocumentCookie: need to use document.cookie for legacy browsers + // biome-ignore lint/suspicious/noDocumentCookie: needed for legacy browsers document.cookie = `CCX_Language=${lang.code}; max-age=2629800; samesite=strict; secure`; - setSelectedLanguage(lang); setIsOpen(false); - - // Apply translations immediately - await applyTranslations(lang.code); + applyLangData(await fetchJSON>(`/lang/${lang.code}.json`)); }; return ( @@ -205,7 +138,6 @@ export function LanguageSelector() { {selectedLanguage.name} -
| undefined>(undefined); useEffect(() => { - // Check if mobile on mount and resize - const checkMobile = () => { - setIsMobile(window.innerWidth < appConfig.breakpoints.mobile); - }; + const checkMobile = () => setIsMobile(window.innerWidth < appConfig.breakpoints.mobile); checkMobile(); window.addEventListener('resize', checkMobile); const fadeOutDelay = secondsToMs(appConfig.mobile.menuFadeOutTime); + if (isMobile) timeoutRef.current = setTimeout(() => setIsVisible(false), fadeOutDelay); - // Auto-hide after configured time on mobile - if (isMobile) { - timeoutRef.current = setTimeout(() => { - setIsVisible(false); - }, fadeOutDelay); - } - - // Show on scroll const handleScroll = () => { - if (isMobile) { - setIsVisible(true); - // Clear existing timeout and set new one - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setIsVisible(false); - }, fadeOutDelay); - } + if (!isMobile) return; + setIsVisible(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setIsVisible(false), fadeOutDelay); }; window.addEventListener('scroll', handleScroll, { passive: true }); - return () => { window.removeEventListener('resize', checkMobile); window.removeEventListener('scroll', handleScroll); @@ -77,40 +44,87 @@ export function MajorLinks() { }; }, [isMobile]); - const handleLinkClick = (e: React.MouseEvent, link: MajorLink) => { - if (link.external) { - return; // Let default behavior handle external links - } + return { isVisible, isMobile }; +} - e.preventDefault(); +function scrollToHash(hash: string) { + const element = document.querySelector(hash); + if (!element) return; + requestAnimationFrame(() => { + window.scrollTo({ + top: element.getBoundingClientRect().top + window.pageYOffset - 100, + behavior: 'smooth', + }); + }); +} - // If hash link (starts with /#) - if (link.url.startsWith('/#')) { - const hash = link.url.substring(1); // Get #markets, #features, etc. +function MajorLinkItem({ + link, + index, + onClick, +}: { + link: MajorLink; + index: number; + onClick: (e: React.MouseEvent, link: MajorLink) => void; +}) { + const colorVar = index % 2 === 0 ? 'var(--color1)' : 'var(--color2)'; + const glowVar = index % 2 === 0 ? 'var(--color1-glow-rgba)' : 'var(--color2-glow-rgba)'; + return ( +
  • + onClick(e, link)} + target={link.external ? '_blank' : undefined} + rel={link.external ? 'noopener noreferrer' : undefined} + className="group relative flex items-center justify-center w-[2.3em] h-[2.3em] text-center rounded-full transition-all duration-300 ease-in-out" + style={{ + color: colorVar, + backgroundColor: 'rgba(15, 15, 26, 0.8)', + border: `2px solid ${colorVar}`, + boxShadow: `0 0 10px ${glowVar}`, + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.2)'; + e.currentTarget.style.boxShadow = `0 0 20px ${glowVar}, 0 0 30px ${glowVar}`; + e.currentTarget.style.backgroundColor = colorVar; + e.currentTarget.style.color = 'var(--color-bg-primary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = `0 0 10px ${glowVar}`; + e.currentTarget.style.backgroundColor = 'rgba(15, 15, 26, 0.8)'; + e.currentTarget.style.color = colorVar; + }} + > + + + {link.name} + + +
  • + ); +} + +export function MajorLinks() { + const navigate = useNavigate(); + const location = useLocation(); + const { isVisible, isMobile } = useMobileVisibility(); - // If we're not on the main page, navigate to root first, then scroll - if (location.pathname !== '/') { - // Navigate to root with hash in state - navigate('/', { - state: { scrollToHash: hash }, - replace: false, - }); - } else { - // Already on main page, just scroll - const element = document.querySelector(hash); - if (element) { - requestAnimationFrame(() => { - const elementTop = element.getBoundingClientRect().top + window.pageYOffset; - const offset = 100; // Offset for header - window.scrollTo({ - top: elementTop - offset, - behavior: 'smooth', - }); - }); - } - } + const handleLinkClick = (e: React.MouseEvent, link: MajorLink) => { + if (link.external) return; + e.preventDefault(); + if (link.url.startsWith('/#')) { + const hash = link.url.substring(1); + if (location.pathname !== '/') + navigate('/', { state: { scrollToHash: hash }, replace: false }); + else scrollToHash(hash); } else { - // Regular navigation navigate(link.url); } }; @@ -121,53 +135,9 @@ export function MajorLinks() { isMobile && !isVisible ? 'opacity-0 pointer-events-none' : 'opacity-100' }`} > - {majorLinks.map((link, index) => { - // Alternate between primary (color1) and secondary (color2) for visual variety - const usePrimary = index % 2 === 0; - const colorVar = usePrimary ? 'var(--color1)' : 'var(--color2)'; - const glowVar = usePrimary ? 'var(--color1-glow-rgba)' : 'var(--color2-glow-rgba)'; - - return ( -
  • - handleLinkClick(e, link)} - target={link.external ? '_blank' : undefined} - rel={link.external ? 'noopener noreferrer' : undefined} - className="group relative flex items-center justify-center w-[2.3em] h-[2.3em] text-center rounded-full transition-all duration-300 ease-in-out" - style={{ - color: colorVar, - backgroundColor: 'rgba(15, 15, 26, 0.8)', - border: `2px solid ${colorVar}`, - boxShadow: `0 0 10px ${glowVar}`, - }} - onMouseEnter={(e) => { - e.currentTarget.style.transform = 'scale(1.2)'; - e.currentTarget.style.boxShadow = `0 0 20px ${glowVar}, 0 0 30px ${glowVar}`; - e.currentTarget.style.backgroundColor = colorVar; - e.currentTarget.style.color = 'var(--color-bg-primary)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'scale(1)'; - e.currentTarget.style.boxShadow = `0 0 10px ${glowVar}`; - e.currentTarget.style.backgroundColor = 'rgba(15, 15, 26, 0.8)'; - e.currentTarget.style.color = colorVar; - }} - > - - - {link.name} - - -
  • - ); - })} + {majorLinks.map((link, index) => ( + + ))} ); } diff --git a/src/components/ui/SocialMenu.tsx b/src/components/ui/SocialMenu.tsx index 26d40a2..6107748 100644 --- a/src/components/ui/SocialMenu.tsx +++ b/src/components/ui/SocialMenu.tsx @@ -67,42 +67,27 @@ const socialLinks: SocialLink[] = [ }, ]; -export function SocialMenu() { +function useSocialMenuVisibility() { const [isVisible, setIsVisible] = useState(true); const [isMobile, setIsMobile] = useState(false); const timeoutRef = useRef | undefined>(undefined); useEffect(() => { - // Check if mobile on mount and resize - const checkMobile = () => { - setIsMobile(window.innerWidth < appConfig.breakpoints.mobile); - }; + const checkMobile = () => setIsMobile(window.innerWidth < appConfig.breakpoints.mobile); checkMobile(); window.addEventListener('resize', checkMobile); const fadeOutDelay = secondsToMs(appConfig.mobile.menuFadeOutTime); + if (isMobile) timeoutRef.current = setTimeout(() => setIsVisible(false), fadeOutDelay); - // Auto-hide after configured time on mobile - if (isMobile) { - timeoutRef.current = setTimeout(() => { - setIsVisible(false); - }, fadeOutDelay); - } - - // Show on scroll const handleScroll = () => { - if (isMobile) { - setIsVisible(true); - // Clear existing timeout and set new one - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setIsVisible(false); - }, fadeOutDelay); - } + if (!isMobile) return; + setIsVisible(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setIsVisible(false), fadeOutDelay); }; window.addEventListener('scroll', handleScroll, { passive: true }); - return () => { window.removeEventListener('resize', checkMobile); window.removeEventListener('scroll', handleScroll); @@ -110,62 +95,68 @@ export function SocialMenu() { }; }, [isMobile]); + return { isVisible, isMobile }; +} + +function SocialMenuItem({ link, index }: { link: SocialLink; index: number }) { + const colorVar = index % 2 === 0 ? 'var(--color1)' : 'var(--color2)'; + const glowVar = index % 2 === 0 ? 'var(--color1-glow-rgba)' : 'var(--color2-glow-rgba)'; + return ( +
  • + { + e.currentTarget.style.transform = 'scale(1.2)'; + e.currentTarget.style.boxShadow = `0 0 20px ${glowVar}, 0 0 30px ${glowVar}`; + e.currentTarget.style.backgroundColor = colorVar; + e.currentTarget.style.color = 'var(--color-bg-primary)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = `0 0 10px ${glowVar}`; + e.currentTarget.style.backgroundColor = 'rgba(15, 15, 26, 0.8)'; + e.currentTarget.style.color = colorVar; + }} + > + {link.svgIcon ? ( + {link.svgIcon} + ) : ( + + )} + + {link.name} + + +
  • + ); +} + +export function SocialMenu() { + const { isVisible, isMobile } = useSocialMenuVisibility(); return ( ); } diff --git a/src/theme.ts b/src/theme.ts index ca87a52..153312a 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -83,122 +83,86 @@ export const theme = { * Import themeConfig from app.config.ts instead of defining it here. */ -import { themeConfig } from '@/config/app.config'; +import { type ThemeColor, themeConfig } from '@/config/app.config'; -// Helper function to get CSS custom properties based on theme selection -export const getThemeCSSVars = (): Record => { - const primary = themeConfig.primaryColor; - const secondary = themeConfig.secondaryColor; - - // Determine primary color values based on theme selection - const primaryColor = theme.colors.neon[primary]; - const primaryGlow = - primary === 'orange' ? theme.effects.textGlowOrange : theme.effects.textGlowCyan; - const primaryGlowStrong = - primary === 'orange' ? theme.effects.textGlowOrangeStrong : theme.effects.textGlowCyanStrong; - const primaryBoxGlow = - primary === 'orange' ? theme.effects.boxGlowOrange : theme.effects.boxGlowCyan; - const primaryBoxGlowStrong = - primary === 'orange' ? theme.effects.boxGlowOrangeStrong : theme.effects.boxGlowCyanStrong; - const primaryBgGlow = - primary === 'orange' ? theme.effects.bgGlowOrange : theme.effects.bgGlowCyan; - const primaryBorder = - primary === 'orange' ? theme.colors.border.neonOrange : theme.colors.border.neonCyan; - const primaryGlowRgba = primary === 'orange' ? theme.colors.glow.orange : theme.colors.glow.cyan; - const primaryGlowStrongRgba = - primary === 'orange' ? theme.colors.glow.orangeStrong : theme.colors.glow.cyanStrong; - - // Determine secondary color values based on theme selection - const secondaryColor = theme.colors.neon[secondary]; - const secondaryGlow = - secondary === 'orange' - ? theme.effects.textGlowOrange - : secondary === 'cyan' - ? theme.effects.textGlowCyan - : theme.effects.textGlowMagenta; - const secondaryGlowStrong = - secondary === 'orange' - ? theme.effects.textGlowOrangeStrong - : secondary === 'cyan' - ? theme.effects.textGlowCyanStrong - : theme.effects.textGlowMagentaStrong; - const secondaryBoxGlow = - secondary === 'orange' - ? theme.effects.boxGlowOrange - : secondary === 'cyan' - ? theme.effects.boxGlowCyan - : theme.effects.boxGlowMagenta; - const secondaryBoxGlowStrong = - secondary === 'orange' - ? theme.effects.boxGlowOrangeStrong - : secondary === 'cyan' - ? theme.effects.boxGlowCyanStrong - : theme.effects.boxGlowMagentaStrong; - const secondaryBgGlow = - secondary === 'orange' - ? theme.effects.bgGlowOrange - : secondary === 'cyan' - ? theme.effects.bgGlowCyan - : theme.effects.bgGlowMagenta; - const secondaryBorder = - secondary === 'orange' - ? theme.colors.border.neonOrange - : secondary === 'cyan' - ? theme.colors.border.neonCyan - : theme.colors.border.neonMagenta; - const secondaryGlowRgba = - secondary === 'orange' - ? theme.colors.glow.orange - : secondary === 'cyan' - ? theme.colors.glow.cyan - : theme.colors.glow.magenta; - const secondaryGlowStrongRgba = - secondary === 'orange' - ? theme.colors.glow.orangeStrong - : secondary === 'cyan' - ? theme.colors.glow.cyanStrong - : theme.colors.glow.magentaStrong; +// Per-color lookup — add a new entry here to support a new color across all CSS vars +const COLOR_VARS: Record< + ThemeColor, + { + color: string; + glow: string; + glowStrong: string; + boxGlow: string; + boxGlowStrong: string; + bgGlow: string; + border: string; + glowRgba: string; + glowStrongRgba: string; + } +> = { + cyan: { + color: theme.colors.neon.cyan, + glow: theme.effects.textGlowCyan, + glowStrong: theme.effects.textGlowCyanStrong, + boxGlow: theme.effects.boxGlowCyan, + boxGlowStrong: theme.effects.boxGlowCyanStrong, + bgGlow: theme.effects.bgGlowCyan, + border: theme.colors.border.neonCyan, + glowRgba: theme.colors.glow.cyan, + glowStrongRgba: theme.colors.glow.cyanStrong, + }, + magenta: { + color: theme.colors.neon.magenta, + glow: theme.effects.textGlowMagenta, + glowStrong: theme.effects.textGlowMagentaStrong, + boxGlow: theme.effects.boxGlowMagenta, + boxGlowStrong: theme.effects.boxGlowMagentaStrong, + bgGlow: theme.effects.bgGlowMagenta, + border: theme.colors.border.neonMagenta, + glowRgba: theme.colors.glow.magenta, + glowStrongRgba: theme.colors.glow.magentaStrong, + }, + orange: { + color: theme.colors.neon.orange, + glow: theme.effects.textGlowOrange, + glowStrong: theme.effects.textGlowOrangeStrong, + boxGlow: theme.effects.boxGlowOrange, + boxGlowStrong: theme.effects.boxGlowOrangeStrong, + bgGlow: theme.effects.bgGlowOrange, + border: theme.colors.border.neonOrange, + glowRgba: theme.colors.glow.orange, + glowStrongRgba: theme.colors.glow.orangeStrong, + }, +}; +function colorCSSVars(prefix: string, color: ThemeColor): Record { + const v = COLOR_VARS[color]; return { - // Primary color (cyan or orange based on themeConfig) - '--color1': primaryColor, - '--color1-glow': primaryGlow, - '--color1-glow-strong': primaryGlowStrong, - '--color1-box-glow': primaryBoxGlow, - '--color1-box-glow-strong': primaryBoxGlowStrong, - '--color1-bg-glow': primaryBgGlow, - '--color1-border': primaryBorder, - '--color1-glow-rgba': primaryGlowRgba, - '--color1-glow-strong-rgba': primaryGlowStrongRgba, - - // Secondary color (based on themeConfig.secondaryColor) - '--color2': secondaryColor, - '--color2-glow': secondaryGlow, - '--color2-glow-strong': secondaryGlowStrong, - '--color2-box-glow': secondaryBoxGlow, - '--color2-box-glow-strong': secondaryBoxGlowStrong, - '--color2-bg-glow': secondaryBgGlow, - '--color2-border': secondaryBorder, - '--color2-glow-rgba': secondaryGlowRgba, - '--color2-glow-strong-rgba': secondaryGlowStrongRgba, - - // Base colors - '--color-bg-primary': theme.colors.bg.primary, - '--color-bg-secondary': theme.colors.bg.secondary, - '--color-text-primary': theme.colors.text.primary, - '--color-text-secondary': theme.colors.text.secondary, - - // Font family - '--font-family': `${themeConfig.fontFamily}, Arial, Helvetica, sans-serif`, + [`--${prefix}`]: v.color, + [`--${prefix}-glow`]: v.glow, + [`--${prefix}-glow-strong`]: v.glowStrong, + [`--${prefix}-box-glow`]: v.boxGlow, + [`--${prefix}-box-glow-strong`]: v.boxGlowStrong, + [`--${prefix}-bg-glow`]: v.bgGlow, + [`--${prefix}-border`]: v.border, + [`--${prefix}-glow-rgba`]: v.glowRgba, + [`--${prefix}-glow-strong-rgba`]: v.glowStrongRgba, }; -}; +} + +export const getThemeCSSVars = (): Record => ({ + ...colorCSSVars('color1', themeConfig.primaryColor), + ...colorCSSVars('color2', themeConfig.secondaryColor), + '--color-bg-primary': theme.colors.bg.primary, + '--color-bg-secondary': theme.colors.bg.secondary, + '--color-text-primary': theme.colors.text.primary, + '--color-text-secondary': theme.colors.text.secondary, + '--font-family': `${themeConfig.fontFamily}, Arial, Helvetica, sans-serif`, +}); -// Apply theme CSS variables to document root export const applyTheme = () => { - const vars = getThemeCSSVars(); const root = document.documentElement; - - Object.entries(vars).forEach(([key, value]) => { + for (const [key, value] of Object.entries(getThemeCSSVars())) { root.style.setProperty(key, value); - }); + } }; diff --git a/src/utils/scrollAnimations.ts b/src/utils/scrollAnimations.ts index 05a091d..753f591 100644 --- a/src/utils/scrollAnimations.ts +++ b/src/utils/scrollAnimations.ts @@ -58,6 +58,40 @@ export interface ScrollAnimationOptions { startOpacity?: number; } +const ROTATION_TYPES = ['rotateInX', 'rotateInY', 'rotateInClockwise', 'rotateInCounterClockwise']; + +function setupPerspectiveParent( + element: HTMLElement, + types: string[], + parentRef?: RefObject +) { + if (!types.some((t) => ROTATION_TYPES.includes(t))) return; + const parent = parentRef?.current ?? element.parentElement; + if (parent && !parent.classList.contains('anim_perspectiveParent')) { + parent.classList.add('anim_perspectiveParent'); + } +} + +function isInViewport(el: HTMLElement, triggerOffset: number): boolean { + const { top, bottom, left, right } = el.getBoundingClientRect(); + const wh = window.innerHeight || document.documentElement.clientHeight; + const ww = window.innerWidth || document.documentElement.clientWidth; + return top < wh - triggerOffset && bottom > triggerOffset && left < ww && right > 0; +} + +function computeTriggerOffset(types: string[], offset?: number): number { + if (offset !== undefined) return offset; + const wh = window.innerHeight || document.documentElement.clientHeight; + return types.includes('fadeIn') ? 80 : wh / 2; +} + +function buildClassName(types: string[], speed: string, isVisible: boolean): string { + const typeClasses = types.map((t) => `anim_${t}`).join(' '); + const speedClass = speed !== 'normal' ? `anim_${speed}` : ''; + const showClass = isVisible ? 'anim_show' : ''; + return ['anim', typeClasses, speedClass, showClass].filter(Boolean).join(' '); +} + /** * Hook to add scroll-triggered animations to elements * Returns a ref to attach to the element and className to apply @@ -78,90 +112,33 @@ export function useScrollAnimation(options: ScrollAnimationOptions = {}) { const element = elementRef.current; if (!element) return; - // Add perspective parent class if needed for 3D rotations - const needsPerspective = types.some((t) => - ['rotateInX', 'rotateInY', 'rotateInClockwise', 'rotateInCounterClockwise'].includes(t) - ); + setupPerspectiveParent(element, types, parentRef); - if (needsPerspective) { - const parent = parentRef?.current || element.parentElement; - if (parent && !parent.classList.contains('anim_perspectiveParent')) { - parent.classList.add('anim_perspectiveParent'); - } + if (triggerImmediately) { + requestAnimationFrame(() => requestAnimationFrame(() => setIsVisible(true))); + return; } const checkVisibility = () => { - const rect = element.getBoundingClientRect(); - const windowHeight = window.innerHeight || document.documentElement.clientHeight; - const windowWidth = window.innerWidth || document.documentElement.clientWidth; - - // Calculate trigger point based on animation type - let triggerOffset = offset; - if (triggerOffset === undefined) { - const hasFadeIn = types.includes('fadeIn'); - triggerOffset = hasFadeIn ? 80 : windowHeight / 2; - } - - // For elements at the top of the page with offset 0, trigger immediately if visible - const isInViewport = - rect.top < windowHeight - triggerOffset && - rect.bottom > triggerOffset && - rect.left < windowWidth && - rect.right > 0; - - if (isInViewport && !isVisible) { + if (!isVisible && isInViewport(element, computeTriggerOffset(types, offset))) { setIsVisible(true); } }; - // If triggerImmediately is true, set visible immediately without viewport check - if (triggerImmediately) { - // Use requestAnimationFrame to ensure DOM is painted, then set visible - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setIsVisible(true); - }); - }); - } else { - // Otherwise, check visibility on mount and scroll - checkVisibility(); - - // Set up scroll listener only if not triggering immediately - window.addEventListener('scroll', checkVisibility, { passive: true }); - window.addEventListener('resize', checkVisibility, { passive: true }); - - return () => { - window.removeEventListener('scroll', checkVisibility); - window.removeEventListener('resize', checkVisibility); - }; - } - - // No cleanup needed for triggerImmediately=true case + checkVisibility(); + window.addEventListener('scroll', checkVisibility, { passive: true }); + window.addEventListener('resize', checkVisibility, { passive: true }); + return () => { + window.removeEventListener('scroll', checkVisibility); + window.removeEventListener('resize', checkVisibility); + }; }, [isVisible, types, offset, triggerImmediately, parentRef]); - // Build className - const baseClasses = 'anim'; - const typeClasses = types.map((t) => `anim_${t}`).join(' '); - const speedClasses = speed !== 'normal' ? `anim_${speed}` : ''; - const showClass = isVisible ? 'anim_show' : ''; - - const className = [baseClasses, typeClasses, speedClasses, showClass].filter(Boolean).join(' '); - - // Apply custom starting opacity if fadeIn is used and startOpacity is not 0 useEffect(() => { const element = elementRef.current; - if (!element) return; - - if (types.includes('fadeIn') && startOpacity > 0 && startOpacity < 1) { - // Set initial opacity - if (!isVisible) { - element.style.opacity = String(startOpacity); - } else { - // When visible, ensure opacity is 1 (CSS will handle it, but we can remove inline style) - element.style.opacity = ''; - } - } + if (!(element && types.includes('fadeIn')) || startOpacity <= 0 || startOpacity >= 1) return; + element.style.opacity = isVisible ? '' : String(startOpacity); }, [types, startOpacity, isVisible]); - return { ref: elementRef, className, isVisible }; + return { ref: elementRef, className: buildClassName(types, speed, isVisible), isVisible }; } From 8ff3e84cc3856b9496ee75aae0c0cd7b29545ffc Mon Sep 17 00:00:00 2001 From: acktarius Date: Tue, 17 Mar 2026 20:13:40 -0400 Subject: [PATCH 2/3] under the hood #2 --- src/App.tsx | 12 ++++----- src/components/Header.tsx | 25 +++++++++++-------- src/components/SplashScreen.tsx | 8 +++--- .../sections/CryptoWidgetSection.tsx | 4 +-- src/components/sections/FeaturesSection.tsx | 12 +++++---- src/components/sections/LabsSection.tsx | 2 +- src/components/sections/MarketsSection.tsx | 6 ++--- src/components/sections/MiningSection.tsx | 6 ++--- src/components/sections/RoadmapSection.tsx | 14 +++++------ src/components/sections/WalletsSection.tsx | 4 +-- src/components/ui/Carousel.tsx | 2 +- src/components/ui/MajorLinks.tsx | 4 +-- src/components/ui/SocialMenu.tsx | 2 +- 13 files changed, 53 insertions(+), 48 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index da2c50e..6952638 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,8 +22,8 @@ interface AppProps { const doubleRAF = (cb: () => void) => requestAnimationFrame(() => requestAnimationFrame(cb)); function scrollToWithOffset(element: Element) { - const top = element.getBoundingClientRect().top + window.pageYOffset - 100; - window.scrollTo({ top, behavior: 'smooth' }); + const top = element.getBoundingClientRect().top + globalThis.pageYOffset - 100; + globalThis.scrollTo({ top, behavior: 'smooth' }); } function useHeroScroll(heroRef: React.RefObject) { @@ -31,11 +31,11 @@ function useHeroScroll(heroRef: React.RefObject) { useEffect(() => { const check = () => { if (!heroRef.current) return; - setIsPast(window.scrollY > heroRef.current.offsetTop + heroRef.current.offsetHeight); + setIsPast(globalThis.scrollY > heroRef.current.offsetTop + heroRef.current.offsetHeight); }; - window.addEventListener('scroll', check); + globalThis.addEventListener('scroll', check); check(); - return () => window.removeEventListener('scroll', check); + return () => globalThis.removeEventListener('scroll', check); }, [heroRef]); return isPast; } @@ -44,7 +44,7 @@ function useHashScroll(location: ReturnType) { useEffect(() => { if (location.pathname !== '/') return; const state = location.state as { scrollToHash?: string } | null; - const hash = state?.scrollToHash || location.hash || window.location.hash; + const hash = state?.scrollToHash || location.hash || globalThis.location.hash; if (!hash) return; const cleanHash = hash.startsWith('#') ? hash : `#${hash}`; let attempts = 0; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b324506..d8231f2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -76,7 +76,10 @@ const onHoverLeave = (e: React.MouseEvent) => { e.currentTarget.style.backgroundColor = 'transparent'; }; -function HamburgerButton({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { +function HamburgerButton({ + isOpen, + onToggle, +}: Readonly<{ isOpen: boolean; onToggle: () => void }>) { return (