149 lines
3.5 KiB
TypeScript
149 lines
3.5 KiB
TypeScript
"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>
|
|
);
|
|
};
|