feat: implement solutions page, use cases, and update landing page sections including MagicBento, ScrollStack, and interactive components
BIN
public/problems/1.png
Normal file
|
After Width: | Height: | Size: 7.8 MiB |
BIN
public/problems/10.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/problems/2.png
Normal file
|
After Width: | Height: | Size: 9.3 MiB |
BIN
public/problems/3.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
public/problems/4.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
public/problems/5.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/problems/6.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/problems/7.png
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
public/problems/8.png
Normal file
|
After Width: | Height: | Size: 7.6 MiB |
BIN
public/problems/9.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/retail_cycle/01_Earn_online.png
Normal file
|
After Width: | Height: | Size: 16 MiB |
BIN
public/retail_cycle/02_unlock_deals.png
Normal file
|
After Width: | Height: | Size: 17 MiB |
BIN
public/retail_cycle/03_discover+visits.png
Normal file
|
After Width: | Height: | Size: 19 MiB |
BIN
public/retail_cycle/04_redeem+repeat.png
Normal file
|
After Width: | Height: | Size: 16 MiB |
BIN
public/shift/1.png
Normal file
|
After Width: | Height: | Size: 7.9 MiB |
BIN
public/shift/2.png
Normal file
|
After Width: | Height: | Size: 8.2 MiB |
BIN
public/shift/3.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
public/shift/4.png
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/shift/5.png
Normal file
|
After Width: | Height: | Size: 7.3 MiB |
BIN
public/shift/6.png
Normal file
|
After Width: | Height: | Size: 8.1 MiB |
BIN
public/shift/7.png
Normal file
|
After Width: | Height: | Size: 11 MiB |
BIN
public/shift/8.png
Normal file
|
After Width: | Height: | Size: 7.5 MiB |
@@ -55,7 +55,7 @@ export default function ContactsPage() {
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 12, marginTop: 44 }}>
|
||||
{[
|
||||
{ label: 'loyalydigital@gmail.com', href: 'mailto:loyalydigital@gmail.com' },
|
||||
{ label: 'Coimbatore, TN', href: undefined },
|
||||
{ label: 'Hyderabad, TG', href: undefined },
|
||||
].map((c) => {
|
||||
const inner = (
|
||||
<span style={{
|
||||
|
||||
@@ -10,11 +10,11 @@ const FAQS = [
|
||||
category: 'For Customers',
|
||||
color: '#f4be28',
|
||||
items: [
|
||||
{ q: 'How do I earn Loyaly Coins?', a: 'You earn coins through daily app login (+50 coins), completing 5,000-step walking challenges (+80 coins), playing interactive games like Maze, Spin Wheel, Scratch Card, and Match Master, and visiting Selfie Booths at partner malls.' },
|
||||
{ q: 'How much is 1 Loyaly Coin worth?', a: '1 Loyaly Coin = ₹1. So 284 coins = ₹284 redeemable value at any participating offline store.' },
|
||||
{ q: 'Where can I redeem my coins?', a: 'You can redeem Loyaly Coins at any participating offline store near you. Open the app, browse nearby stores, visit in person, and use your coins or coupons at checkout.' },
|
||||
{ q: 'How do I earn LYT?', a: 'You earn LYT through daily app login (+50 LYT), completing 5,000-step walking challenges (+80 LYT), playing interactive games like Maze, Spin Wheel, Scratch Card, and Match Master, and visiting Selfie Booths at partner malls.' },
|
||||
{ q: 'How much is 1 LYT worth?', a: '1 LYT = ₹1. So 284 LYT = ₹284 redeemable value at any participating offline store.' },
|
||||
{ q: 'Where can I redeem my LYT?', a: 'You can redeem LYT at any participating offline store near you. Open the app, browse nearby stores, visit in person, and use your LYT or coupons at checkout.' },
|
||||
{ q: 'What is the Battery Challenge?', a: 'The Battery Challenge is a daily surprise coupon based on your phone\'s battery percentage when you open the app. Every day brings a different discount — flat off, BOGO, festival specials, and more.' },
|
||||
{ q: 'Do my coins expire?', a: 'Loyaly Coins are valid as long as your account is active. We recommend redeeming regularly to enjoy maximum savings.' },
|
||||
{ q: 'Does my LYT expire?', a: 'LYT is valid as long as your account is active. We recommend redeeming regularly to enjoy maximum savings.' },
|
||||
{ q: 'How do I sign up?', a: 'Download the Loyaly app, sign up with your mobile number, and you\'re ready to start earning on day one. No credit card needed.' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function IndexPage() {
|
||||
<span className="hero-gradient-text">Loyal Customer</span>
|
||||
</h1>
|
||||
<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 coins through games and daily activities, then redeem them at offline stores near them.
|
||||
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn LYT 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">
|
||||
@@ -445,7 +445,7 @@ export default function IndexPage() {
|
||||
<span className="hero-gradient-text">Loyal Customer</span>
|
||||
</h1>
|
||||
<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 coins through games and daily activities, then redeem them at offline stores near them.
|
||||
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn LYT 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">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import GSAPAnimator from '@/components/GSAPAnimator';
|
||||
import { useReveal } from '@/hooks/useReveal';
|
||||
import ScrollStack, { ScrollStackItem } from '@/components/ScrollStack';
|
||||
|
||||
function RevealBlock({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
||||
const { ref, visible } = useReveal();
|
||||
@@ -17,6 +18,53 @@ function RevealBlock({ children, delay = 0 }: { children: React.ReactNode; delay
|
||||
);
|
||||
}
|
||||
|
||||
const PRODUCTS = [
|
||||
{
|
||||
n: '01', title: 'Behavision', subtitle: 'AI Powered Footfall Analytics', color: '#f4be28',
|
||||
desc: 'AI powered footfall analytics and behavioral intelligence for physical retail. Behavision turns every camera and sensor you already have into a live map of how customers move, where they linger, and what makes them walk away, so store layout and staffing decisions are based on evidence, not guesswork.',
|
||||
capabilities: [
|
||||
'Real-time footfall counting, accurate to 98% across every entry point',
|
||||
'Dwell-time and zone heatmaps that show exactly where attention concentrates',
|
||||
'Entry-to-purchase funnel tracking that flags where shoppers drop off',
|
||||
],
|
||||
metric: { val: '3.2×', lbl: 'conversion uplift' },
|
||||
tags: ['Footfall Tracking', 'Heatmaps', 'Behavior Analysis'],
|
||||
},
|
||||
{
|
||||
n: '02', title: 'SpendSense', subtitle: 'Customer Spending Intelligence', color: '#10B981',
|
||||
desc: 'Deep, continuously updating intelligence on how your customers spend. SpendSense studies basket size, purchase frequency, and category preferences across every visit, surfacing the high-value segments and the early warning signs of churn well before a customer stops coming back.',
|
||||
capabilities: [
|
||||
'Automatic customer segmentation by spend, frequency, and loyalty tier',
|
||||
'Predictive churn scoring that flags at-risk high-value shoppers early',
|
||||
'Revenue opportunity alerts triggered by shifting basket-size trends',
|
||||
],
|
||||
metric: { val: '68%', lbl: 'retention improvement' },
|
||||
tags: ['Spending Trends', 'Revenue Analytics', 'Customer Value'],
|
||||
},
|
||||
{
|
||||
n: '03', title: 'Dyscount', subtitle: 'Smart Discount Engine', color: '#EF4444',
|
||||
desc: 'A discount engine that treats margin as a budget, not an afterthought. Dyscount decides who gets an offer, how deep it goes, and when it fires, targeting the customers most likely to convert while capping every discount so promotions never quietly eat into profit.',
|
||||
capabilities: [
|
||||
'Dynamic offer targeting based on real-time purchase intent signals',
|
||||
'Margin-safe discount caps enforced per product, category, or campaign',
|
||||
'A/B tested offer formats that keep conversion up without overspending',
|
||||
],
|
||||
metric: { val: '41%', lbl: 'discount waste reduced' },
|
||||
tags: ['Smart Discounts', 'Offer Automation', 'Margin Protection'],
|
||||
},
|
||||
{
|
||||
n: '04', title: 'Identiq', subtitle: 'Consumer Loyalty Platform', color: '#f4be28',
|
||||
desc: 'The consumer-facing app that closes the loop. Identiq turns daily logins, walking challenges, and quick mini-games into LYT, Loyaly’s reward token, redeemable instantly at any partner store, so every idle moment on a phone becomes a reason to walk into a real one.',
|
||||
capabilities: [
|
||||
'Daily login and step-challenge rewards that build a lasting habit loop',
|
||||
'Game based offers like Spin Wheel, Scratch Card, and Maze Challenge',
|
||||
'Instant offline redemption at any partner store, no card required',
|
||||
],
|
||||
metric: { val: '50K+', lbl: 'active shoppers' },
|
||||
tags: ['Daily Rewards', 'Game Based Offers', 'Offline Redemption'],
|
||||
},
|
||||
];
|
||||
|
||||
const CAPS = [
|
||||
{
|
||||
n: '01', title: 'Merchant Campaign Platform', color: '#f4be28',
|
||||
@@ -139,6 +187,83 @@ export default function SolutionsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── OUR PRODUCTS ── */}
|
||||
<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' }} />
|
||||
<RevealBlock>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20, position: 'relative' }}>Our Products</div>
|
||||
<h2 style={{ fontSize: 'clamp(36px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif', position: 'relative', maxWidth: 700 }}>
|
||||
Four products.<br />One ecosystem.
|
||||
</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' }}>
|
||||
Each product solves a distinct part of the retail journey, and together they form one connected platform, from footfall to spend to loyalty.
|
||||
</p>
|
||||
</RevealBlock>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ScrollStack useWindowScroll itemDistance={160} itemScale={0.035} itemStackDistance={40} baseScale={0.86} blurAmount={1.2}>
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<ScrollStackItem key={i}>
|
||||
<div style={{
|
||||
background: `linear-gradient(155deg, #14141c 0%, #0c0c12 100%)`,
|
||||
border: `1px solid ${p.color}40`,
|
||||
borderRadius: 28, padding: 'clamp(28px, 3.2vw, 48px)', height: '100%', boxSizing: 'border-box',
|
||||
}}>
|
||||
<div className="sp-product-grid" style={{ display: 'grid', gridTemplateColumns: '1.35fr 1fr', gap: 'clamp(28px, 4vw, 56px)', alignItems: 'start' }}>
|
||||
|
||||
{/* Left — identity + description */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '0.05em' }}>{p.n}</span>
|
||||
<span style={{ width: 24, height: 1, background: `${p.color}66`, display: 'block' }} />
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: p.color, fontFamily: 'Sora, sans-serif' }}>{p.subtitle}</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 'clamp(1.8rem, 2.8vw, 2.8rem)', fontWeight: 800, letterSpacing: '-0.03em', color: '#fff', margin: '0 0 16px', fontFamily: 'Sora, sans-serif' }}>{p.title}</h3>
|
||||
<p style={{ fontSize: 15, lineHeight: 1.75, color: 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif', margin: '0 0 24px', maxWidth: 480 }}>{p.desc}</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{p.tags.map((tag) => (
|
||||
<span key={tag} style={{
|
||||
padding: '6px 14px', borderRadius: 100, fontSize: '0.72rem', fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.7)', background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)', fontFamily: 'Sora, sans-serif',
|
||||
}}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — metric + capabilities */}
|
||||
<div>
|
||||
<div style={{
|
||||
padding: 'clamp(18px, 2vw, 24px)', borderRadius: 18, marginBottom: 18,
|
||||
background: `${p.color}12`, border: `1px solid ${p.color}35`,
|
||||
}}>
|
||||
<div style={{ fontSize: 'clamp(1.8rem, 2.6vw, 2.4rem)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', lineHeight: 1 }}>{p.metric.val}</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{p.metric.lbl}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{p.capabilities.map((cap) => (
|
||||
<div key={cap} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: '50%', flexShrink: 0, marginTop: 2,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: `${p.color}18`, border: `1px solid ${p.color}45`,
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke={p.color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, lineHeight: 1.55, color: 'rgba(255,255,255,0.65)', fontFamily: 'Sora, sans-serif' }}>{cap}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollStackItem>
|
||||
))}
|
||||
</ScrollStack>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── WHY IT MATTERS ── */}
|
||||
<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' }} />
|
||||
@@ -192,6 +317,7 @@ export default function SolutionsPage() {
|
||||
.sp-cap-row { grid-template-columns: 1fr !important; gap: 14px !important; padding: 28px 0 !important; }
|
||||
.sp-cap-row > div:last-child { justify-content: flex-start !important; }
|
||||
.sp-cta-grid { grid-template-columns: 1fr !important; gap: 40px !important; }
|
||||
.sp-product-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.sp-cta-grid > div > div { grid-template-columns: 1fr !important; }
|
||||
|
||||
@@ -5,30 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import GSAPAnimator from '@/components/GSAPAnimator';
|
||||
import { useReveal } from '@/hooks/useReveal';
|
||||
|
||||
const PRODUCTS = [
|
||||
{
|
||||
name: 'Behavision',
|
||||
tag: 'Footfall AI',
|
||||
desc: 'Track footfall, measure conversions, and understand in-store behavior with precision heatmaps and dwell-time analytics.',
|
||||
metric: '3.2×', metricLabel: 'conversion uplift',
|
||||
color: '#f4be28',
|
||||
},
|
||||
{
|
||||
name: 'SpendSense',
|
||||
tag: 'Spend Intelligence',
|
||||
desc: 'Analyze spending patterns and identify high-value customers before your competition does.',
|
||||
metric: '68%', metricLabel: 'retention improvement',
|
||||
color: '#a78bfa',
|
||||
},
|
||||
{
|
||||
name: 'Dyscount',
|
||||
tag: 'Smart Discounts',
|
||||
desc: 'Deliver targeted discounts that actually convert, without killing your margins.',
|
||||
metric: '41%', metricLabel: 'discount waste reduced',
|
||||
color: '#34d399',
|
||||
},
|
||||
];
|
||||
import ProductsScrollSection from '@/components/use-cases/ProductsScrollSection';
|
||||
|
||||
const OUTCOMES = [
|
||||
{ stat: '3.2×', label: 'Conversion rate increase' },
|
||||
@@ -70,7 +47,6 @@ function RevealBlock({ children, delay = 0, className = '' }: { children: React.
|
||||
}
|
||||
|
||||
export default function ForBusinessPage() {
|
||||
const [activeProduct, setActiveProduct] = useState(0);
|
||||
const [countVisible, setCountVisible] = useState(false);
|
||||
const statsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -184,60 +160,7 @@ export default function ForBusinessPage() {
|
||||
</section>
|
||||
|
||||
{/* ── PRODUCTS ── */}
|
||||
<section style={{ padding: '140px 8%', background: '#07070d', margin: '0 20px 20px', borderRadius: 24 }}>
|
||||
<RevealBlock>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 20 }}>Our Products</div>
|
||||
<h2 style={{ fontSize: 'clamp(40px, 5.5vw, 80px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.92, color: '#fff', margin: '0 0 80px', fontFamily: 'Sora, sans-serif', maxWidth: 700 }}>
|
||||
AI Powered Retail Intelligence.
|
||||
</h2>
|
||||
</RevealBlock>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 48, background: 'rgba(255,255,255,0.04)', borderRadius: 100, padding: 4, width: 'fit-content', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<button key={i} onClick={() => setActiveProduct(i)} style={{
|
||||
padding: '10px 24px', borderRadius: 100, border: 'none', cursor: 'pointer',
|
||||
background: activeProduct === i ? p.color : 'transparent',
|
||||
color: activeProduct === i ? '#111' : 'rgba(255,255,255,0.45)',
|
||||
fontSize: 14, fontWeight: 700, fontFamily: 'Sora, sans-serif',
|
||||
transition: 'all 0.3s cubic-bezier(0.22,1,0.36,1)',
|
||||
}}>{p.name}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Product detail */}
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<div key={i} className="uc-product-grid" style={{
|
||||
display: activeProduct === i ? 'grid' : 'none',
|
||||
gridTemplateColumns: '1fr 1fr', gap: '6vw', alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'inline-block', fontSize: 11, fontWeight: 700, letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase', color: p.color, fontFamily: 'Sora, sans-serif',
|
||||
marginBottom: 20, background: `${p.color}15`, padding: '6px 14px', borderRadius: 100,
|
||||
border: `1px solid ${p.color}30`,
|
||||
}}>{p.tag}</div>
|
||||
<h3 style={{ fontSize: 'clamp(32px, 4vw, 60px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 24px', fontFamily: 'Sora, sans-serif' }}>{p.name}</h3>
|
||||
<p style={{ fontSize: 18, lineHeight: 1.75, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 40px' }}>{p.desc}</p>
|
||||
<Link href="/contacts" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '14px 28px', background: p.color, color: '#111',
|
||||
fontSize: 14, fontWeight: 800, borderRadius: 100, textDecoration: 'none',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>Learn More →</Link>
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.03)', border: `1px solid ${p.color}20`,
|
||||
borderRadius: 24, padding: '48px', textAlign: 'center',
|
||||
boxShadow: `0 0 80px ${p.color}08`,
|
||||
}}>
|
||||
<div style={{ fontSize: 'clamp(60px, 9vw, 110px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.05em', lineHeight: 1 }}>{p.metric}</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', marginTop: 12, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{p.metricLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<ProductsScrollSection />
|
||||
|
||||
{/* ── OUTCOMES ── */}
|
||||
<section ref={statsRef} style={{ padding: '140px 8%', background: '#fff', margin: '0 20px 20px', borderRadius: 24 }}>
|
||||
@@ -314,7 +237,6 @@ export default function ForBusinessPage() {
|
||||
@keyframes floatY { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-12px)} }
|
||||
@media (max-width: 860px) {
|
||||
.uc-challenge-grid { grid-template-columns: 1fr !important; gap: 40px !important; }
|
||||
.uc-product-grid { grid-template-columns: 1fr !important; gap: 40px !important; }
|
||||
.uc-outcomes-grid { grid-template-columns: repeat(2,1fr) !important; }
|
||||
.uc-outcomes-grid > div > div { border-radius: 16px !important; }
|
||||
.uc-who-grid { grid-template-columns: repeat(2,1fr) !important; }
|
||||
|
||||
@@ -10,7 +10,7 @@ const NAV = [
|
||||
links: [
|
||||
{ label: 'For People', href: '/for_people' },
|
||||
{ label: 'For Business', href: '/use_cases' },
|
||||
{ label: 'Solutions', href: '/solutions_page' },
|
||||
{ label: 'Meet the Crew', href: '/solutions_page' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -24,7 +24,7 @@ const NAV = [
|
||||
{
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'Meet the Crew', href: '/about_us' },
|
||||
{ label: 'About us', href: '/about_us' },
|
||||
{ label: 'Market Insights', href: '/blog_main' },
|
||||
{ label: 'Contact Us', href: '/contacts' },
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ type FormState = 'idle' | 'sending' | 'success' | 'error';
|
||||
const DETAILS = [
|
||||
{
|
||||
label: 'Visit us',
|
||||
value: <>No.424, Red Rose Towers,<br />DB Road, RS Puram,<br />Coimbatore – 641002</>,
|
||||
value: <>5th Floor, Vision Ultima,<br />Street No.3, Jayabheri Enclave,<br />Gachibowli, Hyderabad, Telangana 500032</>,
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" /><circle cx="12" cy="10" r="3" />
|
||||
|
||||
@@ -9,9 +9,9 @@ const NAV_LINKS = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'For People', href: '/for_people' },
|
||||
{ label: 'For Business', href: '/use_cases' },
|
||||
{ label: 'Solutions', href: '/solutions_page' },
|
||||
{ label: 'Meet the Crew', href: '/solutions_page' },
|
||||
{ label: 'Blogs', href: '/blog_main' },
|
||||
{ label: 'Meet the Crew', href: '/about_us' },
|
||||
{ label: 'About us', href: '/about_us' },
|
||||
];
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023px)';
|
||||
@@ -155,7 +155,7 @@ export default function Header() {
|
||||
<p style={{ margin: '0 0 4px', fontWeight: 700, textTransform: 'uppercase', fontSize: '11px', letterSpacing: '0.08em', color: '#f4be28' }}>
|
||||
Contacts
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>No.424, Red Rose Towers,<br />DB Road, RS Puram<br />Coimbatore – 641002</p>
|
||||
<p style={{ margin: 0 }}>5th Floor, Vision Ultima,<br />Street No.3, Jayabheri Enclave,<br />Gachibowli, Hyderabad, Telangana 500032</p>
|
||||
<a href="mailto:loyalydigital@gmail.com" style={{ color: 'rgba(255,255,255,0.8)', textDecoration: 'none', display: 'block', marginTop: '8px' }}>
|
||||
loyalydigital@gmail.com
|
||||
</a>
|
||||
|
||||
174
src/components/MagicBento.css
Normal file
@@ -0,0 +1,174 @@
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: minmax(120px, auto);
|
||||
gap: clamp(10px, 1.4vw, 16px);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
font-family: 'Sora', sans-serif;
|
||||
}
|
||||
|
||||
/* Asymmetric bento layout for exactly 5 cards, desktop only */
|
||||
@media (min-width: 900px) {
|
||||
.card-grid > *:nth-child(1) { grid-column: 1; grid-row: 1; }
|
||||
.card-grid > *:nth-child(2) { grid-column: 2; grid-row: 1; }
|
||||
.card-grid > *:nth-child(3) { grid-column: 3 / span 2; grid-row: 1 / span 2; }
|
||||
.card-grid > *:nth-child(4) { grid-column: 1 / span 2; grid-row: 2 / span 2; }
|
||||
.card-grid > *:nth-child(5) { grid-column: 3 / span 2; grid-row: 3; }
|
||||
}
|
||||
|
||||
.magic-bento-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
min-height: 170px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: clamp(22px, 2.4vw, 32px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
font-weight: 300;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
--glow-x: 50%;
|
||||
--glow-y: 50%;
|
||||
--glow-intensity: 0;
|
||||
--glow-radius: 200px;
|
||||
}
|
||||
|
||||
.magic-bento-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.magic-bento-card__header,
|
||||
.magic-bento-card__content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.magic-bento-card__header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.magic-bento-card__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.magic-bento-card__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgb(var(--glow-color));
|
||||
}
|
||||
|
||||
.magic-bento-card__title {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.magic-bento-card__description {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__title,
|
||||
.magic-bento-card--text-autohide .magic-bento-card__description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__title {
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
|
||||
.magic-bento-card--text-autohide .magic-bento-card__description {
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Border glow effect, follows the cursor */
|
||||
.magic-bento-card--border-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1.5px;
|
||||
background: radial-gradient(
|
||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||
rgba(var(--glow-color), calc(var(--glow-intensity) * 0.9)) 0%,
|
||||
rgba(var(--glow-color), calc(var(--glow-intensity) * 0.45)) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
border-radius: inherit;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.magic-bento-card--border-glow:hover {
|
||||
box-shadow: 0 24px 48px -20px rgba(0, 0, 0, 0.6), 0 0 30px rgba(var(--glow-color), 0.25);
|
||||
}
|
||||
|
||||
.particle-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: rgba(var(--glow-color), 0.2);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.global-spotlight {
|
||||
mix-blend-mode: screen;
|
||||
will-change: transform, opacity;
|
||||
z-index: 200 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bento-section {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
529
src/components/MagicBento.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import './MagicBento.css';
|
||||
|
||||
const DEFAULT_PARTICLE_COUNT = 12;
|
||||
const DEFAULT_SPOTLIGHT_RADIUS = 300;
|
||||
const DEFAULT_GLOW_COLOR = '132, 0, 255';
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export interface BentoCardItem {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const createParticleElement = (x: number, y: number, color = DEFAULT_GLOW_COLOR) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'particle';
|
||||
el.style.cssText = `
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(${color}, 1);
|
||||
box-shadow: 0 0 6px rgba(${color}, 0.6);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
`;
|
||||
return el;
|
||||
};
|
||||
|
||||
const calculateSpotlightValues = (radius: number) => ({
|
||||
proximity: radius * 0.5,
|
||||
fadeDistance: radius * 0.75,
|
||||
});
|
||||
|
||||
const updateCardGlowProperties = (card: HTMLElement, mouseX: number, mouseY: number, glow: number, radius: number) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const relativeX = ((mouseX - rect.left) / rect.width) * 100;
|
||||
const relativeY = ((mouseY - rect.top) / rect.height) * 100;
|
||||
|
||||
card.style.setProperty('--glow-x', `${relativeX}%`);
|
||||
card.style.setProperty('--glow-y', `${relativeY}%`);
|
||||
card.style.setProperty('--glow-intensity', glow.toString());
|
||||
card.style.setProperty('--glow-radius', `${radius}px`);
|
||||
};
|
||||
|
||||
interface ParticleCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disableAnimations?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
particleCount?: number;
|
||||
glowColor?: string;
|
||||
enableTilt?: boolean;
|
||||
clickEffect?: boolean;
|
||||
enableMagnetism?: boolean;
|
||||
}
|
||||
|
||||
export const ParticleCard = ({
|
||||
children,
|
||||
className = '',
|
||||
disableAnimations = false,
|
||||
style,
|
||||
particleCount = DEFAULT_PARTICLE_COUNT,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
enableTilt = true,
|
||||
clickEffect = false,
|
||||
enableMagnetism = false,
|
||||
}: ParticleCardProps) => {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const particlesRef = useRef<HTMLDivElement[]>([]);
|
||||
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const isHoveredRef = useRef(false);
|
||||
const memoizedParticles = useRef<HTMLDivElement[]>([]);
|
||||
const particlesInitialized = useRef(false);
|
||||
const magnetismAnimationRef = useRef<gsap.core.Tween | null>(null);
|
||||
|
||||
const initializeParticles = useCallback(() => {
|
||||
if (particlesInitialized.current || !cardRef.current) return;
|
||||
|
||||
const { width, height } = cardRef.current.getBoundingClientRect();
|
||||
memoizedParticles.current = Array.from({ length: particleCount }, () =>
|
||||
createParticleElement(Math.random() * width, Math.random() * height, glowColor)
|
||||
);
|
||||
particlesInitialized.current = true;
|
||||
}, [particleCount, glowColor]);
|
||||
|
||||
const clearAllParticles = useCallback(() => {
|
||||
timeoutsRef.current.forEach(clearTimeout);
|
||||
timeoutsRef.current = [];
|
||||
magnetismAnimationRef.current?.kill();
|
||||
|
||||
particlesRef.current.forEach(particle => {
|
||||
gsap.to(particle, {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: 'back.in(1.7)',
|
||||
onComplete: () => {
|
||||
particle.parentNode?.removeChild(particle);
|
||||
},
|
||||
});
|
||||
});
|
||||
particlesRef.current = [];
|
||||
}, []);
|
||||
|
||||
const animateParticles = useCallback(() => {
|
||||
if (!cardRef.current || !isHoveredRef.current) return;
|
||||
|
||||
if (!particlesInitialized.current) {
|
||||
initializeParticles();
|
||||
}
|
||||
|
||||
memoizedParticles.current.forEach((particle, index) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!isHoveredRef.current || !cardRef.current) return;
|
||||
|
||||
const clone = particle.cloneNode(true) as HTMLDivElement;
|
||||
cardRef.current.appendChild(clone);
|
||||
particlesRef.current.push(clone);
|
||||
|
||||
gsap.fromTo(clone, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)' });
|
||||
|
||||
gsap.to(clone, {
|
||||
x: (Math.random() - 0.5) * 100,
|
||||
y: (Math.random() - 0.5) * 100,
|
||||
rotation: Math.random() * 360,
|
||||
duration: 2 + Math.random() * 2,
|
||||
ease: 'none',
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
});
|
||||
|
||||
gsap.to(clone, {
|
||||
opacity: 0.3,
|
||||
duration: 1.5,
|
||||
ease: 'power2.inOut',
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
});
|
||||
}, index * 100);
|
||||
|
||||
timeoutsRef.current.push(timeoutId);
|
||||
});
|
||||
}, [initializeParticles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAnimations || !cardRef.current) return;
|
||||
|
||||
const element = cardRef.current;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHoveredRef.current = true;
|
||||
animateParticles();
|
||||
|
||||
if (enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 5,
|
||||
rotateY: 5,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out',
|
||||
transformPerspective: 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.current = false;
|
||||
clearAllParticles();
|
||||
|
||||
if (enableTilt) {
|
||||
gsap.to(element, {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
gsap.to(element, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!enableTilt && !enableMagnetism) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
if (enableTilt) {
|
||||
const rotateX = ((y - centerY) / centerY) * -10;
|
||||
const rotateY = ((x - centerX) / centerX) * 10;
|
||||
|
||||
gsap.to(element, {
|
||||
rotateX,
|
||||
rotateY,
|
||||
duration: 0.1,
|
||||
ease: 'power2.out',
|
||||
transformPerspective: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMagnetism) {
|
||||
const magnetX = (x - centerX) * 0.05;
|
||||
const magnetY = (y - centerY) * 0.05;
|
||||
|
||||
magnetismAnimationRef.current = gsap.to(element, {
|
||||
x: magnetX,
|
||||
y: magnetY,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!clickEffect) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const maxDistance = Math.max(
|
||||
Math.hypot(x, y),
|
||||
Math.hypot(x - rect.width, y),
|
||||
Math.hypot(x, y - rect.height),
|
||||
Math.hypot(x - rect.width, y - rect.height)
|
||||
);
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${maxDistance * 2}px;
|
||||
height: ${maxDistance * 2}px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
|
||||
left: ${x - maxDistance}px;
|
||||
top: ${y - maxDistance}px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
element.appendChild(ripple);
|
||||
|
||||
gsap.fromTo(
|
||||
ripple,
|
||||
{ scale: 0, opacity: 1 },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => ripple.remove(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', handleMouseEnter);
|
||||
element.addEventListener('mouseleave', handleMouseLeave);
|
||||
element.addEventListener('mousemove', handleMouseMove);
|
||||
element.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
isHoveredRef.current = false;
|
||||
element.removeEventListener('mouseenter', handleMouseEnter);
|
||||
element.removeEventListener('mouseleave', handleMouseLeave);
|
||||
element.removeEventListener('mousemove', handleMouseMove);
|
||||
element.removeEventListener('click', handleClick);
|
||||
clearAllParticles();
|
||||
};
|
||||
}, [animateParticles, clearAllParticles, disableAnimations, enableTilt, enableMagnetism, clickEffect, glowColor]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${className} particle-container`}
|
||||
style={{ position: 'relative', overflow: 'hidden', ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GlobalSpotlightProps {
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
disableAnimations?: boolean;
|
||||
enabled?: boolean;
|
||||
spotlightRadius?: number;
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
const GlobalSpotlight = ({
|
||||
gridRef,
|
||||
disableAnimations = false,
|
||||
enabled = true,
|
||||
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
}: GlobalSpotlightProps) => {
|
||||
const spotlightRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAnimations || !gridRef?.current || !enabled) return;
|
||||
|
||||
const spotlight = document.createElement('div');
|
||||
spotlight.className = 'global-spotlight';
|
||||
spotlight.style.cssText = `
|
||||
position: fixed;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle,
|
||||
rgba(${glowColor}, 0.15) 0%,
|
||||
rgba(${glowColor}, 0.08) 15%,
|
||||
rgba(${glowColor}, 0.04) 25%,
|
||||
rgba(${glowColor}, 0.02) 40%,
|
||||
rgba(${glowColor}, 0.01) 65%,
|
||||
transparent 70%
|
||||
);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
mix-blend-mode: screen;
|
||||
`;
|
||||
document.body.appendChild(spotlight);
|
||||
spotlightRef.current = spotlight;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!spotlightRef.current || !gridRef.current) return;
|
||||
|
||||
const section = gridRef.current.closest('.bento-section');
|
||||
const rect = section?.getBoundingClientRect();
|
||||
const mouseInside =
|
||||
rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
|
||||
const cards = gridRef.current.querySelectorAll<HTMLElement>('.magic-bento-card');
|
||||
|
||||
if (!mouseInside) {
|
||||
gsap.to(spotlightRef.current, { opacity: 0, duration: 0.3, ease: 'power2.out' });
|
||||
cards.forEach(card => card.style.setProperty('--glow-intensity', '0'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { proximity, fadeDistance } = calculateSpotlightValues(spotlightRadius);
|
||||
let minDistance = Infinity;
|
||||
|
||||
cards.forEach(card => {
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const centerX = cardRect.left + cardRect.width / 2;
|
||||
const centerY = cardRect.top + cardRect.height / 2;
|
||||
const distance =
|
||||
Math.hypot(e.clientX - centerX, e.clientY - centerY) - Math.max(cardRect.width, cardRect.height) / 2;
|
||||
const effectiveDistance = Math.max(0, distance);
|
||||
|
||||
minDistance = Math.min(minDistance, effectiveDistance);
|
||||
|
||||
let glowIntensity = 0;
|
||||
if (effectiveDistance <= proximity) {
|
||||
glowIntensity = 1;
|
||||
} else if (effectiveDistance <= fadeDistance) {
|
||||
glowIntensity = (fadeDistance - effectiveDistance) / (fadeDistance - proximity);
|
||||
}
|
||||
|
||||
updateCardGlowProperties(card, e.clientX, e.clientY, glowIntensity, spotlightRadius);
|
||||
});
|
||||
|
||||
gsap.to(spotlightRef.current, { left: e.clientX, top: e.clientY, duration: 0.1, ease: 'power2.out' });
|
||||
|
||||
const targetOpacity =
|
||||
minDistance <= proximity
|
||||
? 0.8
|
||||
: minDistance <= fadeDistance
|
||||
? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8
|
||||
: 0;
|
||||
|
||||
gsap.to(spotlightRef.current, {
|
||||
opacity: targetOpacity,
|
||||
duration: targetOpacity > 0 ? 0.2 : 0.5,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
gridRef.current?.querySelectorAll<HTMLElement>('.magic-bento-card').forEach(card => {
|
||||
card.style.setProperty('--glow-intensity', '0');
|
||||
});
|
||||
if (spotlightRef.current) {
|
||||
gsap.to(spotlightRef.current, { opacity: 0, duration: 0.3, ease: 'power2.out' });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
spotlightRef.current?.parentNode?.removeChild(spotlightRef.current);
|
||||
};
|
||||
}, [gridRef, disableAnimations, enabled, spotlightRadius, glowColor]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useMobileDetection = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
|
||||
export interface MagicBentoProps {
|
||||
items: BentoCardItem[];
|
||||
textAutoHide?: boolean;
|
||||
enableStars?: boolean;
|
||||
enableSpotlight?: boolean;
|
||||
enableBorderGlow?: boolean;
|
||||
disableAnimations?: boolean;
|
||||
spotlightRadius?: number;
|
||||
particleCount?: number;
|
||||
enableTilt?: boolean;
|
||||
glowColor?: string;
|
||||
clickEffect?: boolean;
|
||||
enableMagnetism?: boolean;
|
||||
cardBackground?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MagicBento({
|
||||
items,
|
||||
textAutoHide = true,
|
||||
enableStars = true,
|
||||
enableSpotlight = true,
|
||||
enableBorderGlow = true,
|
||||
disableAnimations = false,
|
||||
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
||||
particleCount = DEFAULT_PARTICLE_COUNT,
|
||||
enableTilt = false,
|
||||
glowColor = DEFAULT_GLOW_COLOR,
|
||||
clickEffect = true,
|
||||
enableMagnetism = true,
|
||||
cardBackground = '#120F17',
|
||||
className = '',
|
||||
}: MagicBentoProps) {
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobileDetection();
|
||||
const shouldDisableAnimations = disableAnimations || isMobile;
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableSpotlight && (
|
||||
<GlobalSpotlight
|
||||
gridRef={gridRef}
|
||||
disableAnimations={shouldDisableAnimations}
|
||||
enabled={enableSpotlight}
|
||||
spotlightRadius={spotlightRadius}
|
||||
glowColor={glowColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`card-grid bento-section ${className}`} ref={gridRef}>
|
||||
{items.map((card, index) => {
|
||||
const baseClassName = `magic-bento-card ${textAutoHide ? 'magic-bento-card--text-autohide' : ''} ${enableBorderGlow ? 'magic-bento-card--border-glow' : ''}`;
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: cardBackground,
|
||||
['--glow-color' as string]: glowColor,
|
||||
};
|
||||
|
||||
const cardBody = (
|
||||
<>
|
||||
<div className="magic-bento-card__header">
|
||||
<div className="magic-bento-card__icon">{card.icon}</div>
|
||||
</div>
|
||||
<div className="magic-bento-card__content">
|
||||
<h2 className="magic-bento-card__title">{card.title}</h2>
|
||||
<p className="magic-bento-card__description">{card.description}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (enableStars) {
|
||||
return (
|
||||
<ParticleCard
|
||||
key={index}
|
||||
className={baseClassName}
|
||||
style={cardStyle}
|
||||
disableAnimations={shouldDisableAnimations}
|
||||
particleCount={particleCount}
|
||||
glowColor={glowColor}
|
||||
enableTilt={enableTilt}
|
||||
clickEffect={clickEffect}
|
||||
enableMagnetism={enableMagnetism}
|
||||
>
|
||||
{cardBody}
|
||||
</ParticleCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={baseClassName} style={cardStyle}>
|
||||
{cardBody}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
const LAT = 11.004985;
|
||||
const LNG = 76.950933;
|
||||
const ADDRESS = 'No.424, Red Rose Towers, DB Road, RS Puram, Coimbatore – 641002';
|
||||
const LAT = 17.4406;
|
||||
const LNG = 78.3489;
|
||||
const ADDRESS = '5th Floor, Vision Ultima, Street No.3, Jayabheri Enclave, Gachibowli, Hyderabad, Telangana 500032';
|
||||
|
||||
export default function MapSection() {
|
||||
const embedUrl = `https://maps.google.com/maps?q=${LAT},${LNG}&z=16&output=embed&iwloc=near`;
|
||||
@@ -45,7 +45,7 @@ export default function MapSection() {
|
||||
<span style={{
|
||||
background: 'linear-gradient(105deg,#f4be28 0%,#ff9500 100%)',
|
||||
WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>In Coimbatore</span>
|
||||
}}>In Hyderabad</span>
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
@@ -66,7 +66,7 @@ export default function MapSection() {
|
||||
svg: <><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></>,
|
||||
},
|
||||
{
|
||||
label: 'City', value: 'Coimbatore, Tamil Nadu, India',
|
||||
label: 'City', value: 'Hyderabad, Telangana, India',
|
||||
svg: <><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></>,
|
||||
},
|
||||
].map((item, i) => (
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PhoneWidget() {
|
||||
<div style={{ fontSize: 16, color: '#fff', fontWeight: 800, fontFamily: 'Sora, sans-serif', marginTop: 2 }}>Loyaly ✦</div>
|
||||
</div>
|
||||
|
||||
{/* Points card */}
|
||||
{/* LYT balance card */}
|
||||
<div style={{
|
||||
margin: '0 14px', borderRadius: 22,
|
||||
background: 'linear-gradient(135deg, #f4be28 0%, #e09820 100%)',
|
||||
@@ -109,7 +109,7 @@ export default function PhoneWidget() {
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: '-30%', right: '-10%', width: 110, height: 110, borderRadius: '50%', background: 'rgba(255,255,255,0.12)' }} />
|
||||
<div style={{ fontSize: 10, color: 'rgba(23,21,18,0.65)', fontWeight: 700, fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Total Points</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(23,21,18,0.65)', fontWeight: 700, fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Total LYT</div>
|
||||
<div style={{ fontSize: 34, color: '#171512', fontWeight: 900, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', marginTop: 2 }}>284</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(23,21,18,0.6)', fontFamily: 'Sora, sans-serif', marginTop: 4 }}>≈ ₹284 redeemable</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ export default function PhoneWidget() {
|
||||
}}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(74,222,128,0.16)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4ade80', fontSize: 14, fontWeight: 800 }}>↑</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>+120 pts</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>+120 LYT</div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif' }}>just now</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
src/components/ScrollStack.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.scroll-stack-scroller {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.scroll-stack-inner {
|
||||
padding: 20vh 0 20rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.scroll-stack-card {
|
||||
transform-origin: top center;
|
||||
will-change: transform, filter;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.5);
|
||||
width: 100%;
|
||||
margin: 30px 0;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scroll-stack-end {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
338
src/components/ScrollStack.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
import React, { useLayoutEffect, useRef, useCallback, ReactNode } from 'react';
|
||||
import Lenis from 'lenis';
|
||||
import './ScrollStack.css';
|
||||
|
||||
export const ScrollStackItem = ({ children, itemClassName = '' }: { children: ReactNode; itemClassName?: string }) => (
|
||||
<div className={`scroll-stack-card ${itemClassName}`.trim()}>{children}</div>
|
||||
);
|
||||
|
||||
interface CardTransform {
|
||||
translateY: number;
|
||||
scale: number;
|
||||
rotation: number;
|
||||
blur: number;
|
||||
}
|
||||
|
||||
interface ScrollStackProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
itemDistance?: number;
|
||||
itemScale?: number;
|
||||
itemStackDistance?: number;
|
||||
stackPosition?: string;
|
||||
scaleEndPosition?: string;
|
||||
baseScale?: number;
|
||||
scaleDuration?: number;
|
||||
rotationAmount?: number;
|
||||
blurAmount?: number;
|
||||
useWindowScroll?: boolean;
|
||||
onStackComplete?: () => void;
|
||||
}
|
||||
|
||||
const ScrollStack = ({
|
||||
children,
|
||||
className = '',
|
||||
itemDistance = 100,
|
||||
itemScale = 0.03,
|
||||
itemStackDistance = 30,
|
||||
stackPosition = '20%',
|
||||
scaleEndPosition = '10%',
|
||||
baseScale = 0.85,
|
||||
rotationAmount = 0,
|
||||
blurAmount = 0,
|
||||
useWindowScroll = false,
|
||||
onStackComplete,
|
||||
}: ScrollStackProps) => {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const stackCompletedRef = useRef(false);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const lenisRef = useRef<Lenis | null>(null);
|
||||
const cardsRef = useRef<HTMLElement[]>([]);
|
||||
const lastTransformsRef = useRef<Map<number, CardTransform>>(new Map());
|
||||
const isUpdatingRef = useRef(false);
|
||||
// Cached, transform-independent layout positions — re-reading getBoundingClientRect()
|
||||
// every frame would pick up each card's own translateY from the previous frame and
|
||||
// feed back into itself, causing jitter. These are measured once (and on resize).
|
||||
const cardTopsRef = useRef<number[]>([]);
|
||||
const endTopRef = useRef(0);
|
||||
|
||||
const calculateProgress = useCallback((scrollTop: number, start: number, end: number) => {
|
||||
if (scrollTop < start) return 0;
|
||||
if (scrollTop > end) return 1;
|
||||
return (scrollTop - start) / (end - start);
|
||||
}, []);
|
||||
|
||||
const parsePercentage = useCallback((value: string | number, containerHeight: number) => {
|
||||
if (typeof value === 'string' && value.includes('%')) {
|
||||
return (parseFloat(value) / 100) * containerHeight;
|
||||
}
|
||||
return parseFloat(value as string);
|
||||
}, []);
|
||||
|
||||
const getScrollData = useCallback(() => {
|
||||
if (useWindowScroll) {
|
||||
return {
|
||||
scrollTop: window.scrollY,
|
||||
containerHeight: window.innerHeight,
|
||||
};
|
||||
} else {
|
||||
const scroller = scrollerRef.current!;
|
||||
return {
|
||||
scrollTop: scroller.scrollTop,
|
||||
containerHeight: scroller.clientHeight,
|
||||
};
|
||||
}
|
||||
}, [useWindowScroll]);
|
||||
|
||||
// Measures each card's static document position with any transform temporarily
|
||||
// cleared, so the cached value is never contaminated by our own animation.
|
||||
const measurePositions = useCallback(() => {
|
||||
const cards = cardsRef.current;
|
||||
const prevTransforms = cards.map(c => c.style.transform);
|
||||
cards.forEach(c => { c.style.transform = 'none'; });
|
||||
|
||||
const endElement = useWindowScroll
|
||||
? document.querySelector<HTMLElement>('.scroll-stack-end')
|
||||
: scrollerRef.current?.querySelector<HTMLElement>('.scroll-stack-end');
|
||||
const prevEndTransform = endElement?.style.transform;
|
||||
if (endElement) endElement.style.transform = 'none';
|
||||
|
||||
if (useWindowScroll) {
|
||||
cardTopsRef.current = cards.map(c => c.getBoundingClientRect().top + window.scrollY);
|
||||
endTopRef.current = endElement ? endElement.getBoundingClientRect().top + window.scrollY : 0;
|
||||
} else {
|
||||
cardTopsRef.current = cards.map(c => c.offsetTop);
|
||||
endTopRef.current = endElement ? endElement.offsetTop : 0;
|
||||
}
|
||||
|
||||
cards.forEach((c, i) => { c.style.transform = prevTransforms[i]; });
|
||||
if (endElement && prevEndTransform !== undefined) endElement.style.transform = prevEndTransform;
|
||||
}, [useWindowScroll]);
|
||||
|
||||
const updateCardTransforms = useCallback(() => {
|
||||
if (!cardsRef.current.length || isUpdatingRef.current) return;
|
||||
|
||||
isUpdatingRef.current = true;
|
||||
|
||||
const { scrollTop, containerHeight } = getScrollData();
|
||||
const stackPositionPx = parsePercentage(stackPosition, containerHeight);
|
||||
const scaleEndPositionPx = parsePercentage(scaleEndPosition, containerHeight);
|
||||
|
||||
const endElementTop = endTopRef.current;
|
||||
|
||||
cardsRef.current.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
|
||||
const cardTop = cardTopsRef.current[i] ?? 0;
|
||||
const triggerStart = cardTop - stackPositionPx - itemStackDistance * i;
|
||||
const triggerEnd = cardTop - scaleEndPositionPx;
|
||||
const pinStart = cardTop - stackPositionPx - itemStackDistance * i;
|
||||
const pinEnd = endElementTop - containerHeight / 2;
|
||||
|
||||
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
|
||||
const targetScale = baseScale + i * itemScale;
|
||||
const scale = 1 - scaleProgress * (1 - targetScale);
|
||||
const rotation = rotationAmount ? i * rotationAmount * scaleProgress : 0;
|
||||
|
||||
let blur = 0;
|
||||
if (blurAmount) {
|
||||
let topCardIndex = 0;
|
||||
for (let j = 0; j < cardsRef.current.length; j++) {
|
||||
const jCardTop = cardTopsRef.current[j] ?? 0;
|
||||
const jTriggerStart = jCardTop - stackPositionPx - itemStackDistance * j;
|
||||
if (scrollTop >= jTriggerStart) {
|
||||
topCardIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < topCardIndex) {
|
||||
const depthInStack = topCardIndex - i;
|
||||
blur = Math.max(0, depthInStack * blurAmount);
|
||||
}
|
||||
}
|
||||
|
||||
let translateY = 0;
|
||||
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
|
||||
if (isPinned) {
|
||||
translateY = scrollTop - cardTop + stackPositionPx + itemStackDistance * i;
|
||||
} else if (scrollTop > pinEnd) {
|
||||
translateY = pinEnd - cardTop + stackPositionPx + itemStackDistance * i;
|
||||
}
|
||||
|
||||
const newTransform: CardTransform = {
|
||||
translateY: Math.round(translateY * 100) / 100,
|
||||
scale: Math.round(scale * 1000) / 1000,
|
||||
rotation: Math.round(rotation * 100) / 100,
|
||||
blur: Math.round(blur * 100) / 100,
|
||||
};
|
||||
|
||||
const lastTransform = lastTransformsRef.current.get(i);
|
||||
const hasChanged =
|
||||
!lastTransform ||
|
||||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
|
||||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
|
||||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
|
||||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
|
||||
|
||||
if (hasChanged) {
|
||||
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
|
||||
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
|
||||
|
||||
card.style.transform = transform;
|
||||
card.style.filter = filter;
|
||||
|
||||
lastTransformsRef.current.set(i, newTransform);
|
||||
}
|
||||
|
||||
if (i === cardsRef.current.length - 1) {
|
||||
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
if (isInView && !stackCompletedRef.current) {
|
||||
stackCompletedRef.current = true;
|
||||
onStackComplete?.();
|
||||
} else if (!isInView && stackCompletedRef.current) {
|
||||
stackCompletedRef.current = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isUpdatingRef.current = false;
|
||||
}, [
|
||||
itemScale,
|
||||
itemStackDistance,
|
||||
stackPosition,
|
||||
scaleEndPosition,
|
||||
baseScale,
|
||||
rotationAmount,
|
||||
blurAmount,
|
||||
useWindowScroll,
|
||||
onStackComplete,
|
||||
calculateProgress,
|
||||
parsePercentage,
|
||||
getScrollData,
|
||||
]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
updateCardTransforms();
|
||||
}, [updateCardTransforms]);
|
||||
|
||||
const setupLenis = useCallback(() => {
|
||||
if (useWindowScroll) {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 2,
|
||||
wheelMultiplier: 1,
|
||||
syncTouch: true,
|
||||
syncTouchLerp: 0.075,
|
||||
});
|
||||
|
||||
lenis.on('scroll', handleScroll);
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
animationFrameRef.current = requestAnimationFrame(raf);
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame(raf);
|
||||
|
||||
lenisRef.current = lenis;
|
||||
return lenis;
|
||||
} else {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
const lenis = new Lenis({
|
||||
wrapper: scroller,
|
||||
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
|
||||
duration: 1.2,
|
||||
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 2,
|
||||
gestureOrientation: 'vertical',
|
||||
wheelMultiplier: 1,
|
||||
syncTouch: true,
|
||||
syncTouchLerp: 0.075,
|
||||
});
|
||||
|
||||
lenis.on('scroll', handleScroll);
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
animationFrameRef.current = requestAnimationFrame(raf);
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame(raf);
|
||||
|
||||
lenisRef.current = lenis;
|
||||
return lenis;
|
||||
}
|
||||
}, [handleScroll, useWindowScroll]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
const cards = Array.from(
|
||||
useWindowScroll
|
||||
? document.querySelectorAll<HTMLElement>('.scroll-stack-card')
|
||||
: scroller.querySelectorAll<HTMLElement>('.scroll-stack-card')
|
||||
);
|
||||
|
||||
cardsRef.current = cards;
|
||||
const transformsCache = lastTransformsRef.current;
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
if (i < cards.length - 1) {
|
||||
card.style.marginBottom = `${itemDistance}px`;
|
||||
}
|
||||
card.style.willChange = 'transform, filter';
|
||||
card.style.transformOrigin = 'top center';
|
||||
card.style.backfaceVisibility = 'hidden';
|
||||
card.style.transform = 'translateZ(0)';
|
||||
});
|
||||
|
||||
measurePositions();
|
||||
setupLenis();
|
||||
|
||||
updateCardTransforms();
|
||||
|
||||
const handleResize = () => {
|
||||
measurePositions();
|
||||
updateCardTransforms();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (lenisRef.current) {
|
||||
lenisRef.current.destroy();
|
||||
}
|
||||
stackCompletedRef.current = false;
|
||||
cardsRef.current = [];
|
||||
transformsCache.clear();
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemDistance, itemScale, itemStackDistance, stackPosition, scaleEndPosition, baseScale, rotationAmount, blurAmount, useWindowScroll, onStackComplete, setupLenis, updateCardTransforms, measurePositions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`scroll-stack-scroller ${className}`.trim()}
|
||||
ref={scrollerRef}
|
||||
style={useWindowScroll ? { overflow: 'visible', height: 'auto', position: 'static' } : undefined}
|
||||
>
|
||||
<div className="scroll-stack-inner">
|
||||
{children}
|
||||
{/* Spacer so the last pin can release cleanly */}
|
||||
<div className="scroll-stack-end" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollStack;
|
||||
@@ -44,7 +44,7 @@ const SOLUTIONS = [
|
||||
title: 'Loyalty Management',
|
||||
subtitle: 'Build Lasting Relationships',
|
||||
excerpt:
|
||||
'Reward customer loyalty with smart programmes that adapt in real time — from points and tiers to personalised perks that feel genuinely earned.',
|
||||
'Reward customer loyalty with smart programmes that adapt in real time — from LYT and tiers to personalised perks that feel genuinely earned.',
|
||||
image: '/loyaly_php/assets/images/cognivision-transformative-ai-for-intelligent-fi-640x640.jpg',
|
||||
imageAlt: 'Loyalty management module showing customer tier performance',
|
||||
href: '/solutions/loyalty-management',
|
||||
|
||||
@@ -1,28 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useLayoutEffect, useEffect, useRef, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
const recs = [
|
||||
{ rank: '01', label: 'Double points at Starbucks', match: '98%', tag: 'Near you · 0.3km' },
|
||||
{ rank: '02', label: 'Nike Air sale ends tonight', match: '94%', tag: 'Trending · Fashion' },
|
||||
{ rank: '03', label: 'Decathlon weekend special', match: '91%', tag: 'Sports · 1.4km' },
|
||||
{ rank: '04', label: 'Zara new arrivals, 15% off', match: '87%', tag: 'Your style' },
|
||||
{ rank: '01', label: 'Earn 2× LYT at Starbucks', match: 98, tag: 'Near you · 0.3km' },
|
||||
{ rank: '02', label: 'Nike Air sale ends tonight', match: 94, tag: 'Trending · Fashion' },
|
||||
{ rank: '03', label: 'Decathlon weekend special', match: 91, tag: 'Sports · 1.4km' },
|
||||
{ rank: '04', label: 'Zara new arrivals, 15% off', match: 87, tag: 'Your style' },
|
||||
];
|
||||
|
||||
export default function AIRecommendationsSection() {
|
||||
const [active, setActive] = useState(false);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const matchRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const statRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const barFillRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
if (el.hasAttribute('data-active')) setActive(true);
|
||||
const obs = new MutationObserver(() => {
|
||||
if (el.hasAttribute('data-active')) setActive(true);
|
||||
});
|
||||
obs.observe(el, { attributes: true, attributeFilter: ['data-active'] });
|
||||
return () => obs.disconnect();
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const section = panelRef.current;
|
||||
if (!section) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: section, start: 'top 78%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
|
||||
// Stat chips: scale + count up
|
||||
tl.fromTo('.ai-stat-chip', { opacity: 0, y: 20, scale: 0.88 }, { opacity: 1, y: 0, scale: 1, duration: 0.55, stagger: 0.1, ease: 'back.out(1.8)' }, 0);
|
||||
statRefs.current.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const target = i === 0 ? 94 : 3;
|
||||
const suffix = i === 0 ? '%' : 'ms';
|
||||
const counter = { val: 0 };
|
||||
tl.to(counter, {
|
||||
val: target, duration: 0.9, ease: 'power2.out',
|
||||
onUpdate: () => { el.textContent = `${Math.round(counter.val)}${suffix}`; },
|
||||
}, 0.1 + i * 0.1);
|
||||
});
|
||||
|
||||
// Recommendation rows: slide + fade + scale in
|
||||
tl.fromTo('.ai-rec-row', { opacity: 0, x: 64, scale: 0.96 }, { opacity: 1, x: 0, scale: 1, duration: 0.65, stagger: 0.12, ease: 'power3.out' }, 0.15);
|
||||
|
||||
// Match percentages count up, following each row
|
||||
recs.forEach((rec, i) => {
|
||||
const el = matchRefs.current[i];
|
||||
if (!el) return;
|
||||
const counter = { val: 0 };
|
||||
tl.to(counter, {
|
||||
val: rec.match, duration: 0.6, ease: 'power2.out',
|
||||
onUpdate: () => { el.textContent = `${Math.round(counter.val)}%`; },
|
||||
}, 0.35 + i * 0.12);
|
||||
});
|
||||
|
||||
// Confidence bar fill, synced after rows finish
|
||||
tl.fromTo(barFillRef.current, { width: '0%' }, { width: '92%', duration: 1, ease: 'power2.out' }, 0.9);
|
||||
|
||||
ScrollTrigger.refresh();
|
||||
}, section);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -32,7 +76,7 @@ export default function AIRecommendationsSection() {
|
||||
|
||||
{/* Left — editorial */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence · 05</span></div>
|
||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">AI Intelligence<span className="fp-kicker-num">05</span></span></div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(40px, 5vw, 72px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.94,
|
||||
@@ -49,14 +93,14 @@ export default function AIRecommendationsSection() {
|
||||
Our recommendation engine learns your taste from every transaction and surfaces offers uniquely matched to you, getting sharper with every visit.
|
||||
</p>
|
||||
|
||||
{/* Stat chips — brand gold palette */}
|
||||
<div className="fp-rise" style={{ display: 'flex', gap: 14, transitionDelay: '0.2s' }}>
|
||||
{/* Stat chips — brand gold palette, count up on scroll */}
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
{[
|
||||
{ val: '94%', lbl: 'match accuracy' },
|
||||
{ val: '3ms', lbl: 'response time' },
|
||||
{ lbl: 'match accuracy' },
|
||||
{ lbl: 'response time' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ padding: '16px 20px', borderRadius: 16, background: '#fff7e0', border: '2px solid #f4be28', minWidth: 110 }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{s.val}</div>
|
||||
<div key={i} className="ai-stat-chip" style={{ padding: '16px 20px', borderRadius: 16, background: '#fff7e0', border: '2px solid #f4be28', minWidth: 110, opacity: 0 }}>
|
||||
<div ref={el => { statRefs.current[i] = el; }} style={{ fontSize: 28, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>0{i === 0 ? '%' : 'ms'}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.55)', fontFamily: 'Sora, sans-serif', marginTop: 4 }}>{s.lbl}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -78,16 +122,17 @@ export default function AIRecommendationsSection() {
|
||||
{recs.map((rec, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { rowRefs.current[i] = el; }}
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="ai-rec-row"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', borderRadius: 16,
|
||||
background: hovered === i ? '#fff7e0' : '#fafafa',
|
||||
border: `2px solid ${hovered === i ? '#f4be28' : 'rgba(23,21,18,0.07)'}`,
|
||||
cursor: 'default',
|
||||
transition: 'all 0.4s cubic-bezier(0.16,1,0.3,1)',
|
||||
transform: `translateX(${active ? 0 : 48}px)`, opacity: active ? 1 : 0,
|
||||
transitionDelay: `${0.12 + i * 0.1}s`,
|
||||
transition: 'background 0.35s ease, border-color 0.35s ease',
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
{/* Rank */}
|
||||
@@ -102,13 +147,13 @@ export default function AIRecommendationsSection() {
|
||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif' }}>{rec.tag}</div>
|
||||
</div>
|
||||
|
||||
{/* Match badge — brand gold */}
|
||||
{/* Match badge — brand gold, counts up on scroll */}
|
||||
<div style={{
|
||||
padding: '6px 14px', borderRadius: 100, flexShrink: 0,
|
||||
background: '#fff7e0', border: '1.5px solid #f4be28',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif' }}>{rec.match}</div>
|
||||
<div ref={el => { matchRefs.current[i] = el; }} style={{ fontSize: 14, fontWeight: 900, color: '#bc7122', fontFamily: 'Sora, sans-serif' }}>0%</div>
|
||||
<div style={{ fontSize: 9, color: 'rgba(23,21,18,0.4)', fontFamily: 'Sora, sans-serif' }}>match</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,10 +169,9 @@ export default function AIRecommendationsSection() {
|
||||
}}>High</span>
|
||||
</div>
|
||||
<div style={{ height: 10, background: 'rgba(23,21,18,0.07)', borderRadius: 100, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', width: active ? '92%' : '0%', borderRadius: 100,
|
||||
<div ref={barFillRef} style={{
|
||||
height: '100%', width: '0%', borderRadius: 100,
|
||||
background: 'linear-gradient(90deg, #f4be28, #bc7122)',
|
||||
transition: 'width 1.4s cubic-bezier(0.22,1,0.36,1) 0.8s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
const deals = [
|
||||
{ store: 'Starbucks', category: 'Coffee & Drinks', offer: '2× Points', savings: '₹120 saved', color: '#00704A', distance: '0.3 km', rating: 4.8 },
|
||||
{ store: 'Starbucks', category: 'Coffee & Drinks', offer: '2× LYT', savings: '₹120 saved', color: '#00704A', distance: '0.3 km', rating: 4.8 },
|
||||
{ store: 'Nike', category: 'Footwear', offer: '30% OFF', savings: '₹890 saved', color: '#ffffff', distance: '0.7 km', rating: 4.9 },
|
||||
{ store: 'H&M', category: 'Fashion', offer: '₹200 Back', savings: '₹200 saved', color: '#E50010', distance: '1.1 km', rating: 4.6 },
|
||||
{ store: 'Decathlon', category: 'Sports', offer: 'Free Gift', savings: '₹350 saved', color: '#0082C8', distance: '1.4 km', rating: 4.7 },
|
||||
@@ -32,7 +32,7 @@ export default function DiscoverDealsSection() {
|
||||
|
||||
{/* Headline column */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover · 02</span></div>
|
||||
<div style={{ marginBottom: 28 }}><span className="fp-kicker fp-rise">Discover<span className="fp-kicker-num">02</span></span></div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(44px, 5.6vw, 88px)', fontWeight: 800, lineHeight: 0.94,
|
||||
@@ -46,7 +46,7 @@ export default function DiscoverDealsSection() {
|
||||
<p className="fp-rise" style={{
|
||||
fontSize: 16, lineHeight: 1.8, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 36px', maxWidth: 380,
|
||||
}}>
|
||||
Find participating stores nearby and see their active game based discount campaigns, live coupons, and coin 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 LYT earning opportunities, all surfaced in real time so you never miss a deal.
|
||||
</p>
|
||||
|
||||
<div className="fp-rise" style={{
|
||||
@@ -70,6 +70,7 @@ export default function DiscoverDealsSection() {
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="fp-rise"
|
||||
style={{
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'grid', gridTemplateColumns: '46px 1fr auto', alignItems: 'center', gap: 16,
|
||||
padding: '16px 20px', borderRadius: 16,
|
||||
background: hovered === i ? 'rgba(244,190,40,0.06)' : 'rgba(255,255,255,0.03)',
|
||||
@@ -80,6 +81,17 @@ export default function DiscoverDealsSection() {
|
||||
transform: `${hovered === i ? 'translateX(8px)' : 'translateX(0)'}`,
|
||||
}}
|
||||
>
|
||||
{/* Bottom border sweep, left to right on hover */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0, height: 2,
|
||||
background: 'linear-gradient(90deg, #f4be28, #ff9500)',
|
||||
transformOrigin: 'left center',
|
||||
transform: hovered === i ? 'scaleX(1)' : 'scaleX(0)',
|
||||
transition: 'transform 0.45s cubic-bezier(0.22,1,0.36,1)',
|
||||
transitionDelay: '0s',
|
||||
boxShadow: '0 0 10px rgba(244,190,40,0.5)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
width: 46, height: 46, borderRadius: 13, background: `${deal.color}22`, border: `1px solid ${deal.color}44`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const feed = [
|
||||
{ store: 'Daily Login', pts: +50, time: '2m ago', type: 'earn' },
|
||||
@@ -16,6 +20,57 @@ export default function EarnRewardsSection() {
|
||||
const [pts, setPts] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const activeRowRef = useRef(0);
|
||||
|
||||
// Move the highlighted feed card as the section scrolls through the viewport
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const applyRow = (idx: number) => {
|
||||
if (idx === activeRowRef.current) return;
|
||||
activeRowRef.current = idx;
|
||||
rowRefs.current.forEach((row, i) => {
|
||||
if (!row) return;
|
||||
const active = i === idx;
|
||||
row.style.background = active ? '#16a34a' : 'rgba(23,21,18,0.55)';
|
||||
row.style.borderColor = active ? '#16a34a' : 'rgba(255,255,255,0.14)';
|
||||
row.style.transform = active ? 'translateX(10px) scale(1.06)' : 'translateX(0) scale(1)';
|
||||
row.style.boxShadow = active ? '0 16px 40px -12px rgba(22,163,74,0.5)' : 'none';
|
||||
row.style.zIndex = active ? '2' : '1';
|
||||
|
||||
// Flip inner text/icon colors for contrast per background
|
||||
const title = row.querySelector<HTMLElement>('.feed-title');
|
||||
if (title) title.style.color = '#ffffff';
|
||||
const time = row.querySelector<HTMLElement>('.feed-time');
|
||||
if (time) time.style.color = active ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.55)';
|
||||
const icon = row.querySelector<HTMLElement>('.feed-icon');
|
||||
if (icon) {
|
||||
icon.style.background = active ? 'rgba(255,255,255,0.15)' : 'rgba(74,222,128,0.1)';
|
||||
icon.style.borderColor = active ? 'rgba(255,255,255,0.5)' : 'rgba(74,222,128,0.45)';
|
||||
icon.style.color = active ? '#ffffff' : '#4ade80';
|
||||
}
|
||||
const points = row.querySelector<HTMLElement>('.feed-pts');
|
||||
if (points) {
|
||||
points.style.color = active ? '#ffffff' : '#4ade80';
|
||||
points.style.background = active ? 'rgba(255,255,255,0.15)' : 'rgba(74,222,128,0.12)';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -48,7 +103,7 @@ export default function EarnRewardsSection() {
|
||||
background: '#f0fdf4', border: '1.5px solid #16a34a', borderRadius: 100, padding: '7px 16px',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0, animation: 'fpPulse 1.5s infinite' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Live points balance</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>Live LYT balance</span>
|
||||
</div>
|
||||
|
||||
{/* Giant gold number */}
|
||||
@@ -70,7 +125,7 @@ export default function EarnRewardsSection() {
|
||||
<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 · 03</span></div>
|
||||
<div style={{ marginBottom: 16 }}><span className="fp-kicker fp-rise">Earn<span className="fp-kicker-num">03</span></span></div>
|
||||
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(28px, 3.4vw, 48px)', fontWeight: 800, lineHeight: 1.08,
|
||||
@@ -84,7 +139,7 @@ export default function EarnRewardsSection() {
|
||||
<p className="fp-rise" style={{
|
||||
fontSize: 15, lineHeight: 1.8, color: '#4f463a', fontFamily: 'Sora, sans-serif', maxWidth: 380, margin: 0,
|
||||
}}>
|
||||
Earn Loyaly Coins 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 puts more coins in your wallet.
|
||||
Earn LYT (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 LYT to your balance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -107,31 +162,39 @@ export default function EarnRewardsSection() {
|
||||
{/* Feed rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{feed.map((tx, i) => (
|
||||
<div key={i} style={{
|
||||
<div key={i} ref={el => { rowRefs.current[i] = el; }} style={{
|
||||
position: 'relative', zIndex: i === 0 ? 2 : 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 14px', borderRadius: 12,
|
||||
background: i === 0 ? '#f0fdf4' : '#fafafa',
|
||||
border: `1.5px solid ${i === 0 ? '#16a34a' : 'rgba(23,21,18,0.07)'}`,
|
||||
background: i === 0 ? '#16a34a' : 'rgba(23,21,18,0.55)',
|
||||
border: `1.5px solid ${i === 0 ? '#16a34a' : 'rgba(255,255,255,0.14)'}`,
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
transform: i === 0 ? 'translateX(10px) scale(1.06)' : 'translateX(0) scale(1)',
|
||||
boxShadow: i === 0 ? '0 16px 40px -12px rgba(22,163,74,0.5)' : '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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
<div className="feed-icon" style={{
|
||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||
background: tx.type === 'earn' ? '#f0fdf4' : '#fef2f2',
|
||||
border: `1.5px solid ${tx.type === 'earn' ? '#16a34a' : '#ef4444'}`,
|
||||
background: i === 0 ? 'rgba(255,255,255,0.15)' : 'rgba(74,222,128,0.1)',
|
||||
border: `1.5px solid ${i === 0 ? 'rgba(255,255,255,0.5)' : 'rgba(74,222,128,0.45)'}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 15, fontWeight: 900,
|
||||
color: tx.type === 'earn' ? '#16a34a' : '#ef4444',
|
||||
color: i === 0 ? '#ffffff' : '#4ade80',
|
||||
transition: 'background 0.35s ease, border-color 0.35s ease, color 0.35s ease',
|
||||
}}>{tx.type === 'earn' ? '↑' : '↓'}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#171512', fontFamily: 'Sora, sans-serif' }}>{tx.store}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 1 }}>{tx.time}</div>
|
||||
<div className="feed-title" style={{ fontSize: 13, fontWeight: 700, color: '#ffffff', fontFamily: 'Sora, sans-serif', transition: 'color 0.35s ease' }}>{tx.store}</div>
|
||||
<div className="feed-time" style={{ fontSize: 11, color: i === 0 ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif', marginTop: 1, transition: 'color 0.35s ease' }}>{tx.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
<span className="feed-pts" style={{
|
||||
fontSize: 15, fontWeight: 900, fontFamily: 'Sora, sans-serif',
|
||||
color: tx.type === 'earn' ? '#16a34a' : '#ef4444', letterSpacing: '-0.02em',
|
||||
background: tx.type === 'earn' ? '#f0fdf4' : '#fef2f2',
|
||||
color: i === 0 ? '#ffffff' : '#4ade80', letterSpacing: '-0.02em',
|
||||
background: i === 0 ? 'rgba(255,255,255,0.15)' : 'rgba(74,222,128,0.12)',
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
transition: 'color 0.35s ease, background 0.35s ease',
|
||||
}}>
|
||||
{tx.pts > 0 ? '+' : ''}{tx.pts}
|
||||
</span>
|
||||
@@ -144,7 +207,7 @@ export default function EarnRewardsSection() {
|
||||
marginTop: 16, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
|
||||
}}>
|
||||
{[
|
||||
{ label: 'Earned this month', val: '660 pts', color: '#16a34a', bg: '#f0fdf4', border: '#16a34a' },
|
||||
{ label: 'Earned this month', val: '660 LYT', color: '#16a34a', bg: '#f0fdf4', border: '#16a34a' },
|
||||
{ label: 'Redeemable value', val: '₹660', color: '#f4be28', bg: '#fff7e0', border: '#f4be28' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ padding: '14px 16px', borderRadius: 14, background: s.bg, border: `1.5px solid ${s.border}` }}>
|
||||
|
||||
@@ -11,7 +11,6 @@ const words = [
|
||||
const steps = [
|
||||
{ n: '01', label: 'Walk into any partner store' },
|
||||
{ n: '02', label: 'Pay the way you always do' },
|
||||
{ n: '03', label: 'Points land in your wallet' },
|
||||
];
|
||||
|
||||
export default function ExperienceSection() {
|
||||
@@ -20,7 +19,7 @@ export default function ExperienceSection() {
|
||||
<div className="fp-shell">
|
||||
|
||||
<div style={{ marginBottom: 'clamp(36px, 5vh, 60px)' }}>
|
||||
<span className="fp-kicker fp-rise">The Experience · 06</span>
|
||||
<span className="fp-kicker fp-rise">The Experience<span className="fp-kicker-num">06</span></span>
|
||||
</div>
|
||||
|
||||
<div className="fp-split" style={{ gridTemplateColumns: '1.1fr 0.9fr', alignItems: 'center' }}>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useLayoutEffect } 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;
|
||||
|
||||
function useMagnetic() {
|
||||
const ref = useRef<HTMLAnchorElement | null>(null);
|
||||
@@ -10,9 +16,9 @@ function useMagnetic() {
|
||||
if (!el) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
el.style.transform = `translate(${((e.clientX - r.left) / r.width - 0.5) * 18}px,${((e.clientY - r.top) / r.height - 0.5) * 18}px) scale(1.04)`;
|
||||
el.style.transform = `translate(${((e.clientX - r.left) / r.width - 0.5) * 16}px,${((e.clientY - r.top) / r.height - 0.5) * 16}px) scale(1.04)`;
|
||||
};
|
||||
const onLeave = () => { el.style.transform = 'translate(0,0) scale(1)'; };
|
||||
const onLeave = () => { el.style.transform = ''; };
|
||||
el.addEventListener('mousemove', onMove);
|
||||
el.addEventListener('mouseleave', onLeave);
|
||||
return () => { el.removeEventListener('mousemove', onMove); el.removeEventListener('mouseleave', onLeave); };
|
||||
@@ -20,97 +26,191 @@ function useMagnetic() {
|
||||
return ref;
|
||||
}
|
||||
|
||||
const proof = [
|
||||
{ val: '50K+', label: 'Active users', color: '#f4be28' },
|
||||
{ val: '10K+', label: 'Partner stores', color: '#16a34a' },
|
||||
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb' },
|
||||
{ val: '4.9★', label: 'App rating', color: '#9333ea' },
|
||||
const PROOF = [
|
||||
{ val: '50K+', label: 'Active users', color: '#f4be28', bg: '#fffbeb', border: 'rgba(244,190,40,0.3)' },
|
||||
{ val: '10K+', label: 'Partner stores', color: '#16a34a', bg: '#f0fdf4', border: 'rgba(22,163,74,0.25)' },
|
||||
{ val: '₹28L+', label: 'Rewards given', color: '#2563eb', bg: '#eff6ff', border: 'rgba(37,99,235,0.2)' },
|
||||
{ val: '4.9★', label: 'App rating', color: '#9333ea', bg: '#faf5ff', border: 'rgba(147,51,234,0.2)' },
|
||||
];
|
||||
|
||||
export default function FinalCTASection() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const mag1 = useMagnetic();
|
||||
const mag2 = useMagnetic();
|
||||
|
||||
return (
|
||||
<section className="fp-panel fp-light" style={{ background: '#ffffff', textAlign: 'center' }}>
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: sectionRef.current, start: 'top 80%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
tl.fromTo('.fcta-badge',
|
||||
{ opacity: 0, y: 20, scale: 0.9 },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: 0.6, ease: 'back.out(2)' }
|
||||
)
|
||||
.fromTo('.fcta-line-1',
|
||||
{ opacity: 0, y: 50, filter: 'blur(8px)' },
|
||||
{ opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.8, ease: 'expo.out' }, '-=0.2'
|
||||
)
|
||||
.fromTo('.fcta-line-2',
|
||||
{ opacity: 0, y: 50, filter: 'blur(8px)' },
|
||||
{ opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.8, ease: 'expo.out' }, '-=0.55'
|
||||
)
|
||||
.fromTo('.fcta-sub',
|
||||
{ opacity: 0, y: 24 },
|
||||
{ opacity: 1, y: 0, duration: 0.65, ease: 'power3.out' }, '-=0.4'
|
||||
)
|
||||
.fromTo('.fcta-btns',
|
||||
{ opacity: 0, y: 20 },
|
||||
{ opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' }, '-=0.3'
|
||||
)
|
||||
.fromTo('.fcta-stat',
|
||||
{ opacity: 0, y: 28, scale: 0.88 },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: 0.55, stagger: 0.1, ease: 'back.out(1.8)' }, '-=0.25'
|
||||
);
|
||||
}, sectionRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
{/* Vivid background shape */}
|
||||
<div className="fp-deco" style={{
|
||||
position: 'absolute', top: '-10%', left: '50%', transform: 'translateX(-50%)',
|
||||
width: '70%', height: '60%', borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(244,190,40,0.12) 0%, transparent 70%)',
|
||||
zIndex: 0, filter: 'blur(50px)',
|
||||
}} />
|
||||
<div className="fp-deco" style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, textAlign: 'center', zIndex: 0, overflow: 'hidden',
|
||||
}}>
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 'calc(100% - 40px)',
|
||||
margin: '0 20px 20px',
|
||||
borderRadius: 32,
|
||||
overflow: 'hidden',
|
||||
background: '#ffffff',
|
||||
padding: 'clamp(80px,10vh,130px) clamp(24px,5vw,80px) clamp(70px,9vh,120px)',
|
||||
textAlign: 'center',
|
||||
zIndex: 2,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(23,21,18,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Subtle dot-grid texture */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', backgroundImage: 'radial-gradient(rgba(23,21,18,0.045) 1px, transparent 1px)', backgroundSize: '28px 28px', maskImage: 'radial-gradient(ellipse 80% 65% at 50% 30%, #000 0%, transparent 80%)', WebkitMaskImage: 'radial-gradient(ellipse 80% 65% at 50% 30%, #000 0%, transparent 80%)' }} />
|
||||
|
||||
{/* Amber glow orb top-center */}
|
||||
<div style={{ position: 'absolute', top: '-20%', left: '50%', transform: 'translateX(-50%)', width: '70%', height: '60%', borderRadius: '50%', background: 'radial-gradient(ellipse, rgba(244,190,40,0.12) 0%, transparent 65%)', filter: 'blur(50px)', pointerEvents: 'none' }} />
|
||||
|
||||
{/* Ghost wordmark */}
|
||||
<div style={{ position: 'absolute', bottom: -20, left: '50%', transform: 'translateX(-50%)', whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 0 }}>
|
||||
<span style={{
|
||||
fontSize: 'clamp(130px, 24vw, 340px)', fontWeight: 900, fontFamily: 'Sora, sans-serif',
|
||||
color: 'transparent', WebkitTextStroke: '1.5px rgba(244,190,40,0.15)', letterSpacing: '-0.06em', lineHeight: 1, display: 'block',
|
||||
fontSize: 'clamp(100px,20vw,280px)', fontWeight: 900,
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.06em', lineHeight: 1,
|
||||
color: 'transparent', WebkitTextStroke: '1px rgba(23,21,18,0.05)',
|
||||
display: 'block',
|
||||
}}>LOYALY</span>
|
||||
</div>
|
||||
|
||||
<div className="fp-shell" style={{ maxWidth: 760 }}>
|
||||
{/* Content */}
|
||||
<div style={{ position: 'relative', zIndex: 10, maxWidth: 820, margin: '0 auto' }}>
|
||||
|
||||
{/* Live badge */}
|
||||
<div className="fp-rise" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 32,
|
||||
background: '#f0fdf4', border: '1.5px solid #16a34a', borderRadius: 100, padding: '10px 22px',
|
||||
<div className="fcta-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 36,
|
||||
background: '#f0fdf4', border: '1.5px solid rgba(22,163,74,0.35)',
|
||||
borderRadius: 100, padding: '9px 20px',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#16a34a', display: 'block', animation: 'fpPulse 1.5s infinite', boxShadow: '0 0 0 3px rgba(22,163,74,0.2)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.06em' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#22c55e', display: 'block', animation: 'fctaPulse 1.6s ease-in-out infinite', boxShadow: '0 0 0 3px rgba(34,197,94,0.2)' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#16a34a', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
Now live in Coimbatore
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(48px, 7.5vw, 112px)', fontWeight: 800, letterSpacing: '-0.06em', lineHeight: 0.92,
|
||||
color: '#171512', margin: '0 0 28px', fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
<span className="fp-mask"><span>Your rewards</span></span>
|
||||
<span className="fp-mask"><span className="fp-shimmer">are waiting.</span></span>
|
||||
<h2 style={{ margin: '0 0 28px', fontFamily: 'Sora, sans-serif', lineHeight: 0.92, letterSpacing: '-0.055em' }}>
|
||||
<span className="fcta-line-1" style={{
|
||||
display: 'block',
|
||||
fontSize: 'clamp(52px,8vw,116px)',
|
||||
fontWeight: 900, color: '#171512',
|
||||
}}>
|
||||
Your rewards
|
||||
</span>
|
||||
<span className="fcta-line-2" style={{
|
||||
display: 'block',
|
||||
fontSize: 'clamp(52px,8vw,116px)',
|
||||
fontWeight: 900,
|
||||
background: 'linear-gradient(105deg,#f4be28 0%,#d97706 40%,#f4be28 60%,#ff9500 100%)',
|
||||
backgroundSize: '200% auto',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
animation: 'fctaShimmer 3s linear infinite',
|
||||
}}>
|
||||
are waiting.
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="fp-rise" style={{
|
||||
fontSize: 18, lineHeight: 1.7, color: '#4f463a', fontFamily: 'Sora, sans-serif',
|
||||
maxWidth: 460, margin: '0 auto 48px', transitionDelay: '0.1s',
|
||||
{/* Subtitle */}
|
||||
<p className="fcta-sub" style={{
|
||||
fontSize: 'clamp(1rem,1.2vw,1.15rem)', lineHeight: 1.75,
|
||||
color: 'rgba(23,21,18,0.5)', fontFamily: 'Sora, sans-serif',
|
||||
maxWidth: 440, margin: '0 auto 52px',
|
||||
}}>
|
||||
Download Loyaly and start earning on your very next purchase. Free. Instant. Yours.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="fp-rise" style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap', marginBottom: 56, transitionDelay: '0.2s' }}>
|
||||
<Link href="/contacts" ref={mag1} className="fp-btn fp-btn-primary">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
|
||||
<div className="fcta-btns" style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap', marginBottom: 64 }}>
|
||||
<Link
|
||||
href="/contacts"
|
||||
ref={mag1}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 9,
|
||||
padding: '16px 34px', borderRadius: 100,
|
||||
background: 'linear-gradient(135deg,#f4be28,#ff9500)',
|
||||
color: '#1a1200', fontWeight: 800, fontSize: '0.97rem',
|
||||
fontFamily: 'Sora, sans-serif', textDecoration: 'none',
|
||||
boxShadow: '0 8px 32px rgba(244,190,40,0.35)',
|
||||
transition: 'box-shadow 0.3s ease, transform 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
|
||||
Download for iOS
|
||||
</Link>
|
||||
<Link href="/contacts" ref={mag2} className="fp-btn" style={{
|
||||
padding: '17px 34px', background: '#171512', color: '#fff',
|
||||
border: 'none', borderRadius: 100,
|
||||
transition: 'all 0.35s cubic-bezier(0.34,1.56,0.64,1)',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><rect x="2" y="8" width="20" height="10" rx="2"/><path d="M7.83 3l1.3 2.35M16.17 3l-1.3 2.35"/></svg>
|
||||
<Link
|
||||
href="/contacts"
|
||||
ref={mag2}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 9,
|
||||
padding: '16px 34px', borderRadius: 100,
|
||||
background: 'transparent',
|
||||
color: '#171512', fontWeight: 700, fontSize: '0.97rem',
|
||||
fontFamily: 'Sora, sans-serif', textDecoration: 'none',
|
||||
border: '1.5px solid rgba(23,21,18,0.2)',
|
||||
transition: 'border-color 0.3s ease, background 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><rect x="2" y="8" width="20" height="10" rx="2"/><path d="M7.83 3l1.3 2.35M16.17 3l-1.3 2.35"/></svg>
|
||||
Get on Android
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Proof stats — colored chips */}
|
||||
<div className="fp-rise" style={{
|
||||
display: 'flex', justifyContent: 'center', gap: 12, flexWrap: 'wrap', transitionDelay: '0.3s',
|
||||
}}>
|
||||
{proof.map((p, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '16px 24px', borderRadius: 18, textAlign: 'center',
|
||||
background: `${p.color}0f`, border: `2px solid ${p.color}`,
|
||||
minWidth: 110,
|
||||
}}>
|
||||
<div style={{ fontSize: 'clamp(20px, 2.5vw, 28px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.55)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.04em' }}>{p.label}</div>
|
||||
{/* Proof stats */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
{PROOF.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="fcta-stat"
|
||||
style={{
|
||||
padding: '18px 26px', borderRadius: 20, textAlign: 'center', minWidth: 110,
|
||||
background: p.bg,
|
||||
border: `1.5px solid ${p.border}`,
|
||||
boxShadow: '0 2px 12px rgba(23,21,18,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 'clamp(20px,2.4vw,28px)', fontWeight: 900, color: p.color, fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em' }}>{p.val}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(23,21,18,0.45)', fontFamily: 'Sora, sans-serif', marginTop: 5, letterSpacing: '0.05em' }}>{p.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fctaPulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.5)} 50%{box-shadow:0 0 0 6px rgba(34,197,94,0)} }
|
||||
@keyframes fctaShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function HeroSection() {
|
||||
fontSize: 'clamp(16px, 1.4vw, 19px)', lineHeight: 1.7, fontWeight: 400,
|
||||
color: 'rgba(255,255,255,0.58)', maxWidth: 560, margin: '0 auto 40px', fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
Earn Loyaly Coins through daily activities, interactive games, and walking challenges, then redeem them at participating stores near you. Earn Online. Spend Offline.
|
||||
Earn LYT through daily activities, interactive games, and walking challenges, then redeem them at participating stores near you. Earn Online. Spend Offline.
|
||||
</p>
|
||||
|
||||
<div className="fp-content fp-cta" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
const GroceryIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>;
|
||||
const FashionIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.38 3.46L16 2a4 4 0 01-8 0L3.62 3.46a2 2 0 00-1.34 2.23l.58 3.57a1 1 0 00.99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 002-2V10h2.15a1 1 0 00.99-.84l.58-3.57a2 2 0 00-1.34-2.23z"/></svg>;
|
||||
@@ -11,16 +17,57 @@ const ElectronicsIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fi
|
||||
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: ['BigBasket', 'DMart', 'Reliance Fresh'], color: '#22c55e' },
|
||||
{ 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: ['Nykaa', 'MAC', 'Forest Essentials'], color: '#ec4899' },
|
||||
{ name: 'Beauty', Icon: BeautyIcon, stores: ['Health & Glow', 'MAC', 'Forest Essentials'], color: '#ec4899' },
|
||||
];
|
||||
|
||||
export default function SpendAnywhereSection() {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Scroll-triggered blur/scale entrance for the cards
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo('.spend-card',
|
||||
{ opacity: 0, y: 44, scale: 0.92, filter: 'blur(8px)' },
|
||||
{
|
||||
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
|
||||
duration: 0.9, stagger: 0.09, ease: 'expo.out',
|
||||
scrollTrigger: { trigger: grid, start: 'top 85%', toggleActions: 'play none none reset' },
|
||||
}
|
||||
);
|
||||
}, grid);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Cursor-tracked spotlight + tilt per card
|
||||
const handleMove = (i: number) => (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const el = cardRefs.current[i];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
el.style.setProperty('--mx', `${(x / rect.width) * 100}%`);
|
||||
el.style.setProperty('--my', `${(y / rect.height) * 100}%`);
|
||||
const rotateX = ((y - rect.height / 2) / rect.height) * -6;
|
||||
const rotateY = ((x - rect.width / 2) / rect.width) * 6;
|
||||
gsap.to(el, { rotateX, rotateY, duration: 0.4, ease: 'power2.out', transformPerspective: 700 });
|
||||
};
|
||||
|
||||
const handleLeave = (i: number) => () => {
|
||||
const el = cardRefs.current[i];
|
||||
if (!el) return;
|
||||
gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.5, ease: 'power3.out' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="fp-panel" style={{ background: '#080810' }}>
|
||||
@@ -33,7 +80,7 @@ export default function SpendAnywhereSection() {
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 24, marginBottom: 'clamp(40px, 6vh, 64px)' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 22 }}><span className="fp-kicker fp-rise">Spend · 04</span></div>
|
||||
<div style={{ marginBottom: 22 }}><span className="fp-kicker fp-rise">Spend<span className="fp-kicker-num">04</span></span></div>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(46px, 7vw, 110px)', fontWeight: 800, letterSpacing: '-0.06em', lineHeight: 0.9,
|
||||
color: '#fff', margin: 0, fontFamily: 'Sora, sans-serif',
|
||||
@@ -45,89 +92,83 @@ export default function SpendAnywhereSection() {
|
||||
<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 wallet. Every store. Your Loyaly points are universal: no restrictions, no expiry, no fine print.
|
||||
One balance. Every store. Your LYT is universal: no restrictions, no expiry, no fine print.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
<div ref={gridRef} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, perspective: 900 }}>
|
||||
{brands.map((brand, i) => {
|
||||
const BrandIcon = brand.Icon;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
className="fp-rise"
|
||||
ref={el => { cardRefs.current[i] = el; }}
|
||||
onMouseMove={handleMove(i)}
|
||||
onMouseLeave={handleLeave(i)}
|
||||
className="spend-card"
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '24px 28px',
|
||||
padding: '26px 28px',
|
||||
borderRadius: 22,
|
||||
background: active === i ? 'rgba(12, 12, 22, 0.65)' : 'rgba(10, 10, 18, 0.45)',
|
||||
border: `1px solid ${active === i ? `${brand.color}45` : 'rgba(255,255,255,0.08)'}`,
|
||||
backdropFilter: 'blur(20px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(140%)',
|
||||
background: '#0d0d16',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
cursor: 'default',
|
||||
transitionDelay: `${0.08 + i * 0.06}s`,
|
||||
transform: active === i ? 'translateY(-6px)' : 'translateY(0)',
|
||||
boxShadow: active === i
|
||||
? `0 24px 50px -12px rgba(0,0,0,0.65), 0 0 25px -4px ${brand.color}25, inset 0 1px 0 rgba(255,255,255,0.1)`
|
||||
: '0 8px 30px -10px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
transition: 'all 0.4s cubic-bezier(0.25, 1, 0.22, 1)',
|
||||
overflow: 'hidden',
|
||||
transformStyle: 'preserve-3d',
|
||||
['--card-color' as string]: brand.color,
|
||||
}}
|
||||
>
|
||||
{/* Glowing Left Accent Bar */}
|
||||
<span style={{
|
||||
position: 'absolute', left: 0, top: '20%', bottom: '20%', width: 3,
|
||||
borderRadius: '0 4px 4px 0',
|
||||
background: brand.color,
|
||||
opacity: active === i ? 1 : 0,
|
||||
transform: active === i ? 'scaleY(1.2)' : 'scaleY(0.8)',
|
||||
transition: 'all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
boxShadow: `0 0 12px ${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={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
|
||||
{/* Icon Container with subtle background shape */}
|
||||
<div style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 13,
|
||||
background: active === i ? `${brand.color}20` : 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${active === i ? `${brand.color}45` : 'rgba(255,255,255,0.09)'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: active === i ? brand.color : 'rgba(255,255,255,0.45)',
|
||||
transition: 'all 0.35s cubic-bezier(0.25, 1, 0.22, 1)',
|
||||
}}>
|
||||
<BrandIcon />
|
||||
<div style={{ position: 'relative', zIndex: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div className="spend-card-icon" style={{
|
||||
width: 42, height: 42, borderRadius: 13,
|
||||
background: `${brand.color}18`, border: `1px solid ${brand.color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: brand.color,
|
||||
}}>
|
||||
<BrandIcon />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 15, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em',
|
||||
}}>{brand.name}</span>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: active === i ? '#fff' : 'rgba(255,255,255,0.75)',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
letterSpacing: '-0.01em',
|
||||
transition: 'color 0.3s ease',
|
||||
}}>{brand.name}</span>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
|
||||
<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: active === i ? '#ffffff' : 'rgba(255,255,255,0.3)',
|
||||
background: active === i ? `${brand.color}22` : 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${active === i ? `${brand.color}40` : 'rgba(255,255,255,0.06)'}`,
|
||||
borderRadius: 100,
|
||||
padding: '5px 12px',
|
||||
transition: 'all 0.3s cubic-bezier(0.25, 1, 0.22, 1)',
|
||||
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>
|
||||
@@ -143,8 +184,8 @@ export default function SpendAnywhereSection() {
|
||||
}}>
|
||||
{[
|
||||
{ val: '10,000+', lbl: 'Partner stores' },
|
||||
{ val: 'Zero', lbl: 'Expiry on points' },
|
||||
{ val: 'Universal', lbl: 'One wallet, all brands' },
|
||||
{ val: 'Zero', lbl: 'Expiry on LYT' },
|
||||
{ val: 'Universal', lbl: 'One balance, all brands' },
|
||||
{ val: 'Instant', lbl: 'Redeem at checkout' },
|
||||
].map((s, i) => (
|
||||
<div key={i}>
|
||||
@@ -154,6 +195,17 @@ export default function SpendAnywhereSection() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,12 +189,22 @@ export default function VerticalScrollWrapper({ children, sections = [] }: Props
|
||||
font-size: 12px; letter-spacing: 0.3em; color: rgba(255,255,255,0.25);
|
||||
}
|
||||
.fp-kicker {
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
font-family: 'Sora', sans-serif; font-weight: 700;
|
||||
font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--gold);
|
||||
display: inline-flex; align-items: center; gap: 14px;
|
||||
font-family: 'Sora', sans-serif; font-weight: 800;
|
||||
font-size: clamp(14px, 1.3vw, 17px); letter-spacing: 0.24em;
|
||||
text-transform: uppercase; color: var(--gold);
|
||||
}
|
||||
.fp-kicker::before {
|
||||
content: ''; width: 30px; height: 1px; background: var(--gold); display: block;
|
||||
content: ''; width: 44px; height: 2px; border-radius: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--gold)); display: block;
|
||||
}
|
||||
.fp-kicker-num {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 34px; height: 34px; padding: 0 8px;
|
||||
border-radius: 50%; border: 1.5px solid var(--gold);
|
||||
font-size: 13px; font-weight: 800; letter-spacing: 0.05em;
|
||||
color: var(--gold); background: rgba(244,190,40,0.08);
|
||||
box-shadow: 0 0 18px rgba(244,190,40,0.18);
|
||||
}
|
||||
|
||||
/* ── Shared buttons ── */
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function WhyLoyalySection() {
|
||||
}, []);
|
||||
|
||||
const bars = [
|
||||
{ label: 'Online retail loyalty', pct: 78, color: '#171512' },
|
||||
{ label: 'Offline retail loyalty', pct: 2, color: '#bc7122' },
|
||||
{ label: 'Online retail rewards', pct: 78, color: '#171512' },
|
||||
{ label: 'Offline retail rewards', pct: 2, color: '#bc7122' },
|
||||
{ label: 'Loyaly target', pct: 60, color: '#f4be28' },
|
||||
];
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function WhyLoyalySection() {
|
||||
|
||||
<div className="fp-shell">
|
||||
<div style={{ marginBottom: 'clamp(40px, 6vh, 64px)' }}>
|
||||
<span className="fp-kicker fp-rise">The Gap · 01</span>
|
||||
<span className="fp-kicker fp-rise">The Gap<span className="fp-kicker-num">01</span></span>
|
||||
</div>
|
||||
|
||||
<div className="fp-split" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
@@ -69,7 +69,7 @@ export default function WhyLoyalySection() {
|
||||
display: 'inline-block', padding: '8px 16px', borderRadius: 10,
|
||||
background: '#bc7122', color: '#fff',
|
||||
fontSize: 12, fontWeight: 700, fontFamily: 'Sora, sans-serif', letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
}}>Loyalty penetration in offline retail</div>
|
||||
}}>Rewards penetration in offline retail</div>
|
||||
</div>
|
||||
|
||||
{/* Three metric chips */}
|
||||
@@ -116,7 +116,7 @@ export default function WhyLoyalySection() {
|
||||
fontSize: 16, lineHeight: 1.8, color: '#4f463a', margin: '0 0 36px',
|
||||
fontFamily: 'Sora, sans-serif', maxWidth: 440,
|
||||
}}>
|
||||
India's offline retail market is worth ₹2.1 lakh crore. Yet loyalty penetration sits at effectively zero. Loyaly exists to close that gap, permanently.
|
||||
India's offline retail market is worth ₹2.1 lakh crore. Yet rewards penetration sits at effectively zero. Loyaly exists to close that gap, permanently.
|
||||
</p>
|
||||
|
||||
{/* Data bars */}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { ParticleCard } from '@/components/MagicBento';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
@@ -14,52 +16,60 @@ interface ProblemSectionProps {
|
||||
isLoaded?: boolean;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const PROBLEM_ITEMS: Item[] = [
|
||||
const DARK_CARDS = [
|
||||
{
|
||||
text: 'No compelling reason for customers to visit your store',
|
||||
title: 'No Compelling Reason',
|
||||
description: 'No compelling reason for customers to visit your store',
|
||||
image: '/problems/4.png',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 12 }}>
|
||||
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'Traditional discounts erode profit margins',
|
||||
title: 'Margin Erosion',
|
||||
description: 'Traditional discounts erode profit margins',
|
||||
image: '/problems/5.png',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 12 }}>
|
||||
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" /><polyline points="17 18 23 18 23 12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'Digital ads rarely convert into store footfall',
|
||||
title: 'Wasted Ad Spend',
|
||||
description: 'Digital ads rarely convert into store footfall',
|
||||
image: '/problems/6.png',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6l8.25 8.25 4.5-4.5 6.75 6.75M21.75 16.5V11.25m0 5.25H16.5" />
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 12 }}>
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" /><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'No real-time connection between online activity and offline shopping',
|
||||
title: 'No Real-Time Signal',
|
||||
description: 'No real-time connection between online activity and offline shopping',
|
||||
image: '/problems/7.png',
|
||||
wide: true,
|
||||
icon: (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581a1.44 1.44 0 002.036 0l4.319-4.319a1.44 1.44 0 000-2.036L11.159 3.659A2.25 2.25 0 009.568 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7.5h.008v.008H6V7.5z" />
|
||||
</>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 12 }}>
|
||||
<line x1="1" y1="1" x2="23" y2="23" /><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.5M5 12.5a10.94 10.94 0 0 1 5.83-2.84M8.59 16.11a5.11 5.11 0 0 1 6.82 0" /><line x1="12" y1="20" x2="12.01" y2="20" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'Customers forget about stores between visits',
|
||||
title: 'Customer Amnesia',
|
||||
description: 'Customers forget about stores between visits',
|
||||
image: '/problems/8.png',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6l4 2M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 12 }}>
|
||||
<circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ value: '68%', label: 'walk in once, never return' },
|
||||
{ value: '0', label: 'visibility after they leave' },
|
||||
];
|
||||
|
||||
export default function ProblemSection({ isLoaded = true }: ProblemSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const accent = '#ef4444';
|
||||
@@ -77,22 +87,14 @@ export default function ProblemSection({ isLoaded = true }: ProblemSectionProps)
|
||||
.fromTo('.problem-title', { opacity: 0, y: 40, clipPath: 'polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)' }, { opacity: 1, y: 0, clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)', duration: 1.1, ease: 'power4.out' }, '-=0.3')
|
||||
.fromTo('.problem-subtitle', { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out' }, '-=0.8');
|
||||
|
||||
const rowTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.problem-list', start: 'top 82%', toggleActions: 'play none none reset' },
|
||||
const statTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.problem-stats-row', start: 'top 85%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
rowTl.fromTo('.problem-row', { opacity: 0, x: -30 }, { opacity: 1, x: 0, duration: 0.6, stagger: 0.09, ease: 'power3.out' });
|
||||
statTl.fromTo('.problem-stat-chip', { opacity: 0, y: 24, scale: 0.92 }, { opacity: 1, y: 0, scale: 1, duration: 0.6, stagger: 0.1, ease: 'back.out(1.7)' });
|
||||
|
||||
const visualTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.problem-visual', start: 'top 78%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
visualTl
|
||||
.fromTo('.problem-visual-stage', { opacity: 0, scale: 0.9, y: 40 }, { opacity: 1, scale: 1, y: 0, duration: 1, ease: 'power3.out' })
|
||||
.fromTo('.problem-chip', { opacity: 0, y: 18, scale: 0.85 }, { opacity: 1, y: 0, scale: 1, duration: 0.6, stagger: 0.15, ease: 'back.out(1.6)' }, '-=0.5');
|
||||
|
||||
gsap.to('.problem-visual-img', {
|
||||
y: -22,
|
||||
ease: 'none',
|
||||
scrollTrigger: { trigger: '.problem-visual', start: 'top bottom', end: 'bottom top', scrub: 1 },
|
||||
gsap.fromTo('.problem-card-grid', { opacity: 0, y: 32, scale: 0.96 }, {
|
||||
opacity: 1, y: 0, scale: 1, duration: 0.8, ease: 'power3.out',
|
||||
scrollTrigger: { trigger: '.problem-card-grid', start: 'top 88%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
|
||||
ScrollTrigger.refresh();
|
||||
@@ -145,79 +147,288 @@ export default function ProblemSection({ isLoaded = true }: ProblemSectionProps)
|
||||
</div>
|
||||
|
||||
<div className="problem-header-right" style={{ paddingBottom: 'clamp(4px, 1vw, 10px)', borderLeft: '2px solid rgba(23,21,18,0.1)', paddingLeft: 'clamp(20px, 2.4vw, 32px)' }}>
|
||||
<p className="problem-subtitle" style={{ color: '#4f463a', fontSize: 'clamp(1rem, 1.2vw, 1.15rem)', fontWeight: 500, margin: '0 0 18px', lineHeight: 1.7, fontFamily: 'Sora, sans-serif' }}>
|
||||
<p className="problem-subtitle" style={{ color: '#4f463a', fontSize: 'clamp(1rem, 1.2vw, 1.15rem)', fontWeight: 500, margin: 0, lineHeight: 1.7, fontFamily: 'Sora, sans-serif' }}>
|
||||
Retailers struggle to attract new customers and retain existing ones without sacrificing profit margins on generic discounts.
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: accent, letterSpacing: '-0.02em' }}>05</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.85rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Recurring pain points, every quarter</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split layout */}
|
||||
<div className="relative z-10 mx-auto grid problem-grid" style={{ maxWidth: 1200, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 'clamp(32px, 5vw, 72px)', alignItems: 'center' }}>
|
||||
{/* Visual */}
|
||||
<div className="problem-visual order-2 md:order-1" style={{ position: 'relative' }}>
|
||||
<div
|
||||
className="problem-visual-stage"
|
||||
{/* Grid wrapper */}
|
||||
<div className="relative z-10 mx-auto" style={{ maxWidth: 1200 }}>
|
||||
{/* Row 1: Red Stat Cards */}
|
||||
<div className="problem-stats-row" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 'clamp(10px, 1.4vw, 16px)',
|
||||
marginBottom: 'clamp(10px, 1.4vw, 16px)',
|
||||
}}>
|
||||
{/* Card 1 */}
|
||||
<ParticleCard
|
||||
className="problem-stat-chip"
|
||||
glowColor="255, 255, 255"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
style={{
|
||||
background: '#e12d2d',
|
||||
borderRadius: '24px',
|
||||
padding: 'clamp(20px, 2.4vw, 28px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '180px',
|
||||
boxShadow: '0 10px 30px -12px rgba(225, 45, 45, 0.4)',
|
||||
position: 'relative',
|
||||
borderRadius: 28,
|
||||
background: 'transparent',
|
||||
padding: 'clamp(12px, 2vw, 24px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
minHeight: 'clamp(340px, 38vw, 520px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 65% 55% at 50% 55%, ${accent}1c 0%, transparent 72%)`, pointerEvents: 'none' }} />
|
||||
<img
|
||||
src="/Problems+shift/Problems.png"
|
||||
alt="Customers walking past a store without engaging"
|
||||
className="problem-visual-img"
|
||||
style={{ maxWidth: '112%', maxHeight: '118%', objectFit: 'contain', filter: 'drop-shadow(0 26px 34px rgba(23,21,18,0.18))', pointerEvents: 'none', userSelect: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: '60%', paddingRight: '12px', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ fontSize: 'clamp(2rem, 3.2vw, 3rem)', fontWeight: 900, color: '#ffffff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
68%+
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: '0.75rem', fontWeight: 800, color: '#ffffff', fontFamily: 'Sora, sans-serif', textTransform: 'uppercase', letterSpacing: '0.06em', lineHeight: 1.3 }}>
|
||||
Walk in once,<br />never return
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-18px',
|
||||
bottom: '-18px',
|
||||
width: '210px',
|
||||
height: '210px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src="/problems/1.png"
|
||||
alt="68%+"
|
||||
width={340}
|
||||
height={340}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'scale(2.0)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
|
||||
{/* Floating stat chips */}
|
||||
<div className="problem-chip" style={{ position: 'absolute', top: '8%', left: '-6%', display: 'flex', alignItems: 'baseline', gap: 8, background: '#ffffff', borderRadius: 16, padding: '12px 18px', boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 20px 40px -20px rgba(23,21,18,0.28)', border: '1px solid rgba(23,21,18,0.05)' }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.35rem', color: accent }}>{STATS[0].value}</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: '#6b6255', lineHeight: 1.3, maxWidth: 110 }}>{STATS[0].label}</span>
|
||||
</div>
|
||||
<div className="problem-chip" style={{ position: 'absolute', bottom: '6%', right: '-4%', display: 'flex', alignItems: 'baseline', gap: 8, background: '#171512', borderRadius: 16, padding: '12px 18px', boxShadow: '0 20px 44px -18px rgba(23,21,18,0.45)' }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.35rem', color: '#fff' }}>{STATS[1].value}</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: 'rgba(255,255,255,0.6)', lineHeight: 1.3, maxWidth: 100 }}>{STATS[1].label}</span>
|
||||
</div>
|
||||
{/* Card 2 */}
|
||||
<ParticleCard
|
||||
className="problem-stat-chip"
|
||||
glowColor="255, 255, 255"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
style={{
|
||||
background: '#e12d2d',
|
||||
borderRadius: '24px',
|
||||
padding: 'clamp(20px, 2.4vw, 28px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '180px',
|
||||
boxShadow: '0 10px 30px -12px rgba(225, 45, 45, 0.4)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '60%', paddingRight: '12px', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ fontSize: 'clamp(2rem, 3.2vw, 3rem)', fontWeight: 900, color: '#ffffff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
0+
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: '0.75rem', fontWeight: 800, color: '#ffffff', fontFamily: 'Sora, sans-serif', textTransform: 'uppercase', letterSpacing: '0.06em', lineHeight: 1.3 }}>
|
||||
Visibility<br />after they leave
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-18px',
|
||||
bottom: '-18px',
|
||||
width: '210px',
|
||||
height: '210px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src="/problems/2.png"
|
||||
alt="0+"
|
||||
width={340}
|
||||
height={340}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'scale(2.0)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
|
||||
{/* Card 3 */}
|
||||
<ParticleCard
|
||||
className="problem-stat-chip"
|
||||
glowColor="255, 255, 255"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
style={{
|
||||
background: '#e12d2d',
|
||||
borderRadius: '24px',
|
||||
padding: 'clamp(20px, 2.4vw, 28px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '180px',
|
||||
boxShadow: '0 10px 30px -12px rgba(225, 45, 45, 0.4)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '60%', paddingRight: '12px', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ fontSize: 'clamp(2rem, 3.2vw, 3rem)', fontWeight: 900, color: '#ffffff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
5+
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: '0.75rem', fontWeight: 800, color: '#ffffff', fontFamily: 'Sora, sans-serif', textTransform: 'uppercase', letterSpacing: '0.06em', lineHeight: 1.3 }}>
|
||||
Recurring pain points,<br />every quarter
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-18px',
|
||||
bottom: '-18px',
|
||||
width: '210px',
|
||||
height: '210px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src="/problems/3.png"
|
||||
alt="5+"
|
||||
width={340}
|
||||
height={340}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'scale(2.0)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="problem-list order-1 md:order-2" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{PROBLEM_ITEMS.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="problem-row"
|
||||
{/* Rows 2 & 3: Dark Bento Cards */}
|
||||
<div className="problem-card-grid dark-bento-grid">
|
||||
{DARK_CARDS.map((card, i) => (
|
||||
<ParticleCard
|
||||
key={card.title}
|
||||
className={`dark-bento-card dark-bento-card-${i + 1}`}
|
||||
glowColor="239, 68, 68"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
padding: 'clamp(16px, 1.8vw, 20px) 4px',
|
||||
borderBottom: idx !== PROBLEM_ITEMS.length - 1 ? '1px solid rgba(23,21,18,0.08)' : 'none',
|
||||
background: '#171512',
|
||||
borderRadius: '24px',
|
||||
padding: 'clamp(20px, 2.4vw, 28px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '190px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 13, background: `${accent}12`, border: `1px solid ${accent}26` }}>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke={accent} strokeWidth="2">{item.icon}</svg>
|
||||
</span>
|
||||
<span style={{ color: '#2e2a24', fontSize: '1rem', fontWeight: 600, lineHeight: 1.4, fontFamily: 'Sora, sans-serif' }}>{item.text}</span>
|
||||
</div>
|
||||
<div style={{ width: card.wide ? '46%' : '54%', paddingRight: '12px', position: 'relative', zIndex: 2 }}>
|
||||
{card.icon}
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, color: '#ffffff', fontFamily: 'Sora, sans-serif', margin: '0 0 6px' }}>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.82rem', color: '#9ca3af', lineHeight: 1.5, fontFamily: 'Sora, sans-serif', margin: 0 }}>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
{card.wide ? (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-6px',
|
||||
bottom: '-6px',
|
||||
width: '300px',
|
||||
height: '180px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: '52% 34%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-24px',
|
||||
bottom: '-24px',
|
||||
width: '230px',
|
||||
height: '230px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'scale(2.1)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ParticleCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.dark-bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: clamp(10px, 1.4vw, 16px);
|
||||
}
|
||||
.dark-bento-card-4 { grid-column: span 2; }
|
||||
.dark-bento-card-5 { grid-column: span 1; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#problem .problem-grid { grid-template-columns: 1fr; }
|
||||
#problem .problem-chip { display: none; }
|
||||
#problem .problem-header-grid { grid-template-columns: 1fr; }
|
||||
#problem .problem-header-right { border-left: none; padding-left: 0; border-top: 2px solid rgba(23,21,18,0.1); padding-top: 20px; margin-top: 20px; }
|
||||
#problem .problem-stats-row { grid-template-columns: 1fr !important; }
|
||||
#problem .dark-bento-grid { grid-template-columns: 1fr !important; }
|
||||
#problem .dark-bento-card-4 { grid-column: span 1; }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
|
||||
@@ -63,7 +63,7 @@ const PRODUCTS: Product[] = [
|
||||
title: 'Identiq',
|
||||
subtitle: 'Consumer Loyalty Platform',
|
||||
description:
|
||||
'Earn Loyaly Coins through daily logins and games, then redeem rewards at real offline stores.',
|
||||
'Earn LYT through daily logins and games, then redeem rewards at real offline stores.',
|
||||
accent: '#f4be28',
|
||||
accentRgb: '244, 190, 40',
|
||||
tags: ['Daily Rewards', 'Game Based Offers', 'Offline Redemption'],
|
||||
@@ -283,21 +283,21 @@ function ProductVisual({ product }: { product: Product }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Points card with progress to next tier */}
|
||||
{/* LYT balance card with progress to next tier */}
|
||||
<div style={{ borderRadius: 18, padding: '18px 20px', background: `linear-gradient(135deg, ${accent} 0%, #e09820 100%)`, position: 'relative', overflow: 'hidden' }}>
|
||||
<div className="pv-sheen" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.6)', fontFamily: 'Sora, sans-serif' }}>Reward Points</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.6)', fontFamily: 'Sora, sans-serif' }}>Reward LYT</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 900, color: '#111', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', marginTop: 2 }}>284</div>
|
||||
</div>
|
||||
<span style={{ width: 34, height: 34, borderRadius: '50%', background: 'rgba(255,255,255,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>🪙</span>
|
||||
<span style={{ width: 34, height: 34, borderRadius: 10, background: 'rgba(255,255,255,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, fontWeight: 900, color: '#111', fontFamily: 'Sora, sans-serif' }}>⬡</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 5, borderRadius: 3, background: 'rgba(0,0,0,0.15)', overflow: 'hidden' }}>
|
||||
<div className="pv-progress-fill" style={{ height: '100%', width: '68%', borderRadius: 3, background: '#111' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.55)', fontFamily: 'Sora, sans-serif', marginTop: 6, fontWeight: 600 }}>116 pts to Platinum</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.55)', fontFamily: 'Sora, sans-serif', marginTop: 6, fontWeight: 600 }}>116 LYT to Platinum</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -389,7 +389,7 @@ function ProductCard({ product, index }: { product: Product; index: number }) {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={product.href}
|
||||
href="/solutions_page"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '8px', width: 'max-content',
|
||||
padding: '11px 22px', borderRadius: '99px', fontSize: '0.85rem', fontWeight: 800,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { ParticleCard } from '@/components/MagicBento';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
@@ -14,67 +16,90 @@ interface ShiftSectionProps {
|
||||
isLoaded?: boolean;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const SHIFT_ITEMS: Item[] = [
|
||||
const GREEN_CARDS = [
|
||||
{
|
||||
text: 'Customers earn Loyaly Coins through daily app activities',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
),
|
||||
value: '3.4x',
|
||||
label: 'MORE REPEAT VISITS',
|
||||
image: '/shift/1.png',
|
||||
},
|
||||
{
|
||||
text: 'Game-based campaigns drive footfall with excitement',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18l8.97-8.97a.75.75 0 011.06 0l2.235 2.234a.75.75 0 001.06 0L21.75 5.5M21.75 5.5h-5.25m5.25 0v5.25" />
|
||||
),
|
||||
value: '20%',
|
||||
label: 'LIVE IN-APP OFFER',
|
||||
image: '/shift/2.png',
|
||||
},
|
||||
{
|
||||
text: 'Smart surprise coupons create a daily reason to engage',
|
||||
icon: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'Real-time discovery of nearby stores and live offers',
|
||||
icon: (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581a1.44 1.44 0 002.036 0l4.319-4.319a1.44 1.44 0 000-2.036L11.159 3.659A2.25 2.25 0 009.568 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7.5h.008v.008H6V7.5z" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: 'Every store visit earns more rewards and deepens loyalty',
|
||||
icon: (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
||||
),
|
||||
value: '5',
|
||||
label: "EVERYDAY MOMENTS,\nTURNED INTO LOYALTY",
|
||||
image: '/shift/3.png',
|
||||
},
|
||||
];
|
||||
|
||||
const BOTTOM_FEATURES = [
|
||||
{ label: 'Earn Daily', icon: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 2v3M12 19v3M2 12h3M19 12h3" /></> },
|
||||
{ label: 'Play & Win', icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> },
|
||||
{ label: 'Smart Coupons', icon: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18l8.97-8.97a.75.75 0 011.06 0l2.235 2.234a.75.75 0 001.06 0L21.75 5.5M21.75 5.5h-5.25m5.25 0v5.25" /> },
|
||||
{ label: 'Shop Offline', icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" /> },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ value: '3.4x', label: 'more repeat visits' },
|
||||
{ value: '20%', label: 'live in-app offer' },
|
||||
const BLACK_CARDS = [
|
||||
{
|
||||
title: 'Earn Daily',
|
||||
description: 'Customers earn LYT through daily app activities',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
),
|
||||
image: '/shift/4.png',
|
||||
},
|
||||
{
|
||||
title: 'Game Campaigns',
|
||||
description: 'Game-based campaigns drive footfall with excitement',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="6" y1="12" x2="10" y2="12"></line>
|
||||
<line x1="8" y1="10" x2="8" y2="14"></line>
|
||||
<line x1="15" y1="13" x2="15.01" y2="13"></line>
|
||||
<line x1="18" y1="11" x2="18.01" y2="11"></line>
|
||||
<rect x="2" y="6" width="20" height="12" rx="3"></rect>
|
||||
</svg>
|
||||
),
|
||||
image: '/shift/5.png',
|
||||
},
|
||||
{
|
||||
title: 'Smart Coupons',
|
||||
description: 'Smart surprise coupons create a daily reason to engage',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
),
|
||||
image: '/shift/6.png',
|
||||
},
|
||||
{
|
||||
title: 'Live Discovery',
|
||||
description: 'Real-time discovery of nearby stores and live offers',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
),
|
||||
image: '/shift/7.png',
|
||||
},
|
||||
{
|
||||
title: 'Deepens Loyalty',
|
||||
description: 'Every store visit earns more rewards and deepens loyalty',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||
</svg>
|
||||
),
|
||||
image: '/shift/8.png',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const accent = '#f4be28';
|
||||
const iconAccent = '#22c55e';
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -89,28 +114,10 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
||||
.fromTo('.shift-title', { opacity: 0, y: 40, clipPath: 'polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)' }, { opacity: 1, y: 0, clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)', duration: 1.1, ease: 'power4.out' }, '-=0.3')
|
||||
.fromTo('.shift-subtitle', { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out' }, '-=0.8');
|
||||
|
||||
const rowTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.shift-list', start: 'top 82%', toggleActions: 'play none none reset' },
|
||||
gsap.fromTo('.shift-bento-card', { opacity: 0, y: 32, scale: 0.96 }, {
|
||||
opacity: 1, y: 0, scale: 1, duration: 0.7, ease: 'power3.out', stagger: 0.08,
|
||||
scrollTrigger: { trigger: '.shift-bento-grid', start: 'top 88%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
rowTl.fromTo('.shift-row', { opacity: 0, x: 30 }, { opacity: 1, x: 0, duration: 0.6, stagger: 0.09, ease: 'power3.out' });
|
||||
|
||||
const visualTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.shift-visual', start: 'top 78%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
visualTl
|
||||
.fromTo('.shift-visual-stage', { opacity: 0, scale: 0.9, y: 40 }, { opacity: 1, scale: 1, y: 0, duration: 1, ease: 'power3.out' })
|
||||
.fromTo('.shift-chip', { opacity: 0, y: 18, scale: 0.85 }, { opacity: 1, y: 0, scale: 1, duration: 0.6, stagger: 0.15, ease: 'back.out(1.6)' }, '-=0.5');
|
||||
|
||||
gsap.to('.shift-visual-img', {
|
||||
y: -22,
|
||||
ease: 'none',
|
||||
scrollTrigger: { trigger: '.shift-visual', start: 'top bottom', end: 'bottom top', scrub: 1 },
|
||||
});
|
||||
|
||||
const bottomTl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.shift-bottom-bar', start: 'top 92%', toggleActions: 'play none none reset' },
|
||||
});
|
||||
bottomTl.fromTo('.shift-feature-item', { opacity: 0, y: 36, scale: 0.88 }, { opacity: 1, y: 0, scale: 1, stagger: 0.1, duration: 0.65, ease: 'back.out(1.7)' });
|
||||
|
||||
ScrollTrigger.refresh();
|
||||
}, sectionRef);
|
||||
@@ -155,24 +162,6 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
||||
<div className="shift-orb shift-orb-2" style={{ position: 'absolute', bottom: '-15%', right: '-10%', width: 460, height: 460, borderRadius: '50%', background: 'radial-gradient(circle, rgba(34,197,94,0.12) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
<div className="shift-orb shift-orb-3" style={{ position: 'absolute', top: '32%', right: '36%', width: 320, height: 320, borderRadius: '50%', background: `radial-gradient(circle, ${accent}14 0%, transparent 72%)`, pointerEvents: 'none' }} />
|
||||
|
||||
{/* Twinkling sparkle accents */}
|
||||
{[
|
||||
{ top: '14%', left: '58%', delay: '0s', size: 6 },
|
||||
{ top: '68%', left: '48%', delay: '1.1s', size: 4 },
|
||||
{ top: '40%', left: '8%', delay: '2s', size: 5 },
|
||||
{ top: '78%', left: '82%', delay: '0.6s', size: 4 },
|
||||
].map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="shift-sparkle"
|
||||
style={{
|
||||
position: 'absolute', top: s.top, left: s.left, width: s.size, height: s.size,
|
||||
borderRadius: '50%', background: '#f4be28', pointerEvents: 'none',
|
||||
animationDelay: s.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inner edge vignette for a framed, premium finish */}
|
||||
<div style={{ position: 'absolute', inset: 0, borderRadius: 32, boxShadow: 'inset 0 0 90px rgba(23,21,18,0.05)', pointerEvents: 'none' }} />
|
||||
|
||||
@@ -187,112 +176,175 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
||||
</div>
|
||||
|
||||
<h2 className="shift-title" style={{ color: '#171512', fontWeight: 800, fontSize: 'clamp(2.6rem, 5.6vw, 4.6rem)', letterSpacing: '-0.05em', lineHeight: 0.96, margin: 0, fontFamily: 'Sora, sans-serif' }}>
|
||||
Loyaly makes it<br /><span style={{ color: accent }}>simple.</span>
|
||||
Loyaly makes it<br /><span style={{ color: iconAccent }}>simple.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="shift-header-right" style={{ paddingBottom: 'clamp(4px, 1vw, 10px)', borderLeft: `2px solid ${accent}44`, paddingLeft: 'clamp(20px, 2.4vw, 32px)' }}>
|
||||
<p className="shift-subtitle" style={{ color: '#4f463a', fontSize: 'clamp(1rem, 1.2vw, 1.15rem)', fontWeight: 500, margin: '0 0 18px', lineHeight: 1.7, fontFamily: 'Sora, sans-serif' }}>
|
||||
<p className="shift-subtitle" style={{ color: '#4f463a', fontSize: 'clamp(1rem, 1.2vw, 1.15rem)', fontWeight: 500, margin: 0, lineHeight: 1.7, fontFamily: 'Sora, sans-serif' }}>
|
||||
Loyaly bridges digital engagement and offline shopping, with daily rewards, interactive games, and surprise coupons redeemable in person.
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.6rem', color: '#bc7122', letterSpacing: '-0.02em' }}>05</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.85rem', color: '#8a8072', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Everyday moments, turned into loyalty</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split layout */}
|
||||
<div className="relative z-10 mx-auto shift-grid" style={{ maxWidth: 1200, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 'clamp(32px, 5vw, 72px)', alignItems: 'center' }}>
|
||||
{/* List */}
|
||||
<div className="shift-list" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{SHIFT_ITEMS.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="shift-row"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
padding: 'clamp(16px, 1.8vw, 20px) 4px',
|
||||
borderBottom: idx !== SHIFT_ITEMS.length - 1 ? '1px solid rgba(23,21,18,0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 13, background: `${accent}1f`, border: `1px solid ${accent}55` }}>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8a5a14" strokeWidth="2">{item.icon}</svg>
|
||||
</span>
|
||||
<span style={{ color: '#2e2a24', fontSize: '1rem', fontWeight: 600, lineHeight: 1.4, fontFamily: 'Sora, sans-serif' }}>{item.text}</span>
|
||||
{/* 8-Card Bento Grid */}
|
||||
<div className="shift-bento-grid relative z-10">
|
||||
|
||||
{/* Render Green Stat Cards (Cards 1-3) */}
|
||||
{GREEN_CARDS.map((card, i) => (
|
||||
<ParticleCard
|
||||
key={`green-${i}`}
|
||||
className={`shift-bento-card shift-bento-card--green shift-card-${i + 1}`}
|
||||
glowColor="255, 255, 255"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
>
|
||||
<div style={{ position: 'relative', zIndex: 2, width: '52%', display: 'flex', flexDirection: 'column', height: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<span style={{ fontSize: 'clamp(2.5rem, 3.8vw, 3.5rem)', fontWeight: 900, color: '#ffffff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
{card.value}+
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 800,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-line',
|
||||
maxWidth: '180px'
|
||||
}}>
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Visual */}
|
||||
<div className="shift-visual" style={{ position: 'relative' }}>
|
||||
<div
|
||||
className="shift-visual-stage"
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 28,
|
||||
background: 'transparent',
|
||||
padding: 'clamp(12px, 2vw, 24px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
minHeight: 'clamp(340px, 38vw, 520px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 65% 55% at 50% 55%, ${accent}2a 0%, transparent 72%)`, pointerEvents: 'none' }} />
|
||||
<img
|
||||
src="/Problems+shift/Shift.png"
|
||||
alt="Customers engaging with the Loyaly app inside a store with live offers"
|
||||
className="shift-visual-img"
|
||||
style={{ maxWidth: '112%', maxHeight: '118%', objectFit: 'contain', filter: 'drop-shadow(0 26px 34px rgba(23,21,18,0.16))', pointerEvents: 'none', userSelect: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating stat chips */}
|
||||
<div className="shift-chip" style={{ position: 'absolute', top: '8%', right: '-6%', display: 'flex', alignItems: 'baseline', gap: 8, background: '#171512', borderRadius: 16, padding: '12px 18px', boxShadow: '0 20px 44px -18px rgba(23,21,18,0.45)' }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.35rem', color: accent }}>{STATS[0].value}</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: 'rgba(255,255,255,0.65)', lineHeight: 1.3, maxWidth: 100 }}>{STATS[0].label}</span>
|
||||
</div>
|
||||
<div className="shift-chip" style={{ position: 'absolute', bottom: '6%', left: '-4%', display: 'flex', alignItems: 'baseline', gap: 8, background: '#ffffff', borderRadius: 16, padding: '12px 18px', boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 20px 40px -20px rgba(23,21,18,0.28)', border: '1px solid rgba(23,21,18,0.05)' }}>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 800, fontSize: '1.35rem', color: '#22c55e' }}>{STATS[1].value}</span>
|
||||
<span style={{ fontFamily: 'Sora, sans-serif', fontWeight: 600, fontSize: '0.78rem', color: '#6b6255', lineHeight: 1.3, maxWidth: 110 }}>{STATS[1].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom features bar */}
|
||||
<div className="shift-bottom-bar relative z-10 mx-auto" style={{ maxWidth: 980, marginTop: 'clamp(48px, 6vw, 80px)', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', gap: 'clamp(12px, 1.6vw, 18px)' }}>
|
||||
{BOTTOM_FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="shift-feature-item"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
background: '#fff', borderRadius: 18,
|
||||
border: '1px solid rgba(23,21,18,0.06)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03), 0 16px 36px -22px rgba(23,21,18,0.22)',
|
||||
padding: 'clamp(16px, 1.6vw, 20px) clamp(16px, 2vw, 22px)',
|
||||
cursor: 'default',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 44, height: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: 13, background: 'linear-gradient(145deg,#fff8e8 0%,#fff2c8 100%)',
|
||||
border: '1px solid rgba(244,190,40,0.25)',
|
||||
|
||||
{/* Papercraft Image */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: i === 2 ? '-18px' : '-22px',
|
||||
bottom: i === 2 ? '-18px' : '-22px',
|
||||
width: i === 2 ? '62%' : '58%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#bc7122" strokeWidth="2">{f.icon}</svg>
|
||||
</span>
|
||||
<span style={{ color: '#171512', fontWeight: 700, fontSize: '0.95rem', fontFamily: 'Sora, sans-serif', lineHeight: 1.3 }}>{f.label}</span>
|
||||
</div>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.label}
|
||||
width={340}
|
||||
height={340}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: i === 2 ? 'scale(1.32)' : 'scale(1.28)',
|
||||
transformOrigin: 'bottom right',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ParticleCard>
|
||||
))}
|
||||
|
||||
{/* Render Black Feature Cards (Cards 4-8) */}
|
||||
{BLACK_CARDS.map((card, i) => {
|
||||
const cardNumber = i + 4; // Card index is 4 to 8
|
||||
return (
|
||||
<ParticleCard
|
||||
key={`black-${i}`}
|
||||
className={`shift-bento-card shift-bento-card--black shift-card-${cardNumber}`}
|
||||
glowColor="34, 197, 94"
|
||||
enableTilt={true}
|
||||
enableMagnetism={true}
|
||||
clickEffect={true}
|
||||
particleCount={6}
|
||||
>
|
||||
<div style={{ position: 'relative', zIndex: 2, width: cardNumber === 7 ? '40%' : (cardNumber === 8 ? '46%' : '52%'), display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ color: iconAccent, marginBottom: 14, display: 'flex', alignItems: 'center' }}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
color: '#ffffff',
|
||||
fontSize: 'clamp(1.1rem, 1.3vw, 1.25rem)',
|
||||
fontWeight: 700,
|
||||
margin: '0 0 8px 0',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
letterSpacing: '-0.01em'
|
||||
}}>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: '#9a9895',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
lineHeight: 1.45,
|
||||
fontFamily: 'Sora, sans-serif'
|
||||
}}>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Papercraft Image */}
|
||||
{(cardNumber === 7 || cardNumber === 8) ? (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: cardNumber === 7 ? '58%' : '54%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
width={cardNumber === 7 ? 480 : 400}
|
||||
height={cardNumber === 7 ? 480 : 400}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: cardNumber === 7 ? '62% 88%' : '68% 82%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '-14px',
|
||||
bottom: '-16px',
|
||||
width: '56%',
|
||||
height: '96%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
width={340}
|
||||
height={340}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'scale(1.22)',
|
||||
transformOrigin: 'bottom right',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ParticleCard>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
#shift .shift-feature-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 24px 48px -22px rgba(23,21,18,0.3);
|
||||
border-color: rgba(244,190,40,0.35);
|
||||
}
|
||||
#shift .shift-orb { animation: shiftOrbDrift 16s ease-in-out infinite; }
|
||||
#shift .shift-orb-2 { animation-duration: 20s; animation-direction: reverse; }
|
||||
#shift .shift-orb-3 { animation-duration: 13s; }
|
||||
@@ -300,22 +352,86 @@ export default function ShiftSection({ isLoaded = true }: ShiftSectionProps) {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(22px, -18px) scale(1.06); }
|
||||
}
|
||||
#shift .shift-sparkle {
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 8px 2px rgba(244,190,40,0.6);
|
||||
animation: shiftSparkleTwinkle 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shiftSparkleTwinkle {
|
||||
0%, 100% { opacity: 0; transform: scale(0.6); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#shift .shift-orb, #shift .shift-sparkle { animation: none; }
|
||||
#shift .shift-orb { animation: none; }
|
||||
}
|
||||
|
||||
/* Bento grid styles */
|
||||
.shift-bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1.1fr 1.4fr;
|
||||
gap: clamp(12px, 1.6vw, 20px);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.shift-bento-card {
|
||||
min-height: 220px;
|
||||
border-radius: 24px;
|
||||
padding: clamp(20px, 2.4vw, 28px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.shift-bento-card--green {
|
||||
background: linear-gradient(135deg, #134e2e 0%, #0d3821 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 10px 30px -12px rgba(19, 78, 46, 0.35);
|
||||
}
|
||||
.shift-bento-card--black {
|
||||
background: #171512;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 10px 30px -12px rgba(0,0,0,0.5);
|
||||
}
|
||||
.shift-bento-card--green:hover {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.shift-bento-card--black:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* Card column mappings */
|
||||
.shift-card-1 { grid-column: span 1; }
|
||||
.shift-card-2 { grid-column: span 1; }
|
||||
.shift-card-3 { grid-column: span 1; }
|
||||
.shift-card-4 { grid-column: span 1; }
|
||||
.shift-card-5 { grid-column: span 1; }
|
||||
.shift-card-6 { grid-column: span 1; }
|
||||
.shift-card-7 { grid-column: span 2; min-height: 240px; }
|
||||
.shift-card-8 { grid-column: span 1; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.shift-bento-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.shift-card-1 { grid-column: span 1; }
|
||||
.shift-card-2 { grid-column: span 1; }
|
||||
.shift-card-3 { grid-column: span 2; }
|
||||
.shift-card-4 { grid-column: span 1; }
|
||||
.shift-card-5 { grid-column: span 1; }
|
||||
.shift-card-6 { grid-column: span 2; }
|
||||
.shift-card-7 { grid-column: span 2; }
|
||||
.shift-card-8 { grid-column: span 2; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shift-bento-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shift-card-1, .shift-card-2, .shift-card-3, .shift-card-4, .shift-card-5, .shift-card-6, .shift-card-7, .shift-card-8 {
|
||||
grid-column: span 1 !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
.shift-card-7 {
|
||||
min-height: 220px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#shift .shift-grid { grid-template-columns: 1fr; }
|
||||
#shift .shift-visual { order: -1; }
|
||||
#shift .shift-chip { display: none; }
|
||||
#shift .shift-header-grid { grid-template-columns: 1fr; }
|
||||
#shift .shift-header-right { border-left: none; padding-left: 0; border-top: 2px solid rgba(244,190,40,0.3); padding-top: 20px; margin-top: 20px; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import AnimatedDarkBg from './AnimatedDarkBg';
|
||||
import Image from 'next/image';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
@@ -19,10 +20,10 @@ interface Step {
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ step: '01', id: 'earn', title: 'Earn Online', desc: 'Daily login, 5,000-step walking challenges, and interactive games like Maze and Spin Wheel earn Loyaly Coins and unlock surprise coupons.' },
|
||||
{ step: '02', id: 'engage', title: 'Unlock Deals', desc: 'Merchants create game based discount campaigns: customers play once to reveal their exclusive reward on a specific product.' },
|
||||
{ step: '03', id: 'spend', title: 'Discover & Visit', desc: 'Browse participating stores nearby, view active game campaigns and coin-earning offers, then walk in and shop in person.' },
|
||||
{ step: '04', id: 'repeat', title: 'Redeem & Repeat', desc: 'Use Loyaly Coins and coupons at checkout to save money, then come back tomorrow to earn more and keep the loop going.' },
|
||||
{ step: '01', id: 'earn', title: 'Earn Online', desc: 'Daily login, 5,000-step walking challenges, and interactive games like Maze and Spin Wheel earn LYT and unlock surprise coupons.' },
|
||||
{ step: '02', id: 'engage', title: 'Unlock Deals', desc: 'Merchants create game based discount campaigns: customers play once to reveal their exclusive reward on a specific product.' },
|
||||
{ step: '03', id: 'spend', title: 'Discover & Visit', desc: 'Browse participating stores nearby, view active game campaigns and LYT-earning offers, then walk in and shop in person.' },
|
||||
{ step: '04', id: 'repeat', title: 'Redeem & Repeat', desc: 'Use LYT and coupons at checkout to save money, then come back tomorrow to earn more and keep the loop going.' },
|
||||
];
|
||||
|
||||
const IMPACTS = [
|
||||
@@ -32,70 +33,126 @@ const IMPACTS = [
|
||||
{ label: 'Real-Time Decisions', value: 500, suffix: 'ms', prefix: '<', decimals: 0 },
|
||||
];
|
||||
|
||||
function StepIcon({ id }: { id: Step['id'] }) {
|
||||
const p = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||
if (id === 'earn') return (
|
||||
<svg viewBox="0 0 24 24" {...p} style={{ width: '100%', height: '100%' }}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M14.5 9a2.5 2.5 0 00-5 0c0 2.5 5 2.5 5 5a2.5 2.5 0 01-5 0" />
|
||||
<path d="M12 6.5V5M12 19v-1.5" />
|
||||
</svg>
|
||||
);
|
||||
if (id === 'engage') return (
|
||||
<svg viewBox="0 0 24 24" {...p} style={{ width: '100%', height: '100%' }}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
if (id === 'spend') return (
|
||||
<svg viewBox="0 0 24 24" {...p} style={{ width: '100%', height: '100%' }}>
|
||||
<rect x="1.5" y="4" width="21" height="16" rx="2" />
|
||||
<line x1="1.5" y1="9.5" x2="22.5" y2="9.5" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...p} style={{ width: '100%', height: '100%' }}>
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const IMAGE_PATHS = [
|
||||
'/retail_cycle/Earn_online.png',
|
||||
'/retail_cycle/unlock_deals.png',
|
||||
'/retail_cycle/discover+visits.png',
|
||||
'/retail_cycle/redeem+repeat.png',
|
||||
];
|
||||
|
||||
export default function TheLoop() {
|
||||
const [active, setActive] = useState(0);
|
||||
// Only counts uses state — everything scroll-driven is ref-mutated
|
||||
const [counts, setCounts] = useState<number[]>(IMPACTS.map(() => 0));
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const metricsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sequential highlight cycle (client-only → no hydration mismatch)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setActive(a => (a + 1) % STEPS.length), 1900);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
const metricsRef = useRef<HTMLDivElement>(null);
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const ghostRef = useRef<HTMLDivElement>(null);
|
||||
const scrollHintRef = useRef<HTMLDivElement>(null);
|
||||
// Per-step refs
|
||||
const labelRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const titleRefs = useRef<(HTMLHeadingElement | null)[]>([]);
|
||||
const descRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const trackDotRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const trackDotTextRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const trackDotGlowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const visualRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Scroll-triggered reveals + count-up, all via GSAP ScrollTrigger
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Set initial active state directly in DOM
|
||||
const applyActive = (idx: number, lineFillPct: number) => {
|
||||
// Growing line
|
||||
if (lineRef.current)
|
||||
lineRef.current.style.height = `calc((100% - 40px) * ${lineFillPct / 100})`;
|
||||
if (tipRef.current)
|
||||
tipRef.current.style.top = `calc(20px + (100% - 40px) * ${lineFillPct / 100} - 7px)`;
|
||||
|
||||
// Ghost step number
|
||||
if (ghostRef.current)
|
||||
ghostRef.current.textContent = STEPS[idx].step;
|
||||
|
||||
// Scroll hint
|
||||
if (scrollHintRef.current)
|
||||
scrollHintRef.current.style.opacity = idx === 0 ? '0.5' : '0';
|
||||
|
||||
STEPS.forEach((_, i) => {
|
||||
const isActive = i === idx;
|
||||
|
||||
if (labelRefs.current[i])
|
||||
labelRefs.current[i]!.style.color = isActive ? 'rgba(244,190,40,0.85)' : 'rgba(255,255,255,0.2)';
|
||||
|
||||
if (titleRefs.current[i]) {
|
||||
titleRefs.current[i]!.style.color = isActive ? '#fff' : 'rgba(255,255,255,0.22)';
|
||||
titleRefs.current[i]!.style.marginBottom = isActive ? '10px' : '0px';
|
||||
}
|
||||
|
||||
if (descRefs.current[i])
|
||||
descRefs.current[i]!.style.maxHeight = isActive ? '140px' : '0px';
|
||||
|
||||
const isPassed = i < idx;
|
||||
const dot = trackDotRefs.current[i];
|
||||
const txt = trackDotTextRefs.current[i];
|
||||
const glow = trackDotGlowRefs.current[i];
|
||||
|
||||
if (dot) {
|
||||
if (isActive) {
|
||||
dot.style.background = '#f4be28';
|
||||
dot.style.borderColor = '#f4be28';
|
||||
dot.style.transform = 'scale(1.18)';
|
||||
dot.style.boxShadow = '0 0 18px rgba(244,190,40,0.7), 0 0 6px rgba(244,190,40,1)';
|
||||
} else if (isPassed) {
|
||||
dot.style.background = 'rgba(244,190,40,0.25)';
|
||||
dot.style.borderColor = 'rgba(244,190,40,0.5)';
|
||||
dot.style.transform = 'scale(1)';
|
||||
dot.style.boxShadow = 'none';
|
||||
} else {
|
||||
dot.style.background = 'transparent';
|
||||
dot.style.borderColor = 'rgba(244,190,40,0.2)';
|
||||
dot.style.transform = 'scale(1)';
|
||||
dot.style.boxShadow = 'none';
|
||||
}
|
||||
}
|
||||
if (txt) {
|
||||
txt.style.color = isActive ? '#1a1200' : isPassed ? 'rgba(244,190,40,0.7)' : 'rgba(244,190,40,0.22)';
|
||||
txt.style.opacity = isActive ? '1' : isPassed ? '1' : '0.5';
|
||||
}
|
||||
if (glow) {
|
||||
glow.style.opacity = isActive ? '1' : '0';
|
||||
glow.style.transform = isActive ? 'scale(1)' : 'scale(0.7)';
|
||||
glow.style.animation = isActive ? 'loopDotPulse 2s ease-out infinite' : 'none';
|
||||
}
|
||||
|
||||
if (visualRefs.current[i])
|
||||
visualRefs.current[i]!.style.opacity = isActive ? '1' : '0';
|
||||
});
|
||||
};
|
||||
|
||||
// Seed with step 0
|
||||
applyActive(0, 0);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Reveal the flow diagram cards on scroll-in
|
||||
const totalSteps = STEPS.length;
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: '.loop-diagram',
|
||||
start: 'top 78%',
|
||||
once: true,
|
||||
onEnter: () => setRevealed(true),
|
||||
trigger: sectionRef.current,
|
||||
start: 'top top',
|
||||
end: () => `+=${window.innerHeight * (totalSteps - 1) * 1.8}`,
|
||||
pin: pinRef.current,
|
||||
scrub: true, // No smoothing lag — direct 1:1 with scroll position
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
const idx = Math.min(Math.floor(p * totalSteps), totalSteps - 1);
|
||||
// Fill goes from 0 to 100% as progress goes 0→(n-1)/n
|
||||
const lineFillPct = Math.min(p * totalSteps, totalSteps - 1) / (totalSteps - 1) * 100;
|
||||
applyActive(idx, lineFillPct);
|
||||
},
|
||||
});
|
||||
|
||||
// Subtle parallax drift on the diagram + metrics as the section scrolls
|
||||
gsap.fromTo('.loop-diagram', { y: 40 }, {
|
||||
y: -40, ease: 'none',
|
||||
scrollTrigger: { trigger: '.loop-diagram', start: 'top bottom', end: 'bottom top', scrub: 1 },
|
||||
});
|
||||
|
||||
// Count-up metrics when they scroll into view
|
||||
// Count-up metrics
|
||||
ScrollTrigger.create({
|
||||
trigger: metricsRef.current,
|
||||
start: 'top 82%',
|
||||
@@ -113,7 +170,6 @@ export default function TheLoop() {
|
||||
},
|
||||
});
|
||||
|
||||
// Metric cells stagger in
|
||||
gsap.fromTo('.loop-metric', { opacity: 0, y: 26 }, {
|
||||
opacity: 1, y: 0, duration: 0.7, stagger: 0.1, ease: 'power3.out',
|
||||
scrollTrigger: { trigger: metricsRef.current, start: 'top 82%', toggleActions: 'play none none reset' },
|
||||
@@ -126,159 +182,296 @@ export default function TheLoop() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="the-loop"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 'calc(100% - 40px)',
|
||||
margin: '0 20px 20px',
|
||||
borderRadius: '32px',
|
||||
margin: '0 20px 0',
|
||||
borderRadius: '32px 32px 0 0',
|
||||
overflow: 'hidden',
|
||||
zIndex: 2,
|
||||
padding: 'clamp(80px, 10vh, 140px) clamp(28px, 5vw, 80px) clamp(72px, 8vh, 120px)',
|
||||
height: `${(STEPS.length - 1) * 180 + 100}vh`,
|
||||
}}
|
||||
>
|
||||
<AnimatedDarkBg variant="amber" />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative', zIndex: 10, textAlign: 'center', maxWidth: 760, margin: '0 auto' }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 18px',
|
||||
fontSize: 'clamp(2.6rem, 6vw, 5rem)',
|
||||
fontWeight: 800, color: '#fff',
|
||||
lineHeight: 0.92, letterSpacing: '-0.05em',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
A smarter<br />
|
||||
<span style={{
|
||||
background: 'linear-gradient(105deg,#f4be28 0%,#fff8e0 48%,#f4be28 62%,#ff9500 100%)',
|
||||
backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
animation: 'loopShimmer 3s linear infinite',
|
||||
}}>retail cycle.</span>
|
||||
</h2>
|
||||
{/* Pinned viewport */}
|
||||
<div
|
||||
ref={pinRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 'clamp(48px,6vh,80px) clamp(28px,5vw,80px)',
|
||||
boxSizing: 'border-box',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 'clamp(36px,5vh,64px)', flexShrink: 0 }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 14px',
|
||||
fontSize: 'clamp(2.2rem,5vw,4.2rem)',
|
||||
fontWeight: 800, color: '#fff',
|
||||
lineHeight: 0.95, letterSpacing: '-0.05em',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
A smarter{' '}
|
||||
<span style={{
|
||||
background: 'linear-gradient(105deg,#f4be28 0%,#fff8e0 48%,#f4be28 62%,#ff9500 100%)',
|
||||
backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
animation: 'loopShimmer 3s linear infinite',
|
||||
}}>retail cycle.</span>
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '0 auto', maxWidth: 460,
|
||||
fontSize: 'clamp(0.88rem,1vw,1rem)',
|
||||
color: 'rgba(255,255,255,0.38)', lineHeight: 1.75,
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
Earn LYT online, unlock game based deals, discover nearby stores, and redeem offline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
margin: '0 auto', maxWidth: 480,
|
||||
fontSize: 'clamp(0.95rem, 1.1vw, 1.05rem)',
|
||||
color: 'rgba(255,255,255,0.42)', lineHeight: 1.8,
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
Earn coins online, unlock game based deals, discover nearby stores, and redeem offline: a self reinforcing loop that keeps customers engaged and drives repeat footfall.
|
||||
</p>
|
||||
</div>
|
||||
{/* Two-column layout */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 'clamp(40px,5vw,72px)', alignItems: 'stretch', width: '100%', maxWidth: 1100, minHeight: 0 }}>
|
||||
|
||||
{/* ── Flow diagram ── */}
|
||||
<div className="loop-diagram" style={{
|
||||
position: 'relative', zIndex: 10,
|
||||
maxWidth: 1180, margin: 'clamp(170px, 19vh, 230px) auto 0',
|
||||
}}>
|
||||
{/* Loop-back arc (desktop) */}
|
||||
<svg className="loop-arc" viewBox="0 0 1000 120" preserveAspectRatio="none" aria-hidden style={{
|
||||
position: 'absolute', top: -46, left: '11%', width: '78%', height: 70, overflow: 'visible',
|
||||
}}>
|
||||
<defs>
|
||||
<linearGradient id="loopArcGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="rgba(244,190,40,0)" />
|
||||
<stop offset="50%" stopColor="rgba(244,190,40,0.55)" />
|
||||
<stop offset="100%" stopColor="rgba(244,190,40,0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 985 118 C 985 20, 800 8, 500 8 C 200 8, 15 20, 15 118"
|
||||
fill="none" stroke="url(#loopArcGrad)" strokeWidth="2"
|
||||
strokeDasharray="7 9" strokeLinecap="round"
|
||||
style={{ animation: revealed ? 'loopDash 1.4s linear infinite' : 'none' }} />
|
||||
<path d="M 8 104 L 15 120 L 22 104" fill="none" stroke="rgba(244,190,40,0.6)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{/* LEFT: step list */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
|
||||
{/* Steps row */}
|
||||
<div className="loop-row" style={{ display: 'flex', alignItems: 'stretch', gap: 0 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const isActive = i === active;
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div
|
||||
className="loop-card"
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
padding: 'clamp(26px, 2.2vw, 38px) clamp(20px, 1.8vw, 28px)',
|
||||
borderRadius: 26,
|
||||
background: isActive ? 'rgba(244,190,40,0.07)' : 'rgba(255,255,255,0.025)',
|
||||
border: `1px solid ${isActive ? 'rgba(244,190,40,0.45)' : 'rgba(255,255,255,0.07)'}`,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: isActive
|
||||
? '0 28px 80px rgba(244,190,40,0.2), inset 0 1px 0 rgba(255,255,255,0.08)'
|
||||
: 'inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||
transform: revealed ? (isActive ? 'translateY(-10px) scale(1.02)' : 'translateY(0) scale(1)') : 'translateY(48px) scale(0.98)',
|
||||
opacity: revealed ? 1 : 0,
|
||||
transition: `transform 0.7s cubic-bezier(0.22,1,0.36,1) ${i * 0.1}s, opacity 0.7s ease ${i * 0.1}s, background 0.5s ease, border-color 0.5s ease, box-shadow 0.5s ease`,
|
||||
}}
|
||||
>
|
||||
{/* big ghost number */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, right: 16,
|
||||
fontSize: 'clamp(3rem, 5vw, 5rem)', fontWeight: 900, lineHeight: 1,
|
||||
color: 'transparent', WebkitTextStroke: `1px rgba(244,190,40,${isActive ? 0.2 : 0.06})`,
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.05em',
|
||||
pointerEvents: 'none', userSelect: 'none', transition: 'all 0.5s ease',
|
||||
}}>{s.step}</span>
|
||||
{/* Track background */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 19, top: 20, bottom: 20,
|
||||
width: 2, background: 'rgba(244,190,40,0.1)', borderRadius: 2,
|
||||
}} />
|
||||
|
||||
{/* icon */}
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 16, padding: 13, marginBottom: 22,
|
||||
background: isActive ? 'rgba(244,190,40,0.16)' : 'rgba(244,190,40,0.08)',
|
||||
border: `1px solid rgba(244,190,40,${isActive ? 0.4 : 0.2})`,
|
||||
color: '#f4be28',
|
||||
boxShadow: isActive ? '0 0 30px rgba(244,190,40,0.35)' : 'none',
|
||||
transition: 'all 0.5s ease',
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
<StepIcon id={s.id} />
|
||||
{/* Growing fill — height set via ref */}
|
||||
<div ref={lineRef} style={{
|
||||
position: 'absolute', left: 19, top: 20,
|
||||
width: 2, height: '0%',
|
||||
background: 'linear-gradient(180deg,#f4be28 0%,rgba(244,190,40,0.55) 100%)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 0 10px rgba(244,190,40,0.5)',
|
||||
// No CSS transition — GSAP scrub drives this every frame
|
||||
}} />
|
||||
|
||||
{/* Glowing tip — top set via ref */}
|
||||
<div ref={tipRef} style={{
|
||||
position: 'absolute', left: 12, top: 14,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
background: '#f4be28',
|
||||
boxShadow: '0 0 14px rgba(244,190,40,0.9)',
|
||||
zIndex: 3,
|
||||
// No CSS transition
|
||||
}} />
|
||||
|
||||
{/* Steps */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'clamp(20px,3.5vh,36px)', paddingLeft: 48 }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} style={{ position: 'relative' }}>
|
||||
{/* Outer glow ring — visible only on active */}
|
||||
<div
|
||||
ref={el => { trackDotGlowRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', left: -47, top: -9,
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
border: '1.5px solid rgba(244,190,40,0.35)',
|
||||
pointerEvents: 'none', zIndex: 1,
|
||||
opacity: i === 0 ? 1 : 0,
|
||||
transform: i === 0 ? 'scale(1)' : 'scale(0.7)',
|
||||
transition: 'opacity 0.35s ease, transform 0.35s ease',
|
||||
animation: i === 0 ? 'loopDotPulse 2s ease-out infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Numbered badge */}
|
||||
<div
|
||||
ref={el => { trackDotRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', left: -40, top: -3,
|
||||
width: 26, height: 26, borderRadius: '50%',
|
||||
background: i === 0 ? '#f4be28' : 'transparent',
|
||||
border: `1.5px solid ${i === 0 ? '#f4be28' : 'rgba(244,190,40,0.2)'}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transform: i === 0 ? 'scale(1.18)' : 'scale(1)',
|
||||
boxShadow: i === 0 ? '0 0 18px rgba(244,190,40,0.7)' : 'none',
|
||||
transition: 'background 0.3s ease, border-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={el => { trackDotTextRefs.current[i] = el; }}
|
||||
style={{
|
||||
fontSize: 8, fontWeight: 800, lineHeight: 1,
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '0.04em',
|
||||
color: i === 0 ? '#1a1200' : 'rgba(244,190,40,0.22)',
|
||||
transition: 'color 0.3s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={el => { labelRefs.current[i] = el; }}
|
||||
style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
color: i === 0 ? 'rgba(244,190,40,0.85)' : 'rgba(255,255,255,0.2)',
|
||||
fontFamily: 'Sora, sans-serif', marginBottom: 5,
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
Step {s.step}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
ref={el => { titleRefs.current[i] = el; }}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'clamp(1.3rem,2vw,1.9rem)',
|
||||
fontWeight: 800,
|
||||
color: i === 0 ? '#fff' : 'rgba(255,255,255,0.22)',
|
||||
letterSpacing: '-0.03em', lineHeight: 1.1,
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
marginBottom: i === 0 ? '10px' : '0px',
|
||||
transition: 'color 0.3s ease, margin-bottom 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{s.title}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
ref={el => { descRefs.current[i] = el; }}
|
||||
style={{
|
||||
maxHeight: i === 0 ? '140px' : '0px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.4s cubic-bezier(0.22,1,0.36,1)',
|
||||
}}
|
||||
>
|
||||
<p style={{
|
||||
margin: 0, paddingTop: 2,
|
||||
maxWidth: 320,
|
||||
fontSize: 'clamp(0.82rem,0.9vw,0.95rem)',
|
||||
lineHeight: 1.7, color: 'rgba(255,255,255,0.5)',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
{s.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(244,190,40,0.7)', fontFamily: 'Sora, sans-serif', marginBottom: 8 }}>
|
||||
Step {s.step}
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 10px', fontSize: 'clamp(1.15rem, 1.5vw, 1.5rem)', fontWeight: 800, color: '#fff', letterSpacing: '-0.02em', lineHeight: 1.1, fontFamily: 'Sora, sans-serif' }}>
|
||||
{s.title}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.86rem', lineHeight: 1.65, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif' }}>
|
||||
{s.desc}
|
||||
</p>
|
||||
</div>
|
||||
{/* RIGHT: visual panel */}
|
||||
<div style={{
|
||||
flex: '0 0 clamp(300px,44%,500px)', minHeight: 360, position: 'relative', borderRadius: 28,
|
||||
background: 'rgba(244,190,40,0.04)',
|
||||
border: '1px solid rgba(244,190,40,0.12)',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(244,190,40,0.08) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* connector arrow between cards */}
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className="loop-connector" style={{
|
||||
flexShrink: 0, alignSelf: 'center',
|
||||
width: 'clamp(28px, 3vw, 56px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
}}>
|
||||
<div className="loop-connector-line" style={{
|
||||
position: 'absolute', left: 0, right: 0, height: 2, borderRadius: 2,
|
||||
background: 'linear-gradient(90deg, rgba(244,190,40,0.1), rgba(244,190,40,0.45), rgba(244,190,40,0.1))',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: revealed ? 'loopFlow 1.8s linear infinite' : 'none',
|
||||
}} />
|
||||
<svg className="loop-connector-arrow" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#f4be28" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'relative', zIndex: 2, opacity: 0.8 }}>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
{/* Ghost step number — text content set via ref */}
|
||||
<div ref={ghostRef} style={{
|
||||
position: 'absolute', bottom: -10, right: 24,
|
||||
fontSize: 'clamp(7rem,14vw,12rem)', fontWeight: 900, lineHeight: 1,
|
||||
color: 'transparent',
|
||||
WebkitTextStroke: '1px rgba(244,190,40,0.07)',
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.06em',
|
||||
pointerEvents: 'none', userSelect: 'none',
|
||||
}}>01</div>
|
||||
|
||||
{/* All visuals stacked; opacity toggled via refs — no re-render */}
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
{STEPS.map((s, i) => {
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
ref={el => { visualRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: i === 0 ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<Image
|
||||
src={IMAGE_PATHS[i]}
|
||||
alt={s.title}
|
||||
fill
|
||||
style={{ objectFit: 'contain' }}
|
||||
sizes="(max-width: 768px) 100vw, 44vw"
|
||||
priority={i === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll hint */}
|
||||
<div
|
||||
ref={scrollHintRef}
|
||||
style={{
|
||||
position: 'absolute', bottom: 28, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
||||
opacity: 0.5, pointerEvents: 'none',
|
||||
transition: 'opacity 0.4s ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif' }}>Scroll</span>
|
||||
<div style={{ width: 1.5, height: 28, background: 'linear-gradient(180deg,rgba(244,190,40,0.6),transparent)', animation: 'loopScrollHint 1.4s ease-in-out infinite' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Impact metrics ── */}
|
||||
<div ref={metricsRef} style={{ position: 'relative', zIndex: 10, maxWidth: 1000, margin: 'clamp(64px, 8vh, 110px) auto 0' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 36 }}>
|
||||
<style>{`
|
||||
@keyframes loopShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||
@keyframes loopDotPulse { 0%{transform:scale(1);opacity:0.35} 60%{transform:scale(1.55);opacity:0.1} 100%{transform:scale(1.8);opacity:0} }
|
||||
@keyframes loopPulse { 0%{opacity:0.8;transform:scale(1)} 100%{opacity:0;transform:scale(1.8)} }
|
||||
@keyframes loopScrollHint { 0%,100%{transform:scaleY(1);opacity:0.6} 50%{transform:scaleY(1.3);opacity:1} }
|
||||
@keyframes loopFloat0 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
|
||||
@keyframes loopFloat1 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
|
||||
@keyframes loopFloat2 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-10px)} }
|
||||
@keyframes loopFloat3 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-7px)} }
|
||||
@media (max-width: 768px) { #the-loop { height: auto !important; } }
|
||||
@media (prefers-reduced-motion: reduce) { #the-loop [style*="animation"] { animation: none !important; } }
|
||||
`}</style>
|
||||
</section>
|
||||
|
||||
{/* Impact metrics — outside section to avoid overflow:hidden clipping */}
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 2,
|
||||
width: 'calc(100% - 40px)',
|
||||
margin: '0 20px 20px',
|
||||
borderRadius: '0 0 32px 32px',
|
||||
background: '#0c0a07',
|
||||
padding: 'clamp(32px,4vh,52px) clamp(28px,5vw,80px) clamp(28px,4vh,48px)',
|
||||
}}>
|
||||
<div ref={metricsRef} style={{ maxWidth: 1000, margin: '0 auto' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', fontFamily: 'Sora, sans-serif' }}>
|
||||
Measured Impact
|
||||
</span>
|
||||
@@ -287,59 +480,31 @@ export default function TheLoop() {
|
||||
{IMPACTS.map((m, i) => {
|
||||
const isEven = i % 2 === 1;
|
||||
return (
|
||||
<div key={m.label} className="loop-metric" style={{
|
||||
padding: 'clamp(28px, 3vw, 44px) 28px',
|
||||
background: isEven ? '#f4be28' : 'rgba(255,255,255,0.025)',
|
||||
borderRight: i < IMPACTS.length - 1 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 'clamp(2.4rem, 4vw, 3.4rem)', fontWeight: 900,
|
||||
color: isEven ? '#171512' : '#f4be28', lineHeight: 1, letterSpacing: '-0.04em',
|
||||
fontVariantNumeric: 'tabular-nums', fontFamily: 'Sora, sans-serif',
|
||||
<div key={m.label} className="loop-metric" style={{
|
||||
padding: 'clamp(28px,3vw,44px) 28px',
|
||||
background: isEven ? '#f4be28' : 'rgba(255,255,255,0.025)',
|
||||
borderRight: i < IMPACTS.length - 1 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '55%', verticalAlign: 'middle', marginRight: 1 }}>{m.prefix}</span>
|
||||
{counts[i].toFixed(m.decimals)}
|
||||
<span style={{ fontSize: '50%', verticalAlign: 'middle', marginLeft: 1 }}>{m.suffix}</span>
|
||||
<div style={{
|
||||
fontSize: 'clamp(2.4rem,4vw,3.4rem)', fontWeight: 900,
|
||||
color: isEven ? '#171512' : '#f4be28', lineHeight: 1,
|
||||
letterSpacing: '-0.04em', fontVariantNumeric: 'tabular-nums',
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
}}>
|
||||
<span style={{ fontSize: '55%', verticalAlign: 'middle', marginRight: 1 }}>{m.prefix}</span>
|
||||
{counts[i].toFixed(m.decimals)}
|
||||
<span style={{ fontSize: '50%', verticalAlign: 'middle', marginLeft: 1 }}>{m.suffix}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: isEven ? 'rgba(23,21,18,0.7)' : 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif', marginTop: 12, lineHeight: 1.4 }}>
|
||||
{m.label}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: isEven ? 'rgba(23,21,18,0.7)' : 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif', marginTop: 12, lineHeight: 1.4 }}>
|
||||
{m.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes loopShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||
@keyframes loopDash { to { stroke-dashoffset: -32; } }
|
||||
@keyframes loopFlow { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
|
||||
@keyframes loopFlowVertical { 0%{background-position:0 200%} 100%{background-position:0 -200%} }
|
||||
@keyframes loopArrowBounceH { 0%,100% { transform: translateX(0); } 50% { transform: translateX(5px); } }
|
||||
@keyframes loopArrowBounceV { 0%,100% { transform: rotate(90deg) translateX(0); } 50% { transform: rotate(90deg) translateX(5px); } }
|
||||
.loop-connector-arrow { animation: loopArrowBounceH 1.4s ease-in-out infinite; }
|
||||
@media (max-width: 880px) {
|
||||
.loop-row { flex-direction: column; }
|
||||
.loop-card { z-index: 1; }
|
||||
.loop-connector {
|
||||
width: 100% !important; height: 40px; z-index: 0;
|
||||
}
|
||||
.loop-connector-line {
|
||||
left: 50% !important; right: auto !important; top: 0 !important; bottom: 0 !important;
|
||||
width: 2px !important; height: 100% !important;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(180deg, rgba(244,190,40,0.1), rgba(244,190,40,0.45), rgba(244,190,40,0.1)) !important;
|
||||
background-size: 100% 200% !important;
|
||||
animation-name: loopFlowVertical !important;
|
||||
}
|
||||
.loop-connector-arrow { animation: loopArrowBounceV 1.4s ease-in-out infinite; }
|
||||
.loop-arc { display: none !important; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#the-loop [style*="animation"] { animation: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,48 +145,49 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Header: blur fade-up with stagger
|
||||
gsap.fromTo(
|
||||
'.wwd-header-el',
|
||||
{ opacity: 0, y: 40 },
|
||||
{ opacity: 0, y: 36, filter: 'blur(6px)' },
|
||||
{
|
||||
opacity: 1, y: 0, duration: DEFAULT_DURATION, stagger: 0.15, ease: DEFAULT_EASE,
|
||||
opacity: 1, y: 0, filter: 'blur(0px)',
|
||||
duration: 1, stagger: 0.18, ease: 'expo.out',
|
||||
scrollTrigger: { trigger: '.wwd-header', start: 'top 85%', toggleActions: 'play none none reset' },
|
||||
}
|
||||
);
|
||||
|
||||
// Per-card reveal: each card gets its own scrubbed-in timeline
|
||||
// (clip-path wipe + rise), staggered across the row, with the
|
||||
// icon and copy cascading in right after.
|
||||
gsap.utils.toArray<HTMLElement>('.wwd-card').forEach((card, i) => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: card, start: 'top 85%', toggleActions: 'play none none reset' },
|
||||
delay: i * 0.1,
|
||||
});
|
||||
// Cards: single staggered timeline triggered once the row enters view
|
||||
const cards = gsap.utils.toArray<HTMLElement>('.wwd-card');
|
||||
const grid = document.querySelector('.wwd-grid-container');
|
||||
|
||||
tl.fromTo(card,
|
||||
{ opacity: 0, y: 70, scale: 0.94, clipPath: 'inset(0% 0% 100% 0% round 22px)' },
|
||||
{ opacity: 1, y: 0, scale: 1, clipPath: 'inset(0% 0% 0% 0% round 22px)', duration: 0.75, ease: 'power3.out' },
|
||||
0
|
||||
)
|
||||
.fromTo(card.querySelector('.wwd-icon'),
|
||||
{ opacity: 0, scale: 0.4, rotate: -20 },
|
||||
{ opacity: 1, scale: 1, rotate: 0, duration: 0.55, ease: 'back.out(2.2)' },
|
||||
0.3
|
||||
gsap.fromTo(cards,
|
||||
{ opacity: 0, y: 56, scale: 0.93, filter: 'blur(4px)' },
|
||||
{
|
||||
opacity: 1, y: 0, scale: 1, filter: 'blur(0px)',
|
||||
duration: 0.8, stagger: 0.12, ease: 'expo.out',
|
||||
scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' },
|
||||
}
|
||||
);
|
||||
|
||||
// Cascade inner elements after cards land
|
||||
cards.forEach((card, i) => {
|
||||
const tl = gsap.timeline({ delay: i * 0.12 + 0.25 });
|
||||
|
||||
tl.fromTo(card.querySelector('.wwd-icon'),
|
||||
{ opacity: 0, scale: 0.5, rotate: -15, filter: 'blur(3px)' },
|
||||
{ opacity: 1, scale: 1, rotate: 0, filter: 'blur(0px)', duration: 0.55, ease: 'back.out(2)' }
|
||||
)
|
||||
.fromTo(card.querySelector('.wwd-title'),
|
||||
{ opacity: 0, y: 16 },
|
||||
{ opacity: 1, y: 0, duration: 0.45, ease: 'power2.out' },
|
||||
0.5
|
||||
{ opacity: 0, y: 14 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power3.out' }, '-=0.2'
|
||||
)
|
||||
.fromTo(card.querySelector('.wwd-tagline'),
|
||||
{ opacity: 0, y: 12 },
|
||||
{ opacity: 1, y: 0, duration: 0.45, ease: 'power2.out' },
|
||||
0.58
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.38, ease: 'power3.out' }, '-=0.2'
|
||||
)
|
||||
.fromTo(card.querySelector('.wwd-detail'),
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' },
|
||||
0.66
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.35, ease: 'power3.out' }, '-=0.18'
|
||||
);
|
||||
});
|
||||
}, containerRef);
|
||||
|
||||
325
src/components/use-cases/ProductsScrollSection.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'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;
|
||||
|
||||
const PRODUCTS = [
|
||||
{
|
||||
name: 'Behavision',
|
||||
tag: 'Footfall AI',
|
||||
desc: 'Track footfall, measure conversions, and understand in-store behavior with precision heatmaps and dwell-time analytics.',
|
||||
metric: '3.2×', metricLabel: 'conversion uplift',
|
||||
color: '#f4be28', rgb: '244,190,40', deep: '#c98f00',
|
||||
},
|
||||
{
|
||||
name: 'SpendSense',
|
||||
tag: 'Spend Intelligence',
|
||||
desc: 'Analyze spending patterns and identify high-value customers before your competition does.',
|
||||
metric: '68%', metricLabel: 'retention improvement',
|
||||
color: '#a78bfa', rgb: '167,139,250', deep: '#7c5ce0',
|
||||
},
|
||||
{
|
||||
name: 'Dyscount',
|
||||
tag: 'Smart Discounts',
|
||||
desc: 'Deliver targeted discounts that actually convert, without killing your margins.',
|
||||
metric: '41%', metricLabel: 'discount waste reduced',
|
||||
color: '#34d399', rgb: '52,211,153', deep: '#0f9e6e',
|
||||
},
|
||||
];
|
||||
|
||||
const TOTAL = PRODUCTS.length;
|
||||
// Scroll distance per product step, in vh
|
||||
const STEP_VH = 160;
|
||||
|
||||
export default function ProductsScrollSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
const fillRef = useRef<HTMLDivElement>(null);
|
||||
const dotRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const dotTextRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const textRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const visualRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const activeRef = useRef(0);
|
||||
|
||||
const applyActive = (idx: number, progress: number) => {
|
||||
if (fillRef.current) fillRef.current.style.transform = `scaleX(${progress})`;
|
||||
|
||||
PRODUCTS.forEach((p, i) => {
|
||||
const reached = progress >= i / (TOTAL - 1) - 0.001;
|
||||
|
||||
const dot = dotRefs.current[i];
|
||||
if (dot) {
|
||||
dot.style.background = reached ? p.color : '#0f0f18';
|
||||
dot.style.borderColor = reached ? p.color : 'rgba(255,255,255,0.15)';
|
||||
dot.style.transform = i === idx ? 'translate(-50%,-50%) scale(1.2)' : 'translate(-50%,-50%) scale(1)';
|
||||
dot.style.boxShadow = reached ? `0 0 22px rgba(${p.rgb},0.6)` : 'none';
|
||||
}
|
||||
const dotText = dotTextRefs.current[i];
|
||||
if (dotText) dotText.style.color = reached ? '#111' : 'rgba(255,255,255,0.3)';
|
||||
|
||||
const label = labelRefs.current[i];
|
||||
if (label) {
|
||||
label.style.color = i === idx ? p.color : reached ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.25)';
|
||||
}
|
||||
|
||||
const isActive = i === idx;
|
||||
const text = textRefs.current[i];
|
||||
if (text) {
|
||||
text.style.opacity = isActive ? '1' : '0';
|
||||
text.style.transform = isActive ? 'translateY(0)' : 'translateY(28px)';
|
||||
text.style.pointerEvents = isActive ? 'auto' : 'none';
|
||||
}
|
||||
const vis = visualRefs.current[i];
|
||||
if (vis) {
|
||||
vis.style.opacity = isActive ? '1' : '0';
|
||||
vis.style.transform = isActive ? 'rotate(0deg) scale(1)' : 'rotate(-2.5deg) scale(0.94)';
|
||||
}
|
||||
});
|
||||
activeRef.current = idx;
|
||||
};
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const section = sectionRef.current;
|
||||
const pin = pinRef.current;
|
||||
if (!section || !pin) return;
|
||||
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: 'top top',
|
||||
end: () => `+=${window.innerHeight * (TOTAL - 1) * (STEP_VH / 100)}`,
|
||||
pin,
|
||||
pinSpacing: true,
|
||||
anticipatePin: 1,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const idx = Math.min(TOTAL - 1, Math.round(self.progress * (TOTAL - 1)));
|
||||
applyActive(idx, self.progress);
|
||||
},
|
||||
});
|
||||
|
||||
applyActive(0, 0);
|
||||
ScrollTrigger.refresh();
|
||||
|
||||
return () => st.kill();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: `${(TOTAL - 1) * STEP_VH + 100}vh`,
|
||||
background: '#07070d',
|
||||
margin: '0 20px 20px',
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={pinRef}
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8%',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Faint grid backdrop */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px)',
|
||||
backgroundSize: '64px 64px',
|
||||
}} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ position: 'relative', zIndex: 2, marginBottom: 'clamp(56px,8vh,96px)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 16 }}>Our Products</div>
|
||||
<h2 style={{ fontSize: 'clamp(32px, 4.5vw, 64px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.92, color: '#fff', margin: 0, fontFamily: 'Sora, sans-serif', maxWidth: 640 }}>
|
||||
AI Powered Retail Intelligence.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Horizontal progress track with 3 points */}
|
||||
<div style={{ position: 'relative', zIndex: 2, margin: '0 0 clamp(72px,10vh,120px)', padding: '0 16px' }}>
|
||||
<div style={{ position: 'relative', height: 2, background: 'rgba(255,255,255,0.08)', borderRadius: 2 }}>
|
||||
{/* Growing fill line */}
|
||||
<div
|
||||
ref={el => { fillRef.current = el; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 2,
|
||||
background: 'linear-gradient(90deg,#f4be28,#a78bfa,#34d399)',
|
||||
transformOrigin: 'left center', transform: 'scaleX(0)',
|
||||
boxShadow: '0 0 14px rgba(244,190,40,0.4)',
|
||||
}}
|
||||
/>
|
||||
{/* Point markers */}
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<div key={p.name} style={{ position: 'absolute', left: `${(i / (TOTAL - 1)) * 100}%`, top: '50%' }}>
|
||||
<div
|
||||
ref={el => { dotRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0,
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
background: i === 0 ? p.color : '#0f0f18',
|
||||
border: `1.5px solid ${i === 0 ? p.color : 'rgba(255,255,255,0.15)'}`,
|
||||
transform: i === 0 ? 'translate(-50%,-50%) scale(1.2)' : 'translate(-50%,-50%) scale(1)',
|
||||
boxShadow: i === 0 ? `0 0 22px rgba(${p.rgb},0.6)` : 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.35s ease, border-color 0.35s ease, transform 0.35s ease, box-shadow 0.35s ease',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={el => { dotTextRefs.current[i] = el; }}
|
||||
style={{ fontSize: 11, fontWeight: 800, color: i === 0 ? '#111' : 'rgba(255,255,255,0.3)', fontFamily: 'Sora, sans-serif', transition: 'color 0.35s ease' }}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Product name under each point */}
|
||||
<span
|
||||
ref={el => { labelRefs.current[i] = el; }}
|
||||
className="uc-pp-label"
|
||||
style={{
|
||||
position: 'absolute', top: 26, left: 0, transform: 'translateX(-50%)',
|
||||
whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
color: i === 0 ? p.color : 'rgba(255,255,255,0.25)',
|
||||
fontFamily: 'Sora, sans-serif', transition: 'color 0.35s ease',
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: color panel left, editorial text right */}
|
||||
<div className="uc-pp-grid" style={{ position: 'relative', zIndex: 2, display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 'clamp(32px,4.5vw,72px)', alignItems: 'stretch' }}>
|
||||
{/* Left — bold gradient color panel (stacked, cross-fading) */}
|
||||
<div className="uc-pp-visual" style={{ position: 'relative', minHeight: 'clamp(300px, 38vh, 420px)' }}>
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<div
|
||||
key={p.name}
|
||||
ref={el => { visualRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
opacity: i === 0 ? 1 : 0,
|
||||
transform: i === 0 ? 'rotate(0deg) scale(1)' : 'rotate(-2.5deg) scale(0.94)',
|
||||
transition: 'opacity 0.55s ease, transform 0.55s cubic-bezier(0.22,1,0.36,1)',
|
||||
borderRadius: 28,
|
||||
background: `linear-gradient(150deg, ${p.color} 0%, ${p.deep} 100%)`,
|
||||
boxShadow: `0 30px 80px -30px rgba(${p.rgb},0.5)`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
padding: 'clamp(24px,2.6vw,40px)',
|
||||
}}
|
||||
>
|
||||
{/* Decorative concentric rings */}
|
||||
<div style={{ position: 'absolute', top: '-30%', right: '-20%', width: '80%', aspectRatio: '1', borderRadius: '50%', border: '1.5px solid rgba(255,255,255,0.25)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: '-18%', right: '-10%', width: '58%', aspectRatio: '1', borderRadius: '50%', border: '1.5px solid rgba(255,255,255,0.18)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: '-25%', left: '-15%', width: '55%', aspectRatio: '1', borderRadius: '50%', background: 'rgba(0,0,0,0.08)', pointerEvents: 'none' }} />
|
||||
|
||||
{/* Ghost number */}
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -30, right: 14, lineHeight: 1,
|
||||
fontSize: 'clamp(130px,13vw,200px)', fontWeight: 900,
|
||||
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.06em',
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.03) 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
pointerEvents: 'none', userSelect: 'none',
|
||||
}}>0{i + 1}</span>
|
||||
|
||||
{/* Top row: product wordmark */}
|
||||
<div style={{ position: 'relative', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.55)', fontFamily: 'Sora, sans-serif' }}>{p.name}</span>
|
||||
<span style={{
|
||||
width: 38, height: 38, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(0,0,0,0.6)" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17L17 7M9 7h8v8" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hero metric */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{
|
||||
fontSize: 'clamp(72px,8.5vw,130px)', fontWeight: 900, lineHeight: 0.9,
|
||||
color: '#111', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.05em',
|
||||
}}>{p.metric}</div>
|
||||
<div style={{
|
||||
marginTop: 14, display: 'inline-block',
|
||||
fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||
color: p.color, background: '#111', fontFamily: 'Sora, sans-serif',
|
||||
padding: '8px 16px', borderRadius: 100,
|
||||
}}>{p.metricLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right — editorial text (stacked, cross-fading) */}
|
||||
<div style={{ position: 'relative', minHeight: 'clamp(300px, 38vh, 420px)' }}>
|
||||
{PRODUCTS.map((p, i) => (
|
||||
<div
|
||||
key={p.name}
|
||||
ref={el => { textRefs.current[i] = el; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
opacity: i === 0 ? 1 : 0,
|
||||
transform: i === 0 ? 'translateY(0)' : 'translateY(28px)',
|
||||
pointerEvents: i === 0 ? 'auto' : 'none',
|
||||
transition: 'opacity 0.5s ease, transform 0.5s cubic-bezier(0.22,1,0.36,1)',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'inline-block', width: 'fit-content', fontSize: 11, fontWeight: 700, letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase', color: p.color, fontFamily: 'Sora, sans-serif',
|
||||
marginBottom: 20, background: `${p.color}15`, padding: '6px 14px', borderRadius: 100,
|
||||
border: `1px solid ${p.color}30`,
|
||||
}}>{p.tag}</div>
|
||||
|
||||
<h3 style={{ fontSize: 'clamp(34px, 4vw, 60px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 0.95, color: '#fff', margin: '0 0 22px', fontFamily: 'Sora, sans-serif' }}>{p.name}</h3>
|
||||
|
||||
<p style={{
|
||||
fontSize: 'clamp(15px,1.25vw,19px)', lineHeight: 1.7, color: 'rgba(255,255,255,0.45)',
|
||||
fontFamily: 'Sora, sans-serif', margin: '0 0 36px', maxWidth: 460,
|
||||
paddingLeft: 20, borderLeft: `2px solid ${p.color}55`,
|
||||
}}>{p.desc}</p>
|
||||
|
||||
<Link href="/contacts" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, width: 'fit-content',
|
||||
padding: '15px 32px', background: p.color, color: '#111',
|
||||
fontSize: 14, fontWeight: 800, borderRadius: 100, textDecoration: 'none',
|
||||
fontFamily: 'Sora, sans-serif', boxShadow: `0 10px 32px rgba(${p.rgb},0.25)`,
|
||||
}}>Learn More →</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 860px) {
|
||||
.uc-pp-grid { grid-template-columns: 1fr !important; gap: 24px !important; }
|
||||
.uc-pp-visual { min-height: 260px !important; }
|
||||
.uc-pp-label { display: none; }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||