Files
loyalyweb/src/app/page.tsx

531 lines
21 KiB
TypeScript

'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<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const preloaderRef = useRef<HTMLDivElement | null>(null);
const welcomeRef = useRef<HTMLDivElement | null>(null);
const imagesRef = useRef<HTMLImageElement[]>([]);
const currentFrameIndexRef = useRef<number>(0);
const canvasWidthRef = useRef<number>(0);
const canvasHeightRef = useRef<number>(0);
// tracks loaded count without re-rendering on every frame
const loadedCountRef = useRef<number>(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
══════════════════════════════════════════ */}
<div
ref={preloaderRef}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}
>
{/* Background: frame_000 blurred + dark overlay */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: `url('/img_frames/frame_000_delay-0.041s.jpg')`,
backgroundSize: 'cover', backgroundPosition: 'center',
filter: 'blur(3px)', transform: 'scale(1.06)',
}} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(160deg,rgba(7,7,10,0.88) 0%,rgba(10,8,5,0.92) 100%)' }} />
{/* Noise grain */}
<div style={{
position: 'absolute', inset: 0, opacity: 0.035, mixBlendMode: 'overlay',
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
}} />
{/* Brand wordmark */}
<div className="pl-label" style={{
position: 'relative', zIndex: 2,
fontSize: 11, fontWeight: 800, letterSpacing: '0.3em',
textTransform: 'uppercase', color: 'rgba(244,190,40,0.75)',
fontFamily: 'Sora, sans-serif', marginBottom: 36,
userSelect: 'none',
}}>
Loyaly
</div>
{/* Big percentage counter */}
<div style={{ position: 'relative', zIndex: 2, display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<span className="pl-number" style={{
fontSize: 'clamp(88px, 14vw, 156px)',
fontWeight: 900, lineHeight: 0.86,
letterSpacing: '-0.05em', color: '#ffffff',
fontFamily: 'Sora, sans-serif',
fontVariantNumeric: 'tabular-nums',
userSelect: 'none',
willChange: 'transform, opacity',
}}>
{loadProgress}
</span>
<span className="pl-pct" style={{
fontSize: 'clamp(22px, 3.5vw, 38px)',
fontWeight: 800, color: 'rgba(255,255,255,0.35)',
fontFamily: 'Sora, sans-serif', marginTop: 10,
userSelect: 'none',
}}>%</span>
</div>
{/* Sub-label */}
<div className="pl-label" style={{
position: 'relative', zIndex: 2,
fontSize: 11, fontWeight: 600, letterSpacing: '0.2em',
textTransform: 'uppercase', color: 'rgba(255,255,255,0.28)',
fontFamily: 'Sora, sans-serif', marginTop: 28,
userSelect: 'none',
}}>
Loading experience
</div>
{/* Progress bar (full width, bottom) */}
<div className="pl-bar-track" style={{
position: 'absolute', bottom: 0, left: 0, right: 0, height: 3,
background: 'rgba(255,255,255,0.06)',
}}>
<div className="pl-bar" style={{
height: '100%',
width: `${loadProgress}%`,
background: 'linear-gradient(90deg, #f4be28, #FF9800)',
transition: 'width 0.12s linear',
borderRadius: '0 2px 2px 0',
}} />
</div>
</div>
{/* ══════════════════════════════════════════
HERO — pinned, drives canvas frame scrub
══════════════════════════════════════════ */}
<main
ref={containerRef}
className="loyaly-home relative bg-white overflow-hidden"
style={{
width: 'calc(100% - 40px)',
height: 'calc(100vh - 20px)',
margin: '0 20px 20px',
borderRadius: '25px',
}}
>
{/* Canvas */}
<div className="absolute inset-0 z-0 pointer-events-none" style={{ width: '100%', height: '100%' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
{/* ── "Welcome Buddy" — centered, visible at frame 0, fades by frame 25 ── */}
<div
ref={welcomeRef}
style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 12, textAlign: 'center',
pointerEvents: 'none', userSelect: 'none',
willChange: 'transform, opacity',
}}
>
{/* Glow ring behind text */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 320, height: 120,
background: 'radial-gradient(ellipse 60% 60% at 50% 50%, rgba(244,190,40,0.18) 0%, transparent 70%)',
filter: 'blur(20px)',
pointerEvents: 'none',
}} />
<p style={{
margin: 0,
fontSize: 'clamp(2rem, 4.5vw, 4.4rem)',
fontWeight: 900,
letterSpacing: '-0.04em',
lineHeight: 1,
color: '#ffffff',
fontFamily: 'Sora, sans-serif',
textShadow: '0 2px 32px rgba(0,0,0,0.5)',
position: 'relative',
}}>
Welcome{' '}
<span style={{
background: 'linear-gradient(105deg,#f4be28 0%,#ffd95a 50%,#FF9800 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>Buddy</span>
</p>
<p style={{
margin: '14px 0 0',
fontSize: 'clamp(0.78rem, 1.1vw, 1rem)',
fontWeight: 500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.42)',
fontFamily: 'Sora, sans-serif',
position: 'relative',
}}>
Scroll to explore
</p>
{/* Animated scroll chevron */}
<div style={{ marginTop: 18, display: 'flex', justifyContent: 'center', position: 'relative' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="rgba(244,190,40,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="animate-scroll-arrow">
<path d="M19 13l-7 7-7-7m7-7v14" />
</svg>
</div>
</div>
{/* White shade behind the text content on the left only — right side stays fully clear (fades in with hero text at frame 120) */}
<div
ref={overlayRef}
className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-r from-white/90 via-white/40 via-35% to-transparent to-55%"
style={{ opacity: 0 }}
/>
{/* Hero content (fades in at frame 120) */}
<div
ref={heroRef}
className="absolute inset-y-0 left-0 z-10 flex items-center justify-start pointer-events-none h-full"
style={{ opacity: 0, transform: 'translateY(30px)' }}
>
<div className="w-full lg:w-[50vw] px-8 sm:px-12 md:px-16 lg:pl-20 lg:pr-8 pointer-events-auto flex flex-col justify-center py-12 md:py-16">
<h1 className="text-[#171512] hero-heading text-3xl sm:text-4xl md:text-5xl lg:text-5xl xl:text-6xl font-extrabold tracking-tight leading-[1.12] mb-6 select-none">
Turn Every <br />
Walk In Into a <br />
<span className="hero-gradient-text">Loyal Customer</span>
</h1>
<p className="text-[#4f463a] hero-description text-sm sm:text-base md:text-lg xl:text-xl mb-8 leading-relaxed max-w-2xl select-none">
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.
</p>
<div className="flex flex-wrap gap-5">
<Link href="/contacts" className="flex items-center gap-3 bg-[#f4be28] hover:bg-[#ffd95a] hero-btn-primary text-[#171512] px-8 py-4 rounded-xl font-bold text-base md:text-lg transition-all duration-300 shadow-lg shadow-yellow-500/30 hover:shadow-yellow-500/50 hover:scale-[1.02] cursor-pointer no-underline">
Book a Demo
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
<Link href="/contacts" className="flex items-center border-2 border-[#f4be28] hover:bg-[#f4be28]/15 hero-btn-secondary text-[#f4be28] px-8 py-4 rounded-xl font-bold text-base md:text-lg transition-all duration-300 hover:scale-[1.02] cursor-pointer no-underline">
Get Started
</Link>
</div>
</div>
</div>
</main>
{/* Other sections */}
<ProblemsShift isLoaded={imagesLoaded} />
<WhatWeDo isLoaded={imagesLoaded} />
<ProductsSection isLoaded={imagesLoaded} />
<TheLoop />
</>
);
}