done landing page

This commit is contained in:
2025-11-10 17:10:34 +05:30
parent 3852d46661
commit 483515e163
105 changed files with 3529 additions and 104 deletions

148
components/ui/accordion.tsx Normal file
View File

@@ -0,0 +1,148 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
interface FAQItemType {
question: string;
answer: string;
}
interface FAQProps {
title?: string;
subtitle?: string;
faqData: FAQItemType[];
className?: string;
}
export const FAQ: React.FC<FAQProps> = ({
title = "FAQs",
subtitle = "Frequently Asked Questions",
faqData,
className,
...props
}) => {
const accordionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const items = accordionRef.current?.querySelectorAll(".accordion-item");
if (items) {
gsap.set(items, {
opacity: 0,
y: 50,
});
ScrollTrigger.batch(items, {
onEnter: (batch) => {
gsap.to(batch, {
opacity: 1,
y: 0,
stagger: 0.12,
duration: 1,
ease: "power1.inOut",
overwrite: true,
});
},
start: "top 90%",
once: true,
});
}
return () => {
ScrollTrigger.getAll().forEach((t) => t.kill());
};
}, []);
return (
<div className={cn("relative", className)} {...props}>
<FAQList faqData={faqData} accordionRef={accordionRef} />
</div>
);
};
const FAQList = ({
faqData,
accordionRef,
}: {
faqData: FAQItemType[];
accordionRef: React.RefObject<HTMLDivElement | null>;
}) => (
<div ref={accordionRef} className="mx-auto max-w-4xl space-y-4">
<AnimatePresence mode="popLayout">
{faqData?.map((faq, index) => (
<FAQItem
key={index}
question={faq.question}
answer={faq.answer}
className="accordion-item"
/>
))}
</AnimatePresence>
</div>
);
const FAQItem = ({
question,
answer,
className,
}: FAQItemType & { className?: string }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
animate={isOpen ? "open" : "closed"}
className={cn(
"bg-light-200 border-light-200 rounded-xl border text-black transition-colors",
className
)}
>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full cursor-pointer items-center justify-between gap-4 px-5 py-6 text-left"
>
<span
className={cn(
"font-switzer text-lg font-medium text-black transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
>
{question}
</span>
<motion.span
variants={{
open: { rotate: "45deg" },
closed: { rotate: "0deg" },
}}
transition={{ duration: 0.2 }}
>
<Plus
className={cn(
"h-6 w-6 transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
/>
</motion.span>
</button>
<motion.div
initial={false}
animate={{
height: isOpen ? "auto" : "0px",
marginBottom: isOpen ? "16px" : "0px",
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden px-4"
>
<p className="font-mium-reg text-sm leading-6 font-normal tracking-[-0.8px]">
{answer}
</p>
</motion.div>
</motion.div>
);
};

View File

@@ -0,0 +1,44 @@
"use client";
import React, { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
type HeadlineProps = {
text: string;
};
const Headline: React.FC<HeadlineProps> = ({ text }) => {
const headlineRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
if (!headlineRef.current) return;
gsap.from(headlineRef.current, {
y: 80,
opacity: 0,
duration: 1,
ease: "expo.out",
scrollTrigger: {
trigger: headlineRef.current,
start: "top 90%",
toggleActions: "play none none none",
},
});
}, []);
return (
<div className="overflow-hidden">
<h6
ref={headlineRef}
className="bg-light-300 border-border font-switzer sm:text-b-4-14 tab:text-b-3-16 text-border sm:leading-b4 tab:leading-b2 tab:px-4 w-max rounded-full border py-1.5 font-normal sm:px-2.5"
>
{text}
</h6>
</div>
);
};
export default Headline;

43
components/ui/loader.tsx Normal file
View File

@@ -0,0 +1,43 @@
"use client";
import React, { useEffect, useRef } from "react";
import gsap from "gsap";
import Image from "next/image";
const Loader = () => {
// We need a DOM node that will be the clipping container
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const container = containerRef.current;
const img = imgRef.current;
if (!container || !img) return;
gsap.set(container, { width: "0", clipPath: "inset(0 100% 0 0)" });
gsap.to(container, {
width: "280px", // reveal left to right
duration: 1.4,
clipPath: "inset(0 0% 0 0)",
ease: "expo.inout",
});
}, []);
return (
<div className="bg-brand fixed inset-0 z-100 flex h-screen w-full items-center justify-center">
{/* Clipping container */}
<div ref={containerRef} className="overflow-hidden pl-3">
<Image
ref={imgRef}
src="/images/svg/whispering-logo.svg"
alt="Whispering Trees"
width={280}
height={68}
className="h-[68px] w-[250px] object-contain"
priority
/>
</div>
</div>
);
};
export default Loader;

View File

@@ -0,0 +1,60 @@
"use client";
import { cn } from "@/lib/utils";
interface MarqueeProps {
className?: string;
reverse?: boolean;
pauseOnHover?: boolean;
children?: React.ReactNode;
vertical?: boolean;
repeat?: number;
duration?: string;
gap?: string;
[key: string]: unknown;
}
export function Marquee({
className,
reverse = false,
pauseOnHover = false,
children,
vertical = false,
repeat = 4,
gap,
duration = "40s",
...props
}: MarqueeProps) {
return (
<div
{...props}
style={{
["--duration" as string]: duration,
["--gap" as string]: gap, // 👈 dynamic gap applied
}}
className={cn(
"group flex items-center [gap:var(--gap)] overflow-hidden p-2",
vertical ? "flex-col" : "flex-row",
className
)}
>
{Array.from({ length: repeat }).map((_, i) => (
<div
key={i}
className={cn(
"marquee flex shrink-0 justify-around [gap:var(--gap)]",
{
reverse: reverse, // add reverse direction
pause: pauseOnHover, // pause animation on hover
"flex-row": !vertical,
"flex-col": vertical,
}
)}
>
{children}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { motion, Variants } from "framer-motion";
import { cn } from "@/lib/utils";
import Button from "../shared/Button";
const MobileNavbar = () => {
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const mobileRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
const currentScroll = window.scrollY;
setScrolled(currentScroll > 30);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navLinks = [
{ label: "Home", href: "#home" },
{ label: "About Us", href: "#about" },
{ label: "Features", href: "#features" },
{ label: "Benefits", href: "#benefits" },
{ label: "FAQ", href: "#faq" },
];
const containerVariants: Variants = {
hidden: { y: -100, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { duration: 1.2, ease: [0.19, 1, 0.22, 1] }, // expo.outish
},
};
// ✅ Correctly typed Framer Motion variants
const mobileNavVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
staggerChildren: 0.1,
duration: 0.4,
ease: "easeInOut", // ✅ typed correctly
},
},
};
const linkVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeInOut", // ✅ allowed by Framer
},
},
};
// ✅ Safe smooth scroll handler
const handleClick = (
e: React.MouseEvent<HTMLAnchorElement>,
href: string
) => {
e.preventDefault();
const targetId = href.replace("#", "");
const section = document.getElementById(targetId);
if (section) {
const yOffset = -80;
const y =
section.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: "smooth" });
setIsOpen(false);
}
};
return (
<motion.nav
variants={containerVariants}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
className={`fixed left-1/2 z-50 ${
isOpen
? "h-[480px] border-white/30 bg-white/80 shadow-xs backdrop-blur-lg"
: "sm:h-[70px]"
} ${
scrolled
? "top-0 border-white/30 bg-white/80 shadow-xs backdrop-blur-lg"
: "top-2 border-transparent bg-transparent"
} tab:hidden mx-auto w-full -translate-x-1/2 transform px-5 transition-[height] duration-300 sm:block sm:max-w-full sm:py-[15px]`}
>
{/* Header Bar */}
<div className="relative flex w-full items-center justify-between">
<Link href="/" className="logo">
<div className="h-10 w-36">
<Image
src={
scrolled || isOpen
? "/images/svg/dark-logo.svg"
: "/images/svg/whispering-logo.svg"
}
width={144}
height={40}
alt="Whispering Tree"
className="h-full w-full object-contain"
/>
</div>
</Link>
{/* Mobile Menu Toggle */}
<button
className="menu__icon relative z-10 flex h-max w-[28px] flex-col items-center justify-center gap-1.5 p-[3px] md:hidden"
onClick={() => setIsOpen(!isOpen)}
>
<span
className={`h-[0.1rem] w-full rounded-[0.125rem] transition-all duration-300 ${
scrolled || isOpen ? "bg-black" : "bg-white"
} ${isOpen ? "translate-y-[7.5px] rotate-[-45deg]" : ""}`}
></span>
<span
className={`h-[0.1rem] w-[60%] rounded-[0.125rem] transition-all duration-300 ${
scrolled || isOpen ? "bg-black" : "bg-white"
} ${isOpen ? "w-0 opacity-0" : ""}`}
></span>
<span
className={`h-[0.1rem] w-full rounded-[0.125rem] transition-all duration-300 ${
scrolled || isOpen ? "bg-black" : "bg-white"
} ${isOpen ? "translate-y-[-7.5px] rotate-[45deg]" : ""}`}
></span>
</button>
</div>
{/* Dropdown Mobile Menu */}
<motion.div
initial="hidden"
animate={isOpen ? "visible" : "hidden"}
variants={mobileNavVariants}
className={`mt-12 flex flex-col items-center gap-5 ${
isOpen ? "flex" : "hidden"
} md:hidden`}
>
{navLinks.map((link, i) => (
<motion.div
key={i}
variants={linkVariants}
className="flex w-full items-center justify-start"
>
<a
href={link.href}
onClick={(e) => handleClick(e, link.href)}
className={cn(
"font-switzer text-h-4-34 group relative inline-block h-full cursor-pointer leading-9 font-medium transition-all duration-700 ease-[cubic-bezier(0.19,1,0.22,1)]"
)}
>
{/* Text hover animation */}
<span className="relative block overflow-hidden">
<span className="block leading-9 transition-transform duration-[1.125s] ease-[cubic-bezier(0.19,1,0.22,1)] group-hover:-translate-y-6">
{link.label}
</span>
<span className="absolute top-8 left-0 leading-9 transition-all duration-[1.125s] ease-[cubic-bezier(0.19,1,0.22,1)] group-hover:top-0">
{link.label}
</span>
</span>
</a>
</motion.div>
))}
<Button text="Book A Demo" className="mt-5 w-full!" />
</motion.div>
</motion.nav>
);
};
export default MobileNavbar;

