feat: add LytsUpSection and update home page
This commit is contained in:
@@ -15,6 +15,7 @@ html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.lenis, html.lenis body {
|
||||
|
||||
@@ -37,6 +37,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Opt out of browser/OS auto "force dark" theming — the site is
|
||||
light-themed only and has no dark variant to invert into. */}
|
||||
<meta name="color-scheme" content="light" />
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossOrigin="anonymous" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import ProblemSection from '@/components/home/ProblemSection';
|
||||
import ShiftSection from '@/components/home/ShiftSection';
|
||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||
import ProductsSection from '@/components/home/ProductsSection';
|
||||
import LytsUpSection from '@/components/home/LytsUpSection';
|
||||
import TheLoop from '@/components/home/TheLoop';
|
||||
import initGlobalReveal from '@/utils/scrollReveal';
|
||||
|
||||
@@ -473,6 +474,7 @@ export default function IndexPage() {
|
||||
<ShiftSection isLoaded={imagesLoaded} />
|
||||
<WhatWeDo isLoaded={imagesLoaded} />
|
||||
<ProductsSection isLoaded={imagesLoaded} />
|
||||
<LytsUpSection />
|
||||
<TheLoop />
|
||||
</>
|
||||
);
|
||||
|
||||
359
src/components/home/LytsUpSection.tsx
Normal file
359
src/components/home/LytsUpSection.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
/* value: the numeric target the counter animates toward; display formats it once reached */
|
||||
const PROOF_STATS: { value: number; decimals?: number; suffix: string; label: string }[] = [
|
||||
{ value: 50, suffix: 'K+', label: 'Active users' },
|
||||
{ value: 4.8, decimals: 1, suffix: '★', label: 'App rating' },
|
||||
{ value: 1, decimals: 0, suffix: 'M+', label: 'Lyts earned' },
|
||||
];
|
||||
|
||||
/* Headline split into masked words so each line can slide up from behind a hidden overflow band */
|
||||
const HEADLINE_WORDS = ['Get', '__STICKER__', 'Before', 'You', 'Shop.'];
|
||||
|
||||
interface LytsUpSectionProps {
|
||||
isLoaded?: boolean;
|
||||
}
|
||||
|
||||
export default function LytsUpSection({ isLoaded = true }: LytsUpSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const statRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const ctaRef = useRef<HTMLAnchorElement>(null);
|
||||
const flowRef = useRef<HTMLDivElement>(null);
|
||||
const flowFillRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const flowNodeRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const flowActiveRef = useRef(0);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Headline: masked words slide up into place, sticker pops in with a rotate-back
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.lytsup-header', start: 'top 85%', toggleActions: 'play none none none' },
|
||||
});
|
||||
tl.fromTo('.lytsup-word-inner', { yPercent: 115, rotate: 4 }, { yPercent: 0, rotate: 0, duration: 0.85, stagger: 0.08, ease: 'expo.out' }, 0)
|
||||
.fromTo('.lytsup-sticker-wrap', { opacity: 0, scale: 0.4, rotate: 16 }, { opacity: 1, scale: 1, rotate: -4, duration: 0.7, ease: 'back.out(2.2)' }, 0.18)
|
||||
.fromTo('.lytsup-header-sub', { opacity: 0, y: 24, filter: 'blur(6px)' }, { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.8, ease: 'expo.out' }, 0.45)
|
||||
.fromTo('.lytsup-stat-chip', { opacity: 0, y: 18, scale: 0.85 }, { opacity: 1, y: 0, scale: 1, duration: 0.55, stagger: 0.1, ease: 'back.out(1.8)' }, 0.6);
|
||||
|
||||
// Stat numbers count up once the chips have landed
|
||||
PROOF_STATS.forEach((stat, i) => {
|
||||
const el = statRefs.current[i];
|
||||
if (!el) return;
|
||||
const counter = { val: 0 };
|
||||
tl.to(counter, {
|
||||
val: stat.value, duration: 1.1, ease: 'power2.out',
|
||||
onUpdate: () => { el.textContent = counter.val.toFixed(stat.decimals ?? 0) + stat.suffix; },
|
||||
}, 0.7 + i * 0.12);
|
||||
});
|
||||
|
||||
// Flow stepper: nodes pop in on entry, then the active step and the
|
||||
// connecting fills are driven entirely by scroll position (scrubbed).
|
||||
gsap.fromTo(
|
||||
'.lytsup-flow-node',
|
||||
{ opacity: 0, scale: 0.5 },
|
||||
{
|
||||
opacity: 1, scale: 1, duration: 0.6, stagger: 0.18, ease: 'back.out(2)',
|
||||
scrollTrigger: { trigger: '.lytsup-flow', start: 'top 88%', toggleActions: 'play none none none' },
|
||||
}
|
||||
);
|
||||
|
||||
const setNodeActive = (index: number, active: boolean) => {
|
||||
const el = flowNodeRefs.current[index];
|
||||
if (!el) return;
|
||||
gsap.to(el, {
|
||||
background: active ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
|
||||
borderColor: active ? '#171512' : 'rgba(244,190,40,0.35)',
|
||||
borderWidth: active ? '3px' : '1px',
|
||||
boxShadow: active ? '0 12px 26px -10px rgba(23,21,18,0.4)' : '0 0px 0px rgba(0,0,0,0)',
|
||||
duration: 0.35, ease: 'power2.out',
|
||||
});
|
||||
if (active) gsap.fromTo(el, { scale: 1.22 }, { scale: 1, duration: 0.45, ease: 'back.out(2.4)' });
|
||||
};
|
||||
|
||||
const applyActiveStep = (index: number) => {
|
||||
if (flowActiveRef.current === index) return;
|
||||
setNodeActive(flowActiveRef.current, false);
|
||||
setNodeActive(index, true);
|
||||
flowActiveRef.current = index;
|
||||
};
|
||||
|
||||
if (flowRef.current) {
|
||||
ScrollTrigger.create({
|
||||
trigger: flowRef.current,
|
||||
start: 'top 80%',
|
||||
end: 'bottom 40%',
|
||||
scrub: 0.5,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
const fill1 = gsap.utils.clamp(0, 1, p * 2);
|
||||
const fill2 = gsap.utils.clamp(0, 1, (p - 0.5) * 2);
|
||||
if (flowFillRefs.current[0]) gsap.set(flowFillRefs.current[0], { scaleX: fill1 });
|
||||
if (flowFillRefs.current[1]) gsap.set(flowFillRefs.current[1], { scaleX: fill2 });
|
||||
applyActiveStep(p >= 0.95 ? 2 : p >= 0.45 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Closing callout
|
||||
gsap.fromTo(
|
||||
'.lytsup-closing',
|
||||
{ opacity: 0, y: 24, scale: 0.98 },
|
||||
{
|
||||
opacity: 1, y: 0, scale: 1, duration: 0.8, ease: 'power3.out',
|
||||
scrollTrigger: { trigger: '.lytsup-closing', start: 'top 88%', toggleActions: 'play none none none' },
|
||||
}
|
||||
);
|
||||
|
||||
ScrollTrigger.refresh();
|
||||
}, sectionRef);
|
||||
|
||||
// Pinned sections above (e.g. the horizontal Products scroll) can resize
|
||||
// the document after mount and shift trigger positions; re-check once
|
||||
// things settle so this section's reveals can't get stuck off-position.
|
||||
const t = setTimeout(() => ScrollTrigger.refresh(), 500);
|
||||
|
||||
return () => { clearTimeout(t); ctx.revert(); };
|
||||
}, [isLoaded]);
|
||||
|
||||
// Magnetic CTA — button drifts slightly toward the cursor, snaps back on leave
|
||||
useEffect(() => {
|
||||
const btn = ctaRef.current;
|
||||
if (!btn || typeof window === 'undefined') return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const x = (e.clientX - r.left - r.width / 2) * 0.35;
|
||||
const y = (e.clientY - r.top - r.height / 2) * 0.35;
|
||||
gsap.to(btn, { x, y, duration: 0.4, ease: 'power2.out' });
|
||||
};
|
||||
const onLeave = () => gsap.to(btn, { x: 0, y: 0, duration: 0.5, ease: 'elastic.out(1, 0.4)' });
|
||||
btn.addEventListener('mousemove', onMove);
|
||||
btn.addEventListener('mouseleave', onLeave);
|
||||
return () => {
|
||||
btn.removeEventListener('mousemove', onMove);
|
||||
btn.removeEventListener('mouseleave', onLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="lytsup"
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
width: 'calc(100% - 40px)',
|
||||
margin: '0 20px 20px',
|
||||
borderRadius: 32,
|
||||
padding: 'clamp(64px, 8vw, 110px) clamp(20px, 4vw, 64px) clamp(64px, 8vw, 96px)',
|
||||
zIndex: 2,
|
||||
background: '#ffffff',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(23,21,18,0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Faint dot-grid texture for depth, matching the light sections elsewhere */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
backgroundImage: 'radial-gradient(rgba(23,21,18,0.06) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
maskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 75% 60% at 50% 25%, #000 0%, transparent 75%)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: '-10%', right: '-6%', width: 480, height: 480, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
|
||||
{/* Oversized watermark type — big-type Gen-Z background flex */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute', top: '2%', left: '50%', transform: 'translateX(-50%)',
|
||||
fontSize: 'clamp(6rem, 16vw, 13rem)', fontWeight: 900, letterSpacing: '-0.05em',
|
||||
color: 'rgba(23,21,18,0.035)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none', userSelect: 'none', zIndex: 0,
|
||||
}}
|
||||
>
|
||||
LYTSUP LYTSUP
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="lytsup-header relative z-10 text-center max-w-3xl mx-auto mb-12 flex flex-col items-center">
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 'clamp(8px, 2vw, 20px) 0',
|
||||
fontSize: 'clamp(2.4rem, 5.4vw, 4.4rem)',
|
||||
fontWeight: 800, color: '#171512',
|
||||
lineHeight: 1, letterSpacing: '-0.04em',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}
|
||||
>
|
||||
{HEADLINE_WORDS.map((word, i) =>
|
||||
word === '__STICKER__' ? (
|
||||
<span key={i} className="lytsup-sticker-wrap" style={{ display: 'inline-block', position: 'relative', margin: '0 0.18em' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
color: '#ffffff',
|
||||
background: 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)',
|
||||
padding: '0.02em 0.28em 0.06em',
|
||||
borderRadius: '0.3em',
|
||||
border: '4px solid #ffffff',
|
||||
outline: '3px solid #171512',
|
||||
boxShadow: '0 10px 22px -8px rgba(23,21,18,0.35)',
|
||||
}}
|
||||
>
|
||||
Lyts
|
||||
</span>
|
||||
<span className="lytsup-sticker-spark" aria-hidden style={{ position: 'absolute', top: '-0.4em', right: '-0.5em', fontSize: '0.42em' }}>⚡</span>
|
||||
</span>
|
||||
) : (
|
||||
<span key={i} className="lytsup-word" style={{ display: 'inline-block', overflow: 'hidden', verticalAlign: 'top', marginRight: '0.22em' }}>
|
||||
<span className="lytsup-word-inner" style={{ display: 'inline-block' }}>{word}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className="lytsup-header-sub"
|
||||
style={{
|
||||
margin: '22px 0 0', maxWidth: 620,
|
||||
fontSize: 'clamp(0.95rem, 1.15vw, 1.1rem)', lineHeight: 1.7,
|
||||
color: 'rgba(23,21,18,0.55)', fontFamily: 'Sora, sans-serif',
|
||||
}}
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Social-proof stat chips — numbers count up on reveal */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10, marginTop: 26 }}>
|
||||
{PROOF_STATS.map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="lytsup-stat-chip"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 16px', borderRadius: 100,
|
||||
background: 'rgba(244,190,40,0.08)', border: '1px solid rgba(244,190,40,0.28)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={(el) => { statRefs.current[i] = el; }}
|
||||
style={{ fontSize: '0.92rem', fontWeight: 800, color: '#171512', fontFamily: 'Sora, sans-serif', minWidth: '2.6em', display: 'inline-block' }}
|
||||
>
|
||||
0{stat.suffix}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif' }}>{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual flow — numbered stepper; active step + connectors are driven by scroll position */}
|
||||
<div ref={flowRef} className="lytsup-flow relative z-10 flex items-start justify-center gap-2 sm:gap-4 mb-16 sm:mb-20 max-w-xl mx-auto">
|
||||
{['Earn', 'Spend', 'Live More'].map((step, i) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="lytsup-flow-node" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, width: 108 }}>
|
||||
<span
|
||||
ref={(el) => { flowNodeRefs.current[i] = el; }}
|
||||
style={{
|
||||
width: 46, height: 46, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
|
||||
color: '#171512', boxSizing: 'border-box',
|
||||
background: i === 0 ? 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)' : 'rgba(244,190,40,0.1)',
|
||||
border: i === 0 ? '3px solid #171512' : '1px solid rgba(244,190,40,0.35)',
|
||||
boxShadow: i === 0 ? '0 12px 26px -10px rgba(23,21,18,0.4)' : 'none',
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 800, fontFamily: 'Sora, sans-serif', color: '#171512', letterSpacing: '-0.01em' }}>
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
{i < 2 && (
|
||||
<div style={{ position: 'relative', flex: 1, height: 2, marginTop: 23, borderRadius: 2, background: 'rgba(23,21,18,0.12)', overflow: 'hidden' }}>
|
||||
<div
|
||||
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)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Closing statement + CTA — dark contrast callout */}
|
||||
<div
|
||||
className="lytsup-closing relative z-10 mx-auto"
|
||||
style={{
|
||||
maxWidth: 720, textAlign: 'center',
|
||||
padding: 'clamp(36px, 4vw, 52px) clamp(24px, 4vw, 48px)',
|
||||
borderRadius: 26,
|
||||
background: '#171512',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ position: 'absolute', top: -18, right: 26, fontSize: 34, transform: 'rotate(12deg)' }}>🔥</span>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 70% 60% at 30% 0%, rgba(244,190,40,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 28px', position: 'relative',
|
||||
fontSize: 'clamp(1.05rem, 1.6vw, 1.4rem)', fontWeight: 600,
|
||||
lineHeight: 1.6, color: 'rgba(255,255,255,0.85)',
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
The more you live, the more you earn. Every step, every visit, and every adventure brings you closer to your next reward.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
ref={ctaRef}
|
||||
href="/contacts"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 10, position: 'relative',
|
||||
padding: '15px 34px', borderRadius: 100,
|
||||
fontSize: '1rem', fontWeight: 800, fontFamily: 'Sora, sans-serif',
|
||||
color: '#171512', textDecoration: 'none',
|
||||
background: 'linear-gradient(135deg, #f4be28 0%, #FF9800 100%)',
|
||||
boxShadow: '0 16px 40px -12px rgba(244,190,40,0.5)',
|
||||
}}
|
||||
>
|
||||
Download LytsUp
|
||||
<svg width="17" height="17" 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>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.lytsup-sticker-spark { display: inline-block; animation: lytsupSpark 2.2s ease-in-out infinite; }
|
||||
@keyframes lytsupSpark {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); }
|
||||
50% { transform: scale(1.25) rotate(-12deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lytsup-sticker-spark { animation: none; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.lytsup-flow { max-width: 380px !important; }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user