import { useCallback, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useNavigate } from "react-router"; import { gsap } from "gsap"; import { ProductTransitionContext } from "../transitions/ProductTransitionContext"; import "./ProductTransition.css"; const supportsProductTransition = () => { if (typeof window === "undefined") return false; return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; }; const rectToObject = (rect) => ({ top: rect.top, left: rect.left, width: rect.width, height: rect.height, }); const isPlainNavigationClick = (event) => event.button === 0 && !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey; const getStageRect = (sourceRect) => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const sourceRatio = sourceRect.height / sourceRect.width || 1; const stageWidth = Math.min( Math.max(sourceRect.width * 1.16, 260), viewportWidth * 0.56, 620 ); const stageHeight = Math.min(stageWidth * sourceRatio, viewportHeight * 0.72); const normalizedWidth = stageHeight / sourceRatio; return { left: (viewportWidth - normalizedWidth) / 2, top: (viewportHeight - stageHeight) / 2, width: normalizedWidth, height: stageHeight, }; }; const getTransformForRect = (rect, sourceRect) => ({ x: rect.left, y: rect.top, scaleX: rect.width / sourceRect.width, scaleY: rect.height / sourceRect.height, }); export function ProductTransitionProvider({ children }) { const navigate = useNavigate(); const overlayRef = useRef(null); const imageRef = useRef(null); const washRef = useRef(null); const timelineRef = useRef(null); const [transition, setTransition] = useState(null); const clearTransition = useCallback(() => { timelineRef.current?.kill(); timelineRef.current = null; document.body.classList.remove("product-transition-active"); setTransition(null); }, []); const startProductTransition = useCallback( (event, perfume) => { if (!perfume?.slug || !isPlainNavigationClick(event)) { return false; } if (!supportsProductTransition()) { return false; } const card = event.currentTarget; const image = card.querySelector("[data-product-transition-source]"); if (!image) { return false; } const sourceRect = image.getBoundingClientRect(); if (sourceRect.width <= 0 || sourceRect.height <= 0) { return false; } event.preventDefault(); timelineRef.current?.kill(); document.body.classList.add("product-transition-active"); setTransition({ id: `${perfume.slug}-${Date.now()}`, slug: perfume.slug, to: `/duft/${perfume.slug}`, image: image.currentSrc || image.src || perfume.image, alt: perfume.name, sourceRect: rectToObject(sourceRect), phase: "leaving", }); return true; }, [] ); useLayoutEffect(() => { if (transition?.phase !== "leaving") { return undefined; } const overlay = overlayRef.current; const image = imageRef.current; const wash = washRef.current; if (!overlay || !image || !wash) { return undefined; } const routeContent = document.querySelector("[data-route-content]"); const sourceRect = transition.sourceRect; const stageRect = getStageRect(sourceRect); let completed = false; gsap.set(overlay, { autoAlpha: 1, pointerEvents: "auto" }); gsap.set(wash, { autoAlpha: 0 }); gsap.set(image, { x: sourceRect.left, y: sourceRect.top, width: sourceRect.width, height: sourceRect.height, scaleX: 1, scaleY: 1, autoAlpha: 1, transformOrigin: "0 0", force3D: true, }); timelineRef.current = gsap.timeline({ defaults: { ease: "power4.inOut" }, onComplete: () => { completed = true; navigate(transition.to, { state: { productTransition: true, transitionId: transition.id, }, }); setTransition((current) => current?.id === transition.id ? { ...current, phase: "entering" } : current ); }, }); timelineRef.current .to(wash, { autoAlpha: 1, duration: 0.42, ease: "power2.out" }, 0) .to( routeContent, { autoAlpha: 0.16, filter: "blur(10px)", scale: 0.985, duration: 0.62, ease: "power3.out", }, 0 ) .to( image, { ...getTransformForRect(stageRect, sourceRect), duration: 0.78, }, 0.03 ); return () => { if (!completed) { timelineRef.current?.kill(); } }; }, [navigate, transition]); useLayoutEffect(() => { if (transition?.phase !== "entering") { return undefined; } const overlay = overlayRef.current; const image = imageRef.current; const wash = washRef.current; if (!overlay || !image || !wash) { return undefined; } let frame = 0; let cancelled = false; let completed = false; const sourceRect = transition.sourceRect; const runEnterAnimation = () => { if (cancelled) return; window.scrollTo({ top: 0, left: 0, behavior: "instant" }); const target = document.querySelector( `[data-product-transition-target="${transition.slug}"]` ); if (!target && frame < 16) { frame += 1; window.requestAnimationFrame(runEnterAnimation); return; } const routeContent = document.querySelector("[data-route-content]"); gsap.set(routeContent, { autoAlpha: 1, filter: "none", scale: 1, clearProps: "opacity,visibility,filter,transform", }); if (!target) { gsap.to(overlay, { autoAlpha: 0, duration: 0.3, ease: "power2.out", onComplete: clearTransition, }); return; } const targetRect = rectToObject(target.getBoundingClientRect()); const revealItems = gsap.utils.toArray("[data-product-transition-reveal]"); gsap.set(target, { autoAlpha: 0 }); gsap.set(revealItems, { y: 28, autoAlpha: 0, force3D: true }); timelineRef.current?.kill(); timelineRef.current = gsap.timeline({ defaults: { ease: "power4.out" }, onComplete: () => { completed = true; clearTransition(); }, }); timelineRef.current .to(image, { ...getTransformForRect(targetRect, sourceRect), duration: 0.72, }) .set(target, { autoAlpha: 1 }, ">-0.08") .to(image, { autoAlpha: 0, duration: 0.16, ease: "power2.out" }, "<") .to(wash, { autoAlpha: 0, duration: 0.5, ease: "power2.out" }, "<") .to( revealItems, { y: 0, autoAlpha: 1, duration: 0.82, stagger: 0.07, ease: "power4.out", clearProps: "transform,opacity,visibility", }, ">-0.05" ); }; window.requestAnimationFrame(runEnterAnimation); return () => { cancelled = true; if (!completed) { timelineRef.current?.kill(); } }; }, [clearTransition, transition]); const value = useMemo( () => ({ activeSlug: transition?.slug || null, phase: transition?.phase || "idle", startProductTransition, }), [startProductTransition, transition] ); return ( {children} {transition && (