feat: update website layout, components, and assets

This commit is contained in:
R-Bharathraj
2026-07-09 18:12:02 +05:30
parent a5c5ec033a
commit cba47aea2b
52 changed files with 1979 additions and 1221 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
public/images/lyts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 MiB

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 MiB

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 9.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 MiB

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 MiB

After

Width:  |  Height:  |  Size: 7.6 MiB

View File

@@ -3,23 +3,21 @@
import React from 'react'; import React from 'react';
import VerticalScrollWrapper from '@/components/for-people/VerticalScrollWrapper'; import VerticalScrollWrapper from '@/components/for-people/VerticalScrollWrapper';
import HeroSection from '@/components/for-people/HeroSection'; import HeroSection from '@/components/for-people/HeroSection';
import PillarsSection from '@/components/for-people/PillarsSection';
import WhyLoyalySection from '@/components/for-people/WhyLoyalySection';
import DiscoverDealsSection from '@/components/for-people/DiscoverDealsSection'; import DiscoverDealsSection from '@/components/for-people/DiscoverDealsSection';
import EarnRewardsSection from '@/components/for-people/EarnRewardsSection'; import EarnRewardsSection from '@/components/for-people/EarnRewardsSection';
import SpendAnywhereSection from '@/components/for-people/SpendAnywhereSection'; import SpendAnywhereSection from '@/components/for-people/SpendAnywhereSection';
import AIRecommendationsSection from '@/components/for-people/AIRecommendationsSection'; import LiveMoreSection from '@/components/for-people/LiveMoreSection';
import LytsUniverseSection from '@/components/for-people/LytsUniverseSection';
import ExperienceSection from '@/components/for-people/ExperienceSection'; import ExperienceSection from '@/components/for-people/ExperienceSection';
import FinalCTASection from '@/components/for-people/FinalCTASection'; import FinalCTASection from '@/components/for-people/FinalCTASection';
const SECTIONS = [ const SECTIONS = [
'Intro', 'Intro',
'The Gap',
'Save More',
'Discover', 'Discover',
'Earn', 'Earn',
'Spend', 'Spend',
'Intelligence', 'Live',
'Lyts Universe',
'Experience', 'Experience',
'Get Started', 'Get Started',
]; ];
@@ -29,15 +27,15 @@ export default function ForPeoplePage() {
<main style={{ background: '#ffffff' }}> <main style={{ background: '#ffffff' }}>
<VerticalScrollWrapper sections={SECTIONS}> <VerticalScrollWrapper sections={SECTIONS}>
<HeroSection /> <HeroSection />
<WhyLoyalySection />
<PillarsSection />
<DiscoverDealsSection /> <DiscoverDealsSection />
<EarnRewardsSection /> <EarnRewardsSection />
<SpendAnywhereSection /> <SpendAnywhereSection />
<AIRecommendationsSection /> <LiveMoreSection />
<LytsUniverseSection />
<ExperienceSection /> <ExperienceSection />
<FinalCTASection /> <FinalCTASection />
</VerticalScrollWrapper> </VerticalScrollWrapper>
</main> </main>
); );
} }

View File

@@ -260,3 +260,7 @@ body.is-home-page.scroll-up .sticky-active {
animation-play-state: paused !important; animation-play-state: paused !important;
} }
} }
/* Hero carousel layout lives in HeroCarousel.tsx as a scoped <style> tag,
matching every other home-page section — keeps it working even if this
global stylesheet has a load/HMR hiccup. */

View File

@@ -1,198 +1,74 @@
'use client'; 'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; import { useEffect, useState } from 'react';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation';
import Link from 'next/link';
import NextImage from 'next/image';
import ProblemSection from '@/components/home/ProblemSection'; import ProblemSection from '@/components/home/ProblemSection';
import ShiftSection from '@/components/home/ShiftSection'; import ShiftSection from '@/components/home/ShiftSection';
import WhatWeDo from '@/components/home/WhatWeDo'; import WhatWeDo from '@/components/home/WhatWeDo';
import ProductsSection from '@/components/home/ProductsSection'; import ProductsSection from '@/components/home/ProductsSection';
import LytsUpSection from '@/components/home/LytsUpSection'; import LytsUpSection from '@/components/home/LytsUpSection';
import TheLoop from '@/components/home/TheLoop'; import TheLoop from '@/components/home/TheLoop';
import HeroCarousel from '@/components/home/HeroCarousel';
import initGlobalReveal from '@/utils/scrollReveal'; import initGlobalReveal from '@/utils/scrollReveal';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
} }
const DESKTOP_QUERY = '(min-width: 1024px)';
const subscribeDesktopQuery = (callback: () => void) => {
const mql = window.matchMedia(DESKTOP_QUERY);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
};
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches;
// Server has no viewport, so it always renders the desktop hero; the client's
// first render must match that to avoid a hydration mismatch.
const getDesktopServerSnapshot = () => true;
export default function IndexPage() { export default function IndexPage() {
const [imagesLoaded, setImagesLoaded] = useState(false); const [heroReady, setHeroReady] = useState(false);
// Desktop pinned canvas hero vs. mobile static hero. const [layoutSettled, setLayoutSettled] = useState(false);
const isDesktop = useSyncExternalStore(subscribeDesktopQuery, getDesktopSnapshot, getDesktopServerSnapshot);
const containerRef = useRef<HTMLDivElement | null>(null); /* ── global scroll-reveal for sections below the hero ─────────── */
const heroRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayRef = 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 on desktop until frames are ready to avoid the
pinned ScrollTrigger engaging mid-scroll and snapping the page ─ */
useEffect(() => { useEffect(() => {
if (imagesLoaded || !isDesktop) return; if (!heroReady) return;
document.body.style.overflow = 'hidden'; const cleanupReveal = initGlobalReveal();
return () => { document.body.style.overflow = ''; };
}, [imagesLoaded, isDesktop]);
/* ── load frame 0 immediately (canvas visible on first paint) ─── */ // A single immediate refresh isn't enough: below-the-fold images and the
useEffect(() => { // Sora webfont (loaded via <link>, not next/font) settle asynchronously
const firstImg = new Image(); // and change section heights well after first paint — measured up to
firstImg.onload = () => { // ~2000px of shift as images below the hero finish loading. Pinned
if (imagesRef.current.length === 0) { // ScrollTriggers (e.g. ProductsSection's horizontal pin) cache their
imagesRef.current = [firstImg]; // start/end at refresh time, so a stale refresh here makes the pin
const canvas = canvasRef.current; // engage at the wrong scroll offset.
if (canvas) { //
const dpr = window.devicePixelRatio || 1; // If the user has already scrolled into a pinned section by the time a
canvas.width = canvas.offsetWidth * dpr; // later refresh corrects that stale measurement, ScrollTrigger snaps the
canvas.height = canvas.offsetHeight * dpr; // scroll position to compensate — that snap is the "shaking". The body
const ctx = canvas.getContext('2d'); // scroll lock below prevents the user from reaching that state at all:
if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); } // scrolling stays locked until layout has genuinely settled.
drawFrame(0); ScrollTrigger.refresh();
}
}
};
firstImg.src = '/img_frames/frame_000_delay-0.041s.jpg';
}, [drawFrame]);
/* ── preload all frames, update progress counter ─────────────── */ let settled = false;
useEffect(() => { const refreshOnce = () => {
// Reset counter every time this effect runs (guards against Strict Mode if (settled) return;
// double-invocation and any navigation-back scenario that re-mounts the component) settled = true;
loadedCountRef.current = 0; ScrollTrigger.refresh();
setLayoutSettled(true);
const images: HTMLImageElement[] = [];
// Use RAF-throttled updates to avoid 192 rapid re-renders
let rafId = 0;
const scheduleUpdate = (done: boolean) => {
if (!done) return;
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
imagesRef.current = images;
setImagesLoaded(true);
});
}; };
for (let i = 0; i < frameCount; i++) { window.addEventListener('load', refreshOnce);
const img = new Image(); if (typeof document !== 'undefined' && document.fonts?.ready) {
const paddedIndex = String(i).padStart(3, '0'); document.fonts.ready.then(refreshOnce);
const onLoad = () => {
loadedCountRef.current++;
const done = loadedCountRef.current >= frameCount;
scheduleUpdate(done);
};
img.onload = onLoad;
img.onerror = onLoad;
img.src = `/img_frames/frame_${paddedIndex}_delay-0.041s.jpg`;
images.push(img);
} }
// Fallback in case neither signal fires promptly (e.g. already cached).
const fallback = setTimeout(refreshOnce, 800);
return () => { return () => {
cancelAnimationFrame(rafId); clearTimeout(fallback);
// Reset so a re-run (Strict Mode or re-mount) always starts clean window.removeEventListener('load', refreshOnce);
loadedCountRef.current = 0; if (typeof cleanupReveal === 'function') cleanupReveal();
}; };
}, []); }, [heroReady]);
/* ── resize listener ──────────────────────────────────────────── */ /* ── lock scroll until layout has settled, so the user can never land
mid-scroll inside a section whose pin position is about to correct ─ */
useEffect(() => { useEffect(() => {
window.addEventListener('resize', handleResize); if (layoutSettled) return;
handleResize(); document.body.style.overflow = 'hidden';
return () => window.removeEventListener('resize', handleResize); return () => { document.body.style.overflow = ''; };
}, [handleResize, imagesLoaded]); }, [layoutSettled]);
/* ── 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]);
/* ── homepage header show/hide on scroll direction ───────────── */ /* ── homepage header show/hide on scroll direction ───────────── */
useEffect(() => { useEffect(() => {
@@ -229,251 +105,19 @@ export default function IndexPage() {
}; };
}, []); }, []);
/* ── Lenis + GSAP scrub (frame seq + welcome text + hero text) ── */
useIsomorphicLayoutEffect(() => {
if (!imagesLoaded || !isDesktop) 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, isDesktop, drawFrame]);
/* ── render ───────────────────────────────────────────────────── */ /* ── render ───────────────────────────────────────────────────── */
return ( return (
<> <>
{/* ══════════════════════════════════════════ {/* ══════════════════════════════════════════
MOBILE HERO — static image below text content HERO — full-viewport premium carousel
══════════════════════════════════════════ */} ══════════════════════════════════════════ */}
<section <HeroCarousel onReady={() => setHeroReady(true)} />
className="loyaly-home relative bg-white overflow-hidden"
style={{ margin: '20px', borderRadius: '25px', display: isDesktop ? 'none' : 'block' }}
>
<div className="px-6 sm:px-8 pt-24 pb-8 flex flex-col">
<h1 className="text-[#171512] hero-heading text-3xl sm:text-4xl 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-[#f4be28] hero-tagline text-sm sm:text-base font-bold tracking-tight mb-3 select-none">
Offline is the new Online.
</p>
<p className="text-[#4f463a] hero-description text-sm sm:text-base mb-8 leading-relaxed select-none">
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn LYTs through games and daily activities, then redeem them at offline stores near them.
</p>
<div className="flex flex-wrap gap-4">
<Link href="/contacts" className="flex items-center gap-3 bg-[#f4be28] hover:bg-[#ffd95a] hero-btn-primary text-[#171512] px-6 py-3 rounded-xl font-bold text-base transition-all duration-300 shadow-lg shadow-yellow-500/30 hover:shadow-yellow-500/50 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-6 py-3 rounded-xl font-bold text-base transition-all duration-300 cursor-pointer no-underline">
Get Started
</Link>
</div>
</div>
<NextImage
src="/images/mobile_hero.png"
alt="Loyaly.AI"
width={792}
height={1271}
sizes="100vw"
className="w-full h-auto block"
/>
</section>
{/* ══════════════════════════════════════════
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 - 40px)',
margin: '20px',
borderRadius: '25px',
display: isDesktop ? 'block' : 'none',
}}
>
{/* 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-[#f4be28] hero-tagline text-sm sm:text-base md:text-lg font-bold tracking-tight mb-4 select-none">
Offline is the new Online.
</p>
<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 LYTs 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 */} {/* Other sections */}
<ProblemSection isLoaded={imagesLoaded} /> <ProblemSection isLoaded={heroReady} />
<ShiftSection isLoaded={imagesLoaded} /> <ShiftSection isLoaded={heroReady} />
<WhatWeDo isLoaded={imagesLoaded} /> <WhatWeDo isLoaded={heroReady} />
<ProductsSection isLoaded={imagesLoaded} /> <ProductsSection isLoaded={heroReady} />
<LytsUpSection /> <LytsUpSection />
<TheLoop /> <TheLoop />
</> </>

View File

@@ -150,9 +150,9 @@ export default function MeetTheCrewPage() {
<section style={{ padding: '140px 8%', background: '#07070d', margin: '0 20px 20px', borderRadius: 24, overflow: 'hidden', position: 'relative' }}> <section style={{ padding: '140px 8%', background: '#07070d', margin: '0 20px 20px', borderRadius: 24, overflow: 'hidden', position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '64px 64px', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '64px 64px', pointerEvents: 'none' }} />
<Reveal> <Reveal>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20, position: 'relative' }}>Meet the Products</div> <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20, position: 'relative' }}>Meet the Agents</div>
<h2 style={{ fontSize: 'clamp(36px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', position: 'relative', maxWidth: 700 }}> <h2 style={{ fontSize: 'clamp(36px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', position: 'relative', maxWidth: 700 }}>
Four products.<br />One ecosystem. Four agents.<br />One AI.
</h2> </h2>
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', maxWidth: 460, lineHeight: 1.7, marginBottom: 80, position: 'relative' }}> <p style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', maxWidth: 460, lineHeight: 1.7, marginBottom: 80, position: 'relative' }}>
Behavision, SpendSense, Dyscount, and Identiq, each solves a distinct part of the retail journey, and together they form one connected platform, from footfall to spend to loyalty. Behavision, SpendSense, Dyscount, and Identiq, each solves a distinct part of the retail journey, and together they form one connected platform, from footfall to spend to loyalty.

View File

