done landing page
This commit is contained in:
26
components/shared/Button.tsx
Normal file
26
components/shared/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
interface ButtonProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ text, className = "" }) => {
|
||||
return (
|
||||
<button
|
||||
className={`group font-mium-reg text-b-2-17 bg-btn relative h-[52px] w-max min-w-40 cursor-pointer rounded-full px-6 py-2.5 text-center font-normal tracking-[-0.8px] text-white shadow-[inset_0_0_25px_#7ED38C,0_0_25px_rgba(126,211,140,0.6),0_0_50px_rgba(126,211,140,0.3)] transition-all duration-700 ease-[cubic-bezier(0.19,1,0.22,1)] hover:shadow-[inset_0_0_30px_#9CF1A5,0_0_35px_rgba(126,211,140,0.9),0_0_60px_rgba(126,211,140,0.7)] ${className} `}
|
||||
>
|
||||
{/* 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">
|
||||
{text}
|
||||
</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">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
198
components/shared/Footer.tsx
Normal file
198
components/shared/Footer.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const Footer = () => {
|
||||
const companyLinks = [
|
||||
{ label: "Home", href: "/#home" },
|
||||
{ label: "About Us", href: "/#about" },
|
||||
{ label: "Product", href: "/#product" },
|
||||
{ label: "Benefits", href: "/#benefits" },
|
||||
{ label: "How it works", href: "/#how-it-works" },
|
||||
{ label: "Use Cases", href: "/#use-cases" },
|
||||
{ label: "Privacy Policy", href: "/privacy" },
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ label: "LinkedIn", href: "https://linkedin.com", icon: "linkedin" },
|
||||
{ label: "X", href: "https://x.com", icon: "x" },
|
||||
{ label: "Facebook", href: "https://facebook.com", icon: "facebook" },
|
||||
{ label: "Instagram", href: "https://instagram.com", icon: "instagram" },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-brand relative w-full text-white">
|
||||
<Image
|
||||
src="/images/png/grainy.png"
|
||||
width={800}
|
||||
height={900}
|
||||
alt="Munich City with trees and sensor on it"
|
||||
className="absolute top-0 left-0 h-full w-full rounded-2xl object-cover opacity-30 mix-blend-soft-light"
|
||||
/>
|
||||
<div className="tab:px-4 relative z-20 mx-auto max-w-[1420px] pt-16 sm:px-2.5 lg:px-6">
|
||||
<div className="tab:flex-row tab:gap-5 flex items-center justify-between sm:flex-col sm:gap-8 md:gap-0">
|
||||
{/* Left: Logo, Description, Contact */}
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Image
|
||||
src="/images/svg/whispering-logo.svg"
|
||||
alt="Whispering Trees"
|
||||
width={280}
|
||||
height={68}
|
||||
className="h-[68px] w-[280px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="font-mium-reg text-b-3-16 leading-b3 tab:mb-6 max-w-xl tracking-[-0.5px] text-white">
|
||||
FalktronTrees is redefining urban forestry through IoT and AI —
|
||||
turning trees into living data sources for healthier, more
|
||||
sustainable cities. Together, we’re shaping a greener, smarter
|
||||
future.
|
||||
</p>
|
||||
|
||||
<div className="tab:flex-col tab:mb-3 tab:gap-5 mt-6! flex items-start sm:mb-6 sm:flex-col sm:gap-2 md:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-b-3-16 font-mium-reg flex items-center gap-1 font-normal tracking-[-0.5px] text-white">
|
||||
<Image
|
||||
width={20}
|
||||
height={20}
|
||||
src="/images/svg/proicons_call.svg"
|
||||
className="h-5 w-5 object-cover"
|
||||
alt="Call"
|
||||
/>
|
||||
Call us:
|
||||
</span>
|
||||
<a
|
||||
href="tel:+492511234567"
|
||||
className="text-b-3-16 font-mium-reg font-normal tracking-[-0.5px] text-white transition-colors hover:text-green-400"
|
||||
>
|
||||
+49 251 1234567
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-b-3-16 font-mium-reg flex items-center gap-1 font-normal tracking-[-0.5px] text-white">
|
||||
<Image
|
||||
width={20}
|
||||
height={20}
|
||||
src="/images/svg/mage_email.svg"
|
||||
className="h-5 w-5 object-cover"
|
||||
alt="email"
|
||||
/>
|
||||
Email:
|
||||
</span>
|
||||
<a
|
||||
href="mailto:info@falktrontrees.com"
|
||||
className="text-b-3-16 font-mium-reg font-normal tracking-[-0.5px] text-white transition-colors hover:text-green-400"
|
||||
>
|
||||
info@falktrontrees.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <Button text="Contact Us" /> */}
|
||||
</div>
|
||||
|
||||
{/* Right: Links */}
|
||||
<div className="tab:w-max sm:w-full">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-2">
|
||||
{/* Company Links */}
|
||||
<div className="min-w-[180px]">
|
||||
<h3 className="text-b-1-20 font-switzer mb-5 font-medium text-white">
|
||||
Company
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{companyLinks.map((link) => {
|
||||
// ← current route
|
||||
const isHash = link.href.startsWith("/#"); // ← internal hash
|
||||
const targetId = link.href.replace("/#", "");
|
||||
return (
|
||||
<li key={link.label}>
|
||||
{isHash ? (
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const section = document.getElementById(targetId);
|
||||
if (section) {
|
||||
const yOffset = -80;
|
||||
const y =
|
||||
section.getBoundingClientRect().top +
|
||||
window.pageYOffset +
|
||||
yOffset;
|
||||
window.scrollTo({ top: y, behavior: "smooth" });
|
||||
}
|
||||
}}
|
||||
className="text-b-3-16 group font-mium-reg font-normal tracking-[-0.5px] text-white transition-colors hover:text-green-400"
|
||||
>
|
||||
<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">
|
||||
{link.label}
|
||||
</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">
|
||||
{link.label}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-b-3-16 group font-mium-reg font-normal tracking-[-0.5px] text-white transition-colors hover:text-green-400"
|
||||
>
|
||||
<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">
|
||||
{link.label}
|
||||
</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">
|
||||
{link.label}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div>
|
||||
<h3 className="text-b-1-20 font-switzer mb-5 font-medium text-white">
|
||||
Social Links
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{socialLinks.map((link) => (
|
||||
<li key={link.label}>
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-b-3-16 group font-mium-reg font-normal tracking-[-0.5px] text-white transition-colors hover:text-green-400"
|
||||
>
|
||||
<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">
|
||||
{link.label}
|
||||
</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">
|
||||
{link.label}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="font-switzer text-b-2-17 mt-12 border-t border-white/10 pt-6 pb-6 text-center font-normal text-white">
|
||||
© {new Date().getFullYear()} Whispering Trees. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
112
components/shared/Navbar.tsx
Normal file
112
components/shared/Navbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import Button from "./Button";
|
||||
import SmoothScrollLink from "@/components/ui/smooth-scroll-link";
|
||||
import Link from "next/link";
|
||||
import { usePageTransition } from "@/layout/Layout";
|
||||
|
||||
const Navbar = () => {
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [showNavbar, setShowNavbar] = useState(true);
|
||||
const { startTransition } = usePageTransition();
|
||||
|
||||
const [lastScrollY, setLastScrollY] = useState(0);
|
||||
|
||||
// ── GSAP: Initial slide-in ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = navbarRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Start off-screen
|
||||
gsap.set(el, { yPercent: -100, opacity: 0 });
|
||||
|
||||
// Animate in
|
||||
gsap.to(el, {
|
||||
yPercent: 0,
|
||||
opacity: 1,
|
||||
duration: 1.2,
|
||||
ease: "expo.out",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Scroll: Hide/show + background ─────────────────────────────
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const currentScroll = window.scrollY;
|
||||
|
||||
// Hide on scroll down
|
||||
if (currentScroll > lastScrollY && currentScroll > 100) {
|
||||
setShowNavbar(false);
|
||||
} else {
|
||||
setShowNavbar(true);
|
||||
}
|
||||
|
||||
setScrolled(currentScroll > 30);
|
||||
setLastScrollY(currentScroll);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [lastScrollY]);
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Home", href: "#home" },
|
||||
{ label: "About Us", href: "#about" },
|
||||
{ label: "Benefits", href: "#benefits" },
|
||||
{ label: "How it works", href: "/#how-it-works" },
|
||||
{ label: "FAQ", href: "#faq" },
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={navbarRef}
|
||||
className={`tab:block fixed top-0 right-0 left-0 z-50 sm:hidden ${showNavbar ? "translate-y-0" : "-translate-y-full"} ${
|
||||
scrolled
|
||||
? "top-0 border-white/30 bg-white/40 shadow-xs backdrop-blur-md"
|
||||
: "top-6 border-transparent bg-transparent"
|
||||
} `.trim()}
|
||||
>
|
||||
<nav className="mx-auto flex h-[75px] w-full max-w-[1420px] items-center justify-between px-6 py-3 transition-all duration-300">
|
||||
{/* Logo */}
|
||||
<div
|
||||
onClick={() => startTransition(`/`)}
|
||||
className="h-10 w-36 cursor-pointer"
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
scrolled
|
||||
? "/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>
|
||||
|
||||
{/* Desktop Links */}
|
||||
<div className="tab:flex hidden">
|
||||
<ul className="flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<li key={link.href} className="flex items-center justify-center">
|
||||
<SmoothScrollLink href={link.href} scrolled={scrolled}>
|
||||
{link.label}
|
||||
</SmoothScrollLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Button text="Book a Demo" />
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
36
components/shared/Secondary.tsx
Normal file
36
components/shared/Secondary.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
text: string;
|
||||
targetId: string; // ← NEW: pass the section ID
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
text,
|
||||
targetId,
|
||||
className = "",
|
||||
}) => {
|
||||
const handleScroll = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
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={`/#${targetId}`} // ← valid internal hash link
|
||||
onClick={handleScroll}
|
||||
className={`font-mium-reg text-b-2-17 h-[52px] w-max min-w-40 cursor-pointer rounded-full border border-white bg-transparent px-6 py-3 text-center font-normal tracking-[-0.8px] text-white hover:border-white/80 hover:bg-white/10 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${className} `.trim()}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondaryButton;
|
||||
148
components/ui/accordion.tsx
Normal file
148
components/ui/accordion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
components/ui/headline.tsx
Normal file
44
components/ui/headline.tsx
Normal 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
43
components/ui/loader.tsx
Normal 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;
|
||||
60
components/ui/logo-marquee.tsx
Normal file
60
components/ui/logo-marquee.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
components/ui/mobile-navbar.tsx
Normal file
180
components/ui/mobile-navbar.tsx
Normal 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.out‑ish
|
||||
},
|
||||
};
|
||||
// ✅ 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;
|
||||
157
components/ui/page-transition.tsx
Normal file
157
components/ui/page-transition.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
components/ui/smooth-scroll-link.tsx
Normal file
51
components/ui/smooth-scroll-link.tsx
Normal 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;
|
||||
45
components/ui/testimonial-marquee.tsx
Normal file
45
components/ui/testimonial-marquee.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user