feat: update website layout, components, and assets
BIN
public/For_people/01_earn-cropped.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/For_people/01_earn.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/For_people/02_spend-cropped.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/For_people/02_spend.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/For_people/03_live-cropped.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/For_people/03_live.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/For_people/04_Lyts Universe.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/For_people/04_Lyts_Universe-cropped.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/home_hero/home_hero_01.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/home_hero/home_hero_02.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/home_hero/home_hero_03.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/lyts.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/images/stock/discover.gif
Normal file
|
After Width: | Height: | Size: 19 MiB |
BIN
public/images/stock/discover.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/images/stock/one_platform_bg.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 7.9 MiB After Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 8.2 MiB After Width: | Height: | Size: 7.8 MiB |
|
Before Width: | Height: | Size: 6.1 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 9.8 MiB |
|
Before Width: | Height: | Size: 7.3 MiB After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 8.1 MiB After Width: | Height: | Size: 7.6 MiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
452
src/app/page.tsx
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 ~6–8s.
|
||||
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>
|
||||
);
|
||||
|
||||
94
src/components/for-people/FpCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
95
src/components/for-people/LiveMoreSection.tsx
Normal 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't about collecting points. It'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>
|
||||
);
|
||||
}
|
||||
96
src/components/for-people/LytsUniverseSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
153
src/components/for-people/fpMotion.ts
Normal 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;
|
||||
}
|
||||
@@ -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>{`
|
||||
|
||||
468
src/components/home/HeroCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/home/HeroContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/home/HeroNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/home/HeroProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/home/HeroSlide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
61
src/components/home/heroCarouselData.ts
Normal 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;
|
||||
@@ -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 () => {};
|
||||
|
||||