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 React from 'react';
|
||||||
import VerticalScrollWrapper from '@/components/for-people/VerticalScrollWrapper';
|
import VerticalScrollWrapper from '@/components/for-people/VerticalScrollWrapper';
|
||||||
import HeroSection from '@/components/for-people/HeroSection';
|
import HeroSection from '@/components/for-people/HeroSection';
|
||||||
import PillarsSection from '@/components/for-people/PillarsSection';
|
|
||||||
import WhyLoyalySection from '@/components/for-people/WhyLoyalySection';
|
|
||||||
import DiscoverDealsSection from '@/components/for-people/DiscoverDealsSection';
|
import DiscoverDealsSection from '@/components/for-people/DiscoverDealsSection';
|
||||||
import EarnRewardsSection from '@/components/for-people/EarnRewardsSection';
|
import EarnRewardsSection from '@/components/for-people/EarnRewardsSection';
|
||||||
import SpendAnywhereSection from '@/components/for-people/SpendAnywhereSection';
|
import SpendAnywhereSection from '@/components/for-people/SpendAnywhereSection';
|
||||||
import AIRecommendationsSection from '@/components/for-people/AIRecommendationsSection';
|
import LiveMoreSection from '@/components/for-people/LiveMoreSection';
|
||||||
|
import LytsUniverseSection from '@/components/for-people/LytsUniverseSection';
|
||||||
import ExperienceSection from '@/components/for-people/ExperienceSection';
|
import ExperienceSection from '@/components/for-people/ExperienceSection';
|
||||||
import FinalCTASection from '@/components/for-people/FinalCTASection';
|
import FinalCTASection from '@/components/for-people/FinalCTASection';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
'Intro',
|
'Intro',
|
||||||
'The Gap',
|
|
||||||
'Save More',
|
|
||||||
'Discover',
|
'Discover',
|
||||||
'Earn',
|
'Earn',
|
||||||
'Spend',
|
'Spend',
|
||||||
'Intelligence',
|
'Live',
|
||||||
|
'Lyts Universe',
|
||||||
'Experience',
|
'Experience',
|
||||||
'Get Started',
|
'Get Started',
|
||||||
];
|
];
|
||||||
@@ -29,15 +27,15 @@ export default function ForPeoplePage() {
|
|||||||
<main style={{ background: '#ffffff' }}>
|
<main style={{ background: '#ffffff' }}>
|
||||||
<VerticalScrollWrapper sections={SECTIONS}>
|
<VerticalScrollWrapper sections={SECTIONS}>
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<WhyLoyalySection />
|
|
||||||
<PillarsSection />
|
|
||||||
<DiscoverDealsSection />
|
<DiscoverDealsSection />
|
||||||
<EarnRewardsSection />
|
<EarnRewardsSection />
|
||||||
<SpendAnywhereSection />
|
<SpendAnywhereSection />
|
||||||
<AIRecommendationsSection />
|
<LiveMoreSection />
|
||||||
|
<LytsUniverseSection />
|
||||||
<ExperienceSection />
|
<ExperienceSection />
|
||||||
<FinalCTASection />
|
<FinalCTASection />
|
||||||
</VerticalScrollWrapper>
|
</VerticalScrollWrapper>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,3 +260,7 @@ body.is-home-page.scroll-up .sticky-active {
|
|||||||
animation-play-state: paused !important;
|
animation-play-state: paused !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero carousel layout lives in HeroCarousel.tsx as a scoped <style> tag,
|
||||||
|
matching every other home-page section — keeps it working even if this
|
||||||
|
global stylesheet has a load/HMR hiccup. */
|
||||||
|
|||||||
452
src/app/page.tsx
@@ -1,198 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import Lenis from 'lenis';
|
|
||||||
import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import NextImage from 'next/image';
|
|
||||||
import ProblemSection from '@/components/home/ProblemSection';
|
import ProblemSection from '@/components/home/ProblemSection';
|
||||||
import ShiftSection from '@/components/home/ShiftSection';
|
import ShiftSection from '@/components/home/ShiftSection';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||||
import ProductsSection from '@/components/home/ProductsSection';
|
import ProductsSection from '@/components/home/ProductsSection';
|
||||||
import LytsUpSection from '@/components/home/LytsUpSection';
|
import LytsUpSection from '@/components/home/LytsUpSection';
|
||||||
import TheLoop from '@/components/home/TheLoop';
|
import TheLoop from '@/components/home/TheLoop';
|
||||||
|
import HeroCarousel from '@/components/home/HeroCarousel';
|
||||||
import initGlobalReveal from '@/utils/scrollReveal';
|
import initGlobalReveal from '@/utils/scrollReveal';
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 1024px)';
|
|
||||||
const subscribeDesktopQuery = (callback: () => void) => {
|
|
||||||
const mql = window.matchMedia(DESKTOP_QUERY);
|
|
||||||
mql.addEventListener('change', callback);
|
|
||||||
return () => mql.removeEventListener('change', callback);
|
|
||||||
};
|
|
||||||
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches;
|
|
||||||
// Server has no viewport, so it always renders the desktop hero; the client's
|
|
||||||
// first render must match that to avoid a hydration mismatch.
|
|
||||||
const getDesktopServerSnapshot = () => true;
|
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
const [heroReady, setHeroReady] = useState(false);
|
||||||
// Desktop pinned canvas hero vs. mobile static hero.
|
const [layoutSettled, setLayoutSettled] = useState(false);
|
||||||
const isDesktop = useSyncExternalStore(subscribeDesktopQuery, getDesktopSnapshot, getDesktopServerSnapshot);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
/* ── global scroll-reveal for sections below the hero ─────────── */
|
||||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const welcomeRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const imagesRef = useRef<HTMLImageElement[]>([]);
|
|
||||||
const currentFrameIndexRef = useRef<number>(0);
|
|
||||||
const canvasWidthRef = useRef<number>(0);
|
|
||||||
const canvasHeightRef = useRef<number>(0);
|
|
||||||
// tracks loaded count without re-rendering on every frame
|
|
||||||
const loadedCountRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const frameCount = 192;
|
|
||||||
|
|
||||||
/* ── draw ─────────────────────────────────────────────────────── */
|
|
||||||
const drawFrame = useCallback((frameIndex: number) => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
const img = imagesRef.current[frameIndex];
|
|
||||||
if (!img) return;
|
|
||||||
|
|
||||||
let canvasWidth = canvasWidthRef.current;
|
|
||||||
let canvasHeight = canvasHeightRef.current;
|
|
||||||
if (canvasWidth === 0 || canvasHeight === 0) {
|
|
||||||
canvasWidth = canvas.offsetWidth;
|
|
||||||
canvasHeight = canvas.offsetHeight;
|
|
||||||
canvasWidthRef.current = canvasWidth;
|
|
||||||
canvasHeightRef.current = canvasHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imgWidth = img.naturalWidth || img.width;
|
|
||||||
const imgHeight = img.naturalHeight || img.height;
|
|
||||||
if (imgWidth && imgHeight) {
|
|
||||||
const imgRatio = imgWidth / imgHeight;
|
|
||||||
const canvasRatio = canvasWidth / canvasHeight;
|
|
||||||
let drawWidth = canvasWidth;
|
|
||||||
let drawHeight = canvasHeight;
|
|
||||||
let offsetX = 0;
|
|
||||||
let offsetY = 0;
|
|
||||||
if (canvasRatio > imgRatio) {
|
|
||||||
drawHeight = canvasWidth / imgRatio;
|
|
||||||
offsetY = (canvasHeight - drawHeight) / 2;
|
|
||||||
} else {
|
|
||||||
drawWidth = canvasHeight * imgRatio;
|
|
||||||
offsetX = (canvasWidth - drawWidth) / 2;
|
|
||||||
}
|
|
||||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
||||||
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* ── resize ───────────────────────────────────────────────────── */
|
|
||||||
const handleResize = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const logicalWidth = canvas.offsetWidth;
|
|
||||||
const logicalHeight = canvas.offsetHeight;
|
|
||||||
canvasWidthRef.current = logicalWidth;
|
|
||||||
canvasHeightRef.current = logicalHeight;
|
|
||||||
canvas.width = logicalWidth * dpr;
|
|
||||||
canvas.height = logicalHeight * dpr;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
}
|
|
||||||
drawFrame(currentFrameIndexRef.current);
|
|
||||||
}, [drawFrame]);
|
|
||||||
|
|
||||||
/* ── lock scroll on desktop until frames are ready to avoid the
|
|
||||||
pinned ScrollTrigger engaging mid-scroll and snapping the page ─ */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imagesLoaded || !isDesktop) return;
|
if (!heroReady) return;
|
||||||
document.body.style.overflow = 'hidden';
|
const cleanupReveal = initGlobalReveal();
|
||||||
return () => { document.body.style.overflow = ''; };
|
|
||||||
}, [imagesLoaded, isDesktop]);
|
|
||||||
|
|
||||||
/* ── load frame 0 immediately (canvas visible on first paint) ─── */
|
// A single immediate refresh isn't enough: below-the-fold images and the
|
||||||
useEffect(() => {
|
// Sora webfont (loaded via <link>, not next/font) settle asynchronously
|
||||||
const firstImg = new Image();
|
// and change section heights well after first paint — measured up to
|
||||||
firstImg.onload = () => {
|
// ~2000px of shift as images below the hero finish loading. Pinned
|
||||||
if (imagesRef.current.length === 0) {
|
// ScrollTriggers (e.g. ProductsSection's horizontal pin) cache their
|
||||||
imagesRef.current = [firstImg];
|
// start/end at refresh time, so a stale refresh here makes the pin
|
||||||
const canvas = canvasRef.current;
|
// engage at the wrong scroll offset.
|
||||||
if (canvas) {
|
//
|
||||||
const dpr = window.devicePixelRatio || 1;
|
// If the user has already scrolled into a pinned section by the time a
|
||||||
canvas.width = canvas.offsetWidth * dpr;
|
// later refresh corrects that stale measurement, ScrollTrigger snaps the
|
||||||
canvas.height = canvas.offsetHeight * dpr;
|
// scroll position to compensate — that snap is the "shaking". The body
|
||||||
const ctx = canvas.getContext('2d');
|
// scroll lock below prevents the user from reaching that state at all:
|
||||||
if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); }
|
// scrolling stays locked until layout has genuinely settled.
|
||||||
drawFrame(0);
|
ScrollTrigger.refresh();
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
firstImg.src = '/img_frames/frame_000_delay-0.041s.jpg';
|
|
||||||
}, [drawFrame]);
|
|
||||||
|
|
||||||
/* ── preload all frames, update progress counter ─────────────── */
|
let settled = false;
|
||||||
useEffect(() => {
|
const refreshOnce = () => {
|
||||||
// Reset counter every time this effect runs (guards against Strict Mode
|
if (settled) return;
|
||||||
// double-invocation and any navigation-back scenario that re-mounts the component)
|
settled = true;
|
||||||
loadedCountRef.current = 0;
|
ScrollTrigger.refresh();
|
||||||
|
setLayoutSettled(true);
|
||||||
const images: HTMLImageElement[] = [];
|
|
||||||
|
|
||||||
// Use RAF-throttled updates to avoid 192 rapid re-renders
|
|
||||||
let rafId = 0;
|
|
||||||
const scheduleUpdate = (done: boolean) => {
|
|
||||||
if (!done) return;
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
imagesRef.current = images;
|
|
||||||
setImagesLoaded(true);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < frameCount; i++) {
|
window.addEventListener('load', refreshOnce);
|
||||||
const img = new Image();
|
if (typeof document !== 'undefined' && document.fonts?.ready) {
|
||||||
const paddedIndex = String(i).padStart(3, '0');
|
document.fonts.ready.then(refreshOnce);
|
||||||
const onLoad = () => {
|
|
||||||
loadedCountRef.current++;
|
|
||||||
const done = loadedCountRef.current >= frameCount;
|
|
||||||
scheduleUpdate(done);
|
|
||||||
};
|
|
||||||
img.onload = onLoad;
|
|
||||||
img.onerror = onLoad;
|
|
||||||
img.src = `/img_frames/frame_${paddedIndex}_delay-0.041s.jpg`;
|
|
||||||
images.push(img);
|
|
||||||
}
|
}
|
||||||
|
// Fallback in case neither signal fires promptly (e.g. already cached).
|
||||||
|
const fallback = setTimeout(refreshOnce, 800);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafId);
|
clearTimeout(fallback);
|
||||||
// Reset so a re-run (Strict Mode or re-mount) always starts clean
|
window.removeEventListener('load', refreshOnce);
|
||||||
loadedCountRef.current = 0;
|
if (typeof cleanupReveal === 'function') cleanupReveal();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [heroReady]);
|
||||||
|
|
||||||
/* ── resize listener ──────────────────────────────────────────── */
|
/* ── lock scroll until layout has settled, so the user can never land
|
||||||
|
mid-scroll inside a section whose pin position is about to correct ─ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('resize', handleResize);
|
if (layoutSettled) return;
|
||||||
handleResize();
|
document.body.style.overflow = 'hidden';
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [handleResize, imagesLoaded]);
|
}, [layoutSettled]);
|
||||||
|
|
||||||
/* ── ScrollTrigger refresh after all frames load ──────────────── */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!imagesLoaded) return;
|
|
||||||
ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize();
|
|
||||||
const t1 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); handleResize(); }, 200);
|
|
||||||
const t2 = setTimeout(() => { ScrollTrigger.sort(); ScrollTrigger.refresh(); }, 600);
|
|
||||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
|
||||||
}, [imagesLoaded, handleResize]);
|
|
||||||
|
|
||||||
/* ── homepage header show/hide on scroll direction ───────────── */
|
/* ── homepage header show/hide on scroll direction ───────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,251 +105,19 @@ export default function IndexPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Lenis + GSAP scrub (frame seq + welcome text + hero text) ── */
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
|
||||||
if (!imagesLoaded || !isDesktop) return;
|
|
||||||
|
|
||||||
const lenis = new Lenis({
|
|
||||||
duration: 1.2,
|
|
||||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
|
||||||
smoothWheel: true,
|
|
||||||
});
|
|
||||||
lenis.on('scroll', ScrollTrigger.update);
|
|
||||||
const updateTicker = (time: number) => lenis.raf(time * 1000);
|
|
||||||
gsap.ticker.add(updateTicker);
|
|
||||||
gsap.ticker.lagSmoothing(0);
|
|
||||||
|
|
||||||
const cleanupReveal = initGlobalReveal();
|
|
||||||
const air = { frame: 0 };
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
const tl = gsap.timeline({
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: containerRef.current,
|
|
||||||
start: 'top top',
|
|
||||||
end: '+=300%',
|
|
||||||
pin: true,
|
|
||||||
pinType: 'transform',
|
|
||||||
scrub: true,
|
|
||||||
anticipatePin: 1,
|
|
||||||
invalidateOnRefresh: true,
|
|
||||||
refreshPriority: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frame scrub (full duration = 1.0)
|
|
||||||
tl.to(air, {
|
|
||||||
frame: frameCount - 1,
|
|
||||||
ease: 'none',
|
|
||||||
duration: 1.0,
|
|
||||||
onUpdate: () => {
|
|
||||||
const f = Math.floor(air.frame);
|
|
||||||
currentFrameIndexRef.current = f;
|
|
||||||
drawFrame(f);
|
|
||||||
},
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// "Welcome Buddy" text: visible at frame 0, fades OUT by frame 25
|
|
||||||
const welcomeEnd = 25 / (frameCount - 1); // ≈ 0.131
|
|
||||||
if (welcomeRef.current) {
|
|
||||||
tl.fromTo(
|
|
||||||
welcomeRef.current,
|
|
||||||
{ opacity: 1, y: 0, scale: 1 },
|
|
||||||
{ opacity: 0, y: -28, scale: 0.93, ease: 'power2.in', duration: welcomeEnd },
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hero text + white overlay: fade IN starting at frame 120
|
|
||||||
const startProgress = 120 / (frameCount - 1);
|
|
||||||
const fadeDuration = 30 / (frameCount - 1);
|
|
||||||
|
|
||||||
if (heroRef.current) {
|
|
||||||
tl.to(heroRef.current, { opacity: 1, y: 0, ease: DEFAULT_EASE, duration: fadeDuration || DEFAULT_DURATION }, startProgress);
|
|
||||||
}
|
|
||||||
if (overlayRef.current) {
|
|
||||||
tl.to(overlayRef.current, { opacity: 1, ease: DEFAULT_EASE, duration: fadeDuration || DEFAULT_DURATION }, startProgress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ctx.revert();
|
|
||||||
lenis.destroy();
|
|
||||||
gsap.ticker.remove(updateTicker);
|
|
||||||
if (typeof cleanupReveal === 'function') cleanupReveal();
|
|
||||||
};
|
|
||||||
}, [imagesLoaded, isDesktop, drawFrame]);
|
|
||||||
|
|
||||||
/* ── render ───────────────────────────────────────────────────── */
|
/* ── render ───────────────────────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ══════════════════════════════════════════
|
{/* ══════════════════════════════════════════
|
||||||
MOBILE HERO — static image below text content
|
HERO — full-viewport premium carousel
|
||||||
══════════════════════════════════════════ */}
|
══════════════════════════════════════════ */}
|
||||||
<section
|
<HeroCarousel onReady={() => setHeroReady(true)} />
|
||||||
className="loyaly-home relative bg-white overflow-hidden"
|
|
||||||
style={{ margin: '20px', borderRadius: '25px', display: isDesktop ? 'none' : 'block' }}
|
|
||||||
>
|
|
||||||
<div className="px-6 sm:px-8 pt-24 pb-8 flex flex-col">
|
|
||||||
<h1 className="text-[#171512] hero-heading text-3xl sm:text-4xl font-extrabold tracking-tight leading-[1.12] mb-6 select-none">
|
|
||||||
Turn Every <br />
|
|
||||||
Walk In Into a <br />
|
|
||||||
<span className="hero-gradient-text">Loyal Customer</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#f4be28] hero-tagline text-sm sm:text-base font-bold tracking-tight mb-3 select-none">
|
|
||||||
Offline is the new Online.
|
|
||||||
</p>
|
|
||||||
<p className="text-[#4f463a] hero-description text-sm sm:text-base mb-8 leading-relaxed select-none">
|
|
||||||
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn LYTs through games and daily activities, then redeem them at offline stores near them.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<Link href="/contacts" className="flex items-center gap-3 bg-[#f4be28] hover:bg-[#ffd95a] hero-btn-primary text-[#171512] px-6 py-3 rounded-xl font-bold text-base transition-all duration-300 shadow-lg shadow-yellow-500/30 hover:shadow-yellow-500/50 cursor-pointer no-underline">
|
|
||||||
Book a Demo
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link href="/contacts" className="flex items-center border-2 border-[#f4be28] hover:bg-[#f4be28]/15 hero-btn-secondary text-[#f4be28] px-6 py-3 rounded-xl font-bold text-base transition-all duration-300 cursor-pointer no-underline">
|
|
||||||
Get Started
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NextImage
|
|
||||||
src="/images/mobile_hero.png"
|
|
||||||
alt="Loyaly.AI"
|
|
||||||
width={792}
|
|
||||||
height={1271}
|
|
||||||
sizes="100vw"
|
|
||||||
className="w-full h-auto block"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ══════════════════════════════════════════
|
|
||||||
HERO — pinned, drives canvas frame scrub
|
|
||||||
══════════════════════════════════════════ */}
|
|
||||||
<main
|
|
||||||
ref={containerRef}
|
|
||||||
className="loyaly-home relative bg-white overflow-hidden"
|
|
||||||
style={{
|
|
||||||
width: 'calc(100% - 40px)',
|
|
||||||
height: 'calc(100vh - 40px)',
|
|
||||||
margin: '20px',
|
|
||||||
borderRadius: '25px',
|
|
||||||
display: isDesktop ? 'block' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Canvas */}
|
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none" style={{ width: '100%', height: '100%' }}>
|
|
||||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── "Welcome Buddy" — centered, visible at frame 0, fades by frame 25 ── */}
|
|
||||||
<div
|
|
||||||
ref={welcomeRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
zIndex: 12, textAlign: 'center',
|
|
||||||
pointerEvents: 'none', userSelect: 'none',
|
|
||||||
willChange: 'transform, opacity',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Glow ring behind text */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: 320, height: 120,
|
|
||||||
background: 'radial-gradient(ellipse 60% 60% at 50% 50%, rgba(244,190,40,0.18) 0%, transparent 70%)',
|
|
||||||
filter: 'blur(20px)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<p style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: 'clamp(2rem, 4.5vw, 4.4rem)',
|
|
||||||
fontWeight: 900,
|
|
||||||
letterSpacing: '-0.04em',
|
|
||||||
lineHeight: 1,
|
|
||||||
color: '#ffffff',
|
|
||||||
fontFamily: 'Sora, sans-serif',
|
|
||||||
textShadow: '0 2px 32px rgba(0,0,0,0.5)',
|
|
||||||
position: 'relative',
|
|
||||||
}}>
|
|
||||||
Welcome{' '}
|
|
||||||
<span style={{
|
|
||||||
background: 'linear-gradient(105deg,#f4be28 0%,#ffd95a 50%,#FF9800 100%)',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
}}>Buddy</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style={{
|
|
||||||
margin: '14px 0 0',
|
|
||||||
fontSize: 'clamp(0.78rem, 1.1vw, 1rem)',
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'rgba(255,255,255,0.42)',
|
|
||||||
fontFamily: 'Sora, sans-serif',
|
|
||||||
position: 'relative',
|
|
||||||
}}>
|
|
||||||
Scroll to explore
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Animated scroll chevron */}
|
|
||||||
<div style={{ marginTop: 18, display: 'flex', justifyContent: 'center', position: 'relative' }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="rgba(244,190,40,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="animate-scroll-arrow">
|
|
||||||
<path d="M19 13l-7 7-7-7m7-7v14" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* White shade behind the text content on the left only — right side stays fully clear (fades in with hero text at frame 120) */}
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-r from-white/90 via-white/40 via-35% to-transparent to-55%"
|
|
||||||
style={{ opacity: 0 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hero content (fades in at frame 120) */}
|
|
||||||
<div
|
|
||||||
ref={heroRef}
|
|
||||||
className="absolute inset-y-0 left-0 z-10 flex items-center justify-start pointer-events-none h-full"
|
|
||||||
style={{ opacity: 0, transform: 'translateY(30px)' }}
|
|
||||||
>
|
|
||||||
<div className="w-full lg:w-[50vw] px-8 sm:px-12 md:px-16 lg:pl-20 lg:pr-8 pointer-events-auto flex flex-col justify-center py-12 md:py-16">
|
|
||||||
<h1 className="text-[#171512] hero-heading text-3xl sm:text-4xl md:text-5xl lg:text-5xl xl:text-6xl font-extrabold tracking-tight leading-[1.12] mb-6 select-none">
|
|
||||||
Turn Every <br />
|
|
||||||
Walk In Into a <br />
|
|
||||||
<span className="hero-gradient-text">Loyal Customer</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#f4be28] hero-tagline text-sm sm:text-base md:text-lg font-bold tracking-tight mb-4 select-none">
|
|
||||||
Offline is the new Online.
|
|
||||||
</p>
|
|
||||||
<p className="text-[#4f463a] hero-description text-sm sm:text-base md:text-lg xl:text-xl mb-8 leading-relaxed max-w-2xl select-none">
|
|
||||||
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn LYTs through games and daily activities, then redeem them at offline stores near them.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-5">
|
|
||||||
<Link href="/contacts" className="flex items-center gap-3 bg-[#f4be28] hover:bg-[#ffd95a] hero-btn-primary text-[#171512] px-8 py-4 rounded-xl font-bold text-base md:text-lg transition-all duration-300 shadow-lg shadow-yellow-500/30 hover:shadow-yellow-500/50 hover:scale-[1.02] cursor-pointer no-underline">
|
|
||||||
Book a Demo
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link href="/contacts" className="flex items-center border-2 border-[#f4be28] hover:bg-[#f4be28]/15 hero-btn-secondary text-[#f4be28] px-8 py-4 rounded-xl font-bold text-base md:text-lg transition-all duration-300 hover:scale-[1.02] cursor-pointer no-underline">
|
|
||||||
Get Started
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Other sections */}
|
{/* Other sections */}
|
||||||
<ProblemSection isLoaded={imagesLoaded} />
|
<ProblemSection isLoaded={heroReady} />
|
||||||
<ShiftSection isLoaded={imagesLoaded} />
|
<ShiftSection isLoaded={heroReady} />
|
||||||
<WhatWeDo isLoaded={imagesLoaded} />
|
<WhatWeDo isLoaded={heroReady} />
|
||||||
<ProductsSection isLoaded={imagesLoaded} />
|
<ProductsSection isLoaded={heroReady} />
|
||||||
<LytsUpSection />
|
<LytsUpSection />
|
||||||
<TheLoop />
|
<TheLoop />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -150,9 +150,9 @@ export default function MeetTheCrewPage() {
|
|||||||
<section style={{ padding: '140px 8%', background: '#07070d', margin: '0 20px 20px', borderRadius: 24, overflow: 'hidden', position: 'relative' }}>
|
<section style={{ padding: '140px 8%', background: '#07070d', margin: '0 20px 20px', borderRadius: 24, overflow: 'hidden', position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '64px 64px', pointerEvents: 'none' }} />
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '64px 64px', pointerEvents: 'none' }} />
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20, position: 'relative' }}>Meet the Products</div>
|
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20, position: 'relative' }}>Meet the Agents</div>
|
||||||
<h2 style={{ fontSize: 'clamp(36px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', position: 'relative', maxWidth: 700 }}>
|
<h2 style={{ fontSize: 'clamp(36px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', position: 'relative', maxWidth: 700 }}>
|
||||||
Four products.<br />One ecosystem.
|
Four agents.<br />One AI.
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', maxWidth: 460, lineHeight: 1.7, marginBottom: 80, position: 'relative' }}>
|
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', maxWidth: 460, lineHeight: 1.7, marginBottom: 80, position: 'relative' }}>
|
||||||
Behavision, SpendSense, Dyscount, and Identiq, each solves a distinct part of the retail journey, and together they form one connected platform, from footfall to spend to loyalty.
|
Behavision, SpendSense, Dyscount, and Identiq, each solves a distinct part of the retail journey, and together they form one connected platform, from footfall to spend to loyalty.
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export default function Header() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '0 48px',
|
padding: '0 48px',
|
||||||
height: transparentHeader ? '136px' : '88px',
|
height: transparentHeader ? '150px' : '92px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
transition: 'height 0.3s ease',
|
transition: 'height 0.3s ease',
|
||||||
@@ -266,7 +266,7 @@ export default function Header() {
|
|||||||
width={460}
|
width={460}
|
||||||
height={138}
|
height={138}
|
||||||
className="header-logo-img"
|
className="header-logo-img"
|
||||||
style={{ objectFit: 'contain', height: transparentHeader ? '110px' : '42px', width: 'auto' }}
|
style={{ objectFit: 'contain', height: transparentHeader ? '132px' : '46px', width: 'auto' }}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -365,15 +365,15 @@ export default function Header() {
|
|||||||
.header-nav-pill { padding: 5px 6px !important; }
|
.header-nav-pill { padding: 5px 6px !important; }
|
||||||
.header-nav-link { padding: 8px 12px !important; font-size: 11px !important; letter-spacing: 0.02em !important; }
|
.header-nav-link { padding: 8px 12px !important; font-size: 11px !important; letter-spacing: 0.02em !important; }
|
||||||
.header-cta { padding: 9px 16px !important; font-size: 12px !important; }
|
.header-cta { padding: 9px 16px !important; font-size: 12px !important; }
|
||||||
.header-logo-img { height: 32px !important; }
|
.header-logo-img { height: 36px !important; }
|
||||||
.header-transparent .header-logo-img { height: 92px !important; }
|
.header-transparent .header-logo-img { height: 112px !important; }
|
||||||
}
|
}
|
||||||
@media (min-width: 1281px) and (max-width: 1440px) {
|
@media (min-width: 1281px) and (max-width: 1440px) {
|
||||||
.header-nav-pill { padding: 6px 8px !important; }
|
.header-nav-pill { padding: 6px 8px !important; }
|
||||||
.header-nav-link { padding: 9px 16px !important; font-size: 12px !important; }
|
.header-nav-link { padding: 9px 16px !important; font-size: 12px !important; }
|
||||||
.header-cta { padding: 10px 20px !important; font-size: 13px !important; }
|
.header-cta { padding: 10px 20px !important; font-size: 13px !important; }
|
||||||
.header-logo-img { height: 36px !important; }
|
.header-logo-img { height: 40px !important; }
|
||||||
.header-transparent .header-logo-img { height: 100px !important; }
|
.header-transparent .header-logo-img { height: 122px !important; }
|
||||||
}
|
}
|
||||||
.header-cta:hover {
|
.header-cta:hover {
|
||||||
background: #f4be28 !important;
|
background: #f4be28 !important;
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export default function Preloader() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 999999,
|
// Must sit above the fixed Header (z-index 99999999) and its
|
||||||
|
// mobile panel (999999999) so only the logo shows while loading.
|
||||||
|
zIndex: 2147483647,
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function AIRecommendationsSection() {
|
|||||||
|
|
||||||
{/* Left — editorial */}
|
{/* Left — editorial */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence<span className="fp-kicker-num">06</span></span></div>
|
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence<span className="fp-kicker-num">08</span></span></div>
|
||||||
|
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94,
|
fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94,
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useLayoutEffect, useEffect, useRef } from 'react';
|
import React, { useLayoutEffect, useEffect, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { PhoneFrame, PhoneBottomNav, BellIcon, PinIcon, ChevronIcon, SearchIcon, MicIcon, CameraIcon, MapIcon, HeartLogoIcon } from './PhoneMockup';
|
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
import { FpTagCard } from './FpCard';
|
||||||
|
import { useCountUp, useParallax, useStaggerReveal } from './fpMotion';
|
||||||
|
|
||||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
const deals = [
|
const TAGS = [
|
||||||
{ store: 'Starbucks', category: 'Coffee & Drinks', offer: '2× LYTs', savings: '₹120 saved', color: '#00704A', dot: '#22c55e', distance: '0.3 km', rating: 4.8 },
|
{ label: 'Live coupons', icon: '🎟️' },
|
||||||
{ store: 'Nike', category: 'Footwear', offer: '30% OFF', savings: '₹890 saved', color: '#171512', dot: '#d1d5db', distance: '0.7 km', rating: 4.9 },
|
{ label: 'Nearby stores', icon: '📍' },
|
||||||
{ store: 'H&M', category: 'Fashion', offer: '₹200 Back', savings: '₹200 saved', color: '#E50010', dot: '#ef4444', distance: '1.1 km', rating: 4.6 },
|
{ label: 'Game-based deals', icon: '🎮' },
|
||||||
{ store: 'Decathlon', category: 'Sports', offer: 'Free Gift', savings: '₹350 saved', color: '#0082C8', dot: '#3b82f6', distance: '1.4 km', rating: 4.7 },
|
|
||||||
{ store: 'Zara', category: 'Fashion', offer: '15% OFF', savings: '₹450 saved', color: '#171512', dot: '#d1d5db', distance: '0.9 km', rating: 4.5 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function DiscoverDealsSection() {
|
export default function DiscoverDealsSection() {
|
||||||
const [hovered, setHovered] = useState<number | null>(null);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const phoneRef = useRef<HTMLDivElement>(null);
|
const phoneRef = useRef<HTMLDivElement>(null);
|
||||||
|
const glowRef = useParallax<HTMLDivElement>(40);
|
||||||
|
const tagsRef = useStaggerReveal<HTMLDivElement>();
|
||||||
|
const dealsCountRef = useCountUp<HTMLSpanElement>(2400, { suffix: '+' });
|
||||||
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -35,12 +36,12 @@ export default function DiscoverDealsSection() {
|
|||||||
{
|
{
|
||||||
y: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
duration: 1.1,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
scrollTrigger: {
|
scrollTrigger: {
|
||||||
trigger: section,
|
trigger: section,
|
||||||
start: 'top 92%',
|
start: 'top 80%',
|
||||||
end: 'top 25%',
|
once: true,
|
||||||
scrub: 1.2,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -53,143 +54,103 @@ export default function DiscoverDealsSection() {
|
|||||||
<section ref={panelRef} className="fp-panel" style={{ background: '#08080f' }}>
|
<section ref={panelRef} className="fp-panel" style={{ background: '#08080f' }}>
|
||||||
|
|
||||||
{/* Background photo */}
|
{/* Background photo */}
|
||||||
<Image src="/images/stock/deals-retail.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.25, zIndex: 0 }} />
|
<Image src="/images/stock/discover.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.16, zIndex: 0 }} />
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,8,15,0.3) 0%, rgba(8,8,15,0.75) 80%)', zIndex: 0 }} />
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 0,
|
||||||
|
background: 'linear-gradient(100deg, rgba(8,8,15,0.96) 0%, rgba(8,8,15,0.65) 42%, rgba(8,8,15,0.5) 68%, rgba(8,8,15,0.85) 100%), linear-gradient(180deg, rgba(8,8,15,0.2) 0%, rgba(8,8,15,0.8) 85%)',
|
||||||
|
}} />
|
||||||
|
|
||||||
{/* Soft radial */}
|
{/* Soft radial — drifts with parallax for depth */}
|
||||||
<div className="fp-deco" style={{
|
<div ref={glowRef} className="fp-deco" style={{
|
||||||
position: 'absolute', top: '20%', left: '20%', width: 500, height: 500, borderRadius: '50%',
|
position: 'absolute', top: '20%', left: '20%', width: 500, height: 500, borderRadius: '50%',
|
||||||
background: 'radial-gradient(circle, rgba(244,190,40,0.06) 0%, transparent 65%)', zIndex: 0,
|
background: 'radial-gradient(circle, rgba(244,190,40,0.06) 0%, transparent 65%)', zIndex: 0,
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<div className="fp-shell">
|
<div className="fp-shell ddm-shell">
|
||||||
<div className="fp-split" style={{ gridTemplateColumns: '0.9fr 1.1fr', alignItems: 'center' }}>
|
<div className="fp-split ddm-split" style={{ gridTemplateColumns: '0.75fr 1.25fr', alignItems: 'center' }}>
|
||||||
|
|
||||||
{/* Headline column */}
|
{/* Headline column */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover<span className="fp-kicker-num">03</span></span></div>
|
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover<span className="fp-kicker-num">01</span></span></div>
|
||||||
|
|
||||||
<h2 style={{
|
<h2 className="fp-h1" style={{ color: '#fff' }}>
|
||||||
fontSize: 'clamp(44px, 5.6vw, 88px)', fontWeight: 800, lineHeight: 0.94,
|
|
||||||
letterSpacing: '-0.05em', color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif',
|
|
||||||
}}>
|
|
||||||
<span className="fp-mask"><span>Every</span></span>
|
<span className="fp-mask"><span>Every</span></span>
|
||||||
<span className="fp-mask"><span>deal in</span></span>
|
<span className="fp-mask"><span>deal in</span></span>
|
||||||
<span className="fp-mask"><span className="fp-shimmer">your city.</span></span>
|
<span className="fp-mask"><span className="fp-shimmer">your city.</span></span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="fp-rise" style={{
|
<p className="fp-rise fp-desc" style={{ color: 'rgba(255,255,255,0.45)', maxWidth: 380 }}>
|
||||||
fontSize: 16, lineHeight: 1.8, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 36px', maxWidth: 380,
|
|
||||||
}}>
|
|
||||||
Find participating stores nearby and see their active game based discount campaigns, live coupons, and LYTs earning opportunities, all surfaced in real time so you never miss a deal.
|
Find participating stores nearby and see their active game based discount campaigns, live coupons, and LYTs earning opportunities, all surfaced in real time so you never miss a deal.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="fp-rise" style={{
|
<div ref={tagsRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, maxWidth: 460 }}>
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 9,
|
{TAGS.map((tag) => (
|
||||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 100, padding: '11px 18px', backdropFilter: 'blur(12px)',
|
<FpTagCard key={tag.label} theme="dark" icon={tag.icon}>{tag.label}</FpTagCard>
|
||||||
}}>
|
))}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Phone mockup — white app screen, matches the in-app Discover experience */}
|
{/* Discover GIF */}
|
||||||
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
|
<div ref={phoneRef} className="ddm-gif-col" style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
|
||||||
<PhoneFrame floatClassName="ddm-phone-shell">
|
<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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<div style={{ background: 'linear-gradient(180deg, #fcd34d 0%, #f4be28 100%)', padding: '34px 16px 14px' }}>
|
<img
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
src="/images/stock/discover.gif"
|
||||||
<Image src="/images/loyalyai.png" alt="Loyaly.AI" width={100} height={26} style={{ objectFit: 'contain', height: 16, width: 'auto' }} />
|
alt="Discover deals in the Loyaly.AI app"
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
style={{ position: 'relative', zIndex: 1, width: '100%', aspectRatio: '1465 / 1074', objectFit: 'cover', borderRadius: 20, display: 'block' }}
|
||||||
<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>
|
{/* Floating stat chip */}
|
||||||
</div>
|
<div style={{
|
||||||
<div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
position: 'absolute', left: 20, bottom: 20, zIndex: 2,
|
||||||
<BellIcon />
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
</div>
|
background: 'rgba(8,8,15,0.65)', border: '1px solid rgba(255,255,255,0.14)',
|
||||||
</div>
|
borderRadius: 100, padding: '9px 16px', backdropFilter: 'blur(14px)',
|
||||||
</div>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'block', animation: 'fpPulse 1.5s infinite' }} />
|
||||||
<PinIcon />
|
<span style={{ fontSize: 12.5, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>
|
||||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>R.S. Puram, Coimbatore</span>
|
<span ref={dealsCountRef}>2,400+</span> live deals nearby
|
||||||
<ChevronIcon color="#171512" />
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Search bar */}
|
|
||||||
<div style={{ padding: '12px 14px 4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, background: '#f5f5f6', borderRadius: 100, padding: '9px 14px' }}>
|
|
||||||
<SearchIcon />
|
|
||||||
<span style={{ flex: 1, color: '#9ca3af', fontSize: 12, fontFamily: 'Sora, sans-serif' }}>Search</span>
|
|
||||||
<MicIcon />
|
|
||||||
<CameraIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deal rows */}
|
|
||||||
<div style={{ padding: '8px 12px 2px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{deals.map((deal, i) => (
|
|
||||||
<div
|
|
||||||
key={deal.store}
|
|
||||||
onMouseEnter={() => setHovered(i)}
|
|
||||||
onMouseLeave={() => setHovered(null)}
|
|
||||||
style={{
|
|
||||||
display: 'grid', gridTemplateColumns: '34px minmax(0,1fr) 62px 12px', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 8px', borderRadius: 14,
|
|
||||||
background: hovered === i ? '#fff7e0' : 'transparent',
|
|
||||||
transition: 'background 0.25s ease',
|
|
||||||
cursor: 'default',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'relative', width: 34, height: 34, borderRadius: 10, background: deal.color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
<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>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import { PhoneFrame, PhoneBottomNav, BellIcon, FlameIcon } from './PhoneMockup';
|
import { PhoneMockupImage } from './PhoneMockup';
|
||||||
|
import { FpTagCard, FpStatCard } from './FpCard';
|
||||||
|
import { useCountUp, useStaggerReveal } from './fpMotion';
|
||||||
|
|
||||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
const feed = [
|
|
||||||
{ store: 'Daily Login', pts: +50, time: '2m ago', type: 'earn' },
|
|
||||||
{ store: 'Walking 5,000 steps', pts: +80, time: '1h ago', type: 'earn' },
|
|
||||||
{ store: 'Spin Wheel Game', pts: +120, time: '3h ago', type: 'earn' },
|
|
||||||
{ store: 'Selfie Booth', pts: +200, time: 'Yesterday', type: 'earn' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EarnRewardsSection() {
|
export default function EarnRewardsSection() {
|
||||||
const [pts, setPts] = useState(0);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const phoneRef = useRef<HTMLDivElement>(null);
|
const phoneRef = useRef<HTMLDivElement>(null);
|
||||||
const rafRef = useRef<number>(0);
|
const tagsRef = useStaggerReveal<HTMLDivElement>();
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const statsRef = useStaggerReveal<HTMLDivElement>();
|
||||||
const activeRowRef = useRef(0);
|
const waysCountRef = useCountUp<HTMLSpanElement>(10);
|
||||||
|
|
||||||
// Phone mockup rises in from the bottom as the section scrolls into view
|
// Phone mockup rises in from the bottom as the section scrolls into view
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
@@ -52,216 +45,88 @@ export default function EarnRewardsSection() {
|
|||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Move the highlighted feed card as the section scrolls through the viewport
|
const earnTags = [
|
||||||
useEffect(() => {
|
{ label: 'Walk', icon: '🚶', bg: '#eef2ff', fg: '#4c5fd5' },
|
||||||
if (typeof window === 'undefined') return;
|
{ label: 'Visit', icon: '📍', bg: '#fdeeee', fg: '#d5514c' },
|
||||||
const el = panelRef.current;
|
{ label: 'Selfie', icon: '🤳', bg: '#fef1e8', fg: '#d5824c' },
|
||||||
if (!el) return;
|
{ label: 'Spin', icon: '🎡', bg: '#f1eefd', fg: '#7a4cd5' },
|
||||||
|
{ label: 'Scratch', icon: '🎟️', bg: '#fdeef7', fg: '#d54ca3' },
|
||||||
const applyRow = (idx: number) => {
|
{ label: 'Challenge', icon: '🏆', bg: '#fff8e1', fg: '#b8860b' },
|
||||||
if (idx === activeRowRef.current) return;
|
{ label: 'Friend', icon: '🤝', bg: '#eef9f1', fg: '#3f9d5c' },
|
||||||
activeRowRef.current = idx;
|
{ label: 'Shop', icon: '🛍️', bg: '#eef7fd', fg: '#2f8fc4' },
|
||||||
rowRefs.current.forEach((row, i) => {
|
{ label: 'Event', icon: '🎉', bg: '#fdf1ee', fg: '#c45f2f' },
|
||||||
if (!row) return;
|
{ label: 'Brand', icon: '🏷️', bg: '#fef7ea', fg: '#c49a2f' },
|
||||||
const active = i === idx;
|
];
|
||||||
row.style.background = active ? '#f0fdf4' : 'transparent';
|
|
||||||
row.style.borderColor = active ? '#16a34a' : 'transparent';
|
|
||||||
row.style.transform = active ? 'translateX(4px) scale(1.02)' : 'translateX(0) scale(1)';
|
|
||||||
row.style.boxShadow = active ? '0 10px 24px -12px rgba(22,163,74,0.35)' : 'none';
|
|
||||||
|
|
||||||
const icon = row.querySelector<HTMLElement>('.feed-icon');
|
|
||||||
if (icon) {
|
|
||||||
icon.style.background = active ? 'rgba(22,163,74,0.14)' : 'rgba(23,21,18,0.04)';
|
|
||||||
icon.style.borderColor = active ? '#16a34a' : 'rgba(23,21,18,0.08)';
|
|
||||||
icon.style.color = active ? '#16a34a' : '#9ca3af';
|
|
||||||
}
|
|
||||||
const points = row.querySelector<HTMLElement>('.feed-pts');
|
|
||||||
if (points) {
|
|
||||||
points.style.background = active ? 'rgba(22,163,74,0.14)' : 'rgba(22,163,74,0.08)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const st = ScrollTrigger.create({
|
|
||||||
trigger: el,
|
|
||||||
start: 'top 65%',
|
|
||||||
end: 'bottom 60%',
|
|
||||||
onUpdate: (self) => {
|
|
||||||
applyRow(Math.min(feed.length - 1, Math.floor(self.progress * feed.length)));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => st.kill();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = panelRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new MutationObserver(() => {
|
|
||||||
if (el.hasAttribute('data-active')) {
|
|
||||||
const start = performance.now();
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const t = Math.min((now - start) / 1800, 1);
|
|
||||||
setPts(Math.round((1 - Math.pow(1 - t, 3)) * 284));
|
|
||||||
if (t < 1) rafRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
obs.observe(el, { attributes: true, attributeFilter: ['data-active'] });
|
|
||||||
return () => { obs.disconnect(); cancelAnimationFrame(rafRef.current); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff' }}>
|
<section ref={panelRef} className="fp-panel fp-light" style={{ background: '#ffffff', position: 'relative', overflow: 'hidden' }}>
|
||||||
<div className="fp-shell">
|
|
||||||
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
|
|
||||||
|
|
||||||
{/* Left — points + headline */}
|
{/* Ambient decor — fills the white space behind the copy */}
|
||||||
<div>
|
<div style={{ position: 'absolute', inset: 0, zIndex: 0, pointerEvents: 'none' }}>
|
||||||
{/* Live label */}
|
<span style={{
|
||||||
<div className="fp-rise" style={{
|
position: 'absolute', top: '4%', right: '4%', fontSize: 'clamp(160px, 20vw, 340px)',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 20,
|
fontWeight: 900, letterSpacing: '-0.05em', color: 'rgba(244,190,40,0.07)',
|
||||||
background: '#f0fdf4', border: '1.5px solid #16a34a', borderRadius: 100, padding: '7px 16px',
|
fontFamily: 'Sora, sans-serif', lineHeight: 1, userSelect: 'none',
|
||||||
}}>
|
}}>02</span>
|
||||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0, animation: 'fpPulse 1.5s infinite' }} />
|
<div style={{
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Live LYTs balance</span>
|
position: 'absolute', bottom: '-10%', left: '-6%', width: 360, height: 360, borderRadius: '50%',
|
||||||
</div>
|
background: 'radial-gradient(circle, rgba(244,190,40,0.10) 0%, transparent 70%)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Giant gold number */}
|
<div className="fp-shell" style={{ position: 'relative', zIndex: 1 }}>
|
||||||
<div style={{
|
<div className="fp-split" style={{ gridTemplateColumns: '0.95fr 1.05fr' }}>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Redeemable line */}
|
{/* Left — phone mockup, matches the in-app Earn / Home experience */}
|
||||||
<div className="fp-rise" style={{
|
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 10, marginTop: 14, marginBottom: 44,
|
<PhoneMockupImage
|
||||||
padding: '10px 18px', borderRadius: 12,
|
src="/For_people/01_earn-cropped.png"
|
||||||
background: '#fff7e0', border: '1px solid #f4be28',
|
alt="Loyaly.AI Earn screen showing Lyts balance and activity feed"
|
||||||
}}>
|
floatClassName="erm-phone-shell"
|
||||||
<span style={{ fontSize: 18, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif' }}>₹{pts.toLocaleString()}</span>
|
aspectRatio={1684 / 3196}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — phone mockup, matches the in-app Earn / Home experience */}
|
{/* Right — headline */}
|
||||||
<div ref={phoneRef} style={{ display: 'flex', justifyContent: 'center', opacity: 0, width: '100%' }}>
|
<div>
|
||||||
<PhoneFrame floatClassName="erm-phone-shell">
|
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">02</span></span></div>
|
||||||
|
|
||||||
{/* Amber header */}
|
<h2 className="fp-h2" style={{ color: '#171512' }}>
|
||||||
<div style={{ background: 'linear-gradient(180deg, #fcd34d 0%, #f4be28 100%)', padding: '34px 16px 14px' }}>
|
<span className="fp-mask"><span>Every Action</span></span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<span className="fp-mask"><span style={{ color: '#f4be28' }}>Earns Lyts.</span></span>
|
||||||
<Image src="/images/loyalyai.png" alt="Loyaly.AI" width={100} height={26} style={{ objectFit: 'contain', height: 16, width: 'auto' }} />
|
</h2>
|
||||||
<div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<BellIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<FlameIcon />
|
|
||||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: '#171512', fontFamily: 'Sora, sans-serif' }}>6-day streak · keep it going!</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Balance card */}
|
<p className="fp-rise fp-desc" style={{ color: '#4f463a' }}>
|
||||||
<div style={{ margin: '14px 14px 0', borderRadius: 18, padding: '16px 18px', background: 'linear-gradient(135deg, #f4be28 0%, #e09820 100%)', position: 'relative', overflow: 'hidden' }}>
|
No app-opening rituals, no forced check-ins — just live your day and watch the balance move.
|
||||||
<div style={{ position: 'absolute', top: '-30%', right: '-8%', width: 90, height: 90, borderRadius: '50%', background: 'rgba(255,255,255,0.14)' }} />
|
</p>
|
||||||
<div style={{ fontSize: 10, color: 'rgba(23,21,18,0.65)', fontWeight: 700, fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Total LYTs</div>
|
|
||||||
<div style={{ fontSize: 30, color: '#171512', fontWeight: 900, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', marginTop: 2 }}>284</div>
|
|
||||||
<div style={{ fontSize: 10.5, color: 'rgba(23,21,18,0.6)', fontFamily: 'Sora, sans-serif', marginTop: 3 }}>≈ ₹284 redeemable</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feed header */}
|
<div ref={tagsRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, maxWidth: 460 }}>
|
||||||
<div style={{
|
{earnTags.map((tag) => (
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
<FpTagCard key={tag.label} theme="light" icon={tag.icon} iconBg={tag.bg}>
|
||||||
margin: '16px 14px 6px',
|
{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>
|
The more you live, <span style={{ color: '#f4be28' }}>the more you earn.</span>
|
||||||
<span style={{ fontSize: 9.5, color: '#16a34a', fontWeight: 700, fontFamily: 'Sora, sans-serif', display: 'flex', alignItems: 'center', gap: 4,
|
</p>
|
||||||
background: '#f0fdf4', border: '1px solid #16a34a', borderRadius: 100, padding: '3px 8px',
|
</div>
|
||||||
}}>
|
|
||||||
<span style={{ width: 4.5, height: 4.5, borderRadius: '50%', background: '#16a34a', display: 'block', animation: 'fpPulse 1.5s infinite' }} />
|
|
||||||
LIVE
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feed rows */}
|
|
||||||
<div style={{ padding: '2px 10px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
{feed.map((tx, i) => (
|
|
||||||
<div key={i} ref={el => { rowRefs.current[i] = el; }} style={{
|
|
||||||
display: 'grid', gridTemplateColumns: '32px minmax(0,1fr) auto', alignItems: 'center', gap: 10,
|
|
||||||
padding: '8px 8px', borderRadius: 12,
|
|
||||||
background: i === 0 ? '#f0fdf4' : 'transparent',
|
|
||||||
border: `1px solid ${i === 0 ? '#16a34a' : 'transparent'}`,
|
|
||||||
transform: i === 0 ? 'translateX(4px) scale(1.02)' : 'translateX(0) scale(1)',
|
|
||||||
boxShadow: i === 0 ? '0 10px 24px -12px rgba(22,163,74,0.35)' : 'none',
|
|
||||||
transition: 'background 0.35s ease, border-color 0.35s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.35s ease',
|
|
||||||
}}>
|
|
||||||
<div className="feed-icon" style={{
|
|
||||||
width: 32, height: 32, borderRadius: 9, flexShrink: 0,
|
|
||||||
background: i === 0 ? 'rgba(22,163,74,0.14)' : 'rgba(23,21,18,0.04)',
|
|
||||||
border: `1.5px solid ${i === 0 ? '#16a34a' : 'rgba(23,21,18,0.08)'}`,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: i === 0 ? '#16a34a' : '#9ca3af',
|
|
||||||
transition: 'background 0.35s ease, border-color 0.35s ease, color 0.35s ease',
|
|
||||||
}}>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></svg>
|
|
||||||
</div>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 11.5, fontWeight: 700, color: '#171512', fontFamily: 'Sora, sans-serif',
|
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
||||||
}}>{tx.store}</div>
|
|
||||||
<div style={{ fontSize: 9, color: '#9ca3af', fontFamily: 'Sora, sans-serif', marginTop: 1 }}>{tx.time}</div>
|
|
||||||
</div>
|
|
||||||
<span className="feed-pts" style={{
|
|
||||||
fontSize: 12, fontWeight: 900, fontFamily: 'Sora, sans-serif',
|
|
||||||
color: '#16a34a', letterSpacing: '-0.01em',
|
|
||||||
background: i === 0 ? 'rgba(22,163,74,0.14)' : 'rgba(22,163,74,0.08)',
|
|
||||||
padding: '3px 8px', borderRadius: 8, whiteSpace: 'nowrap',
|
|
||||||
transition: 'background 0.35s ease',
|
|
||||||
}}>
|
|
||||||
+{tx.pts}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary strip */}
|
|
||||||
<div style={{ margin: '10px 14px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
||||||
{[
|
|
||||||
{ label: 'Earned this month', val: '660 LYTs', color: '#16a34a', bg: '#f0fdf4', border: '#16a34a' },
|
|
||||||
{ label: 'Redeemable value', val: '₹660', color: '#bc7122', bg: '#fff7e0', border: '#f4be28' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<div key={i} style={{ padding: '10px 12px', borderRadius: 12, background: s.bg, border: `1.5px solid ${s.border}` }}>
|
|
||||||
<div style={{ fontSize: 8.5, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif', marginBottom: 3 }}>{s.label}</div>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 900, color: s.color, fontFamily: 'Sora, sans-serif' }}>{s.val}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PhoneBottomNav active="user" />
|
|
||||||
</PhoneFrame>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function ExperienceSection() {
|
|||||||
<div className="fp-shell">
|
<div className="fp-shell">
|
||||||
|
|
||||||
<div style={{ marginBottom: 'clamp(36px, 5vh, 60px)' }}>
|
<div style={{ marginBottom: 'clamp(36px, 5vh, 60px)' }}>
|
||||||
<span className="fp-kicker fp-rise">The Experience<span className="fp-kicker-num">07</span></span>
|
<span className="fp-kicker fp-rise">The Experience<span className="fp-kicker-num">06</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fp-split" style={{ gridTemplateColumns: '1.1fr 0.9fr', alignItems: 'center' }}>
|
<div className="fp-split" style={{ gridTemplateColumns: '1.1fr 0.9fr', alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useRef, useEffect, useLayoutEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
import { useMagnetic as useMagneticShared } from './fpMotion';
|
||||||
|
|
||||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
@@ -27,19 +28,61 @@ function useMagnetic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROOF = [
|
const PROOF = [
|
||||||
{ val: '50K+', label: 'Active users', color: '#f4be28', bg: '#fffbeb', border: 'rgba(244,190,40,0.3)' },
|
{ val: '50K+', label: 'Active users', color: '#f4be28', icon: '👥', iconBg: '#fffbeb' },
|
||||||
{ val: '10K+', label: 'Partner stores', color: '#16a34a', bg: '#f0fdf4', border: 'rgba(22,163,74,0.25)' },
|
{ val: '10K+', label: 'Partner stores', color: '#16a34a', icon: '🏬', iconBg: '#f0fdf4' },
|
||||||
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', bg: '#eff6ff', border: 'rgba(37,99,235,0.2)' },
|
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', icon: '🎁', iconBg: '#eff6ff' },
|
||||||
{ val: '4.9★', label: 'App rating', color: '#9333ea', bg: '#faf5ff', border: 'rgba(147,51,234,0.2)' },
|
{ val: '4.9★', label: 'App rating', color: '#9333ea', icon: '⭐', iconBg: '#faf5ff' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function FinalCTASection() {
|
export default function FinalCTASection() {
|
||||||
const sectionRef = useRef<HTMLDivElement>(null);
|
const sectionRef = useRef<HTMLDivElement>(null);
|
||||||
const mag1 = useMagnetic();
|
const mag1 = useMagnetic();
|
||||||
const mag2 = useMagnetic();
|
const mag2 = useMagnetic();
|
||||||
|
const coinLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const badgeMag1 = useMagneticShared<HTMLDivElement>(10);
|
||||||
|
const badgeMag2 = useMagneticShared<HTMLDivElement>(10);
|
||||||
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const desktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
|
|
||||||
|
// Coin burst — small LytsUp coins fly toward the badges the first time they appear.
|
||||||
|
let bursted = false;
|
||||||
|
const burstCoins = () => {
|
||||||
|
const layer = coinLayerRef.current;
|
||||||
|
if (bursted || reduced || !desktop || !layer) return;
|
||||||
|
const targets = [badgeMag1.current, badgeMag2.current].filter(Boolean) as HTMLElement[];
|
||||||
|
if (!targets.length) return;
|
||||||
|
bursted = true;
|
||||||
|
const layerRect = layer.getBoundingClientRect();
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const target = targets[i % targets.length];
|
||||||
|
const tRect = target.getBoundingClientRect();
|
||||||
|
const tx = tRect.left - layerRect.left + tRect.width / 2;
|
||||||
|
const ty = tRect.top - layerRect.top + tRect.height / 2;
|
||||||
|
const coin = document.createElement('span');
|
||||||
|
coin.className = 'fcta-coin';
|
||||||
|
layer.appendChild(coin);
|
||||||
|
gsap.set(coin, {
|
||||||
|
x: layerRect.width * (0.15 + Math.random() * 0.7),
|
||||||
|
y: -10 - Math.random() * 40,
|
||||||
|
scale: 0.45 + Math.random() * 0.55,
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
gsap.timeline({ onComplete: () => coin.remove() })
|
||||||
|
.to(coin, { opacity: 1, duration: 0.18 }, 0)
|
||||||
|
.to(coin, {
|
||||||
|
x: tx + (Math.random() * 24 - 12),
|
||||||
|
y: ty + (Math.random() * 24 - 12),
|
||||||
|
rotation: Math.random() * 240 - 120,
|
||||||
|
duration: 0.7 + Math.random() * 0.45,
|
||||||
|
ease: 'power2.in',
|
||||||
|
}, 0)
|
||||||
|
.to(coin, { opacity: 0, scale: 0.15, duration: 0.28 }, '-=0.26');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
scrollTrigger: { trigger: sectionRef.current, start: 'top 80%', toggleActions: 'play none none reset' },
|
scrollTrigger: { trigger: sectionRef.current, start: 'top 80%', toggleActions: 'play none none reset' },
|
||||||
@@ -56,29 +99,13 @@ export default function FinalCTASection() {
|
|||||||
{ opacity: 0, y: 24 },
|
{ opacity: 0, y: 24 },
|
||||||
{ opacity: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4'
|
{ opacity: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4'
|
||||||
)
|
)
|
||||||
.fromTo('.fcta-hanging-logo',
|
// Reveal on the magnet layer so the badge's own transform stays free for CSS hover + GSAP float.
|
||||||
{ opacity: 0, y: -60, rotation: -16 },
|
.fromTo('.fcta-badge-magnet',
|
||||||
{ opacity: 1, y: 0, rotation: 0, duration: 1.1, stagger: 0.16, ease: 'back.out(1.5)' },
|
{ opacity: 0, y: 30, scale: 0.94 },
|
||||||
'-=0.45'
|
{ opacity: 1, y: 0, scale: 1, duration: 0.7, stagger: 0.12, ease: 'back.out(1.6)' },
|
||||||
|
'-=0.35'
|
||||||
)
|
)
|
||||||
.fromTo('.fcta-logo-link',
|
.call(burstCoins)
|
||||||
{ rotation: 10 },
|
|
||||||
{ rotation: 0, duration: 1.2, stagger: 0.16, ease: 'back.out(1.6)' },
|
|
||||||
'<'
|
|
||||||
)
|
|
||||||
.call(() => {
|
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
||||||
gsap.utils.toArray<HTMLElement>('.fcta-hanging-logo').forEach((el, i) => {
|
|
||||||
gsap.to(el, {
|
|
||||||
rotation: i % 2 === 0 ? 3.5 : -3.5,
|
|
||||||
duration: 2.4 + i * 0.3,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
transformOrigin: 'top center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.fromTo('.fcta-btns',
|
.fromTo('.fcta-btns',
|
||||||
{ opacity: 0, y: 20 },
|
{ opacity: 0, y: 20 },
|
||||||
{ opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.45'
|
{ opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.45'
|
||||||
@@ -91,6 +118,30 @@ export default function FinalCTASection() {
|
|||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Continuous badge motion (desktop only): idle float + a glossy sweep & gentle pulse every ~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 (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={sectionRef}
|
ref={sectionRef}
|
||||||
@@ -125,7 +176,7 @@ export default function FinalCTASection() {
|
|||||||
fontSize: 'clamp(52px,8vw,116px)',
|
fontSize: 'clamp(52px,8vw,116px)',
|
||||||
fontWeight: 900, color: '#171512',
|
fontWeight: 900, color: '#171512',
|
||||||
}}>
|
}}>
|
||||||
Your rewards
|
Your lyts
|
||||||
</span>
|
</span>
|
||||||
<span className="fcta-line-2" style={{
|
<span className="fcta-line-2" style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -141,128 +192,73 @@ export default function FinalCTASection() {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{/* Lyts logo */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="/images/lyts.png"
|
||||||
|
alt="Lyts"
|
||||||
|
style={{ height: 88, width: 'auto', display: 'block', margin: '0 auto 28px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<p className="fcta-sub" style={{
|
<p className="fcta-sub" style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
fontSize: 'clamp(1rem,1.2vw,1.15rem)', lineHeight: 1.75,
|
fontSize: 'clamp(0.95rem,1.1vw,1.05rem)', lineHeight: 1.7,
|
||||||
color: '#171512', fontFamily: 'Sora, sans-serif',
|
color: '#5c5344', fontFamily: 'Sora, sans-serif', fontWeight: 500,
|
||||||
maxWidth: 520, width: '100%', margin: '0 auto 0px',
|
maxWidth: 440, width: '100%', margin: '0 auto',
|
||||||
background: '#f4be28',
|
|
||||||
border: '1.5px solid #f4be28',
|
|
||||||
borderRadius: 100,
|
|
||||||
padding: '14px 28px',
|
|
||||||
display: 'inline-block',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}>
|
}}>
|
||||||
Download Loyaly and start earning on your very next purchase. Free. Instant. Yours.
|
Download <strong style={{ color: '#171512', fontWeight: 800 }}>LytsUp</strong> and start earning on your very next purchase.{' '}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5, fontWeight: 800, color: '#171512',
|
||||||
|
background: 'rgba(244,190,40,0.16)', borderRadius: 100, padding: '3px 12px', marginTop: 4,
|
||||||
|
}}>Free · Instant · Yours</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Hanging Logos */}
|
{/* Store badges — layered: magnet (cursor pull) › float (idle bob + pulse) › badge (hover) */}
|
||||||
<div className="fcta-hanging-logos-wrapper" style={{
|
<div className="fcta-store-badges" style={{
|
||||||
position: 'relative',
|
display: 'flex', justifyContent: 'center', flexWrap: 'wrap',
|
||||||
height: 250,
|
gap: 16, margin: '36px auto 48px', width: 'fit-content', zIndex: 1, position: 'relative',
|
||||||
margin: '-34px auto 40px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 140,
|
|
||||||
width: 'fit-content',
|
|
||||||
zIndex: 1,
|
|
||||||
}}>
|
}}>
|
||||||
{/* Play Store Logo (hanging) */}
|
{/* Coin-burst layer */}
|
||||||
<div className="fcta-hanging-logo play-store" style={{
|
<div ref={coinLayerRef} className="fcta-coin-layer" aria-hidden="true" />
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
{/* Google Play */}
|
||||||
alignItems: 'center',
|
<div ref={badgeMag1} className="fcta-badge-magnet">
|
||||||
transformOrigin: 'top center',
|
<div className="fcta-badge-float">
|
||||||
position: 'relative',
|
<Link href="/contacts" className="fcta-store-badge" aria-label="Get it on Google Play">
|
||||||
}}>
|
<span className="fcta-sweep" aria-hidden="true" />
|
||||||
{/* Keyring loop */}
|
<span className="fcta-store-icon">
|
||||||
<div style={{
|
<svg width="26" height="26" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
width: 16, height: 16, borderRadius: '50%',
|
<path fill="#40C4FF" d="M8.2 42.3c-.4-.4-.6-1-.6-1.7V7.4c0-.7.2-1.3.6-1.7l.2-.2 18.9 18.9v.4L8.4 42.5l-.2-.2z" />
|
||||||
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3,
|
<path fill="#FFC107" d="M33.5 30.4l-6.3-6.3v-.4l6.3-6.3.1.1 7.5 4.3c2.1 1.2 2.1 3.2 0 4.4l-7.6 4.2z" />
|
||||||
boxShadow: '0 0 10px rgba(244,190,40,0.35)',
|
<path fill="#FF3D00" d="M33.6 30.3l-6.4-6.4-19 19c.7.7 1.9.8 3.2 0l22.2-12.6" />
|
||||||
flexShrink: 0,
|
<path fill="#00E676" d="M33.6 17.7L11.4 5.1c-1.3-.8-2.5-.7-3.2 0l19 19 6.4-6.4z" />
|
||||||
}} />
|
</svg>
|
||||||
{/* String */}
|
</span>
|
||||||
<div style={{
|
<span className="fcta-store-text">
|
||||||
width: 2,
|
<small>GET IT ON</small>
|
||||||
height: 62,
|
<strong>Google Play</strong>
|
||||||
background: '#f4be28',
|
</span>
|
||||||
opacity: 0.7,
|
</Link>
|
||||||
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
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apple Logo (hanging) */}
|
{/* App Store */}
|
||||||
<div className="fcta-hanging-logo apple-store" style={{
|
<div ref={badgeMag2} className="fcta-badge-magnet">
|
||||||
display: 'flex',
|
<div className="fcta-badge-float">
|
||||||
flexDirection: 'column',
|
<Link href="/contacts" className="fcta-store-badge" aria-label="Download on the App Store">
|
||||||
alignItems: 'center',
|
<span className="fcta-sweep" aria-hidden="true" />
|
||||||
transformOrigin: 'top center',
|
<span className="fcta-store-icon">
|
||||||
position: 'relative',
|
<svg width="24" height="24" viewBox="0 0 384 512" fill="#fff" aria-hidden="true">
|
||||||
}}>
|
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
||||||
{/* Keyring loop */}
|
</svg>
|
||||||
<div style={{
|
</span>
|
||||||
width: 16, height: 16, borderRadius: '50%',
|
<span className="fcta-store-text">
|
||||||
border: '2px solid #f4be28', marginTop: 14, marginBottom: -3,
|
<small>DOWNLOAD ON THE</small>
|
||||||
boxShadow: '0 0 10px rgba(244,190,40,0.35)',
|
<strong>App Store</strong>
|
||||||
flexShrink: 0,
|
</span>
|
||||||
}} />
|
</Link>
|
||||||
{/* 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
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,70 +299,97 @@ export default function FinalCTASection() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Proof stats */}
|
{/* Proof stats — individual sticker cards, matching the dark-border card system used across the page */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, flexWrap: 'wrap' }}>
|
<div className="fcta-proof-row" style={{ display: 'flex', justifyContent: 'center', gap: 18, flexWrap: 'wrap', width: '80%', maxWidth: 720, margin: '0 auto' }}>
|
||||||
{PROOF.map((p, i) => (
|
{PROOF.map((p, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="fcta-stat"
|
className="fcta-stat"
|
||||||
style={{
|
style={{
|
||||||
padding: '18px 26px', borderRadius: 20, textAlign: 'center', minWidth: 110,
|
flex: '1 1 130px', maxWidth: 160,
|
||||||
background: p.bg,
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
border: `1.5px solid ${p.border}`,
|
padding: '22px 16px', borderRadius: 20, textAlign: 'center',
|
||||||
boxShadow: '0 2px 12px rgba(23,21,18,0.06)',
|
background: '#fff', border: '1.5px solid #171512',
|
||||||
|
boxShadow: '4px 4px 0 #171512',
|
||||||
|
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 'clamp(20px,2.4vw,28px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div>
|
<span style={{
|
||||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.05em' }}>{p.label}</div>
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 40, height: 40, borderRadius: 12, background: p.iconBg,
|
||||||
|
fontSize: 18, marginBottom: 12,
|
||||||
|
}}>{p.icon}</span>
|
||||||
|
<div style={{ fontSize: 'clamp(20px,2.4vw,26px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.02em' }}>{p.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.fcta-stat:hover { box-shadow: 6px 6px 0 #171512; transform: translate(-2px, -2px); }
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes fctaPulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.5)} 50%{box-shadow:0 0 0 6px rgba(34,197,94,0)} }
|
@keyframes fctaPulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.5)} 50%{box-shadow:0 0 0 6px rgba(34,197,94,0)} }
|
||||||
@keyframes fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
@keyframes fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||||
.fcta-hanging-logo a {
|
/* Animation layers — magnet (JS transform) › float (GSAP transform) › badge (CSS hover) */
|
||||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
.fcta-badge-magnet { display: inline-block; will-change: transform; }
|
||||||
|
.fcta-badge-float { display: inline-block; will-change: transform; }
|
||||||
|
.fcta-store-badge {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
display: inline-flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 22px; border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #23232b 0%, #0b0b10 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
box-shadow: 0 12px 30px rgba(0,0,0,0.22), 0 0 0 0 rgba(244,190,40,0);
|
||||||
|
text-decoration: none; cursor: pointer;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.4s ease, border-color 0.4s ease;
|
||||||
}
|
}
|
||||||
.fcta-hanging-logo a:hover {
|
/* Hover gradient overlay — fades in for a smooth gradient transition */
|
||||||
transform: scale(1.08);
|
.fcta-store-badge::before {
|
||||||
|
content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: 0;
|
||||||
|
background: linear-gradient(135deg, #313139 0%, #16161d 100%);
|
||||||
|
opacity: 0; transition: opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
.fcta-tooltip {
|
.fcta-store-badge:hover {
|
||||||
position: absolute;
|
transform: translateY(-4px);
|
||||||
top: calc(100% + 4px);
|
box-shadow: 0 18px 44px rgba(0,0,0,0.30), 0 0 30px rgba(244,190,40,0.35), 0 0 0 1px rgba(244,190,40,0.6);
|
||||||
left: 50%;
|
border-color: rgba(244,190,40,0.6);
|
||||||
transform: translateX(-50%) translateY(8px);
|
|
||||||
background: #171512;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'Sora', sans-serif;
|
|
||||||
white-space: nowrap;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.25, 1, 0.22, 1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
.fcta-hanging-logo:hover .fcta-tooltip {
|
.fcta-store-badge:hover::before { opacity: 1; }
|
||||||
opacity: 1;
|
/* Glossy light sweep (driven by GSAP every ~6-8s) */
|
||||||
transform: translateX(-50%) translateY(0);
|
.fcta-sweep {
|
||||||
|
position: absolute; top: 0; left: 0; height: 100%; width: 55%; z-index: 2;
|
||||||
|
background: linear-gradient(105deg, transparent 0%, rgba(255,255,255,0.16) 50%, transparent 100%);
|
||||||
|
transform: translateX(-160%); pointer-events: none;
|
||||||
}
|
}
|
||||||
.fcta-tooltip::before {
|
.fcta-store-icon {
|
||||||
content: '';
|
position: relative; z-index: 1;
|
||||||
position: absolute;
|
display: flex; align-items: center; justify-content: center;
|
||||||
top: -4px;
|
width: 34px; height: 34px; flex-shrink: 0;
|
||||||
left: 50%;
|
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
transform: translateX(-50%) rotate(45deg);
|
}
|
||||||
width: 8px;
|
.fcta-store-badge:hover .fcta-store-icon { transform: scale(1.14); }
|
||||||
height: 8px;
|
.fcta-store-text {
|
||||||
background: #171512;
|
position: relative; z-index: 1;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start;
|
||||||
|
line-height: 1.1; font-family: 'Sora', sans-serif; color: #fff;
|
||||||
|
}
|
||||||
|
.fcta-store-text small { font-size: 9.5px; font-weight: 600; letter-spacing: 0.14em; opacity: 0.72; }
|
||||||
|
.fcta-store-text strong { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; margin-top: 1px; }
|
||||||
|
/* Coin-burst particles */
|
||||||
|
.fcta-coin-layer { position: absolute; inset: -60px 0 0 0; z-index: 3; pointer-events: none; }
|
||||||
|
.fcta-coin {
|
||||||
|
position: absolute; top: 0; left: 0; width: 13px; height: 13px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #ffe89a 0%, #f4be28 55%, #d99f16 100%);
|
||||||
|
box-shadow: 0 0 8px rgba(244,190,40,0.6), inset 0 0 0 1px rgba(255,255,255,0.25);
|
||||||
|
pointer-events: none; will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fcta-sweep { display: none; }
|
||||||
}
|
}
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.fcta-hanging-logos-wrapper {
|
.fcta-store-badges {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.fcta-sub {
|
.fcta-sub {
|
||||||
@@ -378,6 +401,11 @@ export default function FinalCTASection() {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.fcta-proof-row {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
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,
|
fontSize: 'clamp(40px, 5.4vw, 82px)', fontWeight: 800, lineHeight: 1.02,
|
||||||
letterSpacing: '-0.045em', color: '#fff', margin: '50px 0 28px', fontFamily: 'Sora, sans-serif',
|
letterSpacing: '-0.045em', color: '#fff', margin: '50px 0 28px', fontFamily: 'Sora, sans-serif',
|
||||||
}}>
|
}}>
|
||||||
<span className="fp-mask"><span>Shop Smart.</span></span>
|
<span className="fp-mask"><span>Get Lit.</span></span>
|
||||||
<span className="fp-mask"><span className="fp-shimmer">Live Rewarded.</span></span>
|
<span className="fp-mask"><span className="fp-shimmer">Live More.</span></span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="fp-content fp-subtext" style={{
|
<p className="fp-content fp-subtext" style={{
|
||||||
fontSize: 'clamp(16px, 1.4vw, 19px)', lineHeight: 1.7, fontWeight: 400,
|
fontSize: 'clamp(16px, 1.4vw, 19px)', lineHeight: 1.7, fontWeight: 400,
|
||||||
color: 'rgba(255,255,255,0.58)', maxWidth: 640, margin: '0 auto 40px', fontFamily: 'Sora, sans-serif',
|
color: 'rgba(255,255,255,0.58)', maxWidth: 640, margin: '0 auto 40px', fontFamily: 'Sora, sans-serif',
|
||||||
}}>
|
}}>
|
||||||
Meet <strong>LYTs (Loyaly Tokens)</strong>: the single currency behind every reward. Earn them through daily challenges and games, then spend instantly at partner stores near you with zero expiry.
|
Meet <strong>Lyts</strong>: rewards for the things you already do. Walk, play, explore, shop, or complete a challenge, then spend your Lyts instantly at the brands you love. No waiting. No cashback. No complicated redemption.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="fp-content fp-cta" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="fp-content fp-cta" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -93,9 +93,9 @@ export default function HeroSection() {
|
|||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
}}>
|
}}>
|
||||||
{[
|
{[
|
||||||
{ val: '1 LYT = ₹1', label: 'Fixed value' },
|
{ val: '10+ Ways', label: 'To earn Lyts' },
|
||||||
{ val: 'Zero Expiry', label: 'Ever' },
|
{ val: 'Instant Spend', label: 'No delay at checkout' },
|
||||||
{ val: '10,000+', label: 'Partner stores' }
|
{ val: '10,000+', label: 'Partner brands' }
|
||||||
].map((item, idx) => (
|
].map((item, idx) => (
|
||||||
<div key={idx} style={{
|
<div key={idx} style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -124,7 +124,7 @@ export default function HeroSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 13, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif' }}>
|
<span style={{ fontSize: 13, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif' }}>
|
||||||
<strong style={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>50,000+</strong> active shoppers earning daily
|
<strong style={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>50,000+</strong> Lytians earning and living more, every day
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export function HeartLogoIcon({ size = 16, active = false }: { size?: number; active?: boolean }) {
|
export function HeartLogoIcon({ size = 16 }: { size?: number; active?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src="/images/loyalyai-logo.png"
|
src="/images/loyalyai-logo.png"
|
||||||
@@ -67,7 +67,6 @@ export function HeartLogoIcon({ size = 16, active = false }: { size?: number; ac
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
filter: active ? 'brightness(0)' : 'none',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -94,12 +93,30 @@ export function FlameIcon({ color = '#f4be28' }: { color?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom nav — pass the active tab key to highlight it ── */
|
/* ── LytsUp brand mark — used in place of the Loyaly.AI heart+tag logo
|
||||||
export function PhoneBottomNav({ active = 'home' }: { active?: string }) {
|
wherever a screen is themed for the LytsUp sub-brand (Live, Lyts Universe).
|
||||||
|
No own background: the parent nav item already supplies the active-state
|
||||||
|
gold pill, same convention as every other bottom-nav icon. ── */
|
||||||
|
export function LytsUpMarkIcon({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: size, height: size, fontWeight: 900, fontSize: size * 0.72, lineHeight: 1,
|
||||||
|
fontFamily: 'Sora, sans-serif', color: 'currentColor',
|
||||||
|
}}>
|
||||||
|
L
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom nav — pass the active tab key to highlight it ──
|
||||||
|
centerIcon lets LytsUp-branded sections swap the middle tab's mark
|
||||||
|
(default is the Loyaly.AI heart+tag logo) without touching every caller. */
|
||||||
|
export function PhoneBottomNav({ active = 'home', centerIcon }: { active?: string; centerIcon?: (isActive: boolean) => React.ReactNode }) {
|
||||||
const items: { key: string; icon: (isActive: boolean) => React.ReactNode }[] = [
|
const items: { key: string; icon: (isActive: boolean) => React.ReactNode }[] = [
|
||||||
{ key: 'home', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10.5L12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></svg> },
|
{ key: 'home', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10.5L12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></svg> },
|
||||||
{ key: 'tag', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41 12 22l-9-9V4h9z" /><circle cx="7.5" cy="8.5" r="1.2" /></svg> },
|
{ key: 'tag', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41 12 22l-9-9V4h9z" /><circle cx="7.5" cy="8.5" r="1.2" /></svg> },
|
||||||
{ key: 'heart', icon: (isActive) => <HeartLogoIcon size={18} active={isActive} /> },
|
{ key: 'heart', icon: centerIcon ?? ((isActive) => <HeartLogoIcon size={18} active={isActive} />) },
|
||||||
{ key: 'shop', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l1.5-5h15L21 9" /><path d="M3 9h18v11H3z" /><path d="M9 20v-6h6v6" /></svg> },
|
{ key: 'shop', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l1.5-5h15L21 9" /><path d="M3 9h18v11H3z" /><path d="M9 20v-6h6v6" /></svg> },
|
||||||
{ key: 'user', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" /></svg> },
|
{ key: 'user', icon: () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" /></svg> },
|
||||||
];
|
];
|
||||||
@@ -122,11 +139,52 @@ export function PhoneBottomNav({ active = 'home' }: { active?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Static phone-mockup image — pre-rendered device + screen (used in place
|
||||||
|
of the hand-built PhoneFrame where a polished, final-design screenshot is
|
||||||
|
available). Keeps the same float animation and responsive width as
|
||||||
|
PhoneFrame so it drops into the same section markup unchanged. ── */
|
||||||
|
export function PhoneMockupImage({
|
||||||
|
src, alt, floatClassName = 'pm-phone-float', width = 300, aspectRatio = 1684 / 3196,
|
||||||
|
}: { src: string; alt: string; floatClassName?: string; width?: number; aspectRatio?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={`pm-phone-shell ${floatClassName}`} style={{ width, position: 'relative' }}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={1000}
|
||||||
|
height={Math.round(1000 / aspectRatio)}
|
||||||
|
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
<style>{`
|
||||||
|
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||||
|
.pm-phone-shell { animation: pmPhoneFloat 7s ease-in-out infinite; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .pm-phone-shell { animation: none !important; } }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pm-phone-shell { width: ${MOBILE_WIDGET_WIDTH}px !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Phone frame — dark bezel + dynamic island + gentle float, white screen inside ── */
|
/* ── Phone frame — dark bezel + dynamic island + gentle float, white screen inside ── */
|
||||||
|
/* Exact widget size on phone-width viewports — kept identical across every
|
||||||
|
section regardless of the (purely cosmetic) floatClassName passed in. */
|
||||||
|
const MOBILE_WIDGET_WIDTH = 280;
|
||||||
|
|
||||||
export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width = 300 }: { children: React.ReactNode; floatClassName?: string; width?: number }) {
|
export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width = 300 }: { children: React.ReactNode; floatClassName?: string; width?: number }) {
|
||||||
|
// Convention across every section: <PhoneBottomNav /> is always passed as
|
||||||
|
// the last child. Splitting it out lets it sit pinned to the screen's
|
||||||
|
// bottom edge while the rest of the content fills the space above it —
|
||||||
|
// no scrollbar, no risk of the nav getting pushed out of view.
|
||||||
|
const items = React.Children.toArray(children);
|
||||||
|
const bottomNav = items[items.length - 1];
|
||||||
|
const content = items.slice(0, -1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={floatClassName}
|
className={`pm-phone-shell ${floatClassName}`}
|
||||||
style={{
|
style={{
|
||||||
width, borderRadius: 42, padding: 10,
|
width, borderRadius: 42, padding: 10,
|
||||||
background: 'linear-gradient(160deg, #2a2a2e 0%, #0a0a0c 100%)',
|
background: 'linear-gradient(160deg, #2a2a2e 0%, #0a0a0c 100%)',
|
||||||
@@ -134,21 +192,30 @@ export function PhoneFrame({ children, floatClassName = 'pm-phone-float', width
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ borderRadius: 32, overflow: 'hidden', background: '#ffffff', position: 'relative' }}>
|
{/* Screen — locked to a real phone's aspect ratio (~9:19.5, e.g. iPhone 14)
|
||||||
|
so every section's widget reads as the same physical device regardless
|
||||||
|
of how much content it holds. */}
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 32, overflow: 'hidden', background: '#ffffff', position: 'relative',
|
||||||
|
aspectRatio: '9 / 19.5', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
{/* Dynamic island */}
|
{/* Dynamic island */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)',
|
position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)',
|
||||||
width: 82, height: 22, borderRadius: 14, background: '#000', zIndex: 5,
|
width: 82, height: 22, borderRadius: 14, background: '#000', zIndex: 5,
|
||||||
}} />
|
}} />
|
||||||
{children}
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{bottomNav}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
@keyframes pmPhoneFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||||
.pm-phone-float { animation: pmPhoneFloat 7s ease-in-out infinite; }
|
.pm-phone-shell { animation: pmPhoneFloat 7s ease-in-out infinite; }
|
||||||
@media (prefers-reduced-motion: reduce) { .pm-phone-float { animation: none !important; } }
|
@media (prefers-reduced-motion: reduce) { .pm-phone-shell { animation: none !important; } }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.pm-phone-float { width: 280px !important; }
|
.pm-phone-shell { width: ${MOBILE_WIDGET_WIDTH}px !important; }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,211 +1,93 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
import { PhoneMockupImage } from './PhoneMockup';
|
||||||
|
|
||||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
const GroceryIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>;
|
|
||||||
const FashionIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.38 3.46L16 2a4 4 0 01-8 0L3.62 3.46a2 2 0 00-1.34 2.23l.58 3.57a1 1 0 00.99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 002-2V10h2.15a1 1 0 00.99-.84l.58-3.57a2 2 0 00-1.34-2.23z"/></svg>;
|
|
||||||
const CafeIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>;
|
|
||||||
const SportsIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8l4 4-4 4-4-4 4-4z"/></svg>;
|
|
||||||
const ElectronicsIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>;
|
|
||||||
const BeautyIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>;
|
|
||||||
|
|
||||||
const brands = [
|
|
||||||
{ name: 'Groceries', Icon: GroceryIcon, stores: ['DMart', 'Reliance Fresh', "Spencer's"], color: '#22c55e' },
|
|
||||||
{ name: 'Fashion', Icon: FashionIcon, stores: ['Zara', 'H&M', 'Lifestyle'], color: '#a855f7' },
|
|
||||||
{ name: 'Food & Cafe', Icon: CafeIcon, stores: ['Starbucks', 'KFC', 'McD'], color: '#f97316' },
|
|
||||||
{ name: 'Sports', Icon: SportsIcon, stores: ['Decathlon', 'Nike', 'Adidas'], color: '#3b82f6' },
|
|
||||||
{ name: 'Electronics', Icon: ElectronicsIcon, stores: ['Croma', 'Reliance Digital'], color: '#06b6d4' },
|
|
||||||
{ name: 'Beauty', Icon: BeautyIcon, stores: ['Health & Glow', 'MAC', 'Forest Essentials'], color: '#ec4899' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SpendAnywhereSection() {
|
export default function SpendAnywhereSection() {
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const phoneRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Scroll-triggered blur/scale entrance for the cards
|
// Phone mockup rises in from the bottom as the section scrolls into view
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const grid = gridRef.current;
|
const section = panelRef.current;
|
||||||
if (!grid) return;
|
const phone = phoneRef.current;
|
||||||
|
if (!section || !phone) return;
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
gsap.fromTo('.spend-card',
|
gsap.fromTo(phone,
|
||||||
{ opacity: 0, y: 44, scale: 0.92, filter: 'blur(8px)' },
|
{ y: 160, opacity: 0 },
|
||||||
{
|
{
|
||||||
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
|
y: 0, opacity: 1, ease: 'power2.out',
|
||||||
duration: 0.9, stagger: 0.09, ease: 'expo.out',
|
scrollTrigger: { trigger: section, start: 'top 92%', end: 'top 25%', scrub: 1.2 },
|
||||||
scrollTrigger: { trigger: grid, start: 'top 85%', toggleActions: 'play none none reset' },
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, grid);
|
}, section);
|
||||||
|
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cursor-tracked spotlight + tilt per card
|
|
||||||
const handleMove = (i: number) => (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
const el = cardRefs.current[i];
|
|
||||||
if (!el) return;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
el.style.setProperty('--mx', `${(x / rect.width) * 100}%`);
|
|
||||||
el.style.setProperty('--my', `${(y / rect.height) * 100}%`);
|
|
||||||
const rotateX = ((y - rect.height / 2) / rect.height) * -6;
|
|
||||||
const rotateY = ((x - rect.width / 2) / rect.width) * 6;
|
|
||||||
gsap.to(el, { rotateX, rotateY, duration: 0.4, ease: 'power2.out', transformPerspective: 700 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLeave = (i: number) => () => {
|
|
||||||
const el = cardRefs.current[i];
|
|
||||||
if (!el) return;
|
|
||||||
gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.5, ease: 'power3.out' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="fp-panel" style={{ background: '#080810' }}>
|
<section ref={panelRef} className="fp-panel" style={{ background: '#080810' }}>
|
||||||
{/* Background photo */}
|
|
||||||
<Image src="/images/stock/spend-anywhere.png" alt="" fill style={{ objectFit: 'cover', opacity: 0.25, zIndex: 0 }} />
|
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,8,16,0.3) 0%, rgba(8,8,16,0.75) 80%)', zIndex: 0 }} />
|
|
||||||
|
|
||||||
<div className="fp-shell">
|
<div className="fp-shell">
|
||||||
|
<div className="fp-split" style={{ gridTemplateColumns: '1.05fr 0.95fr' }}>
|
||||||
|
|
||||||
{/* Header row */}
|
{/* Left — headline + copy */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 24, marginBottom: 'clamp(40px, 6vh, 64px)' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 22 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">05</span></span></div>
|
<div style={{ marginBottom: 20 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">03</span></span></div>
|
||||||
<h2 style={{
|
|
||||||
fontSize: 'clamp(46px, 7vw, 110px)', fontWeight: 800, letterSpacing: '-0.06em', lineHeight: 0.9,
|
<h2 className="fp-h1" style={{ color: '#fff' }}>
|
||||||
color: '#fff', margin: 0, fontFamily: 'Sora, sans-serif',
|
<span className="fp-mask"><span>Your Lyts.</span></span>
|
||||||
}}>
|
<span className="fp-mask"><span className="fp-shimmer">Your Choice.</span></span>
|
||||||
<span className="fp-mask"><span>Spend it</span></span>
|
|
||||||
<span className="fp-mask"><span className="fp-shimmer">anywhere.</span></span>
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
|
||||||
<p className="fp-rise" style={{
|
|
||||||
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 */}
|
<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' }}>
|
||||||
<div ref={gridRef} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, perspective: 900 }}>
|
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.
|
||||||
{brands.map((brand, i) => {
|
</p>
|
||||||
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',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<div style={{ position: 'relative', zIndex: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
<div className="fp-rise" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 28, maxWidth: 460 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
{[
|
||||||
<div className="spend-card-icon" style={{
|
{ label: 'No waiting', icon: '⚡' },
|
||||||
width: 42, height: 42, borderRadius: 13,
|
{ label: 'No cashback', icon: '🚫' },
|
||||||
background: `${brand.color}18`, border: `1px solid ${brand.color}40`,
|
{ label: 'No complicated redemption', icon: '✅' },
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: brand.color,
|
].map((tag) => (
|
||||||
}}>
|
<div key={tag.label} className="fp-card fp-card--dark">
|
||||||
<BrandIcon />
|
<span className="fp-card-icon" style={{ background: 'rgba(244,190,40,0.14)' }}>{tag.icon}</span>
|
||||||
</div>
|
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif', color: '#fff', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{
|
{tag.label}
|
||||||
fontSize: 15, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em',
|
|
||||||
}}>{brand.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explore arrow, slides in on hover */}
|
|
||||||
<span className="spend-card-arrow" style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
border: `1px solid ${brand.color}40`, color: brand.color,
|
|
||||||
opacity: 0, transform: 'translateX(-6px)',
|
|
||||||
transition: 'opacity 0.3s ease, transform 0.3s cubic-bezier(0.34,1.56,0.64,1)',
|
|
||||||
}}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
<div style={{ position: 'relative', zIndex: 2, height: 1, background: 'rgba(255,255,255,0.07)', marginBottom: 16 }} />
|
|
||||||
|
|
||||||
<div style={{ position: 'relative', zIndex: 2, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
{brand.stores.map((s, j) => (
|
|
||||||
<span key={j} style={{
|
|
||||||
fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em',
|
|
||||||
fontFamily: 'Sora, sans-serif', color: 'rgba(255,255,255,0.55)',
|
|
||||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
|
|
||||||
borderRadius: 100, padding: '5px 12px',
|
|
||||||
}}>{s}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
))}
|
|
||||||
|
<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>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,12 +143,36 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
|
|||||||
--carbon: #171512;
|
--carbon: #171512;
|
||||||
--warm: #4f463a;
|
--warm: #4f463a;
|
||||||
--violet: #8258c8;
|
--violet: #8258c8;
|
||||||
|
/* ── Typography scale (Discover is the reference — one size everywhere) ── */
|
||||||
|
--fp-h-hero: clamp(44px, 5.6vw, 88px);
|
||||||
|
--fp-h-weight: 800;
|
||||||
|
--fp-h-track: -0.045em;
|
||||||
|
--fp-desc-size: 16px;
|
||||||
|
--fp-desc-line: 1.8;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared heading + description rhythm (one source of truth) ── */
|
||||||
|
/* .fp-h1 / .fp-h2 are intentionally identical — Discover's scale on every section. */
|
||||||
|
.fp-h1, .fp-h2 {
|
||||||
|
font-family: 'Sora', sans-serif;
|
||||||
|
font-weight: var(--fp-h-weight);
|
||||||
|
letter-spacing: var(--fp-h-track);
|
||||||
|
font-size: var(--fp-h-hero);
|
||||||
|
line-height: 0.94;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.fp-desc {
|
||||||
|
font-family: 'Sora', sans-serif;
|
||||||
|
font-size: var(--fp-desc-size);
|
||||||
|
line-height: var(--fp-desc-line);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Film grain ── */
|
/* ── Film grain ── */
|
||||||
.fp-grain {
|
.fp-grain {
|
||||||
position: fixed; inset: 0; z-index: 9; pointer-events: none; opacity: 0.04;
|
position: fixed; inset: 0; z-index: 9; pointer-events: none; opacity: 0.04;
|
||||||
@@ -300,6 +324,26 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
|
|||||||
gap: clamp(40px, 6vw, 96px); align-items: center;
|
gap: clamp(40px, 6vw, 96px); align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared tag/stat card system (icon badge + label) ── */
|
||||||
|
.fp-card {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px; border-radius: 12px;
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.fp-card--light { background: #fff; border: 1.5px solid var(--carbon); }
|
||||||
|
.fp-card--light:hover { box-shadow: 3px 3px 0 var(--carbon); transform: translate(-2px, -2px); }
|
||||||
|
.fp-card--dark { background: rgba(255,255,255,0.04); border: 1.5px solid rgba(255,255,255,0.18); }
|
||||||
|
.fp-card--dark:hover { border-color: var(--gold); box-shadow: 3px 3px 0 rgba(244,190,40,0.35); transform: translate(-2px, -2px); }
|
||||||
|
.fp-card--fill { background: var(--carbon); border: 1.5px solid var(--carbon); }
|
||||||
|
.fp-card--fill:hover { box-shadow: 3px 3px 0 rgba(244,190,40,0.4); transform: translate(-2px, -2px); }
|
||||||
|
.fp-card-icon {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 9px; font-size: 14px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Larger card for stat/feature content */
|
||||||
|
.fp-card--stat { padding: 20px 16px; border-radius: 20px; gap: 12px; }
|
||||||
|
.fp-card-icon--lg { width: 40px; height: 40px; border-radius: 12px; font-size: 18px; }
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.fp-cursor { display: none; }
|
.fp-cursor { display: none; }
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Premium animated dark background — gold gradient mesh orbs, drifting grid,
|
* Premium animated dark background — gold gradient mesh orbs, drifting grid,
|
||||||
@@ -9,16 +10,31 @@ import React from 'react';
|
|||||||
*/
|
*/
|
||||||
export default function AnimatedDarkBg({
|
export default function AnimatedDarkBg({
|
||||||
variant = 'amber',
|
variant = 'amber',
|
||||||
}: { variant?: 'amber' | 'cool' | 'mono' }) {
|
bgImage,
|
||||||
|
bgImageAlt = '',
|
||||||
|
}: { variant?: 'amber' | 'cool' | 'mono'; bgImage?: string; bgImageAlt?: string }) {
|
||||||
const orbA = variant === 'cool' ? 'rgba(80,140,220,0.10)' : 'rgba(244,190,40,0.12)';
|
const orbA = variant === 'cool' ? 'rgba(80,140,220,0.10)' : 'rgba(244,190,40,0.12)';
|
||||||
const orbB = variant === 'cool' ? 'rgba(140,90,210,0.10)' : 'rgba(255,149,0,0.08)';
|
const orbB = variant === 'cool' ? 'rgba(140,90,210,0.10)' : 'rgba(255,149,0,0.08)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-hidden className="adb-root" style={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
<div aria-hidden className="adb-root" style={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||||
{/* Base gradient */}
|
{bgImage && (
|
||||||
|
<NextImage
|
||||||
|
src={bgImage}
|
||||||
|
alt={bgImageAlt}
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Base gradient — top-weighted scrim so the headline stays legible while the
|
||||||
|
image reads clearly in the middle, plus a lift at the bottom for card contrast */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', inset: 0,
|
position: 'absolute', inset: 0,
|
||||||
background: 'radial-gradient(ellipse 120% 80% at 50% -10%, #131017 0%, #07070d 55%, #050509 100%)',
|
background: bgImage
|
||||||
|
? 'linear-gradient(180deg, rgba(6,6,10,0.82) 0%, rgba(6,6,10,0.55) 22%, rgba(6,6,10,0.3) 42%, rgba(6,6,10,0.35) 60%, rgba(6,6,10,0.62) 100%)'
|
||||||
|
: 'radial-gradient(ellipse 120% 80% at 50% -10%, #131017 0%, #07070d 55%, #050509 100%)',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
{/* Drifting mesh orbs */}
|
{/* Drifting mesh orbs */}
|
||||||
@@ -48,7 +64,9 @@ export default function AnimatedDarkBg({
|
|||||||
{/* Vignette */}
|
{/* Vignette */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', inset: 0,
|
position: 'absolute', inset: 0,
|
||||||
background: 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 55%, rgba(0,0,0,0.45) 100%)',
|
background: bgImage
|
||||||
|
? 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 60%, rgba(0,0,0,0.3) 100%)'
|
||||||
|
: 'radial-gradient(ellipse 90% 90% at 50% 50%, transparent 55%, rgba(0,0,0,0.45) 100%)',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|||||||
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];
|
const el = flowNodeRefs.current[index];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
gsap.to(el, {
|
gsap.to(el, {
|
||||||
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
|
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
|
||||||
borderColor: active ? '#171512' : 'rgba(244,190,40,0.35)',
|
borderColor: active ? '#ffffff' : 'rgba(244,190,40,0.3)',
|
||||||
borderWidth: active ? '3px' : '1px',
|
borderWidth: active ? '3px' : '1px',
|
||||||
boxShadow: active ? '0 12px 26px -10px rgba(23,21,18,0.4)' : '0 0px 0px rgba(0,0,0,0)',
|
color: active ? '#171512' : 'rgba(255,255,255,0.6)',
|
||||||
|
boxShadow: active ? '0 12px 30px -8px rgba(244,190,40,0.55)' : '0 0px 0px rgba(0,0,0,0)',
|
||||||
duration: 0.35, ease: 'power2.out',
|
duration: 0.35, ease: 'power2.out',
|
||||||
});
|
});
|
||||||
if (active) gsap.fromTo(el, { scale: 1.22 }, { scale: 1, duration: 0.45, ease: 'back.out(2.4)' });
|
if (active) gsap.fromTo(el, { scale: 1.22 }, { scale: 1, duration: 0.45, ease: 'back.out(2.4)' });
|
||||||
@@ -158,8 +159,8 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
borderRadius: 32,
|
borderRadius: 32,
|
||||||
padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)',
|
padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
background: '#ffffff',
|
background: 'radial-gradient(ellipse 110% 80% at 50% 0%, #17130d 0%, #0d0b09 55%, #0a0807 100%)',
|
||||||
boxShadow: 'inset 0 0 0 1px rgba(23,21,18,0.05)',
|
boxShadow: 'inset 0 0 0 1px rgba(244,190,40,0.12)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Faint dot-grid texture for depth, matching the light sections elsewhere */}
|
{/* Faint dot-grid texture for depth, matching the light sections elsewhere */}
|
||||||
@@ -167,13 +168,14 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||||
backgroundImage: 'radial-gradient(rgba(23,21,18,0.06) 1px, transparent 1px)',
|
backgroundImage: 'radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px)',
|
||||||
backgroundSize: '28px 28px',
|
backgroundSize: '28px 28px',
|
||||||
maskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
maskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
||||||
WebkitMaskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
WebkitMaskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ position: 'absolute', top: '-10%', right: '-6%', width: 480, height: 480, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
<div style={{ position: 'absolute', top: '-10%', right: '-6%', width: 520, height: 520, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.20) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||||
|
<div style={{ position: 'absolute', bottom: '-14%', left: '-8%', width: 460, height: 460, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,152,0,0.12) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||||
|
|
||||||
{/* Oversized watermark type — big-type Gen-Z background flex */}
|
{/* Oversized watermark type — big-type Gen-Z background flex */}
|
||||||
<div
|
<div
|
||||||
@@ -181,7 +183,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)',
|
position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)',
|
||||||
fontSize: 'clamp(6rem, 16vw, 13rem)', fontWeight: 900, letterSpacing: '-0.05em',
|
fontSize: 'clamp(6rem, 16vw, 13rem)', fontWeight: 900, letterSpacing: '-0.05em',
|
||||||
color: 'rgba(23,21,18,0.035)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
|
color: 'rgba(255,255,255,0.035)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
|
||||||
pointerEvents: 'none', userSelect: 'none', zIndex: 0,
|
pointerEvents: 'none', userSelect: 'none', zIndex: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -195,7 +197,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
padding: 'clamp(8px, 2vw, 20px) 0',
|
padding: 'clamp(8px, 2vw, 20px) 0',
|
||||||
fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)',
|
fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)',
|
||||||
fontWeight: 800, color: '#171512',
|
fontWeight: 800, color: '#ffffff',
|
||||||
lineHeight: 1, letterSpacing: '-0.04em',
|
lineHeight: 1, letterSpacing: '-0.04em',
|
||||||
fontFamily: 'Sora, sans-serif',
|
fontFamily: 'Sora, sans-serif',
|
||||||
}}
|
}}
|
||||||
@@ -232,7 +234,7 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
style={{
|
style={{
|
||||||
margin: '22px 0 0', maxWidth: 620,
|
margin: '22px 0 0', maxWidth: 620,
|
||||||
fontSize: 'clamp(0.95rem, 1.15vw, 1.1rem)', lineHeight: 1.7,
|
fontSize: 'clamp(0.95rem, 1.15vw, 1.1rem)', lineHeight: 1.7,
|
||||||
color: 'rgba(23,21,18,0.55)', fontFamily: 'Sora, sans-serif',
|
color: 'rgba(255,255,255,0.62)', fontFamily: 'Sora, sans-serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
LytsUp transforms your everyday activities into real shopping rewards. Walk, play, explore, visit your favorite places, complete fun challenges, and earn Lyts that you can spend instantly at participating brands.
|
LytsUp transforms your everyday activities into real shopping rewards. Walk, play, explore, visit your favorite places, complete fun challenges, and earn Lyts that you can spend instantly at participating brands.
|
||||||
@@ -247,16 +249,17 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '8px 16px', borderRadius: 100,
|
padding: '8px 16px', borderRadius: 100,
|
||||||
background: 'rgba(244,190,40,0.08)', border: '1px solid rgba(244,190,40,0.28)',
|
background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(244,190,40,0.3)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
ref={(el) => { statRefs.current[i] = el; }}
|
ref={(el) => { statRefs.current[i] = el; }}
|
||||||
style={{ fontSize: '0.92rem', fontWeight: 800, color: '#171512', fontFamily: 'Sora, sans-serif', minWidth: '2.6em', display: 'inline-block' }}
|
style={{ fontSize: '0.92rem', fontWeight: 800, color: '#f4be28', fontFamily: 'Sora, sans-serif', minWidth: '2.6em', display: 'inline-block' }}
|
||||||
>
|
>
|
||||||
0{stat.suffix}
|
0{stat.suffix}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif' }}>{stat.label}</span>
|
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif' }}>{stat.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,20 +276,20 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
width: 46, height: 46, borderRadius: '50%',
|
width: 46, height: 46, borderRadius: '50%',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
|
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
|
||||||
color: '#171512', boxSizing: 'border-box',
|
color: i === 0 ? '#171512' : 'rgba(255,255,255,0.6)', boxSizing: 'border-box',
|
||||||
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
|
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(255,255,255,0.05)',
|
||||||
border: i === 0 ? '3px solid #171512' : '1px solid rgba(244,190,40,0.35)',
|
border: i === 0 ? '3px solid #ffffff' : '1px solid rgba(244,190,40,0.3)',
|
||||||
boxShadow: i === 0 ? '0 12px 26px -10px rgba(23,21,18,0.4)' : 'none',
|
boxShadow: i === 0 ? '0 12px 30px -8px rgba(244,190,40,0.55)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '0.9rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', color: '#171512', letterSpacing: '-0.01em' }}>
|
<span style={{ fontSize: '0.9rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', color: '#ffffff', letterSpacing: '-0.01em' }}>
|
||||||
{step}
|
{step}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{i < 2 && (
|
{i < 2 && (
|
||||||
<div style={{ position: 'relative', flex: 1, height: 2, marginTop: 23, borderRadius: 2, background: 'rgba(23,21,18,0.12)', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', flex: 1, height: 2, marginTop: 23, borderRadius: 2, background: 'rgba(255,255,255,0.14)', overflow: 'hidden' }}>
|
||||||
<div
|
<div
|
||||||
ref={(el) => { flowFillRefs.current[i] = el; }}
|
ref={(el) => { flowFillRefs.current[i] = el; }}
|
||||||
style={{ position: 'absolute', inset: 0, borderRadius: 2, background: 'linear-gradient(90deg,#f4be28,#FF9800)', transformOrigin: 'left center', transform: 'scaleX(0)' }}
|
style={{ position: 'absolute', inset: 0, borderRadius: 2, background: 'linear-gradient(90deg,#f4be28,#FF9800)', transformOrigin: 'left center', transform: 'scaleX(0)' }}
|
||||||
@@ -304,7 +307,9 @@ export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
|||||||
maxWidth: 720, textAlign: 'center',
|
maxWidth: 720, textAlign: 'center',
|
||||||
padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)',
|
padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)',
|
||||||
borderRadius: 26,
|
borderRadius: 26,
|
||||||
background: '#171512',
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%)',
|
||||||
|
border: '1px solid rgba(244,190,40,0.22)',
|
||||||
|
boxShadow: '0 30px 60px -24px rgba(0,0,0,0.6)',
|
||||||
position: 'relative', overflow: 'hidden',
|
position: 'relative', overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -450,7 +450,24 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
|
|
||||||
// Desktop / tablet: pinned horizontal scroll
|
// Desktop / tablet: pinned horizontal scroll
|
||||||
mm.add('(min-width: 901px)', () => {
|
mm.add('(min-width: 901px)', () => {
|
||||||
|
const cards = gsap.utils.toArray<HTMLElement>('.products-card', track);
|
||||||
const distance = () => Math.max(0, track.scrollWidth - section.clientWidth);
|
const distance = () => Math.max(0, track.scrollWidth - section.clientWidth);
|
||||||
|
const steps = Math.max(1, PRODUCTS.length - 1);
|
||||||
|
|
||||||
|
// Depth cue: the card nearest the viewport centre sits at full scale/
|
||||||
|
// opacity, neighbours ease back slightly — makes the ride feel guided
|
||||||
|
// rather than a flat strip sliding past, and the eased response
|
||||||
|
// absorbs small scrub jitter instead of showing it as a raw jump.
|
||||||
|
const updateDepth = () => {
|
||||||
|
const centerX = section.clientWidth / 2;
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const trackX = Number(gsap.getProperty(track, 'x', 'px'));
|
||||||
|
const cardCenter = card.offsetLeft + card.offsetWidth / 2 + trackX;
|
||||||
|
const dist = Math.abs(cardCenter - centerX);
|
||||||
|
const norm = gsap.utils.clamp(0, 1, dist / (section.clientWidth * 0.62));
|
||||||
|
gsap.set(card, { scale: 1 - norm * 0.07, opacity: 1 - norm * 0.32 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const tween = gsap.to(track, {
|
const tween = gsap.to(track, {
|
||||||
x: () => -distance(),
|
x: () => -distance(),
|
||||||
@@ -460,30 +477,42 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
start: 'top top',
|
start: 'top top',
|
||||||
end: () => `+=${distance()}`,
|
end: () => `+=${distance()}`,
|
||||||
pin: true,
|
pin: true,
|
||||||
pinType: 'transform',
|
|
||||||
pinSpacing: true,
|
pinSpacing: true,
|
||||||
anticipatePin: 1,
|
anticipatePin: 1,
|
||||||
scrub: 1,
|
scrub: 0.4,
|
||||||
invalidateOnRefresh: true,
|
invalidateOnRefresh: true,
|
||||||
refreshPriority: 5,
|
refreshPriority: 5,
|
||||||
|
snap: {
|
||||||
|
snapTo: (value) => Math.round(value * steps) / steps,
|
||||||
|
duration: { min: 0.2, max: 0.5 },
|
||||||
|
ease: 'power2.inOut',
|
||||||
|
},
|
||||||
onUpdate: (self) => {
|
onUpdate: (self) => {
|
||||||
if (progressRef.current) progressRef.current.style.transform = `scaleX(${self.progress})`;
|
if (progressRef.current) progressRef.current.style.transform = `scaleX(${self.progress})`;
|
||||||
if (counterRef.current) {
|
if (counterRef.current) {
|
||||||
const idx = Math.min(PRODUCTS.length, Math.max(1, Math.round(self.progress * (PRODUCTS.length - 1)) + 1));
|
const idx = Math.min(PRODUCTS.length, Math.max(1, Math.round(self.progress * steps) + 1));
|
||||||
counterRef.current.textContent = `0${idx}`;
|
counterRef.current.textContent = `0${idx}`;
|
||||||
}
|
}
|
||||||
|
updateDepth();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDepth();
|
||||||
ScrollTrigger.refresh();
|
ScrollTrigger.refresh();
|
||||||
return () => { tween.scrollTrigger?.kill(); tween.kill(); gsap.set(track, { clearProps: 'transform' }); };
|
return () => {
|
||||||
|
tween.scrollTrigger?.kill();
|
||||||
|
tween.kill();
|
||||||
|
gsap.set(track, { clearProps: 'transform' });
|
||||||
|
gsap.set(cards, { clearProps: 'scale,opacity' });
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => mm.revert();
|
return () => mm.revert();
|
||||||
}, [isLoaded]);
|
}, [isLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="products-section-wrapper">
|
||||||
<section
|
<section
|
||||||
ref={sectionRef}
|
ref={sectionRef}
|
||||||
id="products-section"
|
id="products-section"
|
||||||
@@ -496,24 +525,26 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
{/* Heading (fixed within the pinned viewport) */}
|
{/* Heading (fixed within the pinned viewport) */}
|
||||||
<div className="products-heading">
|
<div className="products-heading">
|
||||||
<div style={{ maxWidth: 520 }}>
|
<div style={{ maxWidth: 520 }}>
|
||||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 18, padding: '6px 16px 6px 8px', borderRadius: 100, background: '#ffffff', border: '1px solid rgba(244,190,40,0.3)', boxShadow: '0 1px 2px rgba(0,0,0,0.03), 0 12px 30px -18px rgba(23,21,18,0.2)' }}>
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 20, padding: '6px 16px 6px 8px', borderRadius: 100, background: '#ffffff', border: '1px solid rgba(244,190,40,0.3)', boxShadow: '0 1px 2px rgba(0,0,0,0.03), 0 12px 30px -18px rgba(23,21,18,0.2)' }}>
|
||||||
<span style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(244,190,40,0.15)' }}>
|
<span style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(244,190,40,0.15)' }}>
|
||||||
<img src="/images/loyalyai-logo.png" alt="" width={15} height={15} style={{ display: 'block', objectFit: 'contain' }} />
|
<img src="/images/loyalyai-logo.png" alt="" width={15} height={15} style={{ display: 'block', objectFit: 'contain' }} />
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#8a5a14', fontFamily: 'Sora, sans-serif' }}>Platform</span>
|
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#8a5a14', fontFamily: 'Sora, sans-serif' }}>Platform</span>
|
||||||
|
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', display: 'block', marginLeft: 2 }} />
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.6vw, 3.2rem)', fontWeight: 800, lineHeight: 0.94, letterSpacing: '-0.04em', color: '#111111', fontFamily: 'Sora, sans-serif' }}>
|
<h2 style={{ margin: 0, padding: '0 0 clamp(10px, 1.6vw, 22px)', fontSize: 'clamp(1.6rem, 3vw, 2.7rem)', fontWeight: 800, lineHeight: 0.94, letterSpacing: '-0.04em', color: '#111111', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap' }}>
|
||||||
Four products.<br />
|
Four agents.{' '}
|
||||||
<span style={{ background: 'linear-gradient(105deg, #f4be28 0%, #FF9800 100%)', backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', animation: 'prodShimmer 3s linear infinite' }}>One ecosystem.</span>
|
<span style={{ background: 'linear-gradient(105deg, #f4be28 0%, #FF9800 100%)', backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', animation: 'prodShimmer 3s linear infinite' }}>One AI.</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="products-heading-hint" style={{ maxWidth: 300, textAlign: 'right', borderRight: '2px solid rgba(244,190,40,0.3)', paddingRight: 'clamp(18px, 2.2vw, 28px)' }}>
|
<div className="products-heading-hint" style={{ maxWidth: 320, textAlign: 'right', borderRight: '2px solid rgba(244,190,40,0.3)', paddingRight: 'clamp(18px, 2.2vw, 28px)' }}>
|
||||||
<p style={{ margin: '0 0 16px', fontSize: 'clamp(0.85rem, 1vw, 1rem)', lineHeight: 1.6, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif' }}>
|
<p style={{ margin: '0 0 18px', fontSize: 'clamp(0.85rem, 1vw, 1rem)', lineHeight: 1.6, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif' }}>
|
||||||
Powering every stage of the retail journey with intelligent AI solutions.
|
Powering every stage of the retail journey with intelligent AI solutions.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10 }}>
|
|
||||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.85rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Built as one connected platform</span>
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Unified into one platform</span>
|
||||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: '#f4be28', letterSpacing: '-0.02em' }}>04</span>
|
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: '#f4be28', letterSpacing: '-0.02em' }}>04</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,11 +635,17 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Margin lives on this non-pinned wrapper, not on the pinned section
|
||||||
|
itself — GSAP's ScrollTrigger pin-spacer can miscalculate the
|
||||||
|
reserved space when the pinned element carries its own margin,
|
||||||
|
which shows up as a jump/shake right as the pin engages. */
|
||||||
|
.products-section-wrapper {
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
.products-section-root {
|
.products-section-root {
|
||||||
--pt: clamp(230px, 28vh, 310px);
|
--pt: clamp(230px, 28vh, 310px);
|
||||||
--pb: clamp(64px, 8vh, 96px);
|
--pb: clamp(64px, 8vh, 96px);
|
||||||
width: calc(100% - 40px);
|
width: 100%;
|
||||||
margin: 20px 20px;
|
|
||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
background: #FAFAFA;
|
background: #FAFAFA;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -681,5 +718,6 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,12 +224,12 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Papercraft Image */}
|
{/* Papercraft Image */}
|
||||||
<div style={{
|
<div className="shift-card-art" style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: i === 2 ? '-20px' : '-24px',
|
right: i === 2 ? '-12px' : '-16px',
|
||||||
bottom: i === 2 ? '-20px' : '-24px',
|
bottom: i === 2 ? '-12px' : '-16px',
|
||||||
width: i === 2 ? '72%' : '68%',
|
width: i === 2 ? '56%' : '52%',
|
||||||
height: '112%',
|
height: '90%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}>
|
}}>
|
||||||
@@ -238,11 +238,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
alt={card.label}
|
alt={card.label}
|
||||||
width={400}
|
width={400}
|
||||||
height={400}
|
height={400}
|
||||||
|
className="shift-card-art-img"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'bottom right',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
transform: i === 2 ? 'scale(1.4)' : 'scale(1.36)',
|
transform: i === 2 ? 'scale(1.1)' : 'scale(1.06)',
|
||||||
transformOrigin: 'bottom right',
|
transformOrigin: 'bottom right',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -291,11 +293,11 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
|
|
||||||
{/* Papercraft Image */}
|
{/* Papercraft Image */}
|
||||||
{(cardNumber === 7 || cardNumber === 8) ? (
|
{(cardNumber === 7 || cardNumber === 8) ? (
|
||||||
<div style={{
|
<div className="shift-card-art" style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: cardNumber === 7 ? '66%' : '62%',
|
width: cardNumber === 7 ? '64%' : '48%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
@@ -305,21 +307,22 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
alt={card.title}
|
alt={card.title}
|
||||||
width={cardNumber === 7 ? 520 : 440}
|
width={cardNumber === 7 ? 520 : 440}
|
||||||
height={cardNumber === 7 ? 520 : 440}
|
height={cardNumber === 7 ? 520 : 440}
|
||||||
|
className="shift-card-art-img"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
objectPosition: cardNumber === 7 ? '62% 88%' : '68% 82%',
|
objectPosition: cardNumber === 7 ? '58% 50%' : '68% 82%',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div className="shift-card-art" style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '-16px',
|
right: '-12px',
|
||||||
bottom: '-20px',
|
bottom: '-14px',
|
||||||
width: '66%',
|
width: '50%',
|
||||||
height: '112%',
|
height: '90%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}>
|
}}>
|
||||||
@@ -328,11 +331,13 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
alt={card.title}
|
alt={card.title}
|
||||||
width={400}
|
width={400}
|
||||||
height={400}
|
height={400}
|
||||||
|
className="shift-card-art-img"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'bottom right',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
transform: 'scale(1.3)',
|
transform: 'scale(1.0)',
|
||||||
transformOrigin: 'bottom right',
|
transformOrigin: 'bottom right',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -429,6 +434,19 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
|||||||
.shift-card-7 {
|
.shift-card-7 {
|
||||||
min-height: 220px !important;
|
min-height: 220px !important;
|
||||||
}
|
}
|
||||||
|
/* Pin card art to the bottom-right corner on narrow screens,
|
||||||
|
where full-width cards would otherwise let it sprawl over the text */
|
||||||
|
#shift .shift-card-art {
|
||||||
|
width: 62% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
}
|
||||||
|
#shift .shift-card-art-img {
|
||||||
|
object-fit: contain !important;
|
||||||
|
object-position: bottom right !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -156,39 +156,37 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cards: single staggered timeline triggered once the row enters view
|
// Cards: 3D flip-up reveal — each card rises and unfolds from its bottom
|
||||||
|
// edge, then its inner content cascades in. One scroll-triggered
|
||||||
|
// timeline keeps card + content in sync.
|
||||||
const cards = gsap.utils.toArray<HTMLElement>('.wwd-card');
|
const cards = gsap.utils.toArray<HTMLElement>('.wwd-card');
|
||||||
const grid = document.querySelector('.wwd-grid-container');
|
const grid = document.querySelector('.wwd-grid-container');
|
||||||
|
|
||||||
gsap.fromTo(cards,
|
const revealTl = gsap.timeline({
|
||||||
{ opacity: 0, y: 56, scale: 0.93, filter: 'blur(4px)' },
|
scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' },
|
||||||
{
|
});
|
||||||
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
|
|
||||||
duration: 0.8, stagger: 0.12, ease: 'expo.out',
|
revealTl.fromTo(cards,
|
||||||
scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' },
|
{ opacity: 0, y: 90, rotationX: -32, transformOrigin: '50% 100%', transformPerspective: 1100 },
|
||||||
}
|
{ opacity: 1, y: 0, rotationX: 0, duration: 0.9, ease: 'power4.out', stagger: 0.14 },
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cascade inner elements after cards land
|
|
||||||
cards.forEach((card, i) => {
|
cards.forEach((card, i) => {
|
||||||
const tl = gsap.timeline({ delay: i * 0.12 + 0.25 });
|
const at = i * 0.14 + 0.35;
|
||||||
|
revealTl
|
||||||
tl.fromTo(card.querySelector('.wwd-icon'),
|
.fromTo(card.querySelector('.wwd-icon'),
|
||||||
{ opacity: 0, scale: 0.5, rotate: -15, filter: 'blur(3px)' },
|
{ opacity: 0, scale: 0.4, rotate: -20 },
|
||||||
{ opacity: 1, scale: 1, rotate: 0, filter: 'blur(0px)', duration: 0.55, ease: 'back.out(2)' }
|
{ opacity: 1, scale: 1, rotate: 0, duration: 0.5, ease: 'back.out(2.2)' }, at)
|
||||||
)
|
.fromTo(card.querySelector('.wwd-title'),
|
||||||
.fromTo(card.querySelector('.wwd-title'),
|
{ opacity: 0, y: 16 },
|
||||||
{ opacity: 0, y: 14 },
|
{ opacity: 1, y: 0, duration: 0.45, ease: 'power3.out' }, at + 0.12)
|
||||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, '-=0.2'
|
.fromTo(card.querySelector('.wwd-tagline'),
|
||||||
)
|
{ opacity: 0, y: 12 },
|
||||||
.fromTo(card.querySelector('.wwd-tagline'),
|
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.22)
|
||||||
{ opacity: 0, y: 10 },
|
.fromTo(card.querySelector('.wwd-detail'),
|
||||||
{ opacity: 1, y: 0, duration: 0.38, ease: 'power3.out' }, '-=0.2'
|
{ opacity: 0, y: 10 },
|
||||||
)
|
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, at + 0.3);
|
||||||
.fromTo(card.querySelector('.wwd-detail'),
|
|
||||||
{ opacity: 0, y: 8 },
|
|
||||||
{ opacity: 1, y: 0, duration: 0.35, ease: 'power3.out' }, '-=0.18'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, containerRef);
|
}, containerRef);
|
||||||
|
|
||||||
@@ -207,7 +205,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatedDarkBg variant="amber" />
|
<AnimatedDarkBg variant="amber" bgImage="/images/stock/one_platform_bg.png" bgImageAlt="" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="wwd-header relative z-10 text-center max-w-4xl mx-auto mb-16 sm:mb-20 flex flex-col items-center">
|
<div className="wwd-header relative z-10 text-center max-w-4xl mx-auto mb-16 sm:mb-20 flex flex-col items-center">
|
||||||
@@ -224,6 +222,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
fontWeight: 800, color: '#fff',
|
fontWeight: 800, color: '#fff',
|
||||||
lineHeight: 0.98, letterSpacing: '-0.04em',
|
lineHeight: 0.98, letterSpacing: '-0.04em',
|
||||||
fontFamily: 'Sora, sans-serif',
|
fontFamily: 'Sora, sans-serif',
|
||||||
|
textShadow: '0 4px 24px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.6)',
|
||||||
}}>
|
}}>
|
||||||
Every visit,<br />
|
Every visit,<br />
|
||||||
a <span className="wwd-shimmer">loyal customer.</span>
|
a <span className="wwd-shimmer">loyal customer.</span>
|
||||||
@@ -242,8 +241,6 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
<style>{`
|
<style>{`
|
||||||
.wwd-card {
|
.wwd-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
transform: perspective(1100px) rotateX(0deg) rotateY(0deg);
|
|
||||||
transition: transform 0.45s cubic-bezier(0.22,1,0.36,1);
|
|
||||||
}
|
}
|
||||||
.wwd-card-inner {
|
.wwd-card-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -253,11 +250,11 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.4s cubic-bezier(0.22,1,0.36,1), box-shadow 0.4s ease;
|
transition: transform 0.4s cubic-bezier(0.22,1,0.36,1), box-shadow 0.4s ease;
|
||||||
}
|
}
|
||||||
.wwd-card-white .wwd-card-inner { background: #ffffff; }
|
.wwd-card-white .wwd-card-inner { background: #ffffff; box-shadow: 0 18px 40px -14px rgba(0,0,0,0.5); }
|
||||||
.wwd-card-yellow .wwd-card-inner { background: #F4C21A; }
|
.wwd-card-yellow .wwd-card-inner { background: #F4C21A; box-shadow: 0 18px 40px -14px rgba(0,0,0,0.45); }
|
||||||
.wwd-card-active .wwd-card-inner {
|
.wwd-card-active .wwd-card-inner {
|
||||||
transform: translateY(-6px);
|
transform: translateY(-6px);
|
||||||
box-shadow: 0 24px 50px -18px rgba(0,0,0,0.35);
|
box-shadow: 0 24px 50px -18px rgba(0,0,0,0.55);
|
||||||
}
|
}
|
||||||
.wwd-card-glow {
|
.wwd-card-glow {
|
||||||
position: absolute; inset: 0; pointer-events: none;
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
@@ -292,11 +289,13 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.wwd-shimmer {
|
.wwd-shimmer {
|
||||||
background: linear-gradient(105deg,#f4be28 0%,#fff8e0 48%,#f4be28 62%,#ff9500 100%);
|
background: linear-gradient(105deg,#ffd95a 0%,#fff8e0 35%,#f4c21a 55%,#ffb020 75%,#ffd95a 100%);
|
||||||
background-size: 200% auto;
|
background-size: 200% auto;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: none;
|
||||||
|
filter: drop-shadow(0 3px 14px rgba(0,0,0,0.55)) drop-shadow(0 0 22px rgba(244,194,26,0.35));
|
||||||
animation: wwdShimmer 3s linear infinite;
|
animation: wwdShimmer 3s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes wwdShimmer { 0% { background-position: 200% center; } 100% { background-position: -200% center; } }
|
@keyframes wwdShimmer { 0% { background-position: 200% center; } 100% { background-position: -200% center; } }
|
||||||
|
|||||||
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 { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import { DEFAULT_EASE, DEFAULT_DURATION } from './animation';
|
import { DEFAULT_EASE, DEFAULT_DURATION } from './animation';
|
||||||
|
|
||||||
const EXCLUDED = '.loyaly-home, #problems-shift, #what-we-do, #the-loop, #products-section';
|
const EXCLUDED = '.loyaly-home, #problem, #shift, #lytsup, #what-we-do, #the-loop, #products-section';
|
||||||
|
|
||||||
export function initGlobalReveal(contextRoot?: Element | string) {
|
export function initGlobalReveal(contextRoot?: Element | string) {
|
||||||
if (typeof window === 'undefined') return () => {};
|
if (typeof window === 'undefined') return () => {};
|
||||||
|
|||||||