diff --git a/frontend/package.json b/frontend/package.json index da42b2e..5ac338b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "prepare": "husky" }, "dependencies": { + "@react-three/drei": "^10.0.5", + "@react-three/fiber": "^9.1.1", "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -20,12 +22,14 @@ "next": "^15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-use": "^17.6.0" + "react-use": "^17.6.0", + "three": "^0.175.0" }, "devDependencies": { "@types/node": "^22.10.5", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@types/three": "^0.175.0", "eslint": "^8", "eslint-config-next": "^15.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/frontend/src/app/home/components/AnimatedStat.tsx b/frontend/src/app/home/components/AnimatedStat.tsx new file mode 100644 index 0000000..59984be --- /dev/null +++ b/frontend/src/app/home/components/AnimatedStat.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import clsx from "clsx"; + +interface AnimatedStatProps { + value: string; + label: string; + secondaryValue?: string; +} + +const glitchChars = "!<>-_\\/[]{}—=+*^?#0123456789"; + +function getRandomChar() { + return glitchChars[Math.floor(Math.random() * glitchChars.length)]; +} + +export function AnimatedStat({ + value, + label, + secondaryValue, +}: AnimatedStatProps) { + const [displayedChars, setDisplayedChars] = useState( + Array(value.length).fill("X"), + ); + const [currentIndex, setCurrentIndex] = useState(0); + const [isGlitching, setIsGlitching] = useState(false); + + useEffect(() => { + if (currentIndex >= value.length) return; + + // Glitch effect for current character + const glitchInterval = setInterval(() => { + setIsGlitching((prev) => !prev); // Toggle glitch state for animation + setDisplayedChars((prev) => { + const next = [...prev]; + // Only glitch characters after the current stable index + for (let i = currentIndex; i < value.length; i++) { + next[i] = getRandomChar(); + } + return next; + }); + }, 50); + + // Stabilize current character after delay and move to next + const stabilizeTimeout = setTimeout(() => { + setDisplayedChars((prev) => { + const next = [...prev]; + next[currentIndex] = value[currentIndex]; + return next; + }); + setCurrentIndex((prev) => prev + 1); + }, 300); // Time before moving to next character + + return () => { + clearInterval(glitchInterval); + clearTimeout(stabilizeTimeout); + }; + }, [currentIndex, value]); + + // Start the animation after initial delay + useEffect(() => { + const startDelay = setTimeout(() => { + setDisplayedChars((prev) => { + const next = [...prev]; + next[0] = value[0]; // Stabilize first character immediately + return next; + }); + setCurrentIndex(1); // Start with second character + }, 500); + + return () => clearTimeout(startDelay); + }, [value]); + + return ( +
+
+

+ {displayedChars.map((char, index) => ( + = currentIndex && { + "animate-glitch-shift": isGlitching, + "relative before:absolute before:left-0 before:top-0 before:z-[-1] before:text-primary-purple before:opacity-50 before:blur-[1px] before:content-[attr(data-char)] after:absolute after:left-0 after:top-0 after:z-[-1] after:text-primary-blue after:opacity-50 after:blur-[1px] after:content-[attr(data-char)]": + true, + "text-shadow-neon": true, + }, + )} + data-char={char} + style={{ + transform: + index >= currentIndex && isGlitching + ? `translate(${Math.random() * 1 - 0.5}px, ${Math.random() * 1 - 0.5}px)` + : "none", + }} + > + {char} + + ))} +

+ {secondaryValue && ( +

{secondaryValue}

+ )} +
+

{label}