View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
interface PageTransitionProps {
isOpen: boolean;
onComplete?: () => void;
direction?: "reveal" | "unreveal";
}
/* ------------------------------------------------------------------
1. Path data identical to the original demo
------------------------------------------------------------------ */
const paths = {
step1: {
unfilled: "M 0 100 V 100 Q 50 100 100 100 V 100 z",
inBetween: {
curve1: "M 0 100 V 50 Q 50 0 100 50 V 100 z",
curve2: "M 0 100 V 50 Q 50 100 100 50 V 100 z",
},
filled: "M 0 100 V 0 Q 50 0 100 0 V 100 z",
},
step2: {
filled: "M 0 0 V 100 Q 50 100 100 100 V 0 z",
inBetween: {
curve1: "M 0 0 V 50 Q 50 0 100 50 V 0 z",
curve2: "M 0 0 V 50 Q 50 100 100 50 V 0 z",
},
unfilled: "M 0 0 V 0 Q 50 0 100 0 V 0 z",
},
};
export const PageTransition = ({
isOpen,
onComplete,
direction = "reveal",
}: PageTransitionProps) => {
const pathRef = useRef<SVGPathElement>(null);
const svgRef = useRef<SVGSVGElement>(null); // 👈 new ref for hiding
const [animating, setAnimating] = useState(false);
/* --------------------------------------------------------------
REVEAL open second view
-------------------------------------------------------------- */
const reveal = () => {
if (animating) return;
setAnimating(true);
// 👇 Show overlay before animation starts
gsap.set(svgRef.current, { display: "block" });
const tl = gsap.timeline({
onComplete: () => {
setAnimating(false);
onComplete?.();
// 👇 Hide overlay after animation completes
gsap.set(svgRef.current, { display: "none" });
},
});
tl.set(pathRef.current, { attr: { d: paths.step1.unfilled } })
.to(pathRef.current, {
duration: 0.8,
ease: "power4.in",
attr: { d: paths.step1.inBetween.curve1 },
})
.to(pathRef.current, {
duration: 0.2,
ease: "power1",
attr: { d: paths.step1.filled },
onComplete: () => onComplete?.(),
})
.set(pathRef.current, { attr: { d: paths.step2.filled } })
.to(pathRef.current, {
duration: 0.2,
ease: "sine.in",
attr: { d: paths.step2.inBetween.curve1 },
})
.to(pathRef.current, {
duration: 1,
ease: "power4",
attr: { d: paths.step2.unfilled },
});
};
/* --------------------------------------------------------------
UNREVEAL back to first view
-------------------------------------------------------------- */
const unreveal = () => {
if (animating) return;
setAnimating(true);
// 👇 Show overlay before animation starts
gsap.set(svgRef.current, { display: "block" });
const tl = gsap.timeline({
onComplete: () => {
setAnimating(false);
onComplete?.();
// 👇 Hide overlay after animation completes
gsap.set(svgRef.current, { display: "none" });
},
});
tl.set(pathRef.current, { attr: { d: paths.step2.unfilled } })
.to(pathRef.current, {
duration: 0.8,
ease: "power4.in",
attr: { d: paths.step2.inBetween.curve2 },
})
.to(pathRef.current, {
duration: 0.2,
ease: "power1",
attr: { d: paths.step2.filled },
onComplete: () => onComplete?.(),
})
.set(pathRef.current, { attr: { d: paths.step1.filled } })
.to(pathRef.current, {
duration: 0.2,
ease: "sine.in",
attr: { d: paths.step1.inBetween.curve2 },
})
.to(pathRef.current, {
duration: 1,
ease: "power4",
attr: { d: paths.step1.unfilled },
});
};
/* --------------------------------------------------------------
React to prop changes
-------------------------------------------------------------- */
useEffect(() => {
if (!isOpen) return;
if (direction === "reveal") reveal();
else if (direction === "unreveal") unreveal();
}, [isOpen, direction]);
return (
<svg
ref={svgRef}
className="pointer-events-none fixed top-0 left-0 z-[9999] h-screen w-screen"
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={{ display: "none" }} // 👈 hidden by default
>
<path
ref={pathRef}
className="overlay__path"
d="M 0 100 V 100 Q 50 100 100 100 V 100 z"
vectorEffect="non-scaling-stroke"
fill="#274b2d"
/>
</svg>
);
};

