500 lines
13 KiB
JavaScript
500 lines
13 KiB
JavaScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Link } from "react-router";
|
|
import { gsap } from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import HeroSection from "../components/landing/HeroSection";
|
|
import SharedNavbar from "../components/SharedNavbar";
|
|
import { useProductTransition } from "../transitions/ProductTransitionContext";
|
|
import perfumes from "../data/perfumes";
|
|
import "../pages/LandingPage.css";
|
|
import "../style/navbar.css";
|
|
|
|
const INTRO_SESSION_KEY = "atmos-landing-intro-played";
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
function LandingPage() {
|
|
const pageRef = useRef(null);
|
|
const overlayRef = useRef(null);
|
|
const overlayTextRef = useRef(null);
|
|
const heroImageWrapRef = useRef(null);
|
|
const heroImageRef = useRef(null);
|
|
const discoveryImageRef = useRef(null);
|
|
const headlineLineRefs = useRef([]);
|
|
const heroMetaRefs = useRef([]);
|
|
const cardRefs = useRef([]);
|
|
const { startProductTransition } = useProductTransition();
|
|
|
|
const [introSettings] = useState(() => {
|
|
if (typeof window === "undefined") {
|
|
return { shouldPlayIntro: true };
|
|
}
|
|
|
|
const prefersReducedMotion = window.matchMedia(
|
|
"(prefers-reduced-motion: reduce)"
|
|
).matches;
|
|
const introAlreadyPlayed =
|
|
window.sessionStorage.getItem(INTRO_SESSION_KEY) === "true";
|
|
|
|
return {
|
|
shouldPlayIntro: !prefersReducedMotion && !introAlreadyPlayed,
|
|
};
|
|
});
|
|
const { shouldPlayIntro } = introSettings;
|
|
|
|
const setHeadlinePrimaryRef = useCallback((element) => {
|
|
headlineLineRefs.current[0] = element;
|
|
}, []);
|
|
|
|
const setHeadlineSecondaryRef = useCallback((element) => {
|
|
headlineLineRefs.current[1] = element;
|
|
}, []);
|
|
|
|
const setDescriptionRef = useCallback((element) => {
|
|
heroMetaRefs.current[0] = element;
|
|
}, []);
|
|
|
|
const setActionsRef = useCallback((element) => {
|
|
heroMetaRefs.current[1] = element;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const overlay = overlayRef.current;
|
|
const overlayText = overlayTextRef.current;
|
|
const heroImageWrap = heroImageWrapRef.current;
|
|
const headlineLines = headlineLineRefs.current.filter(Boolean);
|
|
const heroMeta = heroMetaRefs.current.filter(Boolean);
|
|
|
|
if (!overlay || !overlayText || !heroImageWrap || headlineLines.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const ctx = gsap.context(() => {
|
|
gsap.set(heroImageWrap, {
|
|
scale: 1.22,
|
|
transformOrigin: "center center",
|
|
});
|
|
|
|
gsap.set(headlineLines, {
|
|
yPercent: 115,
|
|
rotate: 2.2,
|
|
transformOrigin: "0% 100%",
|
|
force3D: true,
|
|
});
|
|
|
|
gsap.set(heroMeta, {
|
|
y: 36,
|
|
autoAlpha: 0,
|
|
});
|
|
|
|
if (!shouldPlayIntro) {
|
|
gsap.set(heroImageWrap, { scale: 1 });
|
|
gsap.set(headlineLines, { yPercent: 0, rotate: 0 });
|
|
gsap.set(heroMeta, { y: 0, autoAlpha: 1 });
|
|
gsap.set(overlay, {
|
|
yPercent: -100,
|
|
autoAlpha: 0,
|
|
display: "none",
|
|
});
|
|
return;
|
|
}
|
|
|
|
gsap.set(overlay, { yPercent: 0, autoAlpha: 1, display: "block" });
|
|
gsap.set(overlayText, { xPercent: 28, yPercent: 140, autoAlpha: 0 });
|
|
|
|
const introTimeline = gsap.timeline({
|
|
defaults: { ease: "power3.out" },
|
|
onComplete: () => {
|
|
window.sessionStorage.setItem(INTRO_SESSION_KEY, "true");
|
|
},
|
|
});
|
|
|
|
introTimeline
|
|
.to(overlayText, {
|
|
xPercent: 0,
|
|
yPercent: 0,
|
|
autoAlpha: 1,
|
|
duration: 1.28,
|
|
ease: "expo.out",
|
|
})
|
|
.to({}, { duration: 0.34 })
|
|
.to(
|
|
overlay,
|
|
{
|
|
yPercent: -100,
|
|
duration: 1.46,
|
|
ease: "power4.out",
|
|
},
|
|
">"
|
|
)
|
|
.to(
|
|
heroImageWrap,
|
|
{
|
|
scale: 1,
|
|
duration: 1.6,
|
|
ease: "power2.out",
|
|
},
|
|
"<0.03"
|
|
)
|
|
.to(
|
|
headlineLines,
|
|
{
|
|
yPercent: 0,
|
|
rotate: 0,
|
|
duration: 1.18,
|
|
stagger: 0.1,
|
|
ease: "power4.out",
|
|
},
|
|
"<0.27"
|
|
)
|
|
.to(
|
|
heroMeta,
|
|
{
|
|
y: 0,
|
|
autoAlpha: 1,
|
|
duration: 1.02,
|
|
stagger: 0.12,
|
|
ease: "power4.out",
|
|
},
|
|
"<0.16"
|
|
)
|
|
.set(overlay, { display: "none" });
|
|
}, pageRef);
|
|
|
|
return () => {
|
|
ctx.revert();
|
|
};
|
|
}, [shouldPlayIntro]);
|
|
|
|
useLayoutEffect(() => {
|
|
const heroImageWrap = heroImageWrapRef.current;
|
|
const heroImage = heroImageRef.current;
|
|
const discoveryImage = discoveryImageRef.current;
|
|
|
|
if (!heroImageWrap || !heroImage || !discoveryImage || typeof window === "undefined") {
|
|
return undefined;
|
|
}
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
return undefined;
|
|
}
|
|
|
|
const heroSection = heroImageWrap.closest(".hero");
|
|
const discoveryBanner = discoveryImage.closest(".discovery-banner");
|
|
|
|
if (!heroSection || !discoveryBanner) {
|
|
return undefined;
|
|
}
|
|
|
|
const ctx = gsap.context(() => {
|
|
gsap.set(heroImage, {
|
|
scale: 1.14,
|
|
yPercent: -4,
|
|
transformOrigin: "center center",
|
|
force3D: true,
|
|
});
|
|
|
|
gsap.to(heroImage, {
|
|
yPercent: 8,
|
|
ease: "none",
|
|
scrollTrigger: {
|
|
trigger: heroSection,
|
|
start: "top top",
|
|
end: "bottom top",
|
|
scrub: 1.15,
|
|
},
|
|
});
|
|
|
|
gsap.set(discoveryImage, {
|
|
scale: 1.16,
|
|
yPercent: -8,
|
|
transformOrigin: "center center",
|
|
force3D: true,
|
|
});
|
|
|
|
gsap.to(discoveryImage, {
|
|
yPercent: 10,
|
|
ease: "none",
|
|
scrollTrigger: {
|
|
trigger: discoveryBanner,
|
|
start: "top bottom",
|
|
end: "bottom top",
|
|
scrub: 1.2,
|
|
},
|
|
});
|
|
}, pageRef);
|
|
|
|
return () => {
|
|
ctx.revert();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const cards = cardRefs.current.filter(Boolean);
|
|
const cardStates = cards
|
|
.map((card) => {
|
|
const hoverFill = card.querySelector(".product-hover-fill");
|
|
const hoverVideo = card.querySelector(".product-hover-video");
|
|
const productImage = card.querySelector(".product-image");
|
|
const arrow = card.querySelector(".arrow");
|
|
|
|
if (!hoverFill || !productImage || !arrow) {
|
|
return null;
|
|
}
|
|
|
|
gsap.set(hoverFill, { autoAlpha: 0, scale: 1.08 });
|
|
gsap.set(productImage, { autoAlpha: 1, scale: 1 });
|
|
gsap.set(arrow, { x: 0 });
|
|
|
|
return { card, hoverFill, hoverVideo, productImage, arrow };
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const stopVideo = (video) => {
|
|
if (!video) return;
|
|
video.pause();
|
|
try {
|
|
video.currentTime = 0;
|
|
} catch {
|
|
// Ignore errors when setting currentTime.
|
|
}
|
|
};
|
|
|
|
const playVideo = (video) => {
|
|
if (!video) return;
|
|
|
|
try {
|
|
video.currentTime = 0;
|
|
} catch {
|
|
// Ignore errors when setting currentTime.
|
|
}
|
|
|
|
const playAttempt = video.play();
|
|
if (playAttempt && typeof playAttempt.catch === "function") {
|
|
playAttempt.catch(() => {});
|
|
}
|
|
};
|
|
|
|
const deactivate = (state) => {
|
|
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
|
|
stopVideo(state.hoverVideo);
|
|
|
|
gsap.to(state.hoverFill, {
|
|
autoAlpha: 0,
|
|
scale: 1.08,
|
|
duration: 0.35,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
|
|
gsap.to(state.productImage, {
|
|
scale: 1,
|
|
autoAlpha: 1,
|
|
duration: 0.35,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
|
|
gsap.to(state.arrow, {
|
|
x: 0,
|
|
duration: 0.35,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
};
|
|
|
|
const activate = (state) => {
|
|
cardStates.forEach((item) => {
|
|
if (item !== state) {
|
|
deactivate(item);
|
|
}
|
|
});
|
|
|
|
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
|
|
playVideo(state.hoverVideo);
|
|
|
|
gsap.to(state.hoverFill, {
|
|
autoAlpha: 1,
|
|
scale: 1,
|
|
duration: 0.45,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
|
|
gsap.to(state.productImage, {
|
|
scale: 0.92,
|
|
autoAlpha: 0.35,
|
|
duration: 0.45,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
|
|
gsap.to(state.arrow, {
|
|
x: 8,
|
|
duration: 0.45,
|
|
ease: "power2.out",
|
|
overwrite: "auto",
|
|
});
|
|
};
|
|
|
|
const cardCleanups = cardStates.map((state) => {
|
|
const onEnter = () => activate(state);
|
|
const onLeave = () => deactivate(state);
|
|
|
|
state.card.addEventListener("pointerenter", onEnter);
|
|
state.card.addEventListener("pointerleave", onLeave);
|
|
|
|
return () => {
|
|
state.card.removeEventListener("pointerenter", onEnter);
|
|
state.card.removeEventListener("pointerleave", onLeave);
|
|
};
|
|
});
|
|
|
|
const resetAll = () => {
|
|
cardStates.forEach((state) => deactivate(state));
|
|
};
|
|
|
|
const onMouseOutWindow = (event) => {
|
|
if (!event.relatedTarget) {
|
|
resetAll();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("blur", resetAll);
|
|
document.addEventListener("mouseout", onMouseOutWindow);
|
|
|
|
return () => {
|
|
window.removeEventListener("blur", resetAll);
|
|
document.removeEventListener("mouseout", onMouseOutWindow);
|
|
cardCleanups.forEach((cleanup) => cleanup());
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="page" ref={pageRef}>
|
|
<SharedNavbar variant="hero" active="atmos" />
|
|
|
|
<HeroSection
|
|
heroImageWrapRef={heroImageWrapRef}
|
|
heroImageRef={heroImageRef}
|
|
setHeadlinePrimaryRef={setHeadlinePrimaryRef}
|
|
setHeadlineSecondaryRef={setHeadlineSecondaryRef}
|
|
setDescriptionRef={setDescriptionRef}
|
|
setActionsRef={setActionsRef}
|
|
overlayRef={overlayRef}
|
|
overlayTextRef={overlayTextRef}
|
|
/>
|
|
|
|
<main>
|
|
<section className="section" id="dufte" data-reveal-group>
|
|
<div className="section-heading">
|
|
<h2 data-reveal="lines">
|
|
{"W\u00C4HLE EINE"}
|
|
<br />
|
|
{"ATMOSPH\u00C4RE"}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="product-grid">
|
|
{perfumes.map((item, index) => (
|
|
<Link
|
|
to={`/duft/${item.slug}`}
|
|
className="product-card"
|
|
key={item.id}
|
|
onClick={(event) => startProductTransition(event, item)}
|
|
ref={(element) => {
|
|
cardRefs.current[index] = element;
|
|
}}
|
|
>
|
|
<div className="product-hover-fill" aria-hidden="true">
|
|
{item.fillVideo ? (
|
|
<video
|
|
className="product-hover-video"
|
|
src={item.fillVideo}
|
|
muted
|
|
loop
|
|
playsInline
|
|
preload="metadata"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="product-hover-image"
|
|
style={{
|
|
backgroundImage: `url(${item.fillImage || item.image})`,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="product-top">
|
|
<span className="product-id">{item.id}</span>
|
|
<h3>{item.name}</h3>
|
|
</div>
|
|
|
|
<div className="product-image-wrap">
|
|
<img
|
|
src={item.image}
|
|
alt={item.name}
|
|
className="product-image"
|
|
data-product-transition-source
|
|
loading={index < 3 ? "eager" : "lazy"}
|
|
decoding="async"
|
|
/>
|
|
</div>
|
|
|
|
<div className="product-bottom">
|
|
<p>{item.text}</p>
|
|
<span className="arrow" aria-hidden="true" />
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
className="discovery-section"
|
|
id="testen"
|
|
data-reveal-group
|
|
data-reveal-start="top 82%"
|
|
>
|
|
<div className="discovery-copy">
|
|
<h2 data-reveal="lines">
|
|
DER SICHERE EINSTIEG
|
|
<br />
|
|
DISCOVERY SET
|
|
</h2>
|
|
<p data-reveal="fade">
|
|
{"Alle 6 D\u00FCfte als 2ml Samples."}
|
|
<br />
|
|
Jeden Duft eine Woche tragen.
|
|
<br />
|
|
Verstehen, was funktioniert.
|
|
</p>
|
|
<Link to="/discovery-set" className="discovery-btn" data-reveal="fade">
|
|
Discovery Set bestellen
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="discovery-banner">
|
|
<img
|
|
src="/atmos-discovery-set-thumbnail.png"
|
|
alt="Discovery Set"
|
|
loading="lazy"
|
|
decoding="async"
|
|
ref={discoveryImageRef}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LandingPage;
|