'use client'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { gsap } from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import Lenis from 'lenis'; import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation'; import Link from 'next/link'; import ProblemsShift from '@/components/home/ProblemsShift'; import WhatWeDo from '@/components/home/WhatWeDo'; import ProductsSection from '@/components/home/ProductsSection'; import TheLoop from '@/components/home/TheLoop'; import initGlobalReveal from '@/utils/scrollReveal'; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; if (typeof window !== 'undefined') { gsap.registerPlugin(ScrollTrigger); } export default function IndexPage() { const [imagesLoaded, setImagesLoaded] = useState(false); const [loadProgress, setLoadProgress] = useState(0); const containerRef = useRef(null); const heroRef = useRef(null); const canvasRef = useRef(null); const overlayRef = useRef(null); const preloaderRef = useRef(null); const welcomeRef = useRef(null); const imagesRef = useRef([]); const currentFrameIndexRef = useRef(0); const canvasWidthRef = useRef(0); const canvasHeightRef = useRef(0); // tracks loaded count without re-rendering on every frame const loadedCountRef = useRef(0); const frameCount = 192; /* ── draw ─────────────────────────────────────────────────────── */ const drawFrame = useCallback((frameIndex: number) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const img = imagesRef.current[frameIndex]; if (!img) return; let canvasWidth = canvasWidthRef.current; let canvasHeight = canvasHeightRef.current; if (canvasWidth === 0 || canvasHeight === 0) { canvasWidth = canvas.offsetWidth; canvasHeight = canvas.offsetHeight; canvasWidthRef.current = canvasWidth; canvasHeightRef.current = canvasHeight; } const imgWidth = img.naturalWidth || img.width; const imgHeight = img.naturalHeight || img.height; if (imgWidth && imgHeight) { const imgRatio = imgWidth / imgHeight; const canvasRatio = canvasWidth / canvasHeight; let drawWidth = canvasWidth; let drawHeight = canvasHeight; let offsetX = 0; let offsetY = 0; if (canvasRatio > imgRatio) { drawHeight = canvasWidth / imgRatio; offsetY = (canvasHeight - drawHeight) / 2; } else { drawWidth = canvasHeight * imgRatio; offsetX = (canvasWidth - drawWidth) / 2; } ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); } }, []); /* ── resize ───────────────────────────────────────────────────── */ const handleResize = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; const logicalWidth = canvas.offsetWidth; const logicalHeight = canvas.offsetHeight; canvasWidthRef.current = logicalWidth; canvasHeightRef.current = logicalHeight; canvas.width = logicalWidth * dpr; canvas.height = logicalHeight * dpr; const ctx = canvas.getContext('2d'); if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); } drawFrame(currentFrameIndexRef.current); }, [drawFrame]); /* ── lock scroll until preloader exits ───────────────────────── */ useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); /* ── load frame 0 immediately (canvas visible behind preloader) ─ */ useEffect(() => { const firstImg = new Image(); firstImg.onload = () => { if (imagesRef.current.length === 0) { imagesRef.current = [firstImg]; const canvas = canvasRef.current; if (canvas) { const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; const ctx = canvas.getContext('2d'); if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); } drawFrame(0); } } }; firstImg.src = '/img_frames/frame_000_delay-0.041s.jpg'; }, [drawFrame]); /* ── preload all frames, update progress counter ─────────────── */ useEffect(() => { // Reset counter every time this effect runs (guards against Strict Mode // double-invocation and any navigation-back scenario that re-mounts the component) loadedCountRef.current = 0; const images: HTMLImageElement[] = []; // Use RAF-throttled progress updates to avoid 192 rapid re-renders let rafId = 0; const scheduleUpdate = (pct: number, done: boolean) => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { setLoadProgress(pct); if (done) { imagesRef.current = images; setImagesLoaded(true); } }); }; for (let i = 0; i < frameCount; i++) { const img = new Image(); const paddedIndex = String(i).padStart(3, '0'); const onLoad = () => { loadedCountRef.current++; const pct = Math.min(100, Math.round((loadedCountRef.current / frameCount) * 100)); const done = loadedCountRef.current >= frameCount; scheduleUpdate(pct, done); }; img.onload = onLoad; img.onerror = onLoad; img.src = `/img_frames/frame_${paddedIndex}_delay-0.041s.jpg`; images.push(img); } return () => { cancelAnimationFrame(rafId); // Reset so a re-run (Strict Mode or re-mount) always starts clean loadedCountRef.current = 0; }; }, []); /* ── resize listener ──────────────────────────────────────────── */ useEffect(() => { window.addEventListener('resize', handleResize); handleResize(); return () => window.removeEventListener('resize', handleResize); }, [handleResize, imagesLoaded]); /* ── ScrollTrigger refresh after all frames load ──────────────── */ useEffect(() => { if (!imagesLoaded) return; ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize(); const t1 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize(); }, 200); const t2 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); }, 600); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [imagesLoaded, handleResize]); /* ── animate preloader out when all frames are ready ─────────── */ useEffect(() => { if (!imagesLoaded) return; const preloader = preloaderRef.current; if (!preloader) return; const tl = gsap.timeline({ delay: 0.2, onComplete: () => { preloader.style.display = 'none'; document.body.style.overflow = ''; }, }); tl.to('.pl-number', { opacity: 0, y: -50, duration: 0.55, ease: 'power3.in' }, 0) .to('.pl-pct', { opacity: 0, duration: 0.4, ease: 'power2.in' }, 0.05) .to('.pl-label', { opacity: 0, duration: 0.4, ease: 'power2.in' }, 0.1) .to('.pl-bar', { scaleX: 1, duration: 0.25, ease: 'power3.out' }, 0) .to(preloader, { yPercent: -100, duration: 1.05, ease: 'expo.inOut' }, 0.4); // Kill the timeline on unmount so stale onComplete never runs after navigation return () => { tl.kill(); }; }, [imagesLoaded]); /* ── homepage header show/hide on scroll direction ───────────── */ useEffect(() => { document.body.classList.add('is-home-page', 'scroll-down'); const DELTA = 6; let lastY = window.scrollY; let ticking = false; const onScroll = () => { if (ticking) return; window.requestAnimationFrame(() => { const y = window.scrollY; const delta = y - lastY; if (y <= 100) { document.body.classList.add('scroll-down'); document.body.classList.remove('scroll-up'); } else if (Math.abs(delta) >= DELTA) { if (delta > 0) { document.body.classList.add('scroll-down'); document.body.classList.remove('scroll-up'); } else { document.body.classList.add('scroll-up'); document.body.classList.remove('scroll-down'); } } lastY = y; ticking = false; }); ticking = true; }; window.addEventListener('scroll', onScroll, { passive: true }); return () => { document.body.classList.remove('is-home-page', 'scroll-down', 'scroll-up'); window.removeEventListener('scroll', onScroll); }; }, []); /* ── Lenis + GSAP scrub (frame seq + welcome text + hero text) ── */ useIsomorphicLayoutEffect(() => { if (!imagesLoaded) return; const lenis = new Lenis({ duration: 1.2, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, }); lenis.on('scroll', ScrollTrigger.update); const updateTicker = (time: number) => lenis.raf(time * 1000); gsap.ticker.add(updateTicker); gsap.ticker.lagSmoothing(0); const cleanupReveal = initGlobalReveal(); const air = { frame: 0 }; const ctx = gsap.context(() => { const tl = gsap.timeline({ scrollTrigger: { trigger: containerRef.current, start: 'top top', end: '+=300%', pin: true, pinType: 'transform', scrub: true, anticipatePin: 1, invalidateOnRefresh: true, refreshPriority: 10, }, }); // Frame scrub (full duration = 1.0) tl.to(air, { frame: frameCount - 1, ease: 'none', duration: 1.0, onUpdate: () => { const f = Math.floor(air.frame); currentFrameIndexRef.current = f; drawFrame(f); }, }, 0); // "Welcome Buddy" text: visible at frame 0, fades OUT by frame 25 const welcomeEnd = 25 / (frameCount - 1); // ≈ 0.131 if (welcomeRef.current) { tl.fromTo( welcomeRef.current, { opacity: 1, y: 0, scale: 1 }, { opacity: 0, y: -28, scale: 0.93, ease: 'power2.in', duration: welcomeEnd }, 0 ); } // Hero text + white overlay: fade IN starting at frame 120 const startProgress = 120 / (frameCount - 1); const fadeDuration = 30 / (frameCount - 1); if (heroRef.current) { tl.to(heroRef.current, { opacity: 1, y: 0, ease: DEFAULT_EASE, duration: fadeDuration || DEFAULT_DURATION }, startProgress); } if (overlayRef.current) { tl.to(overlayRef.current, { opacity: 1, ease: DEFAULT_EASE, duration: fadeDuration || DEFAULT_DURATION }, startProgress); } }); return () => { ctx.revert(); lenis.destroy(); gsap.ticker.remove(updateTicker); if (typeof cleanupReveal === 'function') cleanupReveal(); }; }, [imagesLoaded, drawFrame]); /* ── render ───────────────────────────────────────────────────── */ return ( <> {/* ══════════════════════════════════════════ PRELOADER Fixed overlay — slides up when images done ══════════════════════════════════════════ */}
{/* Background: frame_000 blurred + dark overlay */}
{/* Noise grain */}
{/* Brand wordmark */}
Loyaly
{/* Big percentage counter */}
{loadProgress} %
{/* Sub-label */}
Loading experience
{/* Progress bar (full width, bottom) */}
{/* ══════════════════════════════════════════ HERO — pinned, drives canvas frame scrub ══════════════════════════════════════════ */}
{/* Canvas */}
{/* ── "Welcome Buddy" — centered, visible at frame 0, fades by frame 25 ── */}
{/* Glow ring behind text */}

Welcome{' '} Buddy

Scroll to explore

{/* Animated scroll chevron */}
{/* White shade behind the text content on the left only — right side stays fully clear (fades in with hero text at frame 120) */}
{/* Hero content (fades in at frame 120) */}

Turn Every
Walk In Into a
Loyal Customer

Loyaly.ai turns everyday digital engagement into real world shopping rewards — customers earn coins through games and daily activities, then redeem them at offline stores near them.

Book a Demo Get Started
{/* Other sections */} ); }