View File

@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
interface SmoothScrollLinkProps {
href: string;
children: React.ReactNode;
scrolled?: boolean;
}
const SmoothScrollLink = ({
href,
children,
scrolled = false,
}: SmoothScrollLinkProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const targetId = href.replace("#", "");
const section = document.getElementById(targetId);
if (section) {
const yOffset = -80;
const y =
section.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: "smooth" });
}
};
return (
<a
href={href}
onClick={handleClick}
className={cn(
"font-mium-reg text-b-2-17 group relative inline-block h-full cursor-pointer font-medium tracking-[-0.8px] transition-all duration-700 ease-[cubic-bezier(0.19,1,0.22,1)]",
scrolled ? "text-black" : "text-white"
)}
>
{/* Smooth hover text animation */}
<span className="relative block overflow-hidden">
<span className="block transition-transform duration-[1.125s] ease-[cubic-bezier(0.19,1,0.22,1)] group-hover:-translate-y-6">
{children}
</span>
<span className="absolute top-6 left-0 transition-all duration-[1.125s] ease-[cubic-bezier(0.19,1,0.22,1)] group-hover:top-0">
{children}
</span>
</span>
</a>
);
};
export default SmoothScrollLink;

View File

@@ -0,0 +1,45 @@
import Image from "next/image";
export const TestimonialCard = ({
id,
name,
title,
body,
img,
}: {
id: number;
name: string;
title: string;
img: string;
body: string;
}) => {
return (
<div
key={id}
className={`group mx-auto w-[360px] max-w-[380px] rounded-xl border border-gray-300 bg-white p-6 transition-all duration-500`}
>
<div className={`flex items-center gap-5 pb-4`}>
<Image
width={64}
height={64}
className="h-16 w-16 rounded-full object-contain"
src={img}
alt="avatar"
/>
<div className="block">
<p className="font-switer font-medium text-black transition-all duration-500">
{name}
</p>
<span className="font-mium-reg text-gray text-sm tracking-[-0.8px]">
{title}
</span>
</div>
</div>
<div className="border-t border-solid border-gray-200 pt-4">
<p className="text-b-4-14 text-gray font-mium-reg line-clamp-4 leading-5 font-normal tracking-[-0.6px]">
{body}
</p>
</div>
</div>
);
};