feat: implement solutions page, use cases, and update landing page sections including MagicBento, ScrollStack, and interactive components
This commit is contained in:
@@ -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
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
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
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
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
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user