diff --git a/src/components/ui/Stepper.tsx b/src/components/ui/Stepper.tsx index a9c7115..19a5ccf 100644 --- a/src/components/ui/Stepper.tsx +++ b/src/components/ui/Stepper.tsx @@ -1,5 +1,15 @@ +"use client"; + import { CheckIcon } from "@/assets/icons"; -import React, { ReactNode, useState } from "react"; +import gsap from "gsap"; +import React, { + ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + export interface Step { title: string; description?: string; @@ -25,6 +35,160 @@ const Stepper: React.FC = ({ const currentStep = controlledStep !== undefined ? controlledStep : internalStep; + const rootRef = useRef(null); + const railRef = useRef(null); + const railFillRef = useRef(null); + const stepRefs = useRef>([]); + const labelRefs = useRef>([]); + const checkRefs = useRef>([]); + const numberRefs = useRef>([]); + const contentRef = useRef(null); + const prevBtnRef = useRef(null); + const nextBtnRef = useRef(null); + const submitGlowRef = useRef(null); + const prevStepRef = useRef(currentStep); + + // Intro animation + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) return; + + const ctx = gsap.context(() => { + gsap.from(stepRefs.current.filter(Boolean), { + scale: 0, + opacity: 0, + rotate: -180, + duration: 0.7, + stagger: 0.08, + ease: "back.out(2)", + }); + gsap.from(labelRefs.current.filter(Boolean), { + y: 12, + opacity: 0, + duration: 0.5, + stagger: 0.08, + delay: 0.15, + ease: "power3.out", + }); + if (contentRef.current) { + gsap.from(contentRef.current, { + y: 24, + opacity: 0, + duration: 0.6, + delay: 0.3, + ease: "power3.out", + }); + } + }, rootRef); + + return () => ctx.revert(); + }, []); + + // Animate rail fill + active circle pulse when current step changes + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const total = steps.length - 1; + const target = total > 0 ? (currentStep / total) * 100 : 0; + if (railFillRef.current) { + gsap.to(railFillRef.current, { + width: `${target}%`, + duration: reduced ? 0 : 0.8, + ease: "power3.inOut", + }); + } + + // Pulse the active circle + const active = stepRefs.current[currentStep]; + if (active && !reduced) { + gsap.fromTo( + active, + { scale: 1 }, + { + scale: 1.18, + duration: 0.25, + yoyo: true, + repeat: 1, + ease: "power2.inOut", + }, + ); + } + + // Animate newly checked step + const prev = prevStepRef.current; + if (currentStep > prev && !reduced) { + const justCompleted = checkRefs.current[prev]; + const oldNumber = numberRefs.current[prev]; + if (justCompleted) { + gsap.fromTo( + justCompleted, + { scale: 0, rotate: -90, opacity: 0 }, + { + scale: 1, + rotate: 0, + opacity: 1, + duration: 0.5, + ease: "back.out(2.4)", + }, + ); + } + if (oldNumber) { + gsap.to(oldNumber, { opacity: 0, duration: 0.2 }); + } + } + prevStepRef.current = currentStep; + }, [currentStep, steps.length]); + + // Animate content swap when step changes + const isFirstContentRender = useRef(true); + useLayoutEffect(() => { + if (isFirstContentRender.current) { + isFirstContentRender.current = false; + return; + } + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced || !contentRef.current) return; + + gsap.fromTo( + contentRef.current, + { y: 18, opacity: 0, filter: "blur(6px)" }, + { + y: 0, + opacity: 1, + filter: "blur(0px)", + duration: 0.55, + ease: "power3.out", + }, + ); + }, [currentStep]); + + // Submit button glow loop on last step + useEffect(() => { + if (currentStep !== steps.length - 1 || !submitGlowRef.current) return; + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) return; + + const tween = gsap.to(submitGlowRef.current, { + opacity: 0.85, + scale: 1.08, + duration: 1.2, + yoyo: true, + repeat: -1, + ease: "sine.inOut", + }); + return () => { + tween.kill(); + }; + }, [currentStep, steps.length]); + const handleStepClick = (index: number) => { if (index <= currentStep) { if (onStepChange) { @@ -35,12 +199,22 @@ const Stepper: React.FC = ({ } }; + const handleButtonHover = ( + btn: HTMLButtonElement | null, + enter: boolean, + ) => { + if (!btn) return; + gsap.to(btn, { + scale: enter ? 1.04 : 1, + y: enter ? -2 : 0, + duration: 0.25, + ease: "power2.out", + }); + }; + const nextStep = () => { if (currentStep === steps.length - 1) { - // On last step, trigger submit - if (onSubmit) { - onSubmit(); - } + if (onSubmit) onSubmit(); } else if (currentStep < steps.length - 1) { const newStep = currentStep + 1; if (onStepChange) { @@ -49,7 +223,7 @@ const Stepper: React.FC = ({ setInternalStep(newStep); } } - if (window) { + if (typeof window !== "undefined") { window.scroll({ top: 0, behavior: "smooth" }); } }; @@ -63,90 +237,285 @@ const Stepper: React.FC = ({ setInternalStep(newStep); } } - if (window) { + if (typeof window !== "undefined") { window.scroll({ top: 0, behavior: "smooth" }); } }; + const isLastStep = currentStep === steps.length - 1; + return ( -
-
- {steps.map((step, index) => ( - -
- -
-
- {step.title} -
- {step.description && ( -
- {step.description} -
- )} -
-
- {index < steps.length - 1 && ( +
+ {/* Decorative background blobs */} +
+
+ + {/* Step indicator row */} +
+ {/* Rail (track) */} +
+ {/* Animated fill */} +
+ +
+
+ +
+ {steps.map((step, index) => { + const isCompleted = index < currentStep; + const isActive = index === currentStep; + const isLocked = index > currentStep; + + return (
- )} - - ))} + key={index} + className="relative flex flex-1 flex-col items-center" + > + + +
{ + labelRefs.current[index] = el; + }} + className="mt-4 text-center" + > +
+ {step.title} +
+ {step.description && ( +
+ {step.description} +
+ )} +
+
+ ); + })} +
-
{steps[currentStep].content}
+ {/* Step content */} +
+ {steps[currentStep].content} +
-
+ {/* Nav buttons */} +
+ + {/* Step counter */} +
+ {currentStep + 1} + / + {steps.length} +
+
+ + {/* Local keyframes (background shimmer + ring pulse) */} +
); };