@@ -252,7 +252,7 @@ export default function Header() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 48px', padding: '0 48px',
height: transparentHeader ? '136px' : '88px', height: transparentHeader ? '150px' : '92px',
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
transition: 'height 0.3s ease', transition: 'height 0.3s ease',
@@ -266,7 +266,7 @@ export default function Header() {
width={460} width={460}
height={138} height={138}
className="header-logo-img" className="header-logo-img"
style={{ objectFit: 'contain', height: transparentHeader ? '110px' : '42px', width: 'auto' }} style={{ objectFit: 'contain', height: transparentHeader ? '132px' : '46px', width: 'auto' }}
priority priority
/> />
</Link> </Link>
@@ -365,15 +365,15 @@ export default function Header() {
.header-nav-pill { padding: 5px 6px !important; } .header-nav-pill { padding: 5px 6px !important; }
.header-nav-link { padding: 8px 12px !important; font-size: 11px !important; letter-spacing: 0.02em !important; } .header-nav-link { padding: 8px 12px !important; font-size: 11px !important; letter-spacing: 0.02em !important; }
.header-cta { padding: 9px 16px !important; font-size: 12px !important; } .header-cta { padding: 9px 16px !important; font-size: 12px !important; }
.header-logo-img { height: 32px !important; } .header-logo-img { height: 36px !important; }
.header-transparent .header-logo-img { height: 92px !important; } .header-transparent .header-logo-img { height: 112px !important; }
} }
@media (min-width: 1281px) and (max-width: 1440px) { @media (min-width: 1281px) and (max-width: 1440px) {
.header-nav-pill { padding: 6px 8px !important; } .header-nav-pill { padding: 6px 8px !important; }
.header-nav-link { padding: 9px 16px !important; font-size: 12px !important; } .header-nav-link { padding: 9px 16px !important; font-size: 12px !important; }
.header-cta { padding: 10px 20px !important; font-size: 13px !important; } .header-cta { padding: 10px 20px !important; font-size: 13px !important; }
.header-logo-img { height: 36px !important; } .header-logo-img { height: 40px !important; }
.header-transparent .header-logo-img { height: 100px !important; } .header-transparent .header-logo-img { height: 122px !important; }
} }
.header-cta:hover { .header-cta:hover {
background: #f4be28 !important; background: #f4be28 !important;

View File

@@ -69,7 +69,9 @@ export default function Preloader() {
style={{ style={{
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
zIndex: 999999, // Must sit above the fixed Header (z-index 99999999) and its
// mobile panel (999999999) so only the logo shows while loading.
zIndex: 2147483647,
background: '#ffffff', background: '#ffffff',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -76,7 +76,7 @@ export default function AIRecommendationsSection() {
{/* Left — editorial */} {/* Left — editorial */}
<div> <div>
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence<span className="fp-kicker-num">06</span></span></div> <div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence<span className="fp-kicker-num">08</span></span></div>
<h2 style={{ <h2 style={{
fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94, fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94,

View File

@@ -1,27 +1,28 @@
'use client'; 'use client';
import React, { useState, useLayoutEffect, useEffect, useRef } from 'react'; import React, { useLayoutEffect, useEffect, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { PhoneFrame, PhoneBottomNav, BellIcon, PinIcon, ChevronIcon, SearchIcon, MicIcon, CameraIcon, MapIcon, HeartLogoIcon } from './PhoneMockup';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { FpTagCard } from './FpCard';
import { useCountUp, useParallax, useStaggerReveal } from './fpMotion';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger); if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const deals = [ const TAGS = [
{ store: 'Starbucks', category: 'Coffee & Drinks', offer: '2× LYTs', savings: '₹120 saved', color: '#00704A', dot: '#22c55e', distance: '0.3 km', rating: 4.8 }, { label: 'Live coupons', icon: '🎟️' },
{ store: 'Nike', category: 'Footwear', offer: '30% OFF', savings: '₹890 saved', color: '#171512', dot: '#d1d5db', distance: '0.7 km', rating: 4.9 }, { label: 'Nearby stores', icon: '📍' },
{ store: 'H&M', category: 'Fashion', offer: '₹200 Back', savings: '₹200 saved', color: '#E50010', dot: '#ef4444', distance: '1.1 km', rating: 4.6 }, { label: 'Game-based deals', icon: '🎮' },
{ store: 'Decathlon', category: 'Sports', offer: 'Free Gift', savings: '₹350 saved', color: '#0082C8', dot: '#3b82f6', distance: '1.4 km', rating: 4.7 },
{ store: 'Zara', category: 'Fashion', offer: '15% OFF', savings: '₹450 saved', color: '#171512', dot: '#d1d5db', distance: '0.9 km', rating: 4.5 },
]; ];
export default function DiscoverDealsSection() { export default function DiscoverDealsSection() {
const [hovered, setHovered] = useState<number | null>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null); const phoneRef = useRef<HTMLDivElement>(null);
const glowRef = useParallax<HTMLDivElement>(40);
const tagsRef = useStaggerReveal<HTMLDivElement>();
const dealsCountRef = useCountUp<HTMLSpanElement>(2400, { suffix: '+' });
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -35,12 +36,12 @@ export default function DiscoverDealsSection() {
{ {
y: 0, y: 0,
opacity: 1, opacity: 1,
duration: 1.1,
ease: 'power2.out', ease: 'power2.out',
scrollTrigger: { scrollTrigger: {
trigger: section, trigger: section,
start: 'top 92%', start: 'top 80%',
end: 'top 25%', once: true,
scrub: 1.2,
} }
} }
); );
@@ -53,143 +54,103 @@ export default function DiscoverDealsSection() {
<section ref={panelRef} className="fp-panel" style={{ background: '#08080f' }}> <section ref={panelRef} className="fp-panel" style={{ background: '#08080f' }}>
{/* Background photo */} {/* Background photo */}
<Image src="/images/stock/deals-retail.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.25, zIndex: 0 }} /> <Image src="/images/stock/discover.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.16, zIndex: 0 }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,8,15,0.3) 0%, rgba(8,8,15,0.75) 80%)', zIndex: 0 }} /> <div style={{
position: 'absolute', inset: 0, zIndex: 0,
background: 'linear-gradient(100deg, rgba(8,8,15,0.96) 0%, rgba(8,8,15,0.65) 42%, rgba(8,8,15,0.5) 68%, rgba(8,8,15,0.85) 100%), linear-gradient(180deg, rgba(8,8,15,0.2) 0%, rgba(8,8,15,0.8) 85%)',
}} />
{/* Soft radial */} {/* Soft radial — drifts with parallax for depth */}
<div className="fp-deco" style={{ <div ref={glowRef} className="fp-deco" style={{
position: 'absolute', top: '20%', left: '20%', width: 500, height: 500, borderRadius: '50%', position: 'absolute', top: '20%', left: '20%', width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(244,190,40,0.06) 0%, transparent 65%)', zIndex: 0, background: 'radial-gradient(circle, rgba(244,190,40,0.06) 0%, transparent 65%)', zIndex: 0,
}} /> }} />
<div className="fp-shell"> <div className="fp-shell ddm-shell">
<div className="fp-split" style={{ gridTemplateColumns: '0.9fr 1.1fr', alignItems: 'center' }}> <div className="fp-split ddm-split" style={{ gridTemplateColumns: '0.75fr 1.25fr', alignItems: 'center' }}>
{/* Headline column */} {/* Headline column */}
<div> <div>
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover<span className="fp-kicker-num">03</span></span></div> <div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover<span className="fp-kicker-num">01</span></span></div>
<h2 style={{ <h2 className="fp-h1" style={{ color: '#fff' }}>
fontSize: 'clamp(44px, 5.6vw, 88px)', fontWeight: 800, lineHeight: 0.94,
letterSpacing: '-0.05em', color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif',
}}>
<span className="fp-mask"><span>Every</span></span> <span className="fp-mask"><span>Every</span></span>
<span className="fp-mask"><span>deal in</span></span> <span className="fp-mask"><span>deal in</span></span>
<span className="fp-mask"><span className="fp-shimmer">your city.</span></span> <span className="fp-mask"><span className="fp-shimmer">your city.</span></span>
</h2> </h2>
<p className="fp-rise" style={{ <p className="fp-rise fp-desc" style={{ color: 'rgba(255,255,255,0.45)', maxWidth: 380 }}>
fontSize: 16, lineHeight: 1.8, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 36px', maxWidth: 380,
}}>
Find participating stores nearby and see their active game based discount campaigns, live coupons, and LYTs earning opportunities, all surfaced in real time so you never miss a deal. Find participating stores nearby and see their active game based discount campaigns, live coupons, and LYTs earning opportunities, all surfaced in real time so you never miss a deal.
</p> </p>
<div className="fp-rise" style={{ <div ref={tagsRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, maxWidth: 460 }}>
display: 'inline-flex', alignItems: 'center', gap: 9, {TAGS.map((tag) => (
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 100, padding: '11px 18px', backdropFilter: 'blur(12px)', <FpTagCard key={tag.label} theme="dark" icon={tag.icon}>{tag.label}</FpTagCard>
}}> ))}
</div>
<FpTagCard
theme="dark"
className="fp-rise"
style={{ display: 'inline-flex' }}
icon={(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="10" r="3" stroke="#f4be28" strokeWidth="2" /> <circle cx="12" cy="10" r="3" stroke="#f4be28" strokeWidth="2" />
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="#f4be28" strokeWidth="2" fill="none" /> <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="#f4be28" strokeWidth="2" fill="none" />
</svg> </svg>
<span style={{ fontSize: 13, fontWeight: 600, color: 'rgba(255,255,255,0.65)', fontFamily: 'Sora, sans-serif' }}>Coimbatore, TN</span> )}
</div>
</div>
{/* Phone mockup — white app screen, matches the in-app Discover experience */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneFrame floatClassName="ddm-phone-shell">
{/* Amber header */}
<div style={{ background: 'linear-gradient(180deg, #fcd34d 0%, #f4be28 100%)', padding: '34px 16px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Image src="/images/loyalyai.png" alt="Loyaly.AI" width={100} height={26} style={{ objectFit: 'contain', height: 16, width: 'auto' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, background: '#fff', borderRadius: 100, padding: '5px 10px', boxShadow: '0 2px 6px rgba(0,0,0,0.12)' }}>
<HeartLogoIcon />
<span style={{ fontWeight: 800, fontSize: 11.5, color: '#171512', fontFamily: 'Sora, sans-serif' }}>1,250</span>
</div>
<div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BellIcon />
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<PinIcon />
<span style={{ fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>R.S. Puram, Coimbatore</span>
<ChevronIcon color="#171512" />
</div>
</div>
{/* Search bar */}
<div style={{ padding: '12px 14px 4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, background: '#f5f5f6', borderRadius: 100, padding: '9px 14px' }}>
<SearchIcon />
<span style={{ flex: 1, color: '#9ca3af', fontSize: 12, fontFamily: 'Sora, sans-serif' }}>Search</span>
<MicIcon />
<CameraIcon />
</div>
</div>
{/* Deal rows */}
<div style={{ padding: '8px 12px 2px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{deals.map((deal, i) => (
<div
key={deal.store}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
style={{
display: 'grid', gridTemplateColumns: '34px minmax(0,1fr) 62px 12px', alignItems: 'center', gap: 8,
padding: '10px 8px', borderRadius: 14,
background: hovered === i ? '#fff7e0' : 'transparent',
transition: 'background 0.25s ease',
cursor: 'default',
}}
> >
<div style={{ position: 'relative', width: 34, height: 34, borderRadius: 10, background: deal.color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> Coimbatore, TN
<span style={{ fontSize: 12, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif' }}>{deal.store[0]}</span> </FpTagCard>
<span style={{ position: 'absolute', top: -2, left: -2, width: 9, height: 9, borderRadius: '50%', background: deal.dot, border: '2px solid #fff' }} />
</div> </div>
<div style={{ minWidth: 0 }}>
{/* Discover GIF */}
<div ref={phoneRef} className="ddm-gif-col" style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<div className="ddm-gif-wrap" style={{ position: 'relative', width: '100%', maxWidth: 960 }}>
{/* Ambient glow — depth without a hard border/card */}
<div style={{ <div style={{
fontSize: 12.5, fontWeight: 700, color: '#171512', fontFamily: 'Sora, sans-serif', position: 'absolute', inset: '-8%', zIndex: 0, borderRadius: 32,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', background: 'radial-gradient(60% 60% at 50% 45%, rgba(244,190,40,0.18) 0%, transparent 70%)',
}}>{deal.store}</div> filter: 'blur(40px)', pointerEvents: 'none',
}} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/stock/discover.gif"
alt="Discover deals in the Loyaly.AI app"
style={{ position: 'relative', zIndex: 1, width: '100%', aspectRatio: '1465 / 1074', objectFit: 'cover', borderRadius: 20, display: 'block' }}
/>
{/* Floating stat chip */}
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 5, marginTop: 2, position: 'absolute', left: 20, bottom: 20, zIndex: 2,
fontSize: 9.5, color: '#9ca3af', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'rgba(8,8,15,0.65)', border: '1px solid rgba(255,255,255,0.14)',
borderRadius: 100, padding: '9px 16px', backdropFilter: 'blur(14px)',
}}> }}>
<span>{deal.distance}</span> <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'block', animation: 'fpPulse 1.5s infinite' }} />
<span style={{ color: '#f4be28' }}> {deal.rating}</span> <span style={{ fontSize: 12.5, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>
<span ref={dealsCountRef}>2,400+</span> live deals nearby
</span>
</div> </div>
</div> </div>
<div style={{ textAlign: 'right', minWidth: 0 }}>
<div style={{
fontSize: 11.5, fontWeight: 800, color: '#f4be28', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{deal.offer}</div>
<div style={{
fontSize: 8.5, color: '#9ca3af', fontFamily: 'Sora, sans-serif', marginTop: 1,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{deal.savings}</div>
</div> </div>
<ChevronIcon />
</div> </div>
))}
</div> </div>
{/* More deals row */} <style jsx>{`
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 18px', margin: '2px 10px 10px', borderRadius: 14, background: '#faf7ee' }}> @media (min-width: 1024px) {
<MapIcon /> .ddm-split { grid-template-columns: 1fr 1fr !important; }
<span style={{ flex: 1, fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>+ 2,400 more deals near you</span> }
<ChevronIcon /> @media (min-width: 1440px) {
</div> .ddm-shell { max-width: 1600px !important; }
.ddm-gif-wrap { max-width: 920px !important; }
<PhoneBottomNav active="tag" /> }
</PhoneFrame> @media (min-width: 1800px) {
</div> .ddm-shell { max-width: 1800px !important; }
</div> .ddm-gif-wrap { max-width: 1040px !important; }
</div> }
`}</style>
</section> </section>
); );
} }

View File

@@ -1,29 +1,22 @@
'use client'; 'use client';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef } from 'react';
import Image from 'next/image';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { PhoneFrame, PhoneBottomNav, BellIcon, FlameIcon } from './PhoneMockup'; import { PhoneMockupImage } from './PhoneMockup';
import { FpTagCard, FpStatCard } from './FpCard';
import { useCountUp, useStaggerReveal } from './fpMotion';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger); if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const feed = [
{ store: 'Daily Login', pts: +50, time: '2m ago', type: 'earn' },
{ store: 'Walking 5,000 steps', pts: +80, time: '1h ago', type: 'earn' },
{ store: 'Spin Wheel Game', pts: +120, time: '3h ago', type: 'earn' },
{ store: 'Selfie Booth', pts: +200, time: 'Yesterday', type: 'earn' },
];
export default function EarnRewardsSection() { export default function EarnRewardsSection() {
const [pts, setPts] = useState(0);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null); const phoneRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number>(0); const tagsRef = useStaggerReveal<HTMLDivElement>();
const rowRefs = useRef<(HTMLDivElement | null)[]>([]); const statsRef = useStaggerReveal<HTMLDivElement>();
const activeRowRef = useRef(0); const waysCountRef = useCountUp<HTMLSpanElement>(10);
// Phone mockup rises in from the bottom as the section scrolls into view // Phone mockup rises in from the bottom as the section scrolls into view
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
@@ -52,216 +45,88 @@ export default function EarnRewardsSection() {
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
// Move the highlighted feed card as the section scrolls through the viewport const earnTags = [
useEffect(() => { { label: 'Walk', icon: '🚶', bg: '#eef2ff', fg: '#4c5fd5' },
if (typeof window === 'undefined') return; { label: 'Visit', icon: '📍', bg: '#fdeeee', fg: '#d5514c' },
const el = panelRef.current; { label: 'Selfie', icon: '🤳', bg: '#fef1e8', fg: '#d5824c' },
if (!el) return; { label: 'Spin', icon: '🎡', bg: '#f1eefd', fg: '#7a4cd5' },
{ label: 'Scratch', icon: '🎟️', bg: '#fdeef7', fg: '#d54ca3' },
const applyRow = (idx: number) => { { label: 'Challenge', icon: '🏆', bg: '#fff8e1', fg: '#b8860b' },
if (idx === activeRowRef.current) return; { label: 'Friend', icon: '🤝', bg: '#eef9f1', fg: '#3f9d5c' },
activeRowRef.current = idx; { label: 'Shop', icon: '🛍️', bg: '#eef7fd', fg: '#2f8fc4' },
rowRefs.current.forEach((row, i) => { { label: 'Event', icon: '🎉', bg: '#fdf1ee', fg: '#c45f2f' },
if (!row) return; { label: 'Brand', icon: '🏷️', bg: '#fef7ea', fg: '#c49a2f' },
const active = i === idx; ];
row.style.background = active ? '#f0fdf4' : 'transparent';
row.style.borderColor = active ? '#16a34a' : 'transparent';
row.style.transform = active ? 'translateX(4px) scale(1.02)' : 'translateX(0) scale(1)';
row.style.boxShadow = active ? '0 10px 24px -12px rgba(22,163,74,0.35)' : 'none';
const icon = row.querySelector<HTMLElement>('.feed-icon');
if (icon) {
icon.style.background = active ? 'rgba(22,163,74,0.14)' : 'rgba(23,21,18,0.04)';
icon.style.borderColor = active ? '#16a34a' : 'rgba(23,21,18,0.08)';
icon.style.color = active ? '#16a34a' : '#9ca3af';
}
const points = row.querySelector<HTMLElement>('.feed-pts');
if (points) {
points.style.background = active ? 'rgba(22,163,74,0.14)' : 'rgba(22,163,74,0.08)';
}
});
};
const st = ScrollTrigger.create({
trigger: el,
start: 'top 65%',
end: 'bottom 60%',
onUpdate: (self) => {
applyRow(Math.min(feed.length - 1, Math.floor(self.progress * feed.length)));
},
});
return () => st.kill();
}, []);
useEffect(() => {
const el = panelRef.current;
if (!el) return;
const obs = new MutationObserver(() => {
if (el.hasAttribute('data-active')) {
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / 1800, 1);
setPts(Math.round((1 - Math.pow(1 - t, 3)) * 284));
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}
});
obs.observe(el, { attributes: true, attributeFilter: ['data-active'] });
return () => { obs.disconnect(); cancelAnimationFrame(rafRef.current); };
}, []);
return ( return (
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff' }}> <section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff', position: 'relative', overflow: 'hidden' }}>
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
{/* Left — points + headline */} {/* Ambient decor — fills the white space behind the copy */}
<div> <div style={{ position: 'absolute', inset: 0, zIndex: 0, pointerEvents: 'none' }}>
{/* Live label */} <span style={{
<div className="fp-rise" style={{ position: 'absolute', top: '4%', right: '4%', fontSize: 'clamp(160px, 20vw, 340px)',
display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 20, fontWeight: 900, letterSpacing: '-0.05em', color: 'rgba(244,190,40,0.07)',
background: '#f0fdf4', border: '1.5px solid #16a34a', borderRadius: 100, padding: '7px 16px', fontFamily: 'Sora, sans-serif', lineHeight: 1, userSelect: 'none',
}}> }}>02</span>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0, animation: 'fpPulse 1.5s infinite' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Live LYTs balance</span>
</div>
{/* Giant gold number */}
<div style={{ <div style={{
fontSize: 'clamp(80px, 12vw, 168px)', fontWeight: 900, fontFamily: 'Sora, sans-serif', position: 'absolute', bottom: '-10%', left: '-6%', width: 360, height: 360, borderRadius: '50%',
color: '#f4be28', letterSpacing: '-0.05em', lineHeight: 0.92, background: 'radial-gradient(circle, rgba(244,190,40,0.10) 0%, transparent 70%)',
textShadow: '0 4px 40px rgba(244,190,40,0.25)', }} />
}}>
{pts.toLocaleString()}
</div> </div>
{/* Redeemable line */} <div className="fp-shell" style={{ position: 'relative', zIndex: 1 }}>
<div className="fp-rise" style={{ <div className="fp-split" style={{ gridTemplateColumns: '0.95fr 1.05fr' }}>
display: 'inline-flex', alignItems: 'center', gap: 10, marginTop: 14, marginBottom: 44,
padding: '10px 18px', borderRadius: 12, {/* Left — phone mockup, matches the in-app Earn / Home experience */}
background: '#fff7e0', border: '1px solid #f4be28', <div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
}}> <PhoneMockupImage
<span style={{ fontSize: 18, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif' }}>{pts.toLocaleString()}</span> src="/For_people/01_earn-cropped.png"
<span style={{ fontSize: 13, color: '#4f463a', fontFamily: 'Sora, sans-serif' }}>redeemable value</span> alt="Loyaly.AI Earn screen showing Lyts balance and activity feed"
floatClassName="erm-phone-shell"
aspectRatio={1684 / 3196}
/>
</div> </div>
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">04</span></span></div> {/* Right — headline */}
<div>
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">02</span></span></div>
<h2 style={{ <h2 className="fp-h2" style={{ color: '#171512' }}>
fontSize: 'clamp(28px, 3.4vw, 48px)', fontWeight: 800, lineHeight: 1.08, <span className="fp-mask"><span>Every Action</span></span>
letterSpacing: '-0.04em', color: '#171512', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', <span className="fp-mask"><span style={{ color: '#f4be28' }}>Earns Lyts.</span></span>
}}>
<span className="fp-mask"><span>Every step.</span></span>
<span className="fp-mask"><span style={{ color: '#f4be28' }}>Every game.</span></span>
<span className="fp-mask"><span>Every day.</span></span>
</h2> </h2>
<p className="fp-rise" style={{ <p className="fp-rise fp-desc" style={{ color: '#4f463a' }}>
fontSize: 15, lineHeight: 1.8, color: '#4f463a', fontFamily: 'Sora, sans-serif', maxWidth: 380, margin: 0, No app-opening rituals, no forced check-ins just live your day and watch the balance move.
</p>
<div ref={tagsRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, maxWidth: 460 }}>
{earnTags.map((tag) => (
<FpTagCard key={tag.label} theme="light" icon={tag.icon} iconBg={tag.bg}>
{tag.label} <span style={{ color: '#9a9184', fontWeight: 600 }}>Lyts</span>
</FpTagCard>
))}
</div>
{/* Stat strip — same card system as the tags above, distinguished by a filled dark surface */}
<div ref={statsRef} style={{ display: 'flex', gap: 10, marginBottom: 28, maxWidth: 460 }}>
<FpStatCard theme="fill" layout="row" style={{ flex: '1 1 0' }} icon="⚡"
value={<span ref={waysCountRef}>10</span>} label="ways to earn" />
<FpStatCard theme="fill" layout="row" style={{ flex: '1 1 0' }} icon="🔥"
value="24/7" label="always live" />
<FpStatCard theme="fill" layout="row" style={{ flex: '1 1 0' }} icon="🚀"
value="∞" label="no earn cap" />
</div>
<div className="fp-rise" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 34, height: 3, borderRadius: 2, background: '#f4be28', flexShrink: 0 }} />
<p style={{
fontSize: 'clamp(17px, 1.7vw, 21px)', lineHeight: 1.3, color: '#171512', fontFamily: 'Sora, sans-serif',
margin: 0, fontWeight: 800, letterSpacing: '-0.02em',
}}> }}>
Earn LYTs (Loyaly Tokens) through daily app login, completing 5,000-step walking challenges, playing Maze, Spin Wheel, Scratch Card, and Match Master games, or visiting Selfie Booths at partner malls. Every activity adds more LYTs to your balance. The more you live, <span style={{ color: '#f4be28' }}>the more you earn.</span>
</p> </p>
</div> </div>
{/* Right — phone mockup, matches the in-app Earn / Home experience */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneFrame floatClassName="erm-phone-shell">
{/* Amber header */}
<div style={{ background: 'linear-gradient(180deg, #fcd34d 0%, #f4be28 100%)', padding: '34px 16px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Image src="/images/loyalyai.png" alt="Loyaly.AI" width={100} height={26} style={{ objectFit: 'contain', height: 16, width: 'auto' }} />
<div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BellIcon />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FlameIcon />
<span style={{ fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>6-day streak · keep it going!</span>
</div>
</div>
{/* Balance card */}
<div style={{ margin: '14px 14px 0', borderRadius: 18, padding: '16px 18px', background: 'linear-gradient(135deg, #f4be28 0%, #e09820 100%)', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: '-30%', right: '-8%', width: 90, height: 90, borderRadius: '50%', background: 'rgba(255,255,255,0.14)' }} />
<div style={{ fontSize: 10, color: 'rgba(23,21,18,0.65)', fontWeight: 700, fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Total LYTs</div>
<div style={{ fontSize: 30, color: '#171512', fontWeight: 900, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', marginTop: 2 }}>284</div>
<div style={{ fontSize: 10.5, color: 'rgba(23,21,18,0.6)', fontFamily: 'Sora, sans-serif', marginTop: 3 }}> 284 redeemable</div>
</div>
{/* Feed header */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
margin: '16px 14px 6px',
}}>
<span style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#171512', fontFamily: 'Sora, sans-serif' }}>Activity feed</span>
<span style={{ fontSize: 9.5, color: '#16a34a', fontWeight: 700, fontFamily: 'Sora, sans-serif', display: 'flex', alignItems: 'center', gap: 4,
background: '#f0fdf4', border: '1px solid #16a34a', borderRadius: 100, padding: '3px 8px',
}}>
<span style={{ width: 4.5, height: 4.5, borderRadius: '50%', background: '#16a34a', display: 'block', animation: 'fpPulse 1.5s infinite' }} />
LIVE
</span>
</div>
{/* Feed rows */}
<div style={{ padding: '2px 10px', display: 'flex', flexDirection: 'column', gap: 3 }}>
{feed.map((tx, i) => (
<div key={i} ref={el => { rowRefs.current[i] = el; }} style={{
display: 'grid', gridTemplateColumns: '32px minmax(0,1fr) auto', alignItems: 'center', gap: 10,
padding: '8px 8px', borderRadius: 12,
background: i === 0 ? '#f0fdf4' : 'transparent',
border: `1px solid ${i === 0 ? '#16a34a' : 'transparent'}`,
transform: i === 0 ? 'translateX(4px) scale(1.02)' : 'translateX(0) scale(1)',
boxShadow: i === 0 ? '0 10px 24px -12px rgba(22,163,74,0.35)' : 'none',
transition: 'background 0.35s ease, border-color 0.35s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.35s ease',
}}>
<div className="feed-icon" style={{
width: 32, height: 32, borderRadius: 9, flexShrink: 0,
background: i === 0 ? 'rgba(22,163,74,0.14)' : 'rgba(23,21,18,0.04)',
border: `1.5px solid ${i === 0 ? '#16a34a' : 'rgba(23,21,18,0.08)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: i === 0 ? '#16a34a' : '#9ca3af',
transition: 'background 0.35s ease, border-color 0.35s ease, color 0.35s ease',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></svg>
</div>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 11.5, fontWeight: 700, color: '#171512', fontFamily: 'Sora, sans-serif',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{tx.store}</div>
<div style={{ fontSize: 9, color: '#9ca3af', fontFamily: 'Sora, sans-serif', marginTop: 1 }}>{tx.time}</div>
</div>
<span className="feed-pts" style={{
fontSize: 12, fontWeight: 900, fontFamily: 'Sora, sans-serif',
color: '#16a34a', letterSpacing: '-0.01em',
background: i === 0 ? 'rgba(22,163,74,0.14)' : 'rgba(22,163,74,0.08)',
padding: '3px 8px', borderRadius: 8, whiteSpace: 'nowrap',
transition: 'background 0.35s ease',
}}>
+{tx.pts}
</span>
</div>
))}
</div>
{/* Summary strip */}
<div style={{ margin: '10px 14px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[
{ label: 'Earned this month', val: '660 LYTs', color: '#16a34a', bg: '#f0fdf4', border: '#16a34a' },
{ label: 'Redeemable value', val: '₹660', color: '#bc7122', bg: '#fff7e0', border: '#f4be28' },
].map((s, i) => (
<div key={i} style={{ padding: '10px 12px', borderRadius: 12, background: s.bg, border: `1.5px solid ${s.border}` }}>
<div style={{ fontSize: 8.5, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif', marginBottom: 3 }}>{s.label}</div>
<div style={{ fontSize: 14, fontWeight: 900, color: s.color, fontFamily: 'Sora, sans-serif' }}>{s.val}</div>
</div>
))}
</div>
<PhoneBottomNav active="user" />
</PhoneFrame>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -54,7 +54,7 @@ export default function ExperienceSection() {
<div className="fp-shell"> <div className="fp-shell">
<div style={{ marginBottom: 'clamp(36px, 5vh, 60px)' }}> <div style={{ marginBottom: 'clamp(36px, 5vh, 60px)' }}>
<span className="fp-kicker fp-rise">The Experience<span className="fp-kicker-num">07</span></span> <span className="fp-kicker fp-rise">The Experience<span className="fp-kicker-num">06</span></span>
</div> </div>
<div className="fp-split" style={{ gridTemplateColumns: '1.1fr 0.9fr', alignItems: 'center' }}> <div className="fp-split" style={{ gridTemplateColumns: '1.1fr 0.9fr', alignItems: 'center' }}>

View File

@@ -4,6 +4,7 @@ import React, { useRef, useEffect, useLayoutEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useMagnetic as useMagneticShared } from './fpMotion';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger); if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
@@ -27,19 +28,61 @@ function useMagnetic() {
} }
const PROOF = [ const PROOF = [
{ val: '50K+', label: 'Active users', color: '#f4be28', bg: '#fffbeb', border: 'rgba(244,190,40,0.3)' }, { val: '50K+', label: 'Active users', color: '#f4be28', icon: '👥', iconBg: '#fffbeb' },
{ val: '10K+', label: 'Partner stores', color: '#16a34a', bg: '#f0fdf4', border: 'rgba(22,163,74,0.25)' }, { val: '10K+', label: 'Partner stores', color: '#16a34a', icon: '🏬', iconBg: '#f0fdf4' },
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', bg: '#eff6ff', border: 'rgba(37,99,235,0.2)' }, { val: '₹28L+', label: 'Rewards given', color: '#2563eb', icon: '🎁', iconBg: '#eff6ff' },
{ val: '4.9★', label: 'App rating', color: '#9333ea', bg: '#faf5ff', border: 'rgba(147,51,234,0.2)' }, { val: '4.9★', label: 'App rating', color: '#9333ea', icon: '', iconBg: '#faf5ff' },
]; ];
export default function FinalCTASection() { export default function FinalCTASection() {
const sectionRef = useRef<HTMLDivElement>(null); const sectionRef = useRef<HTMLDivElement>(null);
const mag1 = useMagnetic(); const mag1 = useMagnetic();
const mag2 = useMagnetic(); const mag2 = useMagnetic();
const coinLayerRef = useRef<HTMLDivElement>(null);
const badgeMag1 = useMagneticShared<HTMLDivElement>(10);
const badgeMag2 = useMagneticShared<HTMLDivElement>(10);
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const desktop = window.matchMedia('(min-width: 1024px)').matches;
// Coin burst — small LytsUp coins fly toward the badges the first time they appear.
let bursted = false;
const burstCoins = () => {
const layer = coinLayerRef.current;
if (bursted || reduced || !desktop || !layer) return;
const targets = [badgeMag1.current, badgeMag2.current].filter(Boolean) as HTMLElement[];
if (!targets.length) return;
bursted = true;
const layerRect = layer.getBoundingClientRect();
for (let i = 0; i < 14; i++) {
const target = targets[i % targets.length];
const tRect = target.getBoundingClientRect();
const tx = tRect.left - layerRect.left + tRect.width / 2;
const ty = tRect.top - layerRect.top + tRect.height / 2;
const coin = document.createElement('span');
coin.className = 'fcta-coin';
layer.appendChild(coin);
gsap.set(coin, {
x: layerRect.width * (0.15 + Math.random() * 0.7),
y: -10 - Math.random() * 40,
scale: 0.45 + Math.random() * 0.55,
opacity: 0,
});
gsap.timeline({ onComplete: () => coin.remove() })
.to(coin, { opacity: 1, duration: 0.18 }, 0)
.to(coin, {
x: tx + (Math.random() * 24 - 12),
y: ty + (Math.random() * 24 - 12),
rotation: Math.random() * 240 - 120,
duration: 0.7 + Math.random() * 0.45,
ease: 'power2.in',
}, 0)
.to(coin, { opacity: 0, scale: 0.15, duration: 0.28 }, '-=0.26');
}
};
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
const tl = gsap.timeline({ const tl = gsap.timeline({
scrollTrigger: { trigger: sectionRef.current, start: 'top 80%', toggleActions: 'play none none reset' }, scrollTrigger: { trigger: sectionRef.current, start: 'top 80%', toggleActions: 'play none none reset' },
@@ -56,29 +99,13 @@ export default function FinalCTASection() {
{ opacity: 0, y: 24 }, { opacity: 0, y: 24 },
{ opacity: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4' { opacity: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4'
) )
.fromTo('.fcta-hanging-logo', // Reveal on the magnet layer so the badge's own transform stays free for CSS hover + GSAP float.
{ opacity: 0, y: -60, rotation: -16 }, .fromTo('.fcta-badge-magnet',
{ opacity: 1, y: 0, rotation: 0, duration: 1.1, stagger: 0.16, ease: 'back.out(1.5)' }, { opacity: 0, y: 30, scale: 0.94 },
'-=0.45' { opacity: 1, y: 0, scale: 1, duration: 0.7, stagger: 0.12, ease: 'back.out(1.6)' },
'-=0.35'
) )
.fromTo('.fcta-logo-link', .call(burstCoins)
{ rotation: 10 },
{ rotation: 0, duration: 1.2, stagger: 0.16, ease: 'back.out(1.6)' },
'<'
)
.call(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
gsap.utils.toArray<HTMLElement>('.fcta-hanging-logo').forEach((el, i) => {
gsap.to(el, {
rotation: i % 2 === 0 ? 3.5 : -3.5,
duration: 2.4 + i * 0.3,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
transformOrigin: 'top center',
});
});
})
.fromTo('.fcta-btns', .fromTo('.fcta-btns',
{ opacity: 0, y: 20 }, { opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.45' { opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.45'
@@ -91,6 +118,30 @@ export default function FinalCTASection() {
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
// Continuous badge motion (desktop only): idle float + a glossy sweep & gentle pulse every ~68s.
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!window.matchMedia('(min-width: 1024px)').matches) return;
const ctx = gsap.context(() => {
gsap.utils.toArray<HTMLElement>('.fcta-badge-float').forEach((el, i) => {
gsap.to(el, { y: -6, duration: 2.6 + i * 0.3, ease: 'sine.inOut', yoyo: true, repeat: -1, delay: i * 0.4 });
});
gsap.utils.toArray<HTMLElement>('.fcta-store-badge').forEach((badge, i) => {
const sweep = badge.querySelector('.fcta-sweep');
const floatEl = badge.parentElement; // .fcta-badge-float
const tl = gsap.timeline({ repeat: -1, repeatDelay: 6 + i, delay: 3 + i * 0.9 });
if (sweep) tl.fromTo(sweep, { xPercent: -160 }, { xPercent: 260, duration: 1.1, ease: 'power2.inOut' }, 0);
if (floatEl) {
tl.to(floatEl, { scale: 1.03, duration: 0.5, ease: 'sine.out' }, 0)
.to(floatEl, { scale: 1, duration: 0.6, ease: 'sine.in' }, 0.5);
}
});
}, sectionRef);
return () => ctx.revert();
}, []);
return ( return (
<section <section
ref={sectionRef} ref={sectionRef}
@@ -125,7 +176,7 @@ export default function FinalCTASection() {
fontSize: 'clamp(52px,8vw,116px)', fontSize: 'clamp(52px,8vw,116px)',
fontWeight: 900, color: '#171512', fontWeight: 900, color: '#171512',
}}> }}>
Your rewards Your lyts
</span> </span>
<span className="fcta-line-2" style={{ <span className="fcta-line-2" style={{
display: 'block', display: 'block',
@@ -141,128 +192,73 @@ export default function FinalCTASection() {
</span> </span>
</h2> </h2>
{/* Lyts logo */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/lyts.png"
alt="Lyts"
style={{ height: 88, width: 'auto', display: 'block', margin: '0 auto 28px', objectFit: 'contain' }}
/>
{/* Subtitle */} {/* Subtitle */}
<p className="fcta-sub" style={{ <p className="fcta-sub" style={{
position: 'relative', position: 'relative',
zIndex: 5, zIndex: 5,
fontSize: 'clamp(1rem,1.2vw,1.15rem)', lineHeight: 1.75, fontSize: 'clamp(0.95rem,1.1vw,1.05rem)', lineHeight: 1.7,
color: '#171512', fontFamily: 'Sora, sans-serif', color: '#5c5344', fontFamily: 'Sora, sans-serif', fontWeight: 500,
maxWidth: 520, width: '100%', margin: '0 auto 0px', maxWidth: 440, width: '100%', margin: '0 auto',
background: '#f4be28',
border: '1.5px solid #f4be28',
borderRadius: 100,
padding: '14px 28px',
display: 'inline-block',
boxSizing: 'border-box',
}}> }}>
Download Loyaly and start earning on your very next purchase. Free. Instant. Yours. Download <strong style={{ color: '#171512', fontWeight: 800 }}>LytsUp</strong> and start earning on your very next purchase.{' '}
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5, fontWeight: 800, color: '#171512',
background: 'rgba(244,190,40,0.16)', borderRadius: 100, padding: '3px 12px', marginTop: 4,
}}>Free · Instant · Yours</span>
</p> </p>
{/* Hanging Logos */} {/* Store badges — layered: magnet (cursor pull) float (idle bob + pulse) badge (hover) */}
<div className="fcta-hanging-logos-wrapper" style={{ <div className="fcta-store-badges" style={{
position: 'relative', display: 'flex', justifyContent: 'center', flexWrap: 'wrap',
height: 250, gap: 16, margin: '36px auto 48px', width: 'fit-content', zIndex: 1, position: 'relative',
margin: '-34px auto 40px',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 140,
width: 'fit-content',
zIndex: 1,
}}> }}>
{/* Play Store Logo (hanging) */} {/* Coin-burst layer */}
<div className="fcta-hanging-logo play-store" style={{ <div ref={coinLayerRef} className="fcta-coin-layer" aria-hidden="true" />
display: 'flex',
flexDirection: 'column', {/* Google Play */}
alignItems: 'center', <div ref={badgeMag1} className="fcta-badge-magnet">
transformOrigin: 'top center', <div className="fcta-badge-float">
position: 'relative', <Link href="/contacts" className="fcta-store-badge" aria-label="Get it on Google Play">
}}> <span className="fcta-sweep" aria-hidden="true" />
{/* Keyring loop */} <span className="fcta-store-icon">
<div style={{ <svg width="26" height="26" viewBox="0 0 48 48" aria-hidden="true">
width: 16, height: 16, borderRadius: '50%', <path fill="#40C4FF" d="M8.2 42.3c-.4-.4-.6-1-.6-1.7V7.4c0-.7.2-1.3.6-1.7l.2-.2 18.9 18.9v.4L8.4 42.5l-.2-.2z" />
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3, <path fill="#FFC107" d="M33.5 30.4l-6.3-6.3v-.4l6.3-6.3.1.1 7.5 4.3c2.1 1.2 2.1 3.2 0 4.4l-7.6 4.2z" />
boxShadow: '0 0 10px rgba(244,190,40,0.35)', <path fill="#FF3D00" d="M33.6 30.3l-6.4-6.4-19 19c.7.7 1.9.8 3.2 0l22.2-12.6" />
flexShrink: 0, <path fill="#00E676" d="M33.6 17.7L11.4 5.1c-1.3-.8-2.5-.7-3.2 0l19 19 6.4-6.4z" />
}} /> </svg>
{/* String */} </span>
<div style={{ <span className="fcta-store-text">
width: 2, <small>GET IT ON</small>
height: 62, <strong>Google Play</strong>
background: '#f4be28', </span>
opacity: 0.7,
boxShadow: '0 0 6px rgba(244,190,40,0.25)',
}} />
{/* Logo Circle */}
<Link href="/contacts" className="fcta-logo-link" style={{
width: 116,
height: 116,
borderRadius: '50%',
overflow: 'hidden',
background: '#000000',
boxShadow: '0 18px 40px rgba(0,0,0,0.24), 0 0 0 1px rgba(244,190,40,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '5px solid #f4be28',
cursor: 'pointer',
textDecoration: 'none',
transformOrigin: 'top center',
marginTop: -1,
}}>
<img src="/logo_icons/images.png" alt="Google Play" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Link> </Link>
{/* Tooltip */}
<div className="fcta-tooltip">
Download In Playstore
</div> </div>
</div> </div>
{/* Apple Logo (hanging) */} {/* App Store */}
<div className="fcta-hanging-logo apple-store" style={{ <div ref={badgeMag2} className="fcta-badge-magnet">
display: 'flex', <div className="fcta-badge-float">
flexDirection: 'column', <Link href="/contacts" className="fcta-store-badge" aria-label="Download on the App Store">
alignItems: 'center', <span className="fcta-sweep" aria-hidden="true" />
transformOrigin: 'top center', <span className="fcta-store-icon">
position: 'relative', <svg width="24" height="24" viewBox="0 0 384 512" fill="#fff" aria-hidden="true">
}}> <path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
{/* Keyring loop */} </svg>
<div style={{ </span>
width: 16, height: 16, borderRadius: '50%', <span className="fcta-store-text">
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3, <small>DOWNLOAD ON THE</small>
boxShadow: '0 0 10px rgba(244,190,40,0.35)', <strong>App Store</strong>
flexShrink: 0, </span>
}} />
{/* String */}
<div style={{
width: 2,
height: 92,
background: '#f4be28',
opacity: 0.7,
boxShadow: '0 0 6px rgba(244,190,40,0.25)',
}} />
{/* Logo Circle */}
<Link href="/contacts" className="fcta-logo-link" style={{
width: 116,
height: 116,
borderRadius: '50%',
overflow: 'hidden',
background: '#000000',
boxShadow: '0 18px 40px rgba(0,0,0,0.24), 0 0 0 1px rgba(244,190,40,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '5px solid #f4be28',
cursor: 'pointer',
textDecoration: 'none',
transformOrigin: 'top center',
marginTop: -1,
}}>
<img src="/logo_icons/apple.jpg" alt="App Store" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Link> </Link>
{/* Tooltip */}
<div className="fcta-tooltip">
Download in App store
</div> </div>
</div> </div>
</div> </div>
@@ -303,70 +299,97 @@ export default function FinalCTASection() {
</Link> </Link>
</div> </div>
{/* Proof stats */} {/* Proof stats — individual sticker cards, matching the dark-border card system used across the page */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, flexWrap: 'wrap' }}> <div className="fcta-proof-row" style={{ display: 'flex', justifyContent: 'center', gap: 18, flexWrap: 'wrap', width: '80%', maxWidth: 720, margin: '0 auto' }}>
{PROOF.map((p, i) => ( {PROOF.map((p, i) => (
<div <div
key={i} key={i}
className="fcta-stat" className="fcta-stat"
style={{ style={{
padding: '18px 26px', borderRadius: 20, textAlign: 'center', minWidth: 110, flex: '1 1 130px', maxWidth: 160,
background: p.bg, display: 'flex', flexDirection: 'column', alignItems: 'center',
border: `1.5px solid ${p.border}`, padding: '22px 16px', borderRadius: 20, textAlign: 'center',
boxShadow: '0 2px 12px rgba(23,21,18,0.06)', background: '#fff', border: '1.5px solid #171512',
boxShadow: '4px 4px 0 #171512',
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
}} }}
> >
<div style={{ fontSize: 'clamp(20px,2.4vw,28px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div> <span style={{
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.05em' }}>{p.label}</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 40, height: 40, borderRadius: 12, background: p.iconBg,
fontSize: 18, marginBottom: 12,
}}>{p.icon}</span>
<div style={{ fontSize: 'clamp(20px,2.4vw,26px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div>
<div style={{ fontSize: 11.5, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.02em' }}>{p.label}</div>
</div> </div>
))} ))}
</div> </div>
<style>{`
.fcta-stat:hover { box-shadow: 6px 6px 0 #171512; transform: translate(-2px, -2px); }
`}</style>
</div> </div>
<style>{` <style>{`
@keyframes fctaPulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.5)} 50%{box-shadow:0 0 0 6px rgba(34,197,94,0)} } @keyframes fctaPulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.5)} 50%{box-shadow:0 0 0 6px rgba(34,197,94,0)} }
@keyframes fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} } @keyframes fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
.fcta-hanging-logo a { /* Animation layers — magnet (JS transform) float (GSAP transform) badge (CSS hover) */
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); .fcta-badge-magnet { display: inline-block; will-change: transform; }
.fcta-badge-float { display: inline-block; will-change: transform; }
.fcta-store-badge {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; gap: 12px;
padding: 12px 22px; border-radius: 16px;
background: linear-gradient(135deg, #23232b 0%, #0b0b10 100%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 12px 30px rgba(0,0,0,0.22), 0 0 0 0 rgba(244,190,40,0);
text-decoration: none; cursor: pointer;
transition: transform 0.35s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.4s ease, border-color 0.4s ease;
} }
.fcta-hanging-logo a:hover { /* Hover gradient overlay — fades in for a smooth gradient transition */
transform: scale(1.08); .fcta-store-badge::before {
content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: 0;
background: linear-gradient(135deg, #313139 0%, #16161d 100%);
opacity: 0; transition: opacity 0.4s ease;
} }
.fcta-tooltip { .fcta-store-badge:hover {
position: absolute; transform: translateY(-4px);
top: calc(100% + 4px); box-shadow: 0 18px 44px rgba(0,0,0,0.30), 0 0 30px rgba(244,190,40,0.35), 0 0 0 1px rgba(244,190,40,0.6);
left: 50%; border-color: rgba(244,190,40,0.6);
transform: translateX(-50%) translateY(8px);
background: #171512;
color: #ffffff;
padding: 6px 14px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
font-family: 'Sora', sans-serif;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.25, 1, 0.22, 1);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10;
} }
.fcta-hanging-logo:hover .fcta-tooltip { .fcta-store-badge:hover::before { opacity: 1; }
opacity: 1; /* Glossy light sweep (driven by GSAP every ~6-8s) */
transform: translateX(-50%) translateY(0); .fcta-sweep {
position: absolute; top: 0; left: 0; height: 100%; width: 55%; z-index: 2;
background: linear-gradient(105deg, transparent 0%, rgba(255,255,255,0.16) 50%, transparent 100%);
transform: translateX(-160%); pointer-events: none;
} }
.fcta-tooltip::before { .fcta-store-icon {
content: ''; position: relative; z-index: 1;
position: absolute; display: flex; align-items: center; justify-content: center;
top: -4px; width: 34px; height: 34px; flex-shrink: 0;
left: 50%; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1);
transform: translateX(-50%) rotate(45deg); }
width: 8px; .fcta-store-badge:hover .fcta-store-icon { transform: scale(1.14); }
height: 8px; .fcta-store-text {
background: #171512; position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: flex-start;
line-height: 1.1; font-family: 'Sora', sans-serif; color: #fff;
}
.fcta-store-text small { font-size: 9.5px; font-weight: 600; letter-spacing: 0.14em; opacity: 0.72; }
.fcta-store-text strong { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; margin-top: 1px; }
/* Coin-burst particles */
.fcta-coin-layer { position: absolute; inset: -60px 0 0 0; z-index: 3; pointer-events: none; }
.fcta-coin {
position: absolute; top: 0; left: 0; width: 13px; height: 13px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffe89a 0%, #f4be28 55%, #d99f16 100%);
box-shadow: 0 0 8px rgba(244,190,40,0.6), inset 0 0 0 1px rgba(255,255,255,0.25);
pointer-events: none; will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.fcta-sweep { display: none; }
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
.fcta-hanging-logos-wrapper { .fcta-store-badges {
display: none !important; display: none !important;
} }
.fcta-sub { .fcta-sub {
@@ -378,6 +401,11 @@ export default function FinalCTASection() {
display: none !important; display: none !important;
} }
} }
@media (max-width: 560px) {
.fcta-proof-row {
width: 100% !important;
}
}
`}</style> `}</style>
</section> </section>
); );

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
/**
* The single card system for the "For People" page.
* Wraps the shared `.fp-card*` classes (defined in VerticalScrollWrapper) so
* every section imports one component and the markup can't drift.
*/
type Theme = 'light' | 'dark' | 'fill';
const themeClass: Record<Theme, string> = {
light: 'fp-card--light',
dark: 'fp-card--dark',
fill: 'fp-card--fill',
};
const DEFAULT_ICON_BG: Record<Theme, string> = {
light: 'rgba(244,190,40,0.14)',
dark: 'rgba(244,190,40,0.14)',
fill: 'rgba(244,190,40,0.16)',
};
const sora = 'Sora, sans-serif';
interface TagCardProps {
theme?: Theme;
icon: React.ReactNode;
/** Icon-badge background; falls back to the theme's gold tint. */
iconBg?: string;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/** Compact icon + label pill (radius 12). */
export function FpTagCard({ theme = 'light', icon, iconBg, children, className = '', style }: TagCardProps) {
return (
<div className={`fp-card ${themeClass[theme]} ${className}`.trim()} style={style}>
<span className="fp-card-icon" style={{ background: iconBg ?? DEFAULT_ICON_BG[theme] }}>{icon}</span>
<span style={{
fontSize: 13, fontWeight: 700, fontFamily: sora, letterSpacing: '-0.01em',
whiteSpace: 'nowrap', color: theme === 'light' ? '#171512' : '#fff',
}}>
{children}
</span>
</div>
);
}
interface StatCardProps {
theme?: Theme;
layout?: 'row' | 'column';
icon: React.ReactNode;
iconBg?: string;
/** May be a plain node or a count-up <span> ref. */
value: React.ReactNode;
label: string;
/** Value text color; defaults to theme-appropriate. */
color?: string;
className?: string;
style?: React.CSSProperties;
}
/** Larger icon + value + label card (radius 20). */
export function FpStatCard({
theme = 'light', layout = 'column', icon, iconBg, value, label, color, className = '', style,
}: StatCardProps) {
const dark = theme !== 'light';
const column = layout === 'column';
return (
<div
className={`fp-card fp-card--stat ${themeClass[theme]} ${className}`.trim()}
style={{ flexDirection: column ? 'column' : 'row', alignItems: 'center', ...style }}
>
<span className="fp-card-icon fp-card-icon--lg" style={{ background: iconBg ?? DEFAULT_ICON_BG[theme] }}>{icon}</span>
<div style={{ textAlign: column ? 'center' : 'left' }}>
<div style={{
fontSize: column ? 'clamp(20px, 2.4vw, 26px)' : 18, fontWeight: 900, fontFamily: sora,
letterSpacing: '-0.03em', lineHeight: 1, color: color ?? (dark ? '#fff' : '#171512'),
}}>
{value}
</div>
<div style={{
fontSize: column ? 11.5 : 10.5, fontWeight: 600, marginTop: 5, fontFamily: sora,
color: dark ? 'rgba(255,255,255,0.55)' : 'rgba(23,21,18,0.5)',
}}>
{label}
</div>
</div>
</div>
);
}

View File

@@ -63,15 +63,15 @@ export default function HeroSection() {
fontSize: 'clamp(40px, 5.4vw, 82px)', fontWeight: 800, lineHeight: 1.02, fontSize: 'clamp(40px, 5.4vw, 82px)', fontWeight: 800, lineHeight: 1.02,
letterSpacing: '-0.045em', color: '#fff', margin: '50px 0 28px', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.045em', color: '#fff', margin: '50px 0 28px', fontFamily: 'Sora, sans-serif',
}}> }}>
<span className="fp-mask"><span>Shop Smart.</span></span> <span className="fp-mask"><span>Get Lit.</span></span>
<span className="fp-mask"><span className="fp-shimmer">Live Rewarded.</span></span> <span className="fp-mask"><span className="fp-shimmer">Live More.</span></span>
</h1> </h1>
<p className="fp-content fp-subtext" style={{ <p className="fp-content fp-subtext" style={{
fontSize: 'clamp(16px, 1.4vw, 19px)', lineHeight: 1.7, fontWeight: 400, fontSize: 'clamp(16px, 1.4vw, 19px)', lineHeight: 1.7, fontWeight: 400,
color: 'rgba(255,255,255,0.58)', maxWidth: 640, margin: '0 auto 40px', fontFamily: 'Sora, sans-serif', color: 'rgba(255,255,255,0.58)', maxWidth: 640, margin: '0 auto 40px', fontFamily: 'Sora, sans-serif',
}}> }}>
Meet <strong>LYTs (Loyaly Tokens)</strong>: the single currency behind every reward. Earn them through daily challenges and games, then spend instantly at partner stores near you with zero expiry. Meet <strong>Lyts</strong>: rewards for the things you already do. Walk, play, explore, shop, or complete a challenge, then spend your Lyts instantly at the brands you love. No waiting. No cashback. No complicated redemption.
</p> </p>
<div className="fp-content fp-cta" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}> <div className="fp-content fp-cta" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
@@ -93,9 +93,9 @@ export default function HeroSection() {
maxWidth: 600, maxWidth: 600,
}}> }}>
{[ {[
{ val: '1 LYT = ₹1', label: 'Fixed value' }, { val: '10+ Ways', label: 'To earn Lyts' },
{ val: 'Zero Expiry', label: 'Ever' }, { val: 'Instant Spend', label: 'No delay at checkout' },
{ val: '10,000+', label: 'Partner stores' } { val: '10,000+', label: 'Partner brands' }
].map((item, idx) => ( ].map((item, idx) => (
<div key={idx} style={{ <div key={idx} style={{
display: 'flex', display: 'flex',
@@ -124,7 +124,7 @@ export default function HeroSection() {
))} ))}
</div> </div>
<span style={{ fontSize: 13, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif' }}> <span style={{ fontSize: 13, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif' }}>
<strong style={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>50,000+</strong> active shoppers earning daily <strong style={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>50,000+</strong> Lytians earning and living more, every day
</span> </span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,95 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { PhoneMockupImage } from './PhoneMockup';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export default function LiveMoreSection() {
const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null);
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return;
const section = panelRef.current;
const phone = phoneRef.current;
if (!section || !phone) return;
const ctx = gsap.context(() => {
gsap.fromTo(phone,
{ y: 160, opacity: 0 },
{
y: 0, opacity: 1, ease: 'power2.out',
scrollTrigger: { trigger: section, start: 'top 92%', end: 'top 25%', scrub: 1.2 },
}
);
}, section);
return () => ctx.revert();
}, []);
return (
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff' }}>
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '0.95fr 1.05fr' }}>
{/* Left — phone mockup: lifestyle moments feed */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneMockupImage
src="/For_people/03_live-cropped.png"
alt="LytsUp Live More screen showing today's lifestyle moments"
floatClassName="live-phone-shell"
aspectRatio={1608 / 3088}
/>
</div>
{/* Right — headline + copy */}
<div>
<div style={{ marginBottom: 20 }}><span className="fp-kicker fp-rise">Live<span className="fp-kicker-num">04</span></span></div>
<h2 className="fp-h1" style={{ color: '#171512' }}>
<span className="fp-mask"><span>Live</span></span>
<span className="fp-mask"><span style={{ color: '#f4be28' }}>More.</span></span>
</h2>
<p className="fp-rise" style={{ fontSize: 15, lineHeight: 1.8, color: '#4f463a', fontFamily: 'Sora, sans-serif', maxWidth: 420, margin: '0 0 24px' }}>
Life isn&apos;t about collecting points. It&apos;s about collecting moments.
</p>
<div className="fp-rise" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28, maxWidth: 460 }}>
{[
{ label: 'Walk more', icon: '🚶', bg: '#eef2ff' },
{ label: 'Explore more', icon: '🧭', bg: '#fdeef7' },
{ label: 'Meet friends', icon: '🤝', bg: '#eef9f1' },
{ label: 'Support local', icon: '🏪', bg: '#fef1e8' },
{ label: 'Discover new brands', icon: '🏷️', bg: '#fef7ea' },
{ label: 'Celebrate every day', icon: '🎊', bg: '#fdeeee' },
].map((tag) => (
<div key={tag.label} className="fp-card fp-card--light">
<span className="fp-card-icon" style={{ background: tag.bg }}>{tag.icon}</span>
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif', color: '#171512', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>
{tag.label}
</span>
</div>
))}
</div>
<div className="fp-rise" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 34, height: 3, borderRadius: 2, background: '#f4be28', flexShrink: 0 }} />
<p style={{
fontSize: 'clamp(17px, 1.7vw, 21px)', lineHeight: 1.3, color: '#171512', fontFamily: 'Sora, sans-serif',
margin: 0, fontWeight: 800, letterSpacing: '-0.02em',
}}>
LytsUp rewards <span style={{ color: '#f4be28' }}>your lifestyle,</span> not just your purchases.
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { PhoneMockupImage } from './PhoneMockup';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const CHALLENGES = [
{ label: 'Walk More Challenge', icon: '🚶' },
{ label: 'Selfie Quest', icon: '🤳' },
{ label: 'Weekend Explorer', icon: '🗺️' },
{ label: 'Coffee Week', icon: '☕' },
{ label: 'Sneaker Hunt', icon: '👟' },
];
export default function LytsUniverseSection() {
const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null);
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return;
const section = panelRef.current;
const phone = phoneRef.current;
if (!section || !phone) return;
const ctx = gsap.context(() => {
gsap.fromTo(phone,
{ y: 160, opacity: 0 },
{
y: 0, opacity: 1, ease: 'power2.out',
scrollTrigger: { trigger: section, start: 'top 92%', end: 'top 25%', scrub: 1.2 },
}
);
}, section);
return () => ctx.revert();
}, []);
return (
<section ref={panelRef} className="fp-panel" style={{ background: '#080810' }}>
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
{/* Left — headline + copy */}
<div>
<div style={{ marginBottom: 20 }}><span className="fp-kicker fp-rise">The Lyts Universe<span className="fp-kicker-num">05</span></span></div>
<h2 className="fp-h1" style={{ color: '#fff' }}>
<span className="fp-mask"><span>Welcome to the</span></span>
<span className="fp-mask"><span className="fp-shimmer">World of Lyts.</span></span>
</h2>
<p className="fp-rise" style={{ fontSize: 15, lineHeight: 1.8, color: 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif', maxWidth: 420, margin: '0 0 24px' }}>
A world where every action has value. Meet the Lytians, real people with real stories and real rewards, straight from the app.
</p>
<div className="fp-rise" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28, maxWidth: 460 }}>
{CHALLENGES.map((tag) => (
<div key={tag.label} className="fp-card fp-card--dark">
<span className="fp-card-icon" style={{ background: 'rgba(244,190,40,0.14)' }}>{tag.icon}</span>
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif', color: '#fff', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>
{tag.label}
</span>
</div>
))}
</div>
<div className="fp-rise" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 34, height: 3, borderRadius: 2, background: '#f4be28', flexShrink: 0 }} />
<p style={{
fontSize: 'clamp(17px, 1.7vw, 21px)', lineHeight: 1.3, color: '#fff', fontFamily: 'Sora, sans-serif',
margin: 0, fontWeight: 800, letterSpacing: '-0.02em',
}}>
Become a <span style={{ color: '#f4be28' }}>Lytian.</span> Walk. Earn. Spend. Live More.
</p>
</div>
</div>
{/* Right — phone mockup: Lytian profile + India lighting up */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneMockupImage
src="/For_people/04_Lyts_Universe-cropped.png"
alt="LytsUp Lyts Universe screen showing Lytian profile and live city feed"
floatClassName="universe-phone-shell"
aspectRatio={1596 / 3120}
/>
</div>
</div>
</div>
</section>
);
}

View File

@@ -56,7 +56,7 @@ export function MapIcon() {
</svg> </svg>
); );
} }
export function HeartLogoIcon({ size = 16, active = false }: { size?: number; active?: boolean }) { export function HeartLogoIcon({ size = 16 }: { size?: number; active?: boolean }) {
return ( return (
<Image <Image
src="/images/loyalyai-logo.png" src="/images/loyalyai-logo.png"
@@ -67,7 +67,6 @@ export function HeartLogoIcon({ size = 16, active = false }: { size?: number; ac
width: size, width: size,
height: size, height: size,
objectFit: 'contain', objectFit: 'contain',
filter: active ? 'brightness(0)' : 'none',
}} }}
/> />
); );
@@ -94,12 +93,30 @@ export function FlameIcon({ color = '#f4be28' }: { color?: string }) {
); );
} }
/* ── Bottom nav — pass the active tab key to highlight it ── */ /* ── LytsUp brand mark — used in place of the Loyaly.AI heart+tag logo
export function PhoneBottomNav({ active = 'home' }: { active?: string }) { wherever a screen is themed for the LytsUp sub-brand (Live, Lyts Universe).
No own background: the parent nav item already supplies the active-state
gold pill, same convention as every other bottom-nav icon. ── */
export function LytsUpMarkIcon({ size = 18 }: { size?: number }) {
return (
<span style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, fontWeight: 900, fontSize: size * 0.72, lineHeight: 1,
fontFamily: 'Sora, sans-serif', color: 'currentColor',
}}>
L
</span>
);
}
/* ── Bottom nav — pass the active tab key to highlight it ──
centerIcon lets LytsUp-branded sections swap the middle tab's mark
(default is the Loyaly.AI heart+tag logo) without touching every caller. */
export function PhoneBottomNav({ active = 'home', centerIcon }: { active?: string; centerIcon?: (isActive: boolean) => React.ReactNode }) {
const items: { key: string; icon: (isActive: boolean) => React.ReactNode }[] = [ const items: { key: string; icon: (isActive: boolean) => React.ReactNode }[] = [
{ key: 'home', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10.5L12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></svg> }, { key: 'home', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10.5L12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></svg> },
{ key: 'tag', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41 12 22l-9-9V4h9z" /><circle cx="7.5" cy="8.5" r="1.2" /></svg> }, { key: 'tag', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41 12 22l-9-9V4h9z" /><circle cx="7.5" cy="8.5" r="1.2" /></svg> },
{ key: 'heart', icon: (isActive) => <HeartLogoIcon size={18} active={isActive} /> }, { key: 'heart', icon: centerIcon ?? ((isActive) => <HeartLogoIcon size={18} active={isActive} />) },
{ key: 'shop', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l1.5-5h15L21 9" /><path d="M3 9h18v11H3z" /><path d="M9 20v-6h6v6" /></svg> }, { key: 'shop', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l1.5-5h15L21 9" /><path d="M3 9h18v11H3z" /><path d="M9 20v-6h6v6" /></svg> },
{ key: 'user', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" /></svg> }, { key: 'user', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" /></svg> },
]; ];
@@ -122,11 +139,52 @@ export function PhoneBottomNav({ active = 'home' }: { active?: string }) {
); );
} }
/* ── Static phone-mockup image — pre-rendered device + screen (used in place
of the hand-built PhoneFrame where a polished, final-design screenshot is
available). Keeps the same float animation and responsive width as
PhoneFrame so it drops into the same section markup unchanged. ── */
export function PhoneMockupImage({
src, alt, floatClassName = 'pm-phone-float', width = 300, aspectRatio = 1684 / 3196,
}: { src: string; alt: string; floatClassName?: string; width?: number; aspectRatio?: number }) {
return (
<div className={`pm-phone-shell ${floatClassName}`} style={{ width, position: 'relative' }}>
<Image
src={src}
alt={alt}
width={1000}
height={Math.round(1000 / aspectRatio)}
style={{ width: '100%', height: 'auto', display: 'block' }}
priority={false}
/>
<style>{`
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.pm-phone-shell { animation: pmPhoneFloat 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pm-phone-shell { animation: none !important; } }
@media (max-width: 900px) {
.pm-phone-shell { width: ${MOBILE_WIDGET_WIDTH}px !important; }
}
`}</style>
</div>
);
}
/* ── Phone frame — dark bezel + dynamic island + gentle float, white screen inside ── */ /* ── Phone frame — dark bezel + dynamic island + gentle float, white screen inside ── */
/* Exact widget size on phone-width viewports — kept identical across every
section regardless of the (purely cosmetic) floatClassName passed in. */
const MOBILE_WIDGET_WIDTH = 280;
export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width = 300 }: { children: React.ReactNode; floatClassName?: string; width?: number }) { export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width = 300 }: { children: React.ReactNode; floatClassName?: string; width?: number }) {
// Convention across every section: <PhoneBottomNav /> is always passed as
// the last child. Splitting it out lets it sit pinned to the screen's
// bottom edge while the rest of the content fills the space above it —
// no scrollbar, no risk of the nav getting pushed out of view.
const items = React.Children.toArray(children);
const bottomNav = items[items.length - 1];
const content = items.slice(0, -1);
return ( return (
<div <div
className={floatClassName} className={`pm-phone-shell ${floatClassName}`}
style={{ style={{
width, borderRadius: 42, padding: 10, width, borderRadius: 42, padding: 10,
background: 'linear-gradient(160deg, #2a2a2e 0%, #0a0a0c 100%)', background: 'linear-gradient(160deg, #2a2a2e 0%, #0a0a0c 100%)',
@@ -134,21 +192,30 @@ export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width
position: 'relative', position: 'relative',
}} }}
> >
<div style={{ borderRadius: 32, overflow: 'hidden', background: '#ffffff', position: 'relative' }}> {/* Screen — locked to a real phone's aspect ratio (~9:19.5, e.g. iPhone 14)
so every section's widget reads as the same physical device regardless
of how much content it holds. */}
<div style={{
borderRadius: 32, overflow: 'hidden', background: '#ffffff', position: 'relative',
aspectRatio: '9 / 19.5', display: 'flex', flexDirection: 'column',
}}>
{/* Dynamic island */} {/* Dynamic island */}
<div style={{ <div style={{
position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)', position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)',
width: 82, height: 22, borderRadius: 14, background: '#000', zIndex: 5, width: 82, height: 22, borderRadius: 14, background: '#000', zIndex: 5,
}} /> }} />
{children} <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{content}
</div>
{bottomNav}
</div> </div>
<style>{` <style>{`
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } } @keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.pm-phone-float { animation: pmPhoneFloat 7s ease-in-out infinite; } .pm-phone-shell { animation: pmPhoneFloat 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pm-phone-float { animation: none !important; } } @media (prefers-reduced-motion: reduce) { .pm-phone-shell { animation: none !important; } }
@media (max-width: 900px) { @media (max-width: 900px) {
.pm-phone-float { width: 280px !important; } .pm-phone-shell { width: ${MOBILE_WIDGET_WIDTH}px !important; }
} }
`}</style> `}</style>
</div> </div>

View File

@@ -1,211 +1,93 @@
'use client'; 'use client';
import React, { useEffect, useLayoutEffect, useRef } from 'react'; import React, { useEffect, useLayoutEffect, useRef } from 'react';
import Image from 'next/image';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { PhoneMockupImage } from './PhoneMockup';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger); if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const GroceryIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>;
const FashionIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.38 3.46L16 2a4 4 0 01-8 0L3.62 3.46a2 2 0 00-1.34 2.23l.58 3.57a1 1 0 00.99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 002-2V10h2.15a1 1 0 00.99-.84l.58-3.57a2 2 0 00-1.34-2.23z"/></svg>;
const CafeIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>;
const SportsIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8l4 4-4 4-4-4 4-4z"/></svg>;
const ElectronicsIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>;
const BeautyIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>;
const brands = [
{ name: 'Groceries', Icon: GroceryIcon, stores: ['DMart', 'Reliance Fresh', "Spencer's"], color: '#22c55e' },
{ name: 'Fashion', Icon: FashionIcon, stores: ['Zara', 'H&M', 'Lifestyle'], color: '#a855f7' },
{ name: 'Food & Cafe', Icon: CafeIcon, stores: ['Starbucks', 'KFC', 'McD'], color: '#f97316' },
{ name: 'Sports', Icon: SportsIcon, stores: ['Decathlon', 'Nike', 'Adidas'], color: '#3b82f6' },
{ name: 'Electronics', Icon: ElectronicsIcon, stores: ['Croma', 'Reliance Digital'], color: '#06b6d4' },
{ name: 'Beauty', Icon: BeautyIcon, stores: ['Health & Glow', 'MAC', 'Forest Essentials'], color: '#ec4899' },
];
export default function SpendAnywhereSection() { export default function SpendAnywhereSection() {
const gridRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const phoneRef = useRef<HTMLDivElement>(null);
// Scroll-triggered blur/scale entrance for the cards // Phone mockup rises in from the bottom as the section scrolls into view
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const grid = gridRef.current; const section = panelRef.current;
if (!grid) return; const phone = phoneRef.current;
if (!section || !phone) return;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
gsap.fromTo('.spend-card', gsap.fromTo(phone,
{ opacity: 0, y: 44, scale: 0.92, filter: 'blur(8px)' }, { y: 160, opacity: 0 },
{ {
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)', y: 0, opacity: 1, ease: 'power2.out',
duration: 0.9, stagger: 0.09, ease: 'expo.out', scrollTrigger: { trigger: section, start: 'top 92%', end: 'top 25%', scrub: 1.2 },
scrollTrigger: { trigger: grid, start: 'top 85%', toggleActions: 'play none none reset' },
} }
); );
}, grid); }, section);
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
// Cursor-tracked spotlight + tilt per card
const handleMove = (i: number) => (e: React.MouseEvent<HTMLDivElement>) => {
const el = cardRefs.current[i];
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
el.style.setProperty('--mx', `${(x / rect.width) * 100}%`);
el.style.setProperty('--my', `${(y / rect.height) * 100}%`);
const rotateX = ((y - rect.height / 2) / rect.height) * -6;
const rotateY = ((x - rect.width / 2) / rect.width) * 6;
gsap.to(el, { rotateX, rotateY, duration: 0.4, ease: 'power2.out', transformPerspective: 700 });
};
const handleLeave = (i: number) => () => {
const el = cardRefs.current[i];
if (!el) return;
gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.5, ease: 'power3.out' });
};
return ( return (
<section className="fp-panel" style={{ background: '#080810' }}> <section ref={panelRef} className="fp-panel" style={{ background: '#080810' }}>
{/* Background photo */}
<Image src="/images/stock/spend-anywhere.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.25, zIndex: 0 }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,8,16,0.3) 0%, rgba(8,8,16,0.75) 80%)', zIndex: 0 }} />
<div className="fp-shell"> <div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
{/* Header row */} {/* Left — headline + copy */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 24, marginBottom: 'clamp(40px, 6vh, 64px)' }}>
<div> <div>
<div style={{ marginBottom: 22 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">05</span></span></div> <div style={{ marginBottom: 20 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">03</span></span></div>
<h2 style={{
fontSize: 'clamp(46px, 7vw, 110px)', fontWeight: 800, letterSpacing: '-0.06em', lineHeight: 0.9, <h2 className="fp-h1" style={{ color: '#fff' }}>
color: '#fff', margin: 0, fontFamily: 'Sora, sans-serif', <span className="fp-mask"><span>Your Lyts.</span></span>
}}> <span className="fp-mask"><span className="fp-shimmer">Your Choice.</span></span>
<span className="fp-mask"><span>Spend it</span></span>
<span className="fp-mask"><span className="fp-shimmer">anywhere.</span></span>
</h2> </h2>
</div>
<p className="fp-rise" style={{ <p className="fp-rise" style={{ fontSize: 15, lineHeight: 1.8, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', maxWidth: 420, margin: '0 0 24px' }}>
fontSize: 15, lineHeight: 1.75, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: 0, maxWidth: 320, Walk into a participating brand or store. Choose what you love. Tell the cashier you&apos;re paying with Lyts. Enjoy instant savings, no waiting, no cashback, no complicated redemption.
}}>
One balance. Every store. Your LYTs are universal: no restrictions, no expiry, no fine print.
</p> </p>
</div>
{/* Category grid */} <div className="fp-rise" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28, maxWidth: 460 }}>
<div ref={gridRef} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, perspective: 900 }}> {[
{brands.map((brand, i) => { { label: 'No waiting', icon: '⚡' },
const BrandIcon = brand.Icon; { label: 'No cashback', icon: '🚫' },
return ( { label: 'No complicated redemption', icon: '✅' },
<div ].map((tag) => (
key={i} <div key={tag.label} className="fp-card fp-card--dark">
ref={el => { cardRefs.current[i] = el; }} <span className="fp-card-icon" style={{ background: 'rgba(244,190,40,0.14)' }}>{tag.icon}</span>
onMouseMove={handleMove(i)} <span style={{ fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif', color: '#fff', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>
onMouseLeave={handleLeave(i)} {tag.label}
className="spend-card"
style={{
position: 'relative',
padding: '26px 28px',
borderRadius: 22,
background: '#0d0d16',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'default',
overflow: 'hidden',
transformStyle: 'preserve-3d',
['--card-color' as string]: brand.color,
}}
>
{/* Cursor-tracked spotlight */}
<div className="spend-card-glow" style={{
position: 'absolute', inset: 0, pointerEvents: 'none', borderRadius: 'inherit',
background: `radial-gradient(260px circle at var(--mx,50%) var(--my,50%), ${brand.color}26, transparent 65%)`,
}} />
{/* Border glow ring */}
<div className="spend-card-border" style={{
position: 'absolute', inset: 0, borderRadius: 'inherit', pointerEvents: 'none',
padding: 1,
background: `radial-gradient(220px circle at var(--mx,50%) var(--my,50%), ${brand.color}90, transparent 70%)`,
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}} />
<div style={{ position: 'relative', zIndex: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div className="spend-card-icon" style={{
width: 42, height: 42, borderRadius: 13,
background: `${brand.color}18`, border: `1px solid ${brand.color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center', color: brand.color,
}}>
<BrandIcon />
</div>
<span style={{
fontSize: 15, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em',
}}>{brand.name}</span>
</div>
{/* Explore arrow, slides in on hover */}
<span className="spend-card-arrow" style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: `1px solid ${brand.color}40`, color: brand.color,
opacity: 0, transform: 'translateX(-6px)',
transition: 'opacity 0.3s ease, transform 0.3s cubic-bezier(0.34,1.56,0.64,1)',
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span> </span>
</div> </div>
<div style={{ position: 'relative', zIndex: 2, height: 1, background: 'rgba(255,255,255,0.07)', marginBottom: 16 }} />
<div style={{ position: 'relative', zIndex: 2, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{brand.stores.map((s, j) => (
<span key={j} style={{
fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em',
fontFamily: 'Sora, sans-serif', color: 'rgba(255,255,255,0.55)',
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 100, padding: '5px 12px',
}}>{s}</span>
))} ))}
</div> </div>
</div>
);
})}
</div>
{/* Stat strip */} <div className="fp-rise" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="fp-rise" style={{ <span style={{ width: 34, height: 3, borderRadius: 2, background: '#f4be28', flexShrink: 0 }} />
display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 24, <p style={{
marginTop: 'clamp(40px, 6vh, 64px)', paddingTop: 32, borderTop: '1px solid rgba(255,255,255,0.07)', transitionDelay: '0.45s', fontSize: 'clamp(17px, 1.7vw, 21px)', lineHeight: 1.3, color: '#fff', fontFamily: 'Sora, sans-serif',
margin: 0, fontWeight: 800, letterSpacing: '-0.02em',
}}> }}>
{[ One Wallet. <span style={{ color: '#f4be28' }}>Thousands of Rewards.</span>
{ val: '10,000+', lbl: 'Partner stores' }, </p>
{ val: 'Zero', lbl: 'Expiry on LYTs' },
{ val: 'Universal', lbl: 'One balance, all brands' },
{ val: 'Instant', lbl: 'Redeem at checkout' },
].map((s, i) => (
<div key={i}>
<div style={{ fontSize: 'clamp(26px, 3vw, 42px)', fontWeight: 900, fontFamily: 'Sora, sans-serif', color: '#f4be28', letterSpacing: '-0.04em', lineHeight: 1 }}>{s.val}</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.35)', fontFamily: 'Sora, sans-serif', marginTop: 8, letterSpacing: '0.06em' }}>{s.lbl}</div>
</div>
))}
</div> </div>
</div> </div>
<style>{` {/* Right — phone mockup: where to spend */}
.spend-card { transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease; } <div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
.spend-card:hover { <PhoneMockupImage
border-color: rgba(255,255,255,0.16); src="/For_people/02_spend-cropped.png"
box-shadow: 0 24px 50px -16px rgba(0,0,0,0.6); alt="LytsUp Spend screen showing redeem-at categories"
} floatClassName="spa-phone-shell"
.spend-card:hover .spend-card-arrow { opacity: 1; transform: translateX(0); } aspectRatio={1516 / 3040}
.spend-card-glow, .spend-card-border { opacity: 0; transition: opacity 0.35s ease; } />
.spend-card:hover .spend-card-glow, .spend-card:hover .spend-card-border { opacity: 1; } </div>
`}</style> </div>
</div>
</section> </section>
); );
} }

View File

@@ -143,12 +143,36 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
--carbon: #171512; --carbon: #171512;
--warm: #4f463a; --warm: #4f463a;
--violet: #8258c8; --violet: #8258c8;
/* ── Typography scale (Discover is the reference — one size everywhere) ── */
--fp-h-hero: clamp(44px, 5.6vw, 88px);
--fp-h-weight: 800;
--fp-h-track: -0.045em;
--fp-desc-size: 16px;
--fp-desc-line: 1.8;
position: relative; position: relative;
background: #ffffff; background: #ffffff;
color: #fff; color: #fff;
padding-top: 20px; padding-top: 20px;
} }
/* ── Shared heading + description rhythm (one source of truth) ── */
/* .fp-h1 / .fp-h2 are intentionally identical — Discover's scale on every section. */
.fp-h1, .fp-h2 {
font-family: 'Sora', sans-serif;
font-weight: var(--fp-h-weight);
letter-spacing: var(--fp-h-track);
font-size: var(--fp-h-hero);
line-height: 0.94;
margin: 0 0 24px;
}
.fp-desc {
font-family: 'Sora', sans-serif;
font-size: var(--fp-desc-size);
line-height: var(--fp-desc-line);
margin: 0 0 24px;
max-width: 420px;
}
/* ── Film grain ── */ /* ── Film grain ── */
.fp-grain { .fp-grain {
position: fixed; inset: 0; z-index: 9; pointer-events: none; opacity: 0.04; position: fixed; inset: 0; z-index: 9; pointer-events: none; opacity: 0.04;
@@ -300,6 +324,26 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
gap: clamp(40px, 6vw, 96px); align-items: center; gap: clamp(40px, 6vw, 96px); align-items: center;
} }
/* ── Shared tag/stat card system (icon badge + label) ── */
.fp-card {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 12px;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.fp-card--light { background: #fff; border: 1.5px solid var(--carbon); }
.fp-card--light:hover { box-shadow: 3px 3px 0 var(--carbon); transform: translate(-2px, -2px); }
.fp-card--dark { background: rgba(255,255,255,0.04); border: 1.5px solid rgba(255,255,255,0.18); }
.fp-card--dark:hover { border-color: var(--gold); box-shadow: 3px 3px 0 rgba(244,190,40,0.35); transform: translate(-2px, -2px); }
.fp-card--fill { background: var(--carbon); border: 1.5px solid var(--carbon); }
.fp-card--fill:hover { box-shadow: 3px 3px 0 rgba(244,190,40,0.4); transform: translate(-2px, -2px); }
.fp-card-icon {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 9px; font-size: 14px; flex-shrink: 0;
}
/* Larger card for stat/feature content */
.fp-card--stat { padding: 20px 16px; border-radius: 20px; gap: 12px; }
.fp-card-icon--lg { width: 40px; height: 40px; border-radius: 12px; font-size: 18px; }
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 900px) { @media (max-width: 900px) {
.fp-cursor { display: none; } .fp-cursor { display: none; }

View File

@@ -0,0 +1,153 @@
'use client';
/**
* Shared GSAP + Lenis animation primitives for the "For People" page.
* The page's smooth scroll (Lenis) is already wired into GSAP's ticker inside
* VerticalScrollWrapper, so ScrollTrigger works everywhere without extra setup.
*/
import { useRef, useEffect, useLayoutEffect } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
// Run before paint on the client so initial hidden/zeroed states never flash.
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const prefersReduced = () =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Run `cb` once when `el` enters the viewport — or immediately if already in view. */
function onEnterOnce(el: HTMLElement, cb: () => void, startRatio = 0.9): () => void {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * startRatio && rect.bottom > 0) {
cb();
return () => {};
}
const st = ScrollTrigger.create({
trigger: el,
start: `top ${startRatio * 100}%`,
once: true,
onEnter: cb,
});
return () => st.kill();
}
/** Magnetic pull toward the cursor. Desktop only; no-op on touch / reduced-motion. */
export function useMagnetic<T extends HTMLElement>(strength = 12) {
const ref = useRef<T | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (prefersReduced() || window.matchMedia('(pointer: coarse)').matches) return;
const onMove = (e: MouseEvent) => {
const r = el.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width - 0.5) * strength * 2;
const y = ((e.clientY - r.top) / r.height - 0.5) * strength * 2;
el.style.transform = `translate(${x}px, ${y}px)`;
};
const onLeave = () => { el.style.transform = 'translate(0, 0)'; };
el.addEventListener('mousemove', onMove);
el.addEventListener('mouseleave', onLeave);
return () => {
el.removeEventListener('mousemove', onMove);
el.removeEventListener('mouseleave', onLeave);
};
}, [strength]);
return ref;
}
interface CountUpOpts {
duration?: number;
prefix?: string;
suffix?: string;
decimals?: number;
/** Custom formatter for the running value; overrides prefix/suffix/decimals body. */
format?: (v: number) => string;
}
/** Animate a number from 0 → `to` when it scrolls into view. Attach the returned ref to the text node. */
export function useCountUp<T extends HTMLElement>(to: number, opts: CountUpOpts = {}) {
const { duration = 1.6, prefix = '', suffix = '', decimals = 0, format } = opts;
const ref = useRef<T | null>(null);
useIsoLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const render = (v: number) => {
let body: string;
if (format) body = format(v);
else if (decimals > 0) body = v.toFixed(decimals);
else body = Math.round(v).toLocaleString('en-IN');
el.textContent = `${prefix}${body}${suffix}`;
};
if (prefersReduced()) { render(to); return; }
render(0);
const obj = { v: 0 };
return onEnterOnce(el, () => {
gsap.to(obj, { v: to, duration, ease: 'power2.out', onUpdate: () => render(obj.v) });
});
}, [to, duration, prefix, suffix, decimals, format]);
return ref;
}
/** Scrub-linked vertical parallax drift as the element's section moves through the viewport. */
export function useParallax<T extends HTMLElement>(distance = 50) {
const ref = useRef<T | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || prefersReduced()) return;
const anim = gsap.fromTo(
el,
{ y: -distance },
{
y: distance,
ease: 'none',
scrollTrigger: {
trigger: el.parentElement ?? el,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
}
);
return () => { anim.scrollTrigger?.kill(); anim.kill(); };
}, [distance]);
return ref;
}
interface StaggerOpts {
y?: number;
stagger?: number;
duration?: number;
startRatio?: number;
}
/** Staggered fade-up of a container's direct children when it enters view. */
export function useStaggerReveal<T extends HTMLElement>(opts: StaggerOpts = {}) {
const { y = 22, stagger = 0.06, duration = 0.6, startRatio = 0.85 } = opts;
const ref = useRef<T | null>(null);
useIsoLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const items = Array.from(el.children) as HTMLElement[];
if (!items.length) return;
if (prefersReduced()) { gsap.set(items, { opacity: 1, y: 0 }); return; }
gsap.set(items, { opacity: 0, y });
return onEnterOnce(
el,
() => { gsap.to(items, { opacity: 1, y: 0, duration, ease: 'power3.out', stagger }); },
startRatio
);
}, [y, stagger, duration, startRatio]);
return ref;
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import NextImage from 'next/image';
/** /**
* Premium animated dark background — gold gradient mesh orbs, drifting grid, * Premium animated dark background — gold gradient mesh orbs, drifting grid,
@@ -9,16 +10,31 @@ import React from 'react';
*/ */
export default function AnimatedDarkBg({ export default function AnimatedDarkBg({
variant = 'amber', variant = 'amber',
}: { variant?: 'amber' | 'cool' | 'mono' }) { bgImage,
bgImageAlt = '',
}: { variant?: 'amber' | 'cool' | 'mono'; bgImage?: string; bgImageAlt?: string }) {
const orbA = variant === 'cool' ? 'rgba(80,140,220,0.10)' : 'rgba(244,190,40,0.12)'; const orbA = variant === 'cool' ? 'rgba(80,140,220,0.10)' : 'rgba(244,190,40,0.12)';
const orbB = variant === 'cool' ? 'rgba(140,90,210,0.10)' : 'rgba(255,149,0,0.08)'; const orbB = variant === 'cool' ? 'rgba(140,90,210,0.10)' : 'rgba(255,149,0,0.08)';
return ( return (
<div aria-hidden className="adb-root" style={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none' }}> <div aria-hidden className="adb-root" style={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none' }}>
{/* Base gradient */} {bgImage && (
<NextImage
src={bgImage}
alt={bgImageAlt}
fill
sizes="100vw"
style={{ objectFit: 'cover' }}
/>
)}
{/* Base gradient — top-weighted scrim so the headline stays legible while the
image reads clearly in the middle, plus a lift at the bottom for card contrast */}
<div style={{ <div style={{
position: 'absolute', inset: 0, position: 'absolute', inset: 0,
background: 'radial-gradient(ellipse 120% 80% at 50% -10%, #131017 0%, #07070d 55%, #050509 100%)', background: bgImage
? 'linear-gradient(180deg, rgba(6,6,10,0.82) 0%, rgba(6,6,10,0.55) 22%, rgba(6,6,10,0.3) 42%, rgba(6,6,10,0.35) 60%, rgba(6,6,10,0.62) 100%)'
: 'radial-gradient(ellipse 120% 80% at 50% -10%, #131017 0%, #07070d 55%, #050509 100%)',
}} /> }} />
{/* Drifting mesh orbs */} {/* Drifting mesh orbs */}
@@ -48,7 +64,9 @@ export default function AnimatedDarkBg({
{/* Vignette */} {/* Vignette */}
<div style={{ <div style={{
position: 'absolute', inset: 0, position: 'absolute', inset: 0,
background: 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 55%, rgba(0,0,0,0.45) 100%)', background: bgImage
? 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 60%, rgba(0,0,0,0.3) 100%)'
: 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 55%, rgba(0,0,0,0.45) 100%)',
}} /> }} />
<style>{` <style>{`

View File

@@ -0,0 +1,468 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import HeroSlide from './HeroSlide';
import HeroContent from './HeroContent';
import HeroNavigation from './HeroNavigation';
import HeroProgress from './HeroProgress';
import { HERO_SLIDES, HERO_AUTOPLAY_MS } from './heroCarouselData';
if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
}
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
interface HeroCarouselProps {
onReady?: () => void;
}
export default function HeroCarousel({ onReady }: HeroCarouselProps) {
const [activeIndex, setActiveIndex] = useState(0);
const indexRef = useRef(0);
const sectionRef = useRef<HTMLDivElement>(null);
const fillRefs = useRef<(HTMLDivElement | null)[]>([]);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const readyFiredRef = useRef(false);
const count = HERO_SLIDES.length;
const startAutoplay = useCallback((index: number) => {
timelineRef.current?.kill();
fillRefs.current.forEach((el, i) => {
if (!el) return;
if (i < index) gsap.set(el, { scaleX: 1 });
else if (i > index) gsap.set(el, { scaleX: 0 });
});
const fill = fillRefs.current[index];
if (fill) gsap.set(fill, { scaleX: 0 });
const tl = gsap.timeline({
onComplete: () => goTo(index + 1),
});
tl.to(fill, { scaleX: 1, duration: HERO_AUTOPLAY_MS / 1000, ease: 'none' }, 0);
timelineRef.current = tl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const goTo = useCallback((rawIndex: number) => {
const nextIndex = ((rawIndex % count) + count) % count;
indexRef.current = nextIndex;
setActiveIndex(nextIndex);
startAutoplay(nextIndex);
}, [count, startAutoplay]);
const handlePrev = useCallback(() => goTo(indexRef.current - 1), [goTo]);
const handleNext = useCallback(() => goTo(indexRef.current + 1), [goTo]);
const handleSelect = useCallback((i: number) => goTo(i), [goTo]);
const handleMouseEnter = useCallback(() => {
timelineRef.current?.pause();
}, []);
const handleMouseLeave = useCallback(() => {
timelineRef.current?.play();
}, []);
useEffect(() => {
startAutoplay(0);
if (!readyFiredRef.current) {
readyFiredRef.current = true;
onReady?.();
}
return () => { timelineRef.current?.kill(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Scroll-out parallax: as the page scrolls the hero out of view, the
// background eases into a slow zoom and the content/controls drift up and
// fade — scrubbed to actual scroll position (not pinned, so it can't hit
// the pin/margin jump issues the sections below had). Skipped entirely
// under prefers-reduced-motion.
useIsomorphicLayoutEffect(() => {
const section = sectionRef.current;
if (!section) return;
const ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
gsap.to('.hero-carousel-slides', {
scale: 1.08,
ease: 'none',
scrollTrigger: { trigger: section, start: 'top top', end: 'bottom top', scrub: 0.6 },
});
gsap.to('.hero-carousel-inner', {
yPercent: -16,
opacity: 0.1,
ease: 'none',
scrollTrigger: { trigger: section, start: 'top top', end: 'bottom top', scrub: 0.6 },
});
gsap.to('.hero-carousel-nav', {
autoAlpha: 0,
y: 20,
ease: 'none',
scrollTrigger: { trigger: section, start: 'top top', end: '45% top', scrub: 0.6 },
});
return () => {
gsap.set(['.hero-carousel-slides', '.hero-carousel-inner'], { clearProps: 'transform,opacity' });
gsap.set('.hero-carousel-nav', { clearProps: 'transform,opacity,visibility' });
};
});
return () => mm.revert();
}, section);
return () => ctx.revert();
}, []);
return (
<section
ref={sectionRef}
className="hero-carousel"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-roledescription="carousel"
aria-label="Loyaly introduction"
>
<div className="hero-carousel-slides">
{HERO_SLIDES.map((slide, i) => (
<HeroSlide
key={slide.id}
slide={slide}
active={i === activeIndex}
priority={i === 0}
durationMs={HERO_AUTOPLAY_MS}
/>
))}
</div>
<div className="hero-carousel-inner">
<div className="hero-carousel-left">
<HeroContent key={HERO_SLIDES[activeIndex].id} slide={HERO_SLIDES[activeIndex]} />
<HeroProgress
count={count}
activeIndex={activeIndex}
onSelect={handleSelect}
fillRefs={fillRefs}
/>
</div>
</div>
<HeroNavigation onPrev={handlePrev} onNext={handleNext} />
<style>{`
.hero-carousel {
position: relative;
width: calc(100% - 40px);
height: calc(100vh - 40px);
height: calc(100svh - 40px);
margin: 20px;
border-radius: 32px;
overflow: hidden;
background: #0a0a0a;
isolation: isolate;
}
@media (max-width: 900px) {
.hero-carousel {
width: calc(100% - 24px);
height: calc(100vh - 24px);
height: calc(100svh - 24px);
margin: 12px;
border-radius: 22px;
}
}
.hero-carousel-slides {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-carousel-inner {
position: relative;
z-index: 3;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.hero-carousel-left {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 136px 24px 64px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.hero-carousel-content {
margin-bottom: 44px;
}
.hero-carousel-badge {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 0 22px;
padding: 9px 18px 9px 14px;
border-radius: 999px;
border: 1px solid rgba(244, 194, 26, 0.35);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(10px);
color: #ffd95a;
font-family: 'Sora', sans-serif;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.hero-carousel-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f4c21a;
box-shadow: 0 0 0 0 rgba(244, 194, 26, 0.6);
animation: heroBadgePulse 2.2s ease-out infinite;
}
@keyframes heroBadgePulse {
0% { box-shadow: 0 0 0 0 rgba(244, 194, 26, 0.55); }
70% { box-shadow: 0 0 0 8px rgba(244, 194, 26, 0); }
100% { box-shadow: 0 0 0 0 rgba(244, 194, 26, 0); }
}
.hero-carousel-headline {
margin: 0 0 26px;
color: #ffffff;
font-family: 'Sora', sans-serif;
font-weight: 800;
font-size: clamp(2.75rem, 6vw, 5.5rem);
line-height: 1.02;
letter-spacing: -0.035em;
text-shadow: 0 2px 30px rgba(0, 0, 0, 0.35);
}
.hero-word {
display: inline-block;
overflow: hidden;
vertical-align: top;
padding-bottom: 0.08em;
}
.hero-word-inner {
display: inline-block;
will-change: transform, opacity;
}
.hero-word-highlight {
background-image: linear-gradient(105deg, #bc7122 0%, #f4c21a 35%, #ffe488 50%, #f4be28 65%, #8a4f0f 100%);
background-size: 220% auto;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: heroGradientShimmer 6s linear infinite;
filter: drop-shadow(0 0 18px rgba(244, 194, 26, 0.35));
}
@keyframes heroGradientShimmer {
0% { background-position: 220% center; }
100% { background-position: -220% center; }
}
@media (prefers-reduced-motion: reduce) {
.hero-word-highlight { animation: none; }
}
.hero-carousel-description {
margin: 0 auto 40px;
max-width: 600px;
color: rgba(235, 235, 235, 0.88);
font-family: 'Sora', sans-serif;
font-weight: 400;
font-size: clamp(1.05rem, 1.2vw, 1.3rem);
line-height: 1.6;
letter-spacing: -0.005em;
text-shadow: 0 1px 16px rgba(0, 0, 0, 0.4);
}
.hero-carousel-cta {
display: flex;
align-items: center;
gap: 28px;
flex-wrap: wrap;
justify-content: center;
}
.hero-carousel-btn-primary {
display: inline-flex;
align-items: center;
gap: 10px;
background-image: linear-gradient(120deg, #f4c21a 0%, #ffd95a 100%);
color: #171512;
padding: 17px 34px;
border-radius: 12px;
font-family: 'Sora', sans-serif;
font-weight: 700;
font-size: 16px;
text-decoration: none;
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.25s ease, filter 0.25s ease;
box-shadow: 0 12px 28px -6px rgba(244, 194, 26, 0.45);
}
.hero-carousel-btn-primary:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 18px 34px -6px rgba(244, 194, 26, 0.6);
filter: brightness(1.06);
}
.hero-carousel-btn-secondary {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
color: #ffffff;
font-family: 'Sora', sans-serif;
font-weight: 600;
font-size: 15px;
text-decoration: none;
padding-bottom: 4px;
transition: color 0.25s ease, gap 0.25s ease;
}
.hero-carousel-btn-secondary:hover {
color: #ffd95a;
gap: 12px;
}
.hero-carousel-btn-secondary-underline {
position: absolute;
left: 0;
bottom: 0;
height: 1px;
width: 100%;
background: currentColor;
opacity: 0.4;
transform: scaleX(0.4);
transform-origin: left center;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.hero-carousel-btn-secondary:hover .hero-carousel-btn-secondary-underline {
transform: scaleX(1);
opacity: 0.9;
}
.hero-carousel-progress {
display: flex;
gap: 10px;
max-width: 320px;
margin: 0 auto;
}
.hero-carousel-progress-track {
position: relative;
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.22);
border: none;
border-radius: 2px;
padding: 10px 0;
cursor: pointer;
background-clip: content-box;
}
.hero-carousel-progress-fill {
position: absolute;
top: 10px;
left: 0;
height: 3px;
width: 100%;
background: #f4c21a;
border-radius: 2px;
transform-origin: left center;
transform: scaleX(0);
}
.hero-carousel-nav {
position: absolute;
right: 6vw;
bottom: 48px;
z-index: 4;
display: flex;
gap: 12px;
}
.hero-carousel-nav-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(6px);
transition: background-color 0.25s ease, border-color 0.25s ease, transform 0.25s ease;
}
.hero-carousel-nav-btn:hover {
background: rgba(244, 194, 26, 0.16);
border-color: rgba(244, 194, 26, 0.6);
transform: translateY(-2px);
}
@media (max-width: 1023px) {
.hero-carousel-left {
max-width: 100%;
padding: 128px 24px 48px;
}
.hero-carousel-nav {
right: 24px;
bottom: 32px;
}
.hero-carousel-progress {
max-width: 220px;
}
}
@media (max-width: 639px) {
.hero-carousel-badge {
font-size: 11px;
padding: 7px 14px 7px 12px;
}
.hero-carousel-headline {
font-size: clamp(1.9rem, 8vw, 2.6rem);
}
.hero-carousel-description {
font-size: 0.95rem;
}
.hero-carousel-cta {
gap: 16px 22px;
}
.hero-carousel-btn-primary {
padding: 14px 24px;
font-size: 14px;
}
.hero-carousel-nav-btn {
width: 42px;
height: 42px;
}
}
`}</style>
</section>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useEffect, useMemo, useRef } from 'react';
import { gsap } from 'gsap';
import Link from 'next/link';
import type { HeroSlideData } from './heroCarouselData';
interface HeroContentProps {
slide: HeroSlideData;
}
export default function HeroContent({ slide }: HeroContentProps) {
const badgeRef = useRef<HTMLDivElement | null>(null);
const headlineRef = useRef<HTMLHeadingElement | null>(null);
const descRef = useRef<HTMLParagraphElement | null>(null);
const ctaRef = useRef<HTMLDivElement | null>(null);
const words = useMemo(() => slide.headline.split(' '), [slide.headline]);
useEffect(() => {
const wordEls = headlineRef.current
? Array.from(headlineRef.current.querySelectorAll<HTMLElement>('.hero-word-inner'))
: [];
const desc = descRef.current;
const cta = ctaRef.current;
const badge = badgeRef.current;
gsap.set(wordEls, { yPercent: 120, opacity: 0, rotateZ: 4 });
gsap.set([desc, cta], { opacity: 0, y: 24 });
gsap.set(badge, { opacity: 0, y: -12, scale: 0.92 });
const tl = gsap.timeline({ defaults: { ease: 'power4.out' } });
tl.to(badge, { opacity: 1, y: 0, scale: 1, duration: 0.6 }, 0);
tl.to(
wordEls,
{ yPercent: 0, opacity: 1, rotateZ: 0, duration: 1, stagger: 0.055 },
0.18
);
tl.to(desc, { opacity: 1, y: 0, duration: 0.8 }, '-=0.55');
tl.to(cta, { opacity: 1, y: 0, duration: 0.8 }, '-=0.5');
return () => { tl.kill(); };
}, [slide.id]);
return (
<div className="hero-carousel-content">
<div ref={badgeRef} className="hero-carousel-badge">
<span className="hero-carousel-badge-dot" />
{slide.badge}
</div>
<h1 ref={headlineRef} className="hero-carousel-headline">
{words.map((word, i) => {
const isHighlighted = slide.highlightWords.includes(word);
return (
<span className="hero-word" key={`${word}-${i}`}>
<span
className={`hero-word-inner${isHighlighted ? ' hero-word-highlight' : ''}`}
>
{word}
{i < words.length - 1 ? ' ' : ''}
</span>
</span>
);
})}
</h1>
<p ref={descRef} className="hero-carousel-description">
{slide.description}
</p>
<div ref={ctaRef} className="hero-carousel-cta">
<Link href={slide.ctaHref} className="hero-carousel-btn-primary">
{slide.ctaLabel}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
<Link href={slide.secondaryHref} className="hero-carousel-btn-secondary">
{slide.secondaryLabel}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
<span className="hero-carousel-btn-secondary-underline" />
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
interface HeroNavigationProps {
onPrev: () => void;
onNext: () => void;
}
export default function HeroNavigation({ onPrev, onNext }: HeroNavigationProps) {
return (
<div className="hero-carousel-nav">
<button type="button" onClick={onPrev} aria-label="Previous slide" className="hero-carousel-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button type="button" onClick={onNext} aria-label="Next slide" className="hero-carousel-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import type { MutableRefObject } from 'react';
interface HeroProgressProps {
count: number;
activeIndex: number;
onSelect: (index: number) => void;
fillRefs: MutableRefObject<(HTMLDivElement | null)[]>;
}
export default function HeroProgress({ count, activeIndex, onSelect, fillRefs }: HeroProgressProps) {
return (
<div className="hero-carousel-progress" role="tablist" aria-label="Hero slides">
{Array.from({ length: count }).map((_, i) => (
<button
key={i}
type="button"
role="tab"
aria-selected={i === activeIndex}
aria-label={`Go to slide ${i + 1}`}
onClick={() => onSelect(i)}
className="hero-carousel-progress-track"
>
<div
ref={(el) => { fillRefs.current[i] = el; }}
className="hero-carousel-progress-fill"
style={{ transform: `scaleX(${i < activeIndex ? 1 : 0})` }}
/>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import NextImage from 'next/image';
import type { HeroSlideData } from './heroCarouselData';
interface HeroSlideProps {
slide: HeroSlideData;
active: boolean;
priority?: boolean;
durationMs: number;
}
export default function HeroSlide({ slide, active, priority, durationMs }: HeroSlideProps) {
const imgRef = useRef<HTMLDivElement | null>(null);
// Slow zoom on the background image only, GSAP-driven. The crossfade/slide
// below is inline-style + inline-transition, so it can never depend on an
// external stylesheet having loaded/compiled correctly.
useEffect(() => {
const img = imgRef.current;
if (!img) return;
gsap.killTweensOf(img);
if (active) {
gsap.fromTo(
img,
{ scale: 1 },
{ scale: 1.05, duration: durationMs / 1000, ease: 'none' }
);
} else {
gsap.set(img, { scale: 1 });
}
}, [active, durationMs]);
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: active ? 2 : 1,
pointerEvents: 'none',
opacity: active ? 1 : 0,
transform: active ? 'translateX(0)' : 'translateX(28px)',
transition: 'opacity 1.1s cubic-bezier(0.22, 1, 0.36, 1), transform 1.1s cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!active}
>
<div ref={imgRef} style={{ position: 'absolute', inset: 0, transformOrigin: 'center center' }}>
<NextImage
src={slide.image}
alt={slide.imageAlt}
fill
sizes="100vw"
priority={priority}
style={{ objectFit: 'cover' }}
/>
</div>
{/* Dark overlay for text readability — 40-50% per spec */}
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(90deg, rgba(8,8,8,0.5) 0%, rgba(8,8,8,0.42) 45%, rgba(8,8,8,0.4) 100%)',
}}
/>
</div>
);
}

View File

@@ -74,10 +74,11 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
const el = flowNodeRefs.current[index]; const el = flowNodeRefs.current[index];
if (!el) return; if (!el) return;
gsap.to(el, { gsap.to(el, {
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)', background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
borderColor: active ? '#171512' : 'rgba(244,190,40,0.35)', borderColor: active ? '#ffffff' : 'rgba(244,190,40,0.3)',
borderWidth: active ? '3px' : '1px', borderWidth: active ? '3px' : '1px',
boxShadow: active ? '0 12px 26px -10px rgba(23,21,18,0.4)' : '0 0px 0px rgba(0,0,0,0)', color: active ? '#171512' : 'rgba(255,255,255,0.6)',
boxShadow: active ? '0 12px 30px -8px rgba(244,190,40,0.55)' : '0 0px 0px rgba(0,0,0,0)',
duration: 0.35, ease: 'power2.out', duration: 0.35, ease: 'power2.out',
}); });
if (active) gsap.fromTo(el, { scale: 1.22 }, { scale: 1, duration: 0.45, ease: 'back.out(2.4)' }); if (active) gsap.fromTo(el, { scale: 1.22 }, { scale: 1, duration: 0.45, ease: 'back.out(2.4)' });
@@ -158,8 +159,8 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
borderRadius: 32, borderRadius: 32,
padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)', padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)',
zIndex: 2, zIndex: 2,
background: '#ffffff', background: 'radial-gradient(ellipse 110% 80% at 50% 0%, #17130d 0%, #0d0b09 55%, #0a0807 100%)',
boxShadow: 'inset 0 0 0 1px rgba(23,21,18,0.05)', boxShadow: 'inset 0 0 0 1px rgba(244,190,40,0.12)',
}} }}
> >
{/* Faint dot-grid texture for depth, matching the light sections elsewhere */} {/* Faint dot-grid texture for depth, matching the light sections elsewhere */}
@@ -167,13 +168,14 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
aria-hidden aria-hidden
style={{ style={{
position: 'absolute', inset: 0, pointerEvents: 'none', position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: 'radial-gradient(rgba(23,21,18,0.06) 1px, transparent 1px)', backgroundImage: 'radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '28px 28px', backgroundSize: '28px 28px',
maskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)', maskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
WebkitMaskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)', WebkitMaskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
}} }}
/> />
<div style={{ position: 'absolute', top: '-10%', right: '-6%', width: 480, height: 480, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', top: '-10%', right: '-6%', width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.20) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: '-14%', left: '-8%', width: 460, height: 460, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,152,0,0.12) 0%, transparent 70%)', pointerEvents: 'none' }} />
{/* Oversized watermark type — big-type Gen-Z background flex */} {/* Oversized watermark type — big-type Gen-Z background flex */}
<div <div
@@ -181,7 +183,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{ style={{
position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)', position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)',
fontSize: 'clamp(6rem, 16vw, 13rem)', fontWeight: 900, letterSpacing: '-0.05em', fontSize: 'clamp(6rem, 16vw, 13rem)', fontWeight: 900, letterSpacing: '-0.05em',
color: 'rgba(23,21,18,0.035)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap', color: 'rgba(255,255,255,0.035)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
pointerEvents: 'none', userSelect: 'none', zIndex: 0, pointerEvents: 'none', userSelect: 'none', zIndex: 0,
}} }}
> >
@@ -195,7 +197,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
margin: 0, margin: 0,
padding: 'clamp(8px, 2vw, 20px) 0', padding: 'clamp(8px, 2vw, 20px) 0',
fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)', fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)',
fontWeight: 800, color: '#171512', fontWeight: 800, color: '#ffffff',
lineHeight: 1, letterSpacing: '-0.04em', lineHeight: 1, letterSpacing: '-0.04em',
fontFamily: 'Sora, sans-serif', fontFamily: 'Sora, sans-serif',
}} }}
@@ -232,7 +234,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{ style={{
margin: '22px 0 0', maxWidth: 620, margin: '22px 0 0', maxWidth: 620,
fontSize: 'clamp(0.95rem, 1.15vw, 1.1rem)', lineHeight: 1.7, fontSize: 'clamp(0.95rem, 1.15vw, 1.1rem)', lineHeight: 1.7,
color: 'rgba(23,21,18,0.55)', fontFamily: 'Sora, sans-serif', color: 'rgba(255,255,255,0.62)', fontFamily: 'Sora, sans-serif',
}} }}
> >
LytsUp transforms your everyday activities into real shopping rewards. Walk, play, explore, visit your favorite places, complete fun challenges, and earn Lyts that you can spend instantly at participating brands. LytsUp transforms your everyday activities into real shopping rewards. Walk, play, explore, visit your favorite places, complete fun challenges, and earn Lyts that you can spend instantly at participating brands.
@@ -247,16 +249,17 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', borderRadius: 100, padding: '8px 16px', borderRadius: 100,
background: 'rgba(244,190,40,0.08)', border: '1px solid rgba(244,190,40,0.28)', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(244,190,40,0.3)',
backdropFilter: 'blur(8px)',
}} }}
> >
<span <span
ref={(el) => { statRefs.current[i] = el; }} ref={(el) => { statRefs.current[i] = el; }}
style={{ fontSize: '0.92rem', fontWeight: 800, color: '#171512', fontFamily: 'Sora, sans-serif', minWidth: '2.6em', display: 'inline-block' }} style={{ fontSize: '0.92rem', fontWeight: 800, color: '#f4be28', fontFamily: 'Sora, sans-serif', minWidth: '2.6em', display: 'inline-block' }}
> >
0{stat.suffix} 0{stat.suffix}
</span> </span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif' }}>{stat.label}</span> <span style={{ fontSize: '0.72rem', fontWeight: 600, color: 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif' }}>{stat.label}</span>
</div> </div>
))} ))}
</div> </div>
@@ -273,20 +276,20 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
width: 46, height: 46, borderRadius: '50%', width: 46, height: 46, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
color: '#171512', boxSizing: 'border-box', color: i === 0 ? '#171512' : 'rgba(255,255,255,0.6)', boxSizing: 'border-box',
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)', background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
border: i === 0 ? '3px solid #171512' : '1px solid rgba(244,190,40,0.35)', border: i === 0 ? '3px solid #ffffff' : '1px solid rgba(244,190,40,0.3)',
boxShadow: i === 0 ? '0 12px 26px -10px rgba(23,21,18,0.4)' : 'none', boxShadow: i === 0 ? '0 12px 30px -8px rgba(244,190,40,0.55)' : 'none',
}} }}
> >
{i + 1} {i + 1}
</span> </span>
<span style={{ fontSize: '0.9rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', color: '#171512', letterSpacing: '-0.01em' }}> <span style={{ fontSize: '0.9rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', color: '#ffffff', letterSpacing: '-0.01em' }}>
{step} {step}
</span> </span>
</div> </div>
{i < 2 && ( {i < 2 && (
<div style={{ position: 'relative', flex: 1, height: 2, marginTop: 23, borderRadius: 2, background: 'rgba(23,21,18,0.12)', overflow: 'hidden' }}> <div style={{ position: 'relative', flex: 1, height: 2, marginTop: 23, borderRadius: 2, background: 'rgba(255,255,255,0.14)', overflow: 'hidden' }}>
<div <div
ref={(el) => { flowFillRefs.current[i] = el; }} ref={(el) => { flowFillRefs.current[i] = el; }}
style={{ position: 'absolute', inset: 0, borderRadius: 2, background: 'linear-gradient(90deg,#f4be28,#FF9800)', transformOrigin: 'left center', transform: 'scaleX(0)' }} style={{ position: 'absolute', inset: 0, borderRadius: 2, background: 'linear-gradient(90deg,#f4be28,#FF9800)', transformOrigin: 'left center', transform: 'scaleX(0)' }}
@@ -304,7 +307,9 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
maxWidth: 720, textAlign: 'center', maxWidth: 720, textAlign: 'center',
padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)', padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)',
borderRadius: 26, borderRadius: 26,
background: '#171512', background: 'linear-gradient(180deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%)',
border: '1px solid rgba(244,190,40,0.22)',
boxShadow: '0 30px 60px -24px rgba(0,0,0,0.6)',
position: 'relative', overflow: 'hidden', position: 'relative', overflow: 'hidden',
}} }}
> >

