Add static mobile hero, replacing the canvas frame-sequence background
Below the lg breakpoint, render a static image below the hero text instead of the desktop's pinned scroll-scrubbed canvas animation. Use useSyncExternalStore to pick the variant so server and client agree on first render, avoiding a hydration mismatch, and gate GSAP's ScrollTrigger pin so it never attaches to the hidden desktop hero on mobile.
This commit is contained in:
BIN
public/images/mobile_hero.png
Normal file
BIN
public/images/mobile_hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import Lenis from 'lenis';
|
import Lenis from 'lenis';
|
||||||
import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation';
|
import { DEFAULT_EASE, DEFAULT_DURATION } from '@/utils/animation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import NextImage from 'next/image';
|
||||||
import ProblemsShift from '@/components/home/ProblemsShift';
|
import ProblemsShift from '@/components/home/ProblemsShift';
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||||
import ProductsSection from '@/components/home/ProductsSection';
|
import ProductsSection from '@/components/home/ProductsSection';
|
||||||
@@ -18,9 +19,22 @@ if (typeof window !== 'undefined') {
|
|||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESKTOP_QUERY = '(min-width: 1024px)';
|
||||||
|
const subscribeDesktopQuery = (callback: () => void) => {
|
||||||
|
const mql = window.matchMedia(DESKTOP_QUERY);
|
||||||
|
mql.addEventListener('change', callback);
|
||||||
|
return () => mql.removeEventListener('change', callback);
|
||||||
|
};
|
||||||
|
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches;
|
||||||
|
// Server has no viewport, so it always renders the desktop hero; the client's
|
||||||
|
// first render must match that to avoid a hydration mismatch.
|
||||||
|
const getDesktopServerSnapshot = () => true;
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||||
const [loadProgress, setLoadProgress] = useState(0);
|
const [loadProgress, setLoadProgress] = useState(0);
|
||||||
|
// Desktop pinned canvas hero vs. mobile static hero.
|
||||||
|
const isDesktop = useSyncExternalStore(subscribeDesktopQuery, getDesktopSnapshot, getDesktopServerSnapshot);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -242,7 +256,7 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
/* ── Lenis + GSAP scrub (frame seq + welcome text + hero text) ── */
|
/* ── Lenis + GSAP scrub (frame seq + welcome text + hero text) ── */
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (!imagesLoaded) return;
|
if (!imagesLoaded || !isDesktop) return;
|
||||||
|
|
||||||
const lenis = new Lenis({
|
const lenis = new Lenis({
|
||||||
duration: 1.2,
|
duration: 1.2,
|
||||||
@@ -313,7 +327,7 @@ export default function IndexPage() {
|
|||||||
gsap.ticker.remove(updateTicker);
|
gsap.ticker.remove(updateTicker);
|
||||||
if (typeof cleanupReveal === 'function') cleanupReveal();
|
if (typeof cleanupReveal === 'function') cleanupReveal();
|
||||||
};
|
};
|
||||||
}, [imagesLoaded, drawFrame]);
|
}, [imagesLoaded, isDesktop, drawFrame]);
|
||||||
|
|
||||||
/* ── render ───────────────────────────────────────────────────── */
|
/* ── render ───────────────────────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
@@ -404,9 +418,51 @@ export default function IndexPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════
|
||||||
|
MOBILE HERO — static image below text content
|
||||||
|
══════════════════════════════════════════ */}
|
||||||
|
{!isDesktop && (
|
||||||
|
<section
|
||||||
|
className="loyaly-home relative bg-white overflow-hidden"
|
||||||
|
style={{ margin: '0 20px 20px', borderRadius: '25px' }}
|
||||||
|
>
|
||||||
|
<div className="px-6 sm:px-8 pt-12 pb-8 flex flex-col">
|
||||||
|
<h1 className="text-[#171512] hero-heading text-3xl sm:text-4xl font-extrabold tracking-tight leading-[1.12] mb-6 select-none">
|
||||||
|
Turn Every <br />
|
||||||
|
Walk In Into a <br />
|
||||||
|
<span className="hero-gradient-text">Loyal Customer</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#4f463a] hero-description text-sm sm:text-base mb-8 leading-relaxed select-none">
|
||||||
|
Loyaly.ai turns everyday digital engagement into real world shopping rewards. Customers earn coins through games and daily activities, then redeem them at offline stores near them.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href="/contacts" className="flex items-center gap-3 bg-[#f4be28] hover:bg-[#ffd95a] hero-btn-primary text-[#171512] px-6 py-3 rounded-xl font-bold text-base transition-all duration-300 shadow-lg shadow-yellow-500/30 hover:shadow-yellow-500/50 cursor-pointer no-underline">
|
||||||
|
Book a Demo
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link href="/contacts" className="flex items-center border-2 border-[#f4be28] hover:bg-[#f4be28]/15 hero-btn-secondary text-[#f4be28] px-6 py-3 rounded-xl font-bold text-base transition-all duration-300 cursor-pointer no-underline">
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NextImage
|
||||||
|
src="/images/mobile_hero.png"
|
||||||
|
alt="Loyaly.AI"
|
||||||
|
width={792}
|
||||||
|
height={1271}
|
||||||
|
sizes="100vw"
|
||||||
|
className="w-full h-auto block"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ══════════════════════════════════════════
|
{/* ══════════════════════════════════════════
|
||||||
HERO — pinned, drives canvas frame scrub
|
HERO — pinned, drives canvas frame scrub
|
||||||
══════════════════════════════════════════ */}
|
══════════════════════════════════════════ */}
|
||||||
|
{isDesktop && (
|
||||||
<main
|
<main
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="loyaly-home relative bg-white overflow-hidden"
|
className="loyaly-home relative bg-white overflow-hidden"
|
||||||
@@ -519,6 +575,7 @@ export default function IndexPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Other sections */}
|
{/* Other sections */}
|
||||||
<ProblemsShift isLoaded={imagesLoaded} />
|
<ProblemsShift isLoaded={imagesLoaded} />
|
||||||
|
|||||||
Reference in New Issue
Block a user