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

View File

@@ -260,3 +260,7 @@ body.is-home-page.scroll-up .sticky-active {
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';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useEffect, useState } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation';
import Link from 'next/link';
import NextImage from 'next/image';
import ProblemSection from '@/components/home/ProblemSection';
import ShiftSection from '@/components/home/ShiftSection';
import WhatWeDo from '@/components/home/WhatWeDo';
import ProductsSection from '@/components/home/ProductsSection';
import LytsUpSection from '@/components/home/LytsUpSection';
import TheLoop from '@/components/home/TheLoop';
import HeroCarousel from '@/components/home/HeroCarousel';
import initGlobalReveal from '@/utils/scrollReveal';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
if (typeof window !== 'undefined') {
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() {
const [imagesLoaded, setImagesLoaded] = useState(false);
// Desktop pinned canvas hero vs. mobile static hero.
const isDesktop = useSyncExternalStore(subscribeDesktopQuery, getDesktopSnapshot, getDesktopServerSnapshot);
const [heroReady, setHeroReady] = useState(false);
const [layoutSettled, setLayoutSettled] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const heroRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const 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 ─ */
/* ── global scroll-reveal for sections below the hero ─────────── */
useEffect(() => {
if (imagesLoaded || !isDesktop) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [imagesLoaded, isDesktop]);
if (!heroReady) return;
const cleanupReveal = initGlobalReveal();
/* ── load frame 0 immediately (canvas visible on first paint) ─── */
useEffect(() => {
const firstImg = new Image();
firstImg.onload = () => {
if (imagesRef.current.length === 0) {
imagesRef.current = [firstImg];
const canvas = canvasRef.current;
if (canvas) {
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
const ctx = canvas.getContext('2d');
if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); }
drawFrame(0);
}
}
};
firstImg.src = '/img_frames/frame_000_delay-0.041s.jpg';
}, [drawFrame]);
// A single immediate refresh isn't enough: below-the-fold images and the
// Sora webfont (loaded via <link>, not next/font) settle asynchronously
// and change section heights well after first paint — measured up to
// ~2000px of shift as images below the hero finish loading. Pinned
// ScrollTriggers (e.g. ProductsSection's horizontal pin) cache their
// start/end at refresh time, so a stale refresh here makes the pin
// engage at the wrong scroll offset.
//
// If the user has already scrolled into a pinned section by the time a
// later refresh corrects that stale measurement, ScrollTrigger snaps the
// scroll position to compensate — that snap is the "shaking". The body
// scroll lock below prevents the user from reaching that state at all:
// scrolling stays locked until layout has genuinely settled.
ScrollTrigger.refresh();
/* ── preload all frames, update progress counter ─────────────── */
useEffect(() => {
// Reset counter every time this effect runs (guards against Strict Mode
// double-invocation and any navigation-back scenario that re-mounts the component)
loadedCountRef.current = 0;
const images: HTMLImageElement[] = [];
// Use RAF-throttled 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);
});
let settled = false;
const refreshOnce = () => {
if (settled) return;
settled = true;
ScrollTrigger.refresh();
setLayoutSettled(true);
};
for (let i = 0; i < frameCount; i++) {
const img = new Image();
const paddedIndex = String(i).padStart(3, '0');
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);
window.addEventListener('load', refreshOnce);
if (typeof document !== 'undefined' && document.fonts?.ready) {
document.fonts.ready.then(refreshOnce);
}
// Fallback in case neither signal fires promptly (e.g. already cached).
const fallback = setTimeout(refreshOnce, 800);
return () => {
cancelAnimationFrame(rafId);
// Reset so a re-run (Strict Mode or re-mount) always starts clean
loadedCountRef.current = 0;
clearTimeout(fallback);
window.removeEventListener('load', refreshOnce);
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(() => {
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, imagesLoaded]);
/* ── ScrollTrigger refresh after all frames load ──────────────── */
useEffect(() => {
if (!imagesLoaded) return;
ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize();
const t1 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize(); }, 200);
const t2 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); }, 600);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [imagesLoaded, handleResize]);
if (layoutSettled) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [layoutSettled]);
/* ── homepage header show/hide on scroll direction ───────────── */
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 ───────────────────────────────────────────────────── */
return (
<>
{/* ══════════════════════════════════════════
MOBILE HERO — static image below text content
HERO — full-viewport premium carousel
══════════════════════════════════════════ */}
<section
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>
<HeroCarousel onReady={() => setHeroReady(true)} />
{/* Other sections */}
<ProblemSection isLoaded={imagesLoaded} />
<ShiftSection isLoaded={imagesLoaded} />
<WhatWeDo isLoaded={imagesLoaded} />
<ProductsSection isLoaded={imagesLoaded} />
<ProblemSection isLoaded={heroReady} />
<ShiftSection isLoaded={heroReady} />
<WhatWeDo isLoaded={heroReady} />
<ProductsSection isLoaded={heroReady} />
<LytsUpSection />
<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' }}>
<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>
<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 }}>
Four products.<br />One ecosystem.
Four agents.<br />One AI.
</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' }}>
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',
justifyContent: 'space-between',
padding: '0 48px',
height: transparentHeader ? '136px' : '88px',
height: transparentHeader ? '150px' : '92px',
width: '100%',
boxSizing: 'border-box',
transition: 'height 0.3s ease',
@@ -266,7 +266,7 @@ export default function Header() {
width={460}
height={138}
className="header-logo-img"
style={{ objectFit: 'contain', height: transparentHeader ? '110px' : '42px', width: 'auto' }}
style={{ objectFit: 'contain', height: transparentHeader ? '132px' : '46px', width: 'auto' }}
priority
/>
</Link>
@@ -365,15 +365,15 @@ export default function Header() {
.header-nav-pill { padding: 5px 6px !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-logo-img { height: 32px !important; }
.header-transparent .header-logo-img { height: 92px !important; }
.header-logo-img { height: 36px !important; }
.header-transparent .header-logo-img { height: 112px !important; }
}
@media (min-width: 1281px) and (max-width: 1440px) {
.header-nav-pill { padding: 6px 8px !important; }
.header-nav-link { padding: 9px 16px !important; font-size: 12px !important; }
.header-cta { padding: 10px 20px !important; font-size: 13px !important; }
.header-logo-img { height: 36px !important; }
.header-transparent .header-logo-img { height: 100px !important; }
.header-logo-img { height: 40px !important; }
.header-transparent .header-logo-img { height: 122px !important; }
}
.header-cta:hover {
background: #f4be28 !important;

View File

@@ -69,7 +69,9 @@ export default function Preloader() {
style={{
position: 'fixed',
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',
display: 'flex',
alignItems: 'center',

View File

@@ -76,7 +76,7 @@ export default function AIRecommendationsSection() {
{/* Left — editorial */}
<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={{
fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94,

View File

@@ -1,27 +1,28 @@
'use client';
import React, { useState, useLayoutEffect, useEffect, useRef } from 'react';
import React, { useLayoutEffect, useEffect, useRef } from 'react';
import Image from 'next/image';
import { PhoneFrame, PhoneBottomNav, BellIcon, PinIcon, ChevronIcon, SearchIcon, MicIcon, CameraIcon, MapIcon, HeartLogoIcon } from './PhoneMockup';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { FpTagCard } from './FpCard';
import { useCountUp, useParallax, useStaggerReveal } from './fpMotion';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const deals = [
{ store: 'Starbucks', category: 'Coffee & Drinks', offer: '2× LYTs', savings: '₹120 saved', color: '#00704A', dot: '#22c55e', distance: '0.3 km', rating: 4.8 },
{ store: 'Nike', category: 'Footwear', offer: '30% OFF', savings: '₹890 saved', color: '#171512', dot: '#d1d5db', distance: '0.7 km', rating: 4.9 },
{ store: 'H&M', category: 'Fashion', offer: '₹200 Back', savings: '₹200 saved', color: '#E50010', dot: '#ef4444', distance: '1.1 km', rating: 4.6 },
{ 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 },
const TAGS = [
{ label: 'Live coupons', icon: '🎟️' },
{ label: 'Nearby stores', icon: '📍' },
{ label: 'Game-based deals', icon: '🎮' },
];
export default function DiscoverDealsSection() {
const [hovered, setHovered] = useState<number | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null);
const glowRef = useParallax<HTMLDivElement>(40);
const tagsRef = useStaggerReveal<HTMLDivElement>();
const dealsCountRef = useCountUp<HTMLSpanElement>(2400, { suffix: '+' });
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') return;
@@ -35,12 +36,12 @@ export default function DiscoverDealsSection() {
{
y: 0,
opacity: 1,
duration: 1.1,
ease: 'power2.out',
scrollTrigger: {
trigger: section,
start: 'top 92%',
end: 'top 25%',
scrub: 1.2,
start: 'top 80%',
once: true,
}
}
);
@@ -53,143 +54,103 @@ export default function DiscoverDealsSection() {
<section ref={panelRef} className="fp-panel" style={{ background: '#08080f' }}>
{/* Background photo */}
<Image src="/images/stock/deals-retail.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.25, 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 }} />
<Image src="/images/stock/discover.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.16, 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 */}
<div className="fp-deco" style={{
{/* Soft radial — drifts with parallax for depth */}
<div ref={glowRef} className="fp-deco" style={{
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,
}} />
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '0.9fr 1.1fr', alignItems: 'center' }}>
<div className="fp-shell ddm-shell">
<div className="fp-split ddm-split" style={{ gridTemplateColumns: '0.75fr 1.25fr', alignItems: 'center' }}>
{/* Headline column */}
<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={{
fontSize: 'clamp(44px, 5.6vw, 88px)', fontWeight: 800, lineHeight: 0.94,
letterSpacing: '-0.05em', color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif',
}}>
<h2 className="fp-h1" style={{ color: '#fff' }}>
<span className="fp-mask"><span>Every</span></span>
<span className="fp-mask"><span>deal in</span></span>
<span className="fp-mask"><span className="fp-shimmer">your city.</span></span>
</h2>
<p className="fp-rise" style={{
fontSize: 16, lineHeight: 1.8, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 36px', maxWidth: 380,
}}>
<p className="fp-rise fp-desc" style={{ color: 'rgba(255,255,255,0.45)', 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.
</p>
<div className="fp-rise" style={{
display: 'inline-flex', alignItems: 'center', gap: 9,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 100, padding: '11px 18px', backdropFilter: 'blur(12px)',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<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" />
</svg>
<span style={{ fontSize: 13, fontWeight: 600, color: 'rgba(255,255,255,0.65)', fontFamily: 'Sora, sans-serif' }}>Coimbatore, TN</span>
<div ref={tagsRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, maxWidth: 460 }}>
{TAGS.map((tag) => (
<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">
<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" />
</svg>
)}
>
Coimbatore, TN
</FpTagCard>
</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">
{/* 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={{
position: 'absolute', inset: '-8%', zIndex: 0, borderRadius: 32,
background: 'radial-gradient(60% 60% at 50% 45%, rgba(244,190,40,0.18) 0%, transparent 70%)',
filter: 'blur(40px)', pointerEvents: 'none',
}} />
{/* 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>
{/* 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={{
position: 'absolute', left: 20, bottom: 20, zIndex: 2,
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 style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'block', animation: 'fpPulse 1.5s infinite' }} />
<span style={{ fontSize: 12.5, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>
<span ref={dealsCountRef}>2,400+</span> live deals nearby
</span>
</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 }}>
<span style={{ fontSize: 12, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif' }}>{deal.store[0]}</span>
<span style={{ position: 'absolute', top: -2, left: -2, width: 9, height: 9, borderRadius: '50%', background: deal.dot, border: '2px solid #fff' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 12.5, fontWeight: 700, color: '#171512', fontFamily: 'Sora, sans-serif',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{deal.store}</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 5, marginTop: 2,
fontSize: 9.5, color: '#9ca3af', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
}}>
<span>{deal.distance}</span>
<span style={{ color: '#f4be28' }}> {deal.rating}</span>
</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>
<ChevronIcon />
</div>
))}
</div>
{/* More deals row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 18px', margin: '2px 10px 10px', borderRadius: 14, background: '#faf7ee' }}>
<MapIcon />
<span style={{ flex: 1, fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>+ 2,400 more deals near you</span>
<ChevronIcon />
</div>
<PhoneBottomNav active="tag" />
</PhoneFrame>
</div>
</div>
</div>
</div>
<style jsx>{`
@media (min-width: 1024px) {
.ddm-split { grid-template-columns: 1fr 1fr !important; }
}
@media (min-width: 1440px) {
.ddm-shell { max-width: 1600px !important; }
.ddm-gif-wrap { max-width: 920px !important; }
}
@media (min-width: 1800px) {
.ddm-shell { max-width: 1800px !important; }
.ddm-gif-wrap { max-width: 1040px !important; }
}
`}</style>
</section>
);
}

View File

@@ -1,29 +1,22 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import Image from 'next/image';
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import { gsap } from 'gsap';
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);
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() {
const [pts, setPts] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
const phoneRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number>(0);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const activeRowRef = useRef(0);
const tagsRef = useStaggerReveal<HTMLDivElement>();
const statsRef = useStaggerReveal<HTMLDivElement>();
const waysCountRef = useCountUp<HTMLSpanElement>(10);
// Phone mockup rises in from the bottom as the section scrolls into view
useIsomorphicLayoutEffect(() => {
@@ -52,216 +45,88 @@ export default function EarnRewardsSection() {
return () => ctx.revert();
}, []);
// Move the highlighted feed card as the section scrolls through the viewport
useEffect(() => {
if (typeof window === 'undefined') return;
const el = panelRef.current;
if (!el) return;
const applyRow = (idx: number) => {
if (idx === activeRowRef.current) return;
activeRowRef.current = idx;
rowRefs.current.forEach((row, i) => {
if (!row) return;
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); };
}, []);
const earnTags = [
{ label: 'Walk', icon: '🚶', bg: '#eef2ff', fg: '#4c5fd5' },
{ label: 'Visit', icon: '📍', bg: '#fdeeee', fg: '#d5514c' },
{ label: 'Selfie', icon: '🤳', bg: '#fef1e8', fg: '#d5824c' },
{ label: 'Spin', icon: '🎡', bg: '#f1eefd', fg: '#7a4cd5' },
{ label: 'Scratch', icon: '🎟️', bg: '#fdeef7', fg: '#d54ca3' },
{ label: 'Challenge', icon: '🏆', bg: '#fff8e1', fg: '#b8860b' },
{ label: 'Friend', icon: '🤝', bg: '#eef9f1', fg: '#3f9d5c' },
{ label: 'Shop', icon: '🛍️', bg: '#eef7fd', fg: '#2f8fc4' },
{ label: 'Event', icon: '🎉', bg: '#fdf1ee', fg: '#c45f2f' },
{ label: 'Brand', icon: '🏷️', bg: '#fef7ea', fg: '#c49a2f' },
];
return (
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff' }}>
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff', position: 'relative', overflow: 'hidden' }}>
{/* Left — points + headline */}
<div>
{/* Live label */}
<div className="fp-rise" style={{
display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 20,
background: '#f0fdf4', border: '1.5px solid #16a34a', borderRadius: 100, padding: '7px 16px',
}}>
<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>
{/* Ambient decor — fills the white space behind the copy */}
<div style={{ position: 'absolute', inset: 0, zIndex: 0, pointerEvents: 'none' }}>
<span style={{
position: 'absolute', top: '4%', right: '4%', fontSize: 'clamp(160px, 20vw, 340px)',
fontWeight: 900, letterSpacing: '-0.05em', color: 'rgba(244,190,40,0.07)',
fontFamily: 'Sora, sans-serif', lineHeight: 1, userSelect: 'none',
}}>02</span>
<div style={{
position: 'absolute', bottom: '-10%', left: '-6%', width: 360, height: 360, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(244,190,40,0.10) 0%, transparent 70%)',
}} />
</div>
{/* Giant gold number */}
<div style={{
fontSize: 'clamp(80px, 12vw, 168px)', fontWeight: 900, fontFamily: 'Sora, sans-serif',
color: '#f4be28', letterSpacing: '-0.05em', lineHeight: 0.92,
textShadow: '0 4px 40px rgba(244,190,40,0.25)',
}}>
{pts.toLocaleString()}
</div>
<div className="fp-shell" style={{ position: 'relative', zIndex: 1 }}>
<div className="fp-split" style={{ gridTemplateColumns: '0.95fr 1.05fr' }}>
{/* Redeemable line */}
<div className="fp-rise" style={{
display: 'inline-flex', alignItems: 'center', gap: 10, marginTop: 14, marginBottom: 44,
padding: '10px 18px', borderRadius: 12,
background: '#fff7e0', border: '1px solid #f4be28',
}}>
<span style={{ fontSize: 18, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif' }}>{pts.toLocaleString()}</span>
<span style={{ fontSize: 13, color: '#4f463a', fontFamily: 'Sora, sans-serif' }}>redeemable value</span>
</div>
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">04</span></span></div>
<h2 style={{
fontSize: 'clamp(28px, 3.4vw, 48px)', fontWeight: 800, lineHeight: 1.08,
letterSpacing: '-0.04em', color: '#171512', margin: '0 0 20px', fontFamily: 'Sora, sans-serif',
}}>
<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>
<p className="fp-rise" style={{
fontSize: 15, lineHeight: 1.8, color: '#4f463a', fontFamily: 'Sora, sans-serif', maxWidth: 380, margin: 0,
}}>
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.
</p>
{/* Left — phone mockup, matches the in-app Earn / Home experience */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneMockupImage
src="/For_people/01_earn-cropped.png"
alt="Loyaly.AI Earn screen showing Lyts balance and activity feed"
floatClassName="erm-phone-shell"
aspectRatio={1684 / 3196}
/>
</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">
{/* Right — headline */}
<div>
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">02</span></span></div>
{/* 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>
<h2 className="fp-h2" style={{ color: '#171512' }}>
<span className="fp-mask"><span>Every Action</span></span>
<span className="fp-mask"><span style={{ color: '#f4be28' }}>Earns Lyts.</span></span>
</h2>
{/* 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>
<p className="fp-rise fp-desc" style={{ color: '#4f463a' }}>
No app-opening rituals, no forced check-ins just live your day and watch the balance move.
</p>
{/* Feed header */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
margin: '16px 14px 6px',
<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',
}}>
<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>
The more you live, <span style={{ color: '#f4be28' }}>the more you earn.</span>
</p>
</div>
</div>
</div>
</div>

View File

@@ -54,7 +54,7 @@ export default function ExperienceSection() {
<div className="fp-shell">
<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 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 { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useMagnetic as useMagneticShared } from './fpMotion';
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
@@ -27,19 +28,61 @@ function useMagnetic() {
}
const PROOF = [
{ val: '50K+', label: 'Active users', color: '#f4be28', bg: '#fffbeb', border: 'rgba(244,190,40,0.3)' },
{ val: '10K+', label: 'Partner stores', color: '#16a34a', bg: '#f0fdf4', border: 'rgba(22,163,74,0.25)' },
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', bg: '#eff6ff', border: 'rgba(37,99,235,0.2)' },
{ val: '4.9★', label: 'App rating', color: '#9333ea', bg: '#faf5ff', border: 'rgba(147,51,234,0.2)' },
{ val: '50K+', label: 'Active users', color: '#f4be28', icon: '👥', iconBg: '#fffbeb' },
{ val: '10K+', label: 'Partner stores', color: '#16a34a', icon: '🏬', iconBg: '#f0fdf4' },
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', icon: '🎁', iconBg: '#eff6ff' },
{ val: '4.9★', label: 'App rating', color: '#9333ea', icon: '', iconBg: '#faf5ff' },
];
export default function FinalCTASection() {
const sectionRef = useRef<HTMLDivElement>(null);
const mag1 = useMagnetic();
const mag2 = useMagnetic();
const coinLayerRef = useRef<HTMLDivElement>(null);
const badgeMag1 = useMagneticShared<HTMLDivElement>(10);
const badgeMag2 = useMagneticShared<HTMLDivElement>(10);
useIsomorphicLayoutEffect(() => {
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 tl = gsap.timeline({
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: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4'
)
.fromTo('.fcta-hanging-logo',
{ opacity: 0, y: -60, rotation: -16 },
{ opacity: 1, y: 0, rotation: 0, duration: 1.1, stagger: 0.16, ease: 'back.out(1.5)' },
'-=0.45'
// Reveal on the magnet layer so the badge's own transform stays free for CSS hover + GSAP float.
.fromTo('.fcta-badge-magnet',
{ opacity: 0, y: 30, scale: 0.94 },
{ opacity: 1, y: 0, scale: 1, duration: 0.7, stagger: 0.12, ease: 'back.out(1.6)' },
'-=0.35'
)
.fromTo('.fcta-logo-link',
{ 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',
});
});
})
.call(burstCoins)
.fromTo('.fcta-btns',
{ opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.45'
@@ -91,6 +118,30 @@ export default function FinalCTASection() {
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 (
<section
ref={sectionRef}
@@ -125,7 +176,7 @@ export default function FinalCTASection() {
fontSize: 'clamp(52px,8vw,116px)',
fontWeight: 900, color: '#171512',
}}>
Your rewards
Your lyts
</span>
<span className="fcta-line-2" style={{
display: 'block',
@@ -141,128 +192,73 @@ export default function FinalCTASection() {
</span>
</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 */}
<p className="fcta-sub" style={{
position: 'relative',
zIndex: 5,
fontSize: 'clamp(1rem,1.2vw,1.15rem)', lineHeight: 1.75,
color: '#171512', fontFamily: 'Sora, sans-serif',
maxWidth: 520, width: '100%', margin: '0 auto 0px',
background: '#f4be28',
border: '1.5px solid #f4be28',
borderRadius: 100,
padding: '14px 28px',
display: 'inline-block',
boxSizing: 'border-box',
fontSize: 'clamp(0.95rem,1.1vw,1.05rem)', lineHeight: 1.7,
color: '#5c5344', fontFamily: 'Sora, sans-serif', fontWeight: 500,
maxWidth: 440, width: '100%', margin: '0 auto',
}}>
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>
{/* Hanging Logos */}
<div className="fcta-hanging-logos-wrapper" style={{
position: 'relative',
height: 250,
margin: '-34px auto 40px',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 140,
width: 'fit-content',
zIndex: 1,
{/* Store badges — layered: magnet (cursor pull) float (idle bob + pulse) badge (hover) */}
<div className="fcta-store-badges" style={{
display: 'flex', justifyContent: 'center', flexWrap: 'wrap',
gap: 16, margin: '36px auto 48px', width: 'fit-content', zIndex: 1, position: 'relative',
}}>
{/* Play Store Logo (hanging) */}
<div className="fcta-hanging-logo play-store" style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transformOrigin: 'top center',
position: 'relative',
}}>
{/* Keyring loop */}
<div style={{
width: 16, height: 16, borderRadius: '50%',
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3,
boxShadow: '0 0 10px rgba(244,190,40,0.35)',
flexShrink: 0,
}} />
{/* String */}
<div style={{
width: 2,
height: 62,
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/images.png" alt="Google Play" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Link>
{/* Tooltip */}
<div className="fcta-tooltip">
Download In Playstore
{/* Coin-burst layer */}
<div ref={coinLayerRef} className="fcta-coin-layer" aria-hidden="true" />
{/* Google Play */}
<div ref={badgeMag1} className="fcta-badge-magnet">
<div className="fcta-badge-float">
<Link href="/contacts" className="fcta-store-badge" aria-label="Get it on Google Play">
<span className="fcta-sweep" aria-hidden="true" />
<span className="fcta-store-icon">
<svg width="26" height="26" viewBox="0 0 48 48" aria-hidden="true">
<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" />
<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" />
<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" />
<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>
</span>
<span className="fcta-store-text">
<small>GET IT ON</small>
<strong>Google Play</strong>
</span>
</Link>
</div>
</div>
{/* Apple Logo (hanging) */}
<div className="fcta-hanging-logo apple-store" style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transformOrigin: 'top center',
position: 'relative',
}}>
{/* Keyring loop */}
<div style={{
width: 16, height: 16, borderRadius: '50%',
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3,
boxShadow: '0 0 10px rgba(244,190,40,0.35)',
flexShrink: 0,
}} />
{/* 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>
{/* Tooltip */}
<div className="fcta-tooltip">
Download in App store
{/* App Store */}
<div ref={badgeMag2} className="fcta-badge-magnet">
<div className="fcta-badge-float">
<Link href="/contacts" className="fcta-store-badge" aria-label="Download on the App Store">
<span className="fcta-sweep" aria-hidden="true" />
<span className="fcta-store-icon">
<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" />
</svg>
</span>
<span className="fcta-store-text">
<small>DOWNLOAD ON THE</small>
<strong>App Store</strong>
</span>
</Link>
</div>
</div>
</div>
@@ -303,70 +299,97 @@ export default function FinalCTASection() {
</Link>
</div>
{/* Proof stats */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, flexWrap: 'wrap' }}>
{/* Proof stats — individual sticker cards, matching the dark-border card system used across the page */}
<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) => (
<div
key={i}
className="fcta-stat"
style={{
padding: '18px 26px', borderRadius: 20, textAlign: 'center', minWidth: 110,
background: p.bg,
border: `1.5px solid ${p.border}`,
boxShadow: '0 2px 12px rgba(23,21,18,0.06)',
flex: '1 1 130px', maxWidth: 160,
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '22px 16px', borderRadius: 20, textAlign: 'center',
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>
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.05em' }}>{p.label}</div>
<span style={{
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>
<style>{`
.fcta-stat:hover { box-shadow: 6px 6px 0 #171512; transform: translate(-2px, -2px); }
`}</style>
</div>
<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 fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
.fcta-hanging-logo a {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
/* Animation layers — magnet (JS transform) float (GSAP transform) badge (CSS hover) */
.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 {
transform: scale(1.08);
/* Hover gradient overlay — fades in for a smooth gradient transition */
.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 {
position: absolute;
top: calc(100% + 4px);
left: 50%;
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-store-badge:hover {
transform: translateY(-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);
border-color: rgba(244,190,40,0.6);
}
.fcta-hanging-logo:hover .fcta-tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0);
.fcta-store-badge:hover::before { opacity: 1; }
/* Glossy light sweep (driven by GSAP every ~6-8s) */
.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 {
content: '';
position: absolute;
top: -4px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 8px;
height: 8px;
background: #171512;
.fcta-store-icon {
position: relative; z-index: 1;
display: flex; align-items: center; justify-content: center;
width: 34px; height: 34px; flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1);
}
.fcta-store-badge:hover .fcta-store-icon { transform: scale(1.14); }
.fcta-store-text {
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) {
.fcta-hanging-logos-wrapper {
.fcta-store-badges {
display: none !important;
}
.fcta-sub {
@@ -378,6 +401,11 @@ export default function FinalCTASection() {
display: none !important;
}
}
@media (max-width: 560px) {
.fcta-proof-row {
width: 100% !important;
}
}
`}</style>
</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,
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 className="fp-shimmer">Live Rewarded.</span></span>
<span className="fp-mask"><span>Get Lit.</span></span>
<span className="fp-mask"><span className="fp-shimmer">Live More.</span></span>
</h1>
<p className="fp-content fp-subtext" style={{
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',
}}>
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>
<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,
}}>
{[
{ val: '1 LYT = ₹1', label: 'Fixed value' },
{ val: 'Zero Expiry', label: 'Ever' },
{ val: '10,000+', label: 'Partner stores' }
{ val: '10+ Ways', label: 'To earn Lyts' },
{ val: 'Instant Spend', label: 'No delay at checkout' },
{ val: '10,000+', label: 'Partner brands' }
].map((item, idx) => (
<div key={idx} style={{
display: 'flex',
@@ -124,7 +124,7 @@ export default function HeroSection() {
))}
</div>
<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>
</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>
);
}
export function HeartLogoIcon({ size = 16, active = false }: { size?: number; active?: boolean }) {
export function HeartLogoIcon({ size = 16 }: { size?: number; active?: boolean }) {
return (
<Image
src="/images/loyalyai-logo.png"
@@ -67,7 +67,6 @@ export function HeartLogoIcon({ size = 16, active = false }: { size?: number; ac
width: size,
height: size,
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 ── */
export function PhoneBottomNav({ active = 'home' }: { active?: string }) {
/* ── LytsUp brand mark — used in place of the Loyaly.AI heart+tag logo
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 }[] = [
{ 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: '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: '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 ── */
/* 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 }) {
// 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 (
<div
className={floatClassName}
className={`pm-phone-shell ${floatClassName}`}
style={{
width, borderRadius: 42, padding: 10,
background: 'linear-gradient(160deg, #2a2a2e 0%, #0a0a0c 100%)',
@@ -134,21 +192,30 @@ export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width
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 */}
<div style={{
position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)',
width: 82, height: 22, borderRadius: 14, background: '#000', zIndex: 5,
}} />
{children}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{content}
</div>
{bottomNav}
</div>
<style>{`
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
.pm-phone-float { animation: pmPhoneFloat 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pm-phone-float { animation: none !important; } }
.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-float { width: 280px !important; }
.pm-phone-shell { width: ${MOBILE_WIDGET_WIDTH}px !important; }
}
`}</style>
</div>

View File

@@ -1,211 +1,93 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import Image from 'next/image';
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 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() {
const gridRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const panelRef = 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(() => {
if (typeof window === 'undefined') return;
const grid = gridRef.current;
if (!grid) return;
const section = panelRef.current;
const phone = phoneRef.current;
if (!section || !phone) return;
const ctx = gsap.context(() => {
gsap.fromTo('.spend-card',
{ opacity: 0, y: 44, scale: 0.92, filter: 'blur(8px)' },
gsap.fromTo(phone,
{ y: 160, opacity: 0 },
{
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
duration: 0.9, stagger: 0.09, ease: 'expo.out',
scrollTrigger: { trigger: grid, start: 'top 85%', toggleActions: 'play none none reset' },
y: 0, opacity: 1, ease: 'power2.out',
scrollTrigger: { trigger: section, start: 'top 92%', end: 'top 25%', scrub: 1.2 },
}
);
}, grid);
}, section);
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 (
<section 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 }} />
<section ref={panelRef} className="fp-panel" style={{ background: '#080810' }}>
<div className="fp-shell">
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
{/* Header row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 24, marginBottom: 'clamp(40px, 6vh, 64px)' }}>
{/* Left — headline + copy */}
<div>
<div style={{ marginBottom: 22 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">05</span></span></div>
<h2 style={{
fontSize: 'clamp(46px, 7vw, 110px)', fontWeight: 800, letterSpacing: '-0.06em', lineHeight: 0.9,
color: '#fff', margin: 0, fontFamily: 'Sora, sans-serif',
}}>
<span className="fp-mask"><span>Spend it</span></span>
<span className="fp-mask"><span className="fp-shimmer">anywhere.</span></span>
<div style={{ marginBottom: 20 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">03</span></span></div>
<h2 className="fp-h1" style={{ color: '#fff' }}>
<span className="fp-mask"><span>Your Lyts.</span></span>
<span className="fp-mask"><span className="fp-shimmer">Your Choice.</span></span>
</h2>
</div>
<p className="fp-rise" style={{
fontSize: 15, lineHeight: 1.75, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: 0, maxWidth: 320,
}}>
One balance. Every store. Your LYTs are universal: no restrictions, no expiry, no fine print.
</p>
</div>
{/* Category grid */}
<div ref={gridRef} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, perspective: 900 }}>
{brands.map((brand, i) => {
const BrandIcon = brand.Icon;
return (
<div
key={i}
ref={el => { cardRefs.current[i] = el; }}
onMouseMove={handleMove(i)}
onMouseLeave={handleLeave(i)}
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',
}} />
<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' }}>
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.
</p>
<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>
<div className="fp-rise" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28, maxWidth: 460 }}>
{[
{ label: 'No waiting', icon: '⚡' },
{ label: 'No cashback', icon: '🚫' },
{ label: 'No complicated redemption', icon: '✅' },
].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 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>
{/* Stat strip */}
<div className="fp-rise" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 24,
marginTop: 'clamp(40px, 6vh, 64px)', paddingTop: 32, borderTop: '1px solid rgba(255,255,255,0.07)', transitionDelay: '0.45s',
}}>
{[
{ val: '10,000+', lbl: 'Partner stores' },
{ 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 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',
}}>
One Wallet. <span style={{ color: '#f4be28' }}>Thousands of Rewards.</span>
</p>
</div>
</div>
{/* Right — phone mockup: where to spend */}
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
<PhoneMockupImage
src="/For_people/02_spend-cropped.png"
alt="LytsUp Spend screen showing redeem-at categories"
floatClassName="spa-phone-shell"
aspectRatio={1516 / 3040}
/>
</div>
</div>
</div>
<style>{`
.spend-card { transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease; }
.spend-card:hover {
border-color: rgba(255,255,255,0.16);
box-shadow: 0 24px 50px -16px rgba(0,0,0,0.6);
}
.spend-card:hover .spend-card-arrow { opacity: 1; transform: translateX(0); }
.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; }
`}</style>
</section>
);
}

View File

@@ -143,12 +143,36 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
--carbon: #171512;
--warm: #4f463a;
--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;
background: #ffffff;
color: #fff;
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 ── */
.fp-grain {
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;
}
/* ── 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 ── */
@media (max-width: 900px) {
.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';
import React from 'react';
import NextImage from 'next/image';
/**
* Premium animated dark background — gold gradient mesh orbs, drifting grid,
@@ -9,16 +10,31 @@ import React from 'react';
*/
export default function AnimatedDarkBg({
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 orbB = variant === 'cool' ? 'rgba(140,90,210,0.10)' : 'rgba(255,149,0,0.08)';
return (
<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={{
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 */}
@@ -48,7 +64,9 @@ export default function AnimatedDarkBg({
{/* Vignette */}
<div style={{
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>{`

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];
if (!el) return;
gsap.to(el, {
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
borderColor: active ? '#171512' : 'rgba(244,190,40,0.35)',
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
borderColor: active ? '#ffffff' : 'rgba(244,190,40,0.3)',
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',
});
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,
padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)',
zIndex: 2,
background: '#ffffff',
boxShadow: 'inset 0 0 0 1px rgba(23,21,18,0.05)',
background: 'radial-gradient(ellipse 110% 80% at 50% 0%, #17130d 0%, #0d0b09 55%, #0a0807 100%)',
boxShadow: 'inset 0 0 0 1px rgba(244,190,40,0.12)',
}}
>
{/* Faint dot-grid texture for depth, matching the light sections elsewhere */}
@@ -167,13 +168,14 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
aria-hidden
style={{
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',
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%)',
}}
/>
<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 */}
<div
@@ -181,7 +183,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{
position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)',
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,
}}
>
@@ -195,7 +197,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
margin: 0,
padding: 'clamp(8px, 2vw, 20px) 0',
fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)',
fontWeight: 800, color: '#171512',
fontWeight: 800, color: '#ffffff',
lineHeight: 1, letterSpacing: '-0.04em',
fontFamily: 'Sora, sans-serif',
}}
@@ -232,7 +234,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{
margin: '22px 0 0', maxWidth: 620,
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.
@@ -247,16 +249,17 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
style={{
display: 'flex', alignItems: 'center', gap: 8,
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
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}
</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>
@@ -273,20 +276,20 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
width: 46, height: 46, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
color: '#171512', boxSizing: 'border-box',
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
border: i === 0 ? '3px solid #171512' : '1px solid rgba(244,190,40,0.35)',
boxShadow: i === 0 ? '0 12px 26px -10px rgba(23,21,18,0.4)' : 'none',
color: i === 0 ? '#171512' : 'rgba(255,255,255,0.6)', boxSizing: 'border-box',
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
border: i === 0 ? '3px solid #ffffff' : '1px solid rgba(244,190,40,0.3)',
boxShadow: i === 0 ? '0 12px 30px -8px rgba(244,190,40,0.55)' : 'none',
}}
>
{i + 1}
</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}
</span>
</div>
{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
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)' }}
@@ -304,7 +307,9 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
maxWidth: 720, textAlign: 'center',
padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)',
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',
}}
>

View File

@@ -450,7 +450,24 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
// Desktop / tablet: pinned horizontal scroll
mm.add('(min-width: 901px)', () => {
const cards = gsap.utils.toArray<HTMLElement>('.products-card', track);
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, {
x: () => -distance(),
@@ -460,30 +477,42 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
start: 'top top',
end: () => `+=${distance()}`,
pin: true,
pinType: 'transform',
pinSpacing: true,
anticipatePin: 1,
scrub: 1,
scrub: 0.4,
invalidateOnRefresh: true,
refreshPriority: 5,
snap: {
snapTo: (value) => Math.round(value * steps) / steps,
duration: { min: 0.2, max: 0.5 },
ease: 'power2.inOut',
},
onUpdate: (self) => {
if (progressRef.current) progressRef.current.style.transform = `scaleX(${self.progress})`;
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}`;
}
updateDepth();
},
},
});
updateDepth();
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();
}, [isLoaded]);
return (
<div className="products-section-wrapper">
<section
ref={sectionRef}
id="products-section"
@@ -496,24 +525,26 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
{/* Heading (fixed within the pinned viewport) */}
<div className="products-heading">
<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)' }}>
<img src="/images/loyalyai-logo.png" alt="" width={15} height={15} style={{ display: 'block', objectFit: 'contain' }} />
</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>
<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' }}>
Four products.<br />
<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>
<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 agents.{' '}
<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>
</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)' }}>
<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' }}>
<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 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.
</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>
</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 {
--pt: clamp(230px, 28vh, 310px);
--pb: clamp(64px, 8vh, 96px);
width: calc(100% - 40px);
margin: 20px 20px;
width: 100%;
border-radius: 32px;
background: #FAFAFA;
height: 100vh;
@@ -681,5 +718,6 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
}
`}</style>
</section>
</div>
);
}

View File

@@ -224,12 +224,12 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
</div>
{/* Papercraft Image */}
<div style={{
<div className="shift-card-art" style={{
position: 'absolute',
right: i === 2 ? '-20px' : '-24px',
bottom: i === 2 ? '-20px' : '-24px',
width: i === 2 ? '72%' : '68%',
height: '112%',
right: i === 2 ? '-12px' : '-16px',
bottom: i === 2 ? '-12px' : '-16px',
width: i === 2 ? '56%' : '52%',
height: '90%',
pointerEvents: 'none',
zIndex: 1,
}}>
@@ -238,11 +238,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.label}
width={400}
height={400}
className="shift-card-art-img"
style={{
objectFit: 'contain',
objectPosition: 'bottom right',
width: '100%',
height: '100%',
transform: i === 2 ? 'scale(1.4)' : 'scale(1.36)',
transform: i === 2 ? 'scale(1.1)' : 'scale(1.06)',
transformOrigin: 'bottom right',
}}
/>
@@ -291,11 +293,11 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
{/* Papercraft Image */}
{(cardNumber === 7 || cardNumber === 8) ? (
<div style={{
<div className="shift-card-art" style={{
position: 'absolute',
right: 0,
bottom: 0,
width: cardNumber === 7 ? '66%' : '62%',
width: cardNumber === 7 ? '64%' : '48%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
@@ -305,21 +307,22 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.title}
width={cardNumber === 7 ? 520 : 440}
height={cardNumber === 7 ? 520 : 440}
className="shift-card-art-img"
style={{
objectFit: 'cover',
objectPosition: cardNumber === 7 ? '62% 88%' : '68% 82%',
objectPosition: cardNumber === 7 ? '58% 50%' : '68% 82%',
width: '100%',
height: '100%',
}}
/>
</div>
) : (
<div style={{
<div className="shift-card-art" style={{
position: 'absolute',
right: '-16px',
bottom: '-20px',
width: '66%',
height: '112%',
right: '-12px',
bottom: '-14px',
width: '50%',
height: '90%',
pointerEvents: 'none',
zIndex: 1,
}}>
@@ -328,11 +331,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
alt={card.title}
width={400}
height={400}
className="shift-card-art-img"
style={{
objectFit: 'contain',
objectPosition: 'bottom right',
width: '100%',
height: '100%',
transform: 'scale(1.3)',
transform: 'scale(1.0)',
transformOrigin: 'bottom right',
}}
/>
@@ -429,6 +434,19 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
.shift-card-7 {
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) {

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 grid = document.querySelector('.wwd-grid-container');
gsap.fromTo(cards,
{ 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' },
}
const revealTl = gsap.timeline({
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) => {
const tl = gsap.timeline({ delay: i * 0.12 + 0.25 });
tl.fromTo(card.querySelector('.wwd-icon'),
{ opacity: 0, scale: 0.5, rotate: -15, filter: 'blur(3px)' },
{ opacity: 1, scale: 1, rotate: 0, filter: 'blur(0px)', duration: 0.55, ease: 'back.out(2)' }
)
.fromTo(card.querySelector('.wwd-title'),
{ opacity: 0, y: 14 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, '-=0.2'
)
.fromTo(card.querySelector('.wwd-tagline'),
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.38, ease: 'power3.out' }, '-=0.2'
)
.fromTo(card.querySelector('.wwd-detail'),
{ opacity: 0, y: 8 },
{ opacity: 1, y: 0, duration: 0.35, ease: 'power3.out' }, '-=0.18'
);
const at = i * 0.14 + 0.35;
revealTl
.fromTo(card.querySelector('.wwd-icon'),
{ opacity: 0, scale: 0.4, rotate: -20 },
{ opacity: 1, scale: 1, rotate: 0, duration: 0.5, ease: 'back.out(2.2)' }, at)
.fromTo(card.querySelector('.wwd-title'),
{ opacity: 0, y: 16 },
{ opacity: 1, y: 0, duration: 0.45, ease: 'power3.out' }, at + 0.12)
.fromTo(card.querySelector('.wwd-tagline'),
{ opacity: 0, y: 12 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.22)
.fromTo(card.querySelector('.wwd-detail'),
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.3);
});
}, containerRef);
@@ -207,7 +205,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
zIndex: 2,
}}
>
<AnimatedDarkBg variant="amber" />
<AnimatedDarkBg variant="amber" bgImage="/images/stock/one_platform_bg.png" bgImageAlt="" />
{/* 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">
@@ -224,6 +222,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
fontWeight: 800, color: '#fff',
lineHeight: 0.98, letterSpacing: '-0.04em',
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 />
a <span className="wwd-shimmer">loyal customer.</span>
@@ -242,8 +241,6 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
<style>{`
.wwd-card {
position: relative;
transform: perspective(1100px) rotateX(0deg) rotateY(0deg);
transition: transform 0.45s cubic-bezier(0.22,1,0.36,1);
}
.wwd-card-inner {
position: relative;
@@ -253,11 +250,11 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
height: 100%;
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-yellow .wwd-card-inner { background: #F4C21A; }
.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; box-shadow: 0 18px 40px -14px rgba(0,0,0,0.45); }
.wwd-card-active .wwd-card-inner {
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 {
position: absolute; inset: 0; pointer-events: none;
@@ -292,11 +289,13 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
flex-shrink: 0;
}
.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;
-webkit-background-clip: text;
background-clip: text;
-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;
}
@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 { 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) {
if (typeof window === 'undefined') return () => {};