View File

@@ -450,7 +450,24 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
// Desktop / tablet: pinned horizontal scroll // Desktop / tablet: pinned horizontal scroll
mm.add('(min-width: 901px)', () => { mm.add('(min-width: 901px)', () => {
const cards = gsap.utils.toArray<HTMLElement>('.products-card', track);
const distance = () => Math.max(0, track.scrollWidth - section.clientWidth); const distance = () => Math.max(0, track.scrollWidth - section.clientWidth);
const steps = Math.max(1, PRODUCTS.length - 1);
// Depth cue: the card nearest the viewport centre sits at full scale/
// opacity, neighbours ease back slightly — makes the ride feel guided
// rather than a flat strip sliding past, and the eased response
// absorbs small scrub jitter instead of showing it as a raw jump.
const updateDepth = () => {
const centerX = section.clientWidth / 2;
cards.forEach((card) => {
const trackX = Number(gsap.getProperty(track, 'x', 'px'));
const cardCenter = card.offsetLeft + card.offsetWidth / 2 + trackX;
const dist = Math.abs(cardCenter - centerX);
const norm = gsap.utils.clamp(0, 1, dist / (section.clientWidth * 0.62));
gsap.set(card, { scale: 1 - norm * 0.07, opacity: 1 - norm * 0.32 });
});
};
const tween = gsap.to(track, { const tween = gsap.to(track, {
x: () => -distance(), x: () => -distance(),
@@ -460,30 +477,42 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
start: 'top top', start: 'top top',
end: () => `+=${distance()}`, end: () => `+=${distance()}`,
pin: true, pin: true,
pinType: 'transform',
pinSpacing: true, pinSpacing: true,
anticipatePin: 1, anticipatePin: 1,
scrub: 1, scrub: 0.4,
invalidateOnRefresh: true, invalidateOnRefresh: true,
refreshPriority: 5, refreshPriority: 5,
snap: {
snapTo: (value) => Math.round(value * steps) / steps,
duration: { min: 0.2, max: 0.5 },
ease: 'power2.inOut',
},
onUpdate: (self) => { onUpdate: (self) => {
if (progressRef.current) progressRef.current.style.transform = `scaleX(${self.progress})`; if (progressRef.current) progressRef.current.style.transform = `scaleX(${self.progress})`;
if (counterRef.current) { if (counterRef.current) {
const idx = Math.min(PRODUCTS.length, Math.max(1, Math.round(self.progress * (PRODUCTS.length - 1)) + 1)); const idx = Math.min(PRODUCTS.length, Math.max(1, Math.round(self.progress * steps) + 1));
counterRef.current.textContent = `0${idx}`; counterRef.current.textContent = `0${idx}`;
} }
updateDepth();
}, },
}, },
}); });
updateDepth();
ScrollTrigger.refresh(); ScrollTrigger.refresh();
return () => { tween.scrollTrigger?.kill(); tween.kill(); gsap.set(track, { clearProps: 'transform' }); }; return () => {
tween.scrollTrigger?.kill();
tween.kill();
gsap.set(track, { clearProps: 'transform' });
gsap.set(cards, { clearProps: 'scale,opacity' });
};
}); });
return () => mm.revert(); return () => mm.revert();
}, [isLoaded]); }, [isLoaded]);
return ( return (
<div className="products-section-wrapper">
<section <section
ref={sectionRef} ref={sectionRef}
id="products-section" id="products-section"
@@ -496,24 +525,26 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
{/* Heading (fixed within the pinned viewport) */} {/* Heading (fixed within the pinned viewport) */}
<div className="products-heading"> <div className="products-heading">
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 18, padding: '6px 16px 6px 8px', borderRadius: 100, background: '#ffffff', border: '1px solid rgba(244,190,40,0.3)', boxShadow: '0 1px 2px rgba(0,0,0,0.03), 0 12px 30px -18px rgba(23,21,18,0.2)' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 20, padding: '6px 16px 6px 8px', borderRadius: 100, background: '#ffffff', border: '1px solid rgba(244,190,40,0.3)', boxShadow: '0 1px 2px rgba(0,0,0,0.03), 0 12px 30px -18px rgba(23,21,18,0.2)' }}>
<span style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(244,190,40,0.15)' }}> <span style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(244,190,40,0.15)' }}>
<img src="/images/loyalyai-logo.png" alt="" width={15} height={15} style={{ display: 'block', objectFit: 'contain' }} /> <img src="/images/loyalyai-logo.png" alt="" width={15} height={15} style={{ display: 'block', objectFit: 'contain' }} />
</span> </span>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#8a5a14', fontFamily: 'Sora, sans-serif' }}>Platform</span> <span style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#8a5a14', fontFamily: 'Sora, sans-serif' }}>Platform</span>
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', display: 'block', marginLeft: 2 }} />
</div> </div>
<h2 style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.6vw, 3.2rem)', fontWeight: 800, lineHeight: 0.94, letterSpacing: '-0.04em', color: '#111111', fontFamily: 'Sora, sans-serif' }}> <h2 style={{ margin: 0, padding: '0 0 clamp(10px, 1.6vw, 22px)', fontSize: 'clamp(1.6rem, 3vw, 2.7rem)', fontWeight: 800, lineHeight: 0.94, letterSpacing: '-0.04em', color: '#111111', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap' }}>
Four products.<br /> Four agents.{' '}
<span style={{ background: 'linear-gradient(105deg, #f4be28 0%, #FF9800 100%)', backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', animation: 'prodShimmer 3s linear infinite' }}>One ecosystem.</span> <span style={{ background: 'linear-gradient(105deg, #f4be28 0%, #FF9800 100%)', backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', animation: 'prodShimmer 3s linear infinite' }}>One AI.</span>
</h2> </h2>
</div> </div>
<div className="products-heading-hint" style={{ maxWidth: 300, textAlign: 'right', borderRight: '2px solid rgba(244,190,40,0.3)', paddingRight: 'clamp(18px, 2.2vw, 28px)' }}> <div className="products-heading-hint" style={{ maxWidth: 320, textAlign: 'right', borderRight: '2px solid rgba(244,190,40,0.3)', paddingRight: 'clamp(18px, 2.2vw, 28px)' }}>
<p style={{ margin: '0 0 16px', fontSize: 'clamp(0.85rem, 1vw, 1rem)', lineHeight: 1.6, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif' }}> <p style={{ margin: '0 0 18px', fontSize: 'clamp(0.85rem, 1vw, 1rem)', lineHeight: 1.6, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif' }}>
Powering every stage of the retail journey with intelligent AI solutions. Powering every stage of the retail journey with intelligent AI solutions.
</p> </p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10 }}>
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.85rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Built as one connected platform</span> <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'flex-end', gap: 10 }}>
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Unified into one platform</span>
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: '#f4be28', letterSpacing: '-0.02em' }}>04</span> <span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: '#f4be28', letterSpacing: '-0.02em' }}>04</span>
</div> </div>
</div> </div>
@@ -604,11 +635,17 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
} }
} }
/* Margin lives on this non-pinned wrapper, not on the pinned section
itself — GSAP's ScrollTrigger pin-spacer can miscalculate the
reserved space when the pinned element carries its own margin,
which shows up as a jump/shake right as the pin engages. */
.products-section-wrapper {
margin: 20px;
}
.products-section-root { .products-section-root {
--pt: clamp(230px, 28vh, 310px); --pt: clamp(230px, 28vh, 310px);
--pb: clamp(64px, 8vh, 96px); --pb: clamp(64px, 8vh, 96px);
width: calc(100% - 40px); width: 100%;
margin: 20px 20px;
border-radius: 32px; border-radius: 32px;
background: #FAFAFA; background: #FAFAFA;
height: 100vh; height: 100vh;
@@ -681,5 +718,6 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
} }
`}</style> `}</style>
</section> </section>
</div>
); );
} }