+
+ ); +} diff --git a/frontend/src/app/home/components/Hero.tsx b/frontend/src/app/home/components/Hero.tsx index 448086a..c4e81bd 100644 --- a/frontend/src/app/home/components/Hero.tsx +++ b/frontend/src/app/home/components/Hero.tsx @@ -1,7 +1,5 @@ import React from "react"; -import Image from "next/image"; - import Button from "@/components/Button"; import CustomLink from "@/components/CustomLink"; import ExternalLink from "@/components/ExternalLink"; @@ -9,55 +7,56 @@ import { request } from "@/utils/graphQLClient"; import { HeroQueryType, heroQuery } from "../queries/hero"; -import TokenStats from "./TokenStats"; +import { IcosahedronScene } from "./IcosahedronScene"; +import { ScrollIndicator } from "./ScrollIndicator"; +import { StatsSection } from "./StatsSection"; const Hero: React.FC = async () => { const heroData = await request(heroQuery); - const { - title, - subtitle, - primaryButton, - secondaryButton, - arrowLink, - background, - tokenStats, - } = heroData.homePageHero; + const { title, subtitle, primaryButton, secondaryButton, arrowLink } = + heroData.homePageHero; return ( -
-
-

- {title} -

-

{subtitle}

-
- - - -
-
- - - +
+
+ +
+
+
+

+ {title} +

+

+ {subtitle} +

+
+
+ + + +
+
+ + + +
+
+ +
- -
- Hero Image Background +
); }; diff --git a/frontend/src/app/home/components/HowKlerosWorks.tsx b/frontend/src/app/home/components/HowKlerosWorks.tsx index 9ce1d98..148d483 100644 --- a/frontend/src/app/home/components/HowKlerosWorks.tsx +++ b/frontend/src/app/home/components/HowKlerosWorks.tsx @@ -16,32 +16,34 @@ const HowKlerosWorks: React.FC = async () => { howKlerosWorks.homeHowKlerosWorksSection; return ( -
-
- -

- {title} -

-

{subtitle}

+
+
+
+ +

+ {title} +

+

{subtitle}

+
+
-
); }; diff --git a/frontend/src/app/home/components/IcosahedronScene.tsx b/frontend/src/app/home/components/IcosahedronScene.tsx new file mode 100644 index 0000000..f61e247 --- /dev/null +++ b/frontend/src/app/home/components/IcosahedronScene.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef } from "react"; + +import { Icosahedron } from "@react-three/drei"; +import { Canvas, useFrame } from "@react-three/fiber"; +import * as THREE from "three"; + +function RotatingIcosahedron() { + const meshRef = useRef(null); + const groupRef = useRef(null); + + useFrame((state) => { + if (!meshRef.current || !groupRef.current) return; + + // Gentle floating motion + groupRef.current.position.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.2; + + // Smooth rotation + meshRef.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.5) * 0.2; + meshRef.current.rotation.y += 0.005; + }); + + return ( + + {/* Main wireframe icosahedron */} + + + + {/* Inner glow */} + + + + + {/* Outer glow */} + + + + + + ); +} + +export function IcosahedronScene() { + return ( +
+ + + + + + +
+ ); +} diff --git a/frontend/src/app/home/components/ScrollIndicator.tsx b/frontend/src/app/home/components/ScrollIndicator.tsx new file mode 100644 index 0000000..7cab4e4 --- /dev/null +++ b/frontend/src/app/home/components/ScrollIndicator.tsx @@ -0,0 +1,24 @@ +"use client"; + +export function ScrollIndicator() { + return ( +
+
+ Scroll + + + +
+
+ ); +} diff --git a/frontend/src/app/home/components/StatsSection.tsx b/frontend/src/app/home/components/StatsSection.tsx new file mode 100644 index 0000000..94c45f5 --- /dev/null +++ b/frontend/src/app/home/components/StatsSection.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useScreenSize } from "@/hooks/useScreenSize"; + +import { AnimatedStat } from "./AnimatedStat"; + +export function StatsSection() { + const screenSize = useScreenSize(); + + return screenSize === "lg" ? ( +
+ + +
+ ) : null; +} diff --git a/frontend/src/app/home/components/TrustedBy.tsx b/frontend/src/app/home/components/TrustedBy.tsx index f27f6f7..75193db 100644 --- a/frontend/src/app/home/components/TrustedBy.tsx +++ b/frontend/src/app/home/components/TrustedBy.tsx @@ -13,39 +13,41 @@ const TrustedBy: React.FC = async () => { await request(partnersQuery); return ( -
-

- Trusted By -

-
+
+
+

+ Trusted By +

+
+
+ + +
+
- - + {institutions.map(({ name, link, image }) => ( + + {name} + + ))}
-
- {institutions.map(({ name, link, image }) => ( - - {name} - - ))} -
); }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 5c3a260..74ddcf9 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,17 +1,18 @@ import React from "react"; import clsx from "clsx"; -import { Urbanist } from "next/font/google"; +import { Anek_Odia } from "next/font/google"; import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; +import { AnimatedBackground } from "@/components/ui/AnimatedBackground"; import { HeroImagesQueryType, herosImagesQuery } from "@/queries/heroImages"; import { navbarQuery, NavbarQueryType } from "@/queries/navbar"; import "@/styles/globals.css"; import { getHeroImgsProps } from "@/utils/getHeroImgsProps"; import { request } from "@/utils/graphQLClient"; -const urbanist = Urbanist({ +const font = Anek_Odia({ weight: ["400", "500"], subsets: ["latin"], }); @@ -39,10 +40,11 @@ export default async function RootLayout({ > ))} - -
+ + +
-
{children}
+ {children}
diff --git a/frontend/src/components/CtaCard.tsx b/frontend/src/components/CtaCard.tsx index 8b64c01..efa3bad 100644 --- a/frontend/src/components/CtaCard.tsx +++ b/frontend/src/components/CtaCard.tsx @@ -30,7 +30,7 @@ const CtaCard: React.FC = ({
{icon ? ( @@ -42,7 +42,7 @@ const CtaCard: React.FC = ({ alt="Icon" /> ) : null} -

+

{title}

diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index f8dbe21..c12fa37 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -20,65 +20,71 @@ const Footer: React.FC = async () => { cta: result.footerSubscribeCta, })); return ( -

-
-
- {sections.map(({ title, links }) => ( -
-

{title}

- {links.map(({ name, url }) => ( - - {name} - - ))} -
- ))} -
-
-
- {socials.map(({ name, icon_white: icon, url }) => ( - - {name} - - ))} +
+
+
+
+ {sections.map(({ title, links }) => ( +
+

{title}

+ {links.map(({ name, url }) => ( + + {name} + + ))} +
+ ))} +
+
+
+ {socials.map(({ name, icon_white: icon, url }) => ( + + {name} + + ))} +
-
- kleros logo -

- {" "} - {cta.notice}{" "} -

-
-

{cta.cta_text}

- +
+
+
+ kleros logo +

+ {" "} + {cta.notice}{" "} +

+
+

{cta.cta_text}

+ +
+
diff --git a/frontend/src/components/ui/AnimatedBackground.tsx b/frontend/src/components/ui/AnimatedBackground.tsx new file mode 100644 index 0000000..0d74bb5 --- /dev/null +++ b/frontend/src/components/ui/AnimatedBackground.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + +export function AnimatedBackground() { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const mouseRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Configuration + const config = { + particleCount: 80, + particleRadius: { min: 2, max: 4 }, + particleSpeed: { min: 0.2, max: 0.5 }, + connectionDistance: 150, + mouseRadius: 150, + }; + + // Set canvas size + const setCanvasSize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + initParticles(); // Reinitialize particles when canvas size changes + }; + + // Initialize particles + const initParticles = () => { + particlesRef.current = Array.from( + { length: config.particleCount }, + () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * config.particleSpeed.max, + vy: (Math.random() - 0.5) * config.particleSpeed.max, + radius: + Math.random() * + (config.particleRadius.max - config.particleRadius.min) + + config.particleRadius.min, + }), + ); + }; + + // Update mouse position + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + mouseRef.current = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + }; + + // Animation loop + function animate() { + if (!ctx || !canvas) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Update and draw particles + particlesRef.current.forEach((particle, i) => { + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Bounce off edges + if (particle.x < 0 || particle.x > canvas.width) particle.vx *= -1; + if (particle.y < 0 || particle.y > canvas.height) particle.vy *= -1; + + // Keep particles within bounds + particle.x = Math.max(0, Math.min(canvas.width, particle.x)); + particle.y = Math.max(0, Math.min(canvas.height, particle.y)); + + // Draw connections + for (let j = i + 1; j < particlesRef.current.length; j++) { + const other = particlesRef.current[j]; + const dx = other.x - particle.x; + const dy = other.y - particle.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < config.connectionDistance) { + const opacity = 1 - distance / config.connectionDistance; + ctx.beginPath(); + ctx.strokeStyle = `rgba(151, 71, 255, ${opacity * 0.5})`; + ctx.lineWidth = 1; + ctx.moveTo(particle.x, particle.y); + ctx.lineTo(other.x, other.y); + ctx.stroke(); + } + } + + // Mouse interaction + const dx = mouseRef.current.x - particle.x; + const dy = mouseRef.current.y - particle.y; + const mouseDistance = Math.sqrt(dx * dx + dy * dy); + + if (mouseDistance < config.mouseRadius) { + const force = + (config.mouseRadius - mouseDistance) / config.mouseRadius; + particle.vx += (dx / mouseDistance) * force * 0.02; + particle.vy += (dy / mouseDistance) * force * 0.02; + } + + // Speed limit + const speed = Math.sqrt( + particle.vx * particle.vx + particle.vy * particle.vy, + ); + if (speed > config.particleSpeed.max) { + particle.vx = (particle.vx / speed) * config.particleSpeed.max; + particle.vy = (particle.vy / speed) * config.particleSpeed.max; + } + + // Draw particle + ctx.beginPath(); + const gradient = ctx.createRadialGradient( + particle.x, + particle.y, + 0, + particle.x, + particle.y, + particle.radius * 2, + ); + gradient.addColorStop(0, "rgba(151, 71, 255, 0.8)"); + gradient.addColorStop(1, "rgba(151, 71, 255, 0)"); + ctx.fillStyle = gradient; + ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + ctx.fill(); + }); + + requestAnimationFrame(animate); + } + + // Initialize + setCanvasSize(); + window.addEventListener("resize", setCanvasSize); + canvas.addEventListener("mousemove", handleMouseMove); + animate(); + + // Cleanup + return () => { + window.removeEventListener("resize", setCanvasSize); + canvas.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + return ( +