View File

@@ -224,12 +224,12 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
</div> </div>
{/* Papercraft Image */} {/* Papercraft Image */}
<div style={{ <div className="shift-card-art" style={{
position: 'absolute', position: 'absolute',
right: i === 2 ? '-20px' : '-24px', right: i === 2 ? '-12px' : '-16px',
bottom: i === 2 ? '-20px' : '-24px', bottom: i === 2 ? '-12px' : '-16px',
width: i === 2 ? '72%' : '68%', width: i === 2 ? '56%' : '52%',
height: '112%', height: '90%',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 1, zIndex: 1,
}}> }}>
@@ -238,11 +238,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.label} alt={card.label}
width={400} width={400}
height={400} height={400}
className="shift-card-art-img"
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'bottom right',
width: '100%', width: '100%',
height: '100%', height: '100%',
transform: i === 2 ? 'scale(1.4)' : 'scale(1.36)', transform: i === 2 ? 'scale(1.1)' : 'scale(1.06)',
transformOrigin: 'bottom right', transformOrigin: 'bottom right',
}} }}
/> />
@@ -291,11 +293,11 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
{/* Papercraft Image */} {/* Papercraft Image */}
{(cardNumber === 7 || cardNumber === 8) ? ( {(cardNumber === 7 || cardNumber === 8) ? (
<div style={{ <div className="shift-card-art" style={{
position: 'absolute', position: 'absolute',
right: 0, right: 0,
bottom: 0, bottom: 0,
width: cardNumber === 7 ? '66%' : '62%', width: cardNumber === 7 ? '64%' : '48%',
height: '100%', height: '100%',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 1, zIndex: 1,
@@ -305,21 +307,22 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.title} alt={card.title}
width={cardNumber === 7 ? 520 : 440} width={cardNumber === 7 ? 520 : 440}
height={cardNumber === 7 ? 520 : 440} height={cardNumber === 7 ? 520 : 440}
className="shift-card-art-img"
style={{ style={{
objectFit: 'cover', objectFit: 'cover',
objectPosition: cardNumber === 7 ? '62% 88%' : '68% 82%', objectPosition: cardNumber === 7 ? '58% 50%' : '68% 82%',
width: '100%', width: '100%',
height: '100%', height: '100%',
}} }}
/> />
</div> </div>
) : ( ) : (
<div style={{ <div className="shift-card-art" style={{
position: 'absolute', position: 'absolute',
right: '-16px', right: '-12px',
bottom: '-20px', bottom: '-14px',
width: '66%', width: '50%',
height: '112%', height: '90%',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 1, zIndex: 1,
}}> }}>
@@ -328,11 +331,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.title} alt={card.title}
width={400} width={400}
height={400} height={400}
className="shift-card-art-img"
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'bottom right',
width: '100%', width: '100%',
height: '100%', height: '100%',
transform: 'scale(1.3)', transform: 'scale(1.0)',
transformOrigin: 'bottom right', transformOrigin: 'bottom right',
}} }}
/> />
@@ -429,6 +434,19 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
.shift-card-7 { .shift-card-7 {
min-height: 220px !important; min-height: 220px !important;
} }
/* Pin card art to the bottom-right corner on narrow screens,
where full-width cards would otherwise let it sprawl over the text */
#shift .shift-card-art {
width: 62% !important;
height: 100% !important;
right: 0 !important;
bottom: 0 !important;
}
#shift .shift-card-art-img {
object-fit: contain !important;
object-position: bottom right !important;
transform: none !important;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {

View File

@@ -156,39 +156,37 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
} }
); );
// Cards: single staggered timeline triggered once the row enters view // Cards: 3D flip-up reveal — each card rises and unfolds from its bottom
// edge, then its inner content cascades in. One scroll-triggered
// timeline keeps card + content in sync.
const cards = gsap.utils.toArray<HTMLElement>('.wwd-card'); const cards = gsap.utils.toArray<HTMLElement>('.wwd-card');
const grid = document.querySelector('.wwd-grid-container'); const grid = document.querySelector('.wwd-grid-container');
gsap.fromTo(cards, const revealTl = gsap.timeline({
{ opacity: 0, y: 56, scale: 0.93, filter: 'blur(4px)' },
{
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
duration: 0.8, stagger: 0.12, ease: 'expo.out',
scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' }, scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' },
} });
revealTl.fromTo(cards,
{ opacity: 0, y: 90, rotationX: -32, transformOrigin: '50% 100%', transformPerspective: 1100 },
{ opacity: 1, y: 0, rotationX: 0, duration: 0.9, ease: 'power4.out', stagger: 0.14 },
0
); );
// Cascade inner elements after cards land
cards.forEach((card, i) => { cards.forEach((card, i) => {
const tl = gsap.timeline({ delay: i * 0.12 + 0.25 }); const at = i * 0.14 + 0.35;
revealTl
tl.fromTo(card.querySelector('.wwd-icon'), .fromTo(card.querySelector('.wwd-icon'),
{ opacity: 0, scale: 0.5, rotate: -15, filter: 'blur(3px)' }, { opacity: 0, scale: 0.4, rotate: -20 },
{ opacity: 1, scale: 1, rotate: 0, filter: 'blur(0px)', duration: 0.55, ease: 'back.out(2)' } { opacity: 1, scale: 1, rotate: 0, duration: 0.5, ease: 'back.out(2.2)' }, at)
)
.fromTo(card.querySelector('.wwd-title'), .fromTo(card.querySelector('.wwd-title'),
{ opacity: 0, y: 14 }, { opacity: 0, y: 16 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, '-=0.2' { opacity: 1, y: 0, duration: 0.45, ease: 'power3.out' }, at + 0.12)
)
.fromTo(card.querySelector('.wwd-tagline'), .fromTo(card.querySelector('.wwd-tagline'),
{ opacity: 0, y: 10 }, { opacity: 0, y: 12 },
{ opacity: 1, y: 0, duration: 0.38, ease: 'power3.out' }, '-=0.2' { opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.22)
)
.fromTo(card.querySelector('.wwd-detail'), .fromTo(card.querySelector('.wwd-detail'),
{ opacity: 0, y: 8 }, { opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.35, ease: 'power3.out' }, '-=0.18' { opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.3);
);
}); });
}, containerRef); }, containerRef);
@@ -207,7 +205,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
zIndex: 2, zIndex: 2,
}} }}
> >
<AnimatedDarkBg variant="amber" /> <AnimatedDarkBg variant="amber" bgImage="/images/stock/one_platform_bg.png" bgImageAlt="" />
{/* Header */} {/* Header */}
<div className="wwd-header relative z-10 text-center max-w-4xl mx-auto mb-16 sm:mb-20 flex flex-col items-center"> <div className="wwd-header relative z-10 text-center max-w-4xl mx-auto mb-16 sm:mb-20 flex flex-col items-center">
@@ -224,6 +222,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
fontWeight: 800, color: '#fff', fontWeight: 800, color: '#fff',
lineHeight: 0.98, letterSpacing: '-0.04em', lineHeight: 0.98, letterSpacing: '-0.04em',
fontFamily: 'Sora, sans-serif', fontFamily: 'Sora, sans-serif',
textShadow: '0 4px 24px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.6)',
}}> }}>
Every visit,<br /> Every visit,<br />
a <span className="wwd-shimmer">loyal customer.</span> a <span className="wwd-shimmer">loyal customer.</span>
@@ -242,8 +241,6 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
<style>{` <style>{`
.wwd-card { .wwd-card {
position: relative; position: relative;
transform: perspective(1100px) rotateX(0deg) rotateY(0deg);
transition: transform 0.45s cubic-bezier(0.22,1,0.36,1);
} }
.wwd-card-inner { .wwd-card-inner {
position: relative; position: relative;
@@ -253,11 +250,11 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
height: 100%; height: 100%;
transition: transform 0.4s cubic-bezier(0.22,1,0.36,1), box-shadow 0.4s ease; transition: transform 0.4s cubic-bezier(0.22,1,0.36,1), box-shadow 0.4s ease;
} }
.wwd-card-white .wwd-card-inner { background: #ffffff; } .wwd-card-white .wwd-card-inner { background: #ffffff; box-shadow: 0 18px 40px -14px rgba(0,0,0,0.5); }
.wwd-card-yellow .wwd-card-inner { background: #F4C21A; } .wwd-card-yellow .wwd-card-inner { background: #F4C21A; box-shadow: 0 18px 40px -14px rgba(0,0,0,0.45); }
.wwd-card-active .wwd-card-inner { .wwd-card-active .wwd-card-inner {
transform: translateY(-6px); transform: translateY(-6px);
box-shadow: 0 24px 50px -18px rgba(0,0,0,0.35); box-shadow: 0 24px 50px -18px rgba(0,0,0,0.55);
} }
.wwd-card-glow { .wwd-card-glow {
position: absolute; inset: 0; pointer-events: none; position: absolute; inset: 0; pointer-events: none;
@@ -292,11 +289,13 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
flex-shrink: 0; flex-shrink: 0;
} }
.wwd-shimmer { .wwd-shimmer {
background: linear-gradient(105deg,#f4be28 0%,#fff8e0 48%,#f4be28 62%,#ff9500 100%); background: linear-gradient(105deg,#ffd95a 0%,#fff8e0 35%,#f4c21a 55%,#ffb020 75%,#ffd95a 100%);
background-size: 200% auto; background-size: 200% auto;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
text-shadow: none;
filter: drop-shadow(0 3px 14px rgba(0,0,0,0.55)) drop-shadow(0 0 22px rgba(244,194,26,0.35));
animation: wwdShimmer 3s linear infinite; animation: wwdShimmer 3s linear infinite;
} }
@keyframes wwdShimmer { 0% { background-position: 200% center; } 100% { background-position: -200% center; } } @keyframes wwdShimmer { 0% { background-position: 200% center; } 100% { background-position: -200% center; } }

View File

@@ -0,0 +1,61 @@
export interface HeroSlideData {
id: string;
badge: string;
headline: string;
/** Words (exact match, case-sensitive) within `headline` to render as a gradient highlight. */
highlightWords: string[];
description: string;
ctaLabel: string;
ctaHref: string;
secondaryLabel: string;
secondaryHref: string;
image: string;
imageAlt: string;
}
export const HERO_SLIDES: HeroSlideData[] = [
{
id: 'intro',
badge: 'AI-Powered Loyalty',
headline: 'Turn Foot Traffic Into Fans',
highlightWords: ['Fans'],
description:
'Loyaly reads shopper behavior in real time and turns every visit into a reason to come back.',
ctaLabel: 'Book a Demo',
ctaHref: '/contacts',
secondaryLabel: 'See How It Works',
secondaryHref: '/solutions_page',
image: '/home_hero/home_hero_01.png',
imageAlt: 'Shoppers walking through a modern premium shopping mall',
},
{
id: 'app',
badge: 'Built For Every Shopper',
headline: 'Shopping, But Make It Rewarding',
highlightWords: ['Rewarding'],
description:
'Find stores nearby, stack LYTs, unlock drops made for you, and turn every trip into a game.',
ctaLabel: 'Explore the App',
ctaHref: '/for_people',
secondaryLabel: 'Get Early Access',
secondaryHref: '/contacts',
image: '/home_hero/home_hero_02.png',
imageAlt: 'A shopper viewing the Loyaly app splash screen on their phone inside a mall',
},
{
id: 'ecosystem',
badge: 'The Full Stack',
headline: 'Six Tools. One Mega Brain.',
highlightWords: ['Mega', 'Brain.'],
description:
'Behavision, SpendSense, Dyscount, Identiq, Flowmind, and the Loyaly App — built to know your customers better than they know themselves.',
ctaLabel: 'Explore Platform',
ctaHref: '/solutions_page',
secondaryLabel: 'Meet the Tools',
secondaryHref: '/solutions_page',
image: '/home_hero/home_hero_03.png',
imageAlt: 'Aerial view of a large, crowded luxury shopping mall atrium',
},
];
export const HERO_AUTOPLAY_MS = 6000;

View File

@@ -2,7 +2,7 @@ import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { DEFAULT_EASE, DEFAULT_DURATION } from './animation'; import { DEFAULT_EASE, DEFAULT_DURATION } from './animation';
const EXCLUDED = '.loyaly-home, #problems-shift, #what-we-do, #the-loop, #products-section'; const EXCLUDED = '.loyaly-home, #problem, #shift, #lytsup, #what-we-do, #the-loop, #products-section';
export function initGlobalReveal(contextRoot?: Element | string) { export function initGlobalReveal(contextRoot?: Element | string) {
if (typeof window === 'undefined') return () => {}; if (typeof window === 'undefined') return () => {};