Initial commit
This commit is contained in:
143
src/components/sections/features/FeaturesAlternatingBento.tsx
Normal file
143
src/components/sections/features/FeaturesAlternatingBento.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
||||
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
||||
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
||||
import MediaStack from "@/components/ui/MediaStack";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type IconInput = string | LucideIcon;
|
||||
|
||||
type FeatureItem = { title: string; description: string; primaryButton?: { text: string; href: string }; secondaryButton?: { text: string; href: string } } & (
|
||||
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
||||
| { bentoComponent: "animated-bar-chart" }
|
||||
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
||||
);
|
||||
|
||||
const getBentoComponent = (item: FeatureItem) => {
|
||||
switch (item.bentoComponent) {
|
||||
case "info-card-marquee": return <InfoCardMarquee items={item.infoCards} />;
|
||||
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||
case "checklist-timeline": return <ChecklistTimeline heading={item.heading} subheading={item.subheading} items={item.checklistItems} completedLabel={item.completedLabel} />;
|
||||
case "media-stack": return <MediaStack items={item.mediaItems} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface FeaturesAlternatingBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAlternatingBento = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesAlternatingBentoProps) => {
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
itemRefs.current.forEach((ref, position) => {
|
||||
if (!ref) return;
|
||||
|
||||
const isLast = position === itemRefs.current.length - 1;
|
||||
|
||||
gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ref,
|
||||
start: "center center",
|
||||
end: "+=100%",
|
||||
scrub: true,
|
||||
},
|
||||
})
|
||||
.set(ref, { willChange: "opacity" })
|
||||
.to(ref, {
|
||||
ease: "none",
|
||||
opacity: isLast ? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [items.length]);
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 md:gap-[6vh] w-content-width mx-auto">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cls("sticky top-[25vw] md:top-[12.5vh] h-[140vw] md:h-[75vh] flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||
>
|
||||
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
|
||||
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 h-full rounded overflow-hidden bg-foreground/5 p-3 xl:p-4 2xl:p-5">
|
||||
{getBentoComponent(item)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAlternatingBento;
|
||||
128
src/components/sections/features/FeaturesAlternatingSplit.tsx
Normal file
128
src/components/sections/features/FeaturesAlternatingSplit.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesAlternatingSplitProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAlternatingSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesAlternatingSplitProps) => {
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
itemRefs.current.forEach((ref, position) => {
|
||||
if (!ref) return;
|
||||
|
||||
const isLast = position === itemRefs.current.length - 1;
|
||||
|
||||
gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ref,
|
||||
start: "center center",
|
||||
end: "+=100%",
|
||||
scrub: true,
|
||||
},
|
||||
})
|
||||
.set(ref, { willChange: "opacity" })
|
||||
.to(ref, {
|
||||
ease: "none",
|
||||
opacity: isLast ? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [items.length]);
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 md:gap-[6vh] w-content-width mx-auto">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cls("sticky top-[25vw] md:top-[12.5vh] h-[140vw] md:h-[75vh] flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||
>
|
||||
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
|
||||
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAlternatingSplit;
|
||||
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
tags: string[];
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ArrowButton = ({ href, onClick }: { href?: string; onClick?: () => void }) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="group/arrow flex items-center justify-center shrink-0 size-9 primary-button rounded-full cursor-pointer transition-transform duration-300 hover:scale-110"
|
||||
>
|
||||
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover/arrow:rotate-45" strokeWidth={2} />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeaturesArrowCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesArrowCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesArrowCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4">
|
||||
<ArrowButton href={item.href} onClick={item.onClick} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 md:mt-3">
|
||||
{item.tags.map((itemTag) => (
|
||||
<div key={itemTag} className="flex items-center h-9 px-3 text-sm card rounded">
|
||||
<p>{itemTag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesArrowCards;
|
||||
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type AttributeDetail = {
|
||||
icon: string | LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
tags: string;
|
||||
badge?: string | null;
|
||||
details: AttributeDetail[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesAttributeCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAttributeCards = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesAttributeCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features attribute cards section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="group flex flex-col gap-2 xl:gap-3 2xl:gap-4 h-full rounded-none">
|
||||
<div className="relative aspect-4/3 overflow-hidden rounded-none">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none group-hover:scale-105 transition-transform duration-500" />
|
||||
{item.badge && (
|
||||
<span className="absolute top-2 left-2 xl:top-3 xl:left-3 2xl:top-4 2xl:left-4 px-3 py-1 text-sm text-foreground font-medium card rounded-none">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||
<p className="text-base leading-snug">{item.tags}</p>
|
||||
<div className="flex items-center gap-3 text-base mt-0.5">
|
||||
{item.details.map((detail) => {
|
||||
const IconComponent = resolveIcon(detail.icon);
|
||||
return (
|
||||
<span key={detail.label} className="flex items-center gap-1">
|
||||
<IconComponent className="size-[1em]" strokeWidth={1.5} />
|
||||
{detail.label}: {detail.value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAttributeCards;
|
||||
103
src/components/sections/features/FeaturesBento.tsx
Normal file
103
src/components/sections/features/FeaturesBento.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
||||
import TiltedStackCards from "@/components/ui/TiltedStackCards";
|
||||
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
||||
import OrbitingIcons from "@/components/ui/OrbitingIcons";
|
||||
import IconTextMarquee from "@/components/ui/IconTextMarquee";
|
||||
import ChatMarquee from "@/components/ui/ChatMarquee";
|
||||
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
||||
import MediaStack from "@/components/ui/MediaStack";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type IconInput = string | LucideIcon;
|
||||
|
||||
type FeatureCard = { title: string; description: string } & (
|
||||
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
||||
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
|
||||
| { bentoComponent: "animated-bar-chart" }
|
||||
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
|
||||
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
|
||||
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
|
||||
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
||||
);
|
||||
|
||||
const getBentoComponent = (feature: FeatureCard) => {
|
||||
switch (feature.bentoComponent) {
|
||||
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
|
||||
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
|
||||
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
|
||||
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
|
||||
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
|
||||
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
|
||||
case "media-stack": return <MediaStack items={feature.mediaItems} />;
|
||||
}
|
||||
};
|
||||
|
||||
const FeaturesBento = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureCard[];
|
||||
}) => (
|
||||
<section aria-label="Features bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
|
||||
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||
<p className="text-base leading-snug">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default FeaturesBento;
|
||||
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesBentoGridProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
}
|
||||
|
||||
const FeaturesBentoGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: FeaturesBentoGridProps) => {
|
||||
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" className="w-content-width mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBentoGrid;
|
||||
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesBentoGridCtaProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
ctaButton?: {
|
||||
text: string;
|
||||
href: string;
|
||||
avatarSrc?: string;
|
||||
avatarLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const FeaturesBentoGridCta = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
ctaButton,
|
||||
}: FeaturesBentoGridCtaProps) => {
|
||||
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{ctaButton && (
|
||||
<ScrollReveal variant="fade-blur" delay={0.2}>
|
||||
<a
|
||||
href={ctaButton.href}
|
||||
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
||||
>
|
||||
{ctaButton.avatarSrc && (
|
||||
<div className="flex items-center">
|
||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||
<img
|
||||
src={ctaButton.avatarSrc}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
||||
+
|
||||
</span>
|
||||
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
||||
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
||||
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBentoGridCta;
|
||||
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import BorderGlow from "@/components/ui/BorderGlow";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
interface FeaturesBorderGlowProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesBorderGlow = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: FeaturesBorderGlowProps) => {
|
||||
return (
|
||||
<section aria-label="Features border glow section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => {
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
return (
|
||||
<div key={feature.title} className="relative flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 mt-0.5 h-full min-h-60 md:min-h-70 2xl:min-h-80 card rounded">
|
||||
<div className="flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded-full">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
<BorderGlow />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBorderGlow;
|
||||
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
interface FeaturesComparisonProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
negativeItems: string[];
|
||||
positiveItems: string[];
|
||||
}
|
||||
|
||||
const FeaturesComparison = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
negativeItems,
|
||||
positiveItems,
|
||||
}: FeaturesComparisonProps) => {
|
||||
return (
|
||||
<section aria-label="Features comparison section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5">
|
||||
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded opacity-50">
|
||||
{negativeItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 secondary-button rounded">
|
||||
<X className="size-3 text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded">
|
||||
{positiveItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesComparison;
|
||||
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesDetailedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesDetailedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesDetailedCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||
{items.map((item) => (
|
||||
<ScrollReveal
|
||||
variant="fade"
|
||||
key={item.title}
|
||||
className="flex flex-col md:grid md:grid-cols-2 mx-auto gap-6 md:gap-20 p-6 md:p-10 card rounded group"
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-2">
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
|
||||
<div className="flex flex-col-reverse md:flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.tags.map((itemTag) => (
|
||||
<div key={itemTag} className="px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{itemTag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg md:text-xl leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square md:aspect-5/4 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedCards;
|
||||
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type StepItem = {
|
||||
tag: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesDetailedStepsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
steps: StepItem[];
|
||||
}
|
||||
|
||||
const FeaturesDetailedSteps = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
steps,
|
||||
}: FeaturesDetailedStepsProps) => {
|
||||
return (
|
||||
<section aria-label="Features detailed steps section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = String(index + 1).padStart(2, "0");
|
||||
return (
|
||||
<ScrollReveal
|
||||
variant="fade"
|
||||
key={step.title}
|
||||
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-6 p-6 md:p-10 card rounded overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col justify-between w-full md:w-1/2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{step.tag}</p>
|
||||
</div>
|
||||
<h3 className="text-7xl md:text-8xl font-semibold leading-[1.15] text-balance">{step.title}</h3>
|
||||
</div>
|
||||
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-2xl md:text-3xl font-semibold leading-snug text-balance">{step.subtitle}</h4>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full md:w-35/100 gap-10">
|
||||
<span className="hidden md:block self-end text-7xl md:text-8xl font-semibold text-accent">{stepNumber}</span>
|
||||
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
|
||||
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedSteps;
|
||||
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
descriptions: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesFlipCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full cursor-pointer perspective-[3000px]"
|
||||
onClick={() => setIsFlipped(!isFlipped)}
|
||||
>
|
||||
<div
|
||||
data-flipped={isFlipped}
|
||||
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
|
||||
>
|
||||
<div className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden aspect-4/5 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 rotate-45 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5 rounded">
|
||||
{item.descriptions.map((desc, index) => (
|
||||
<p key={index} className="text-base md:text-lg leading-snug text-balance">{desc}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesFlipCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesFlipCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureFlipCard key={item.title} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesFlipCards;
|
||||
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type BottomFeatureItem = FeatureItem & {
|
||||
primaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
};
|
||||
|
||||
interface FeaturesGridSplitProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
topItems: [FeatureItem, FeatureItem];
|
||||
bottomItem: BottomFeatureItem;
|
||||
}
|
||||
|
||||
const FeaturesGridSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
topItems,
|
||||
bottomItem,
|
||||
}: FeaturesGridSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<ScrollReveal variant="fade-blur" className="grid grid-cols-1 md:grid-cols-2 gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{topItems.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="aspect-square rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-lg leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="flex flex-col gap-1 justify-center md:w-1/2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{bottomItem.title}</h3>
|
||||
<p className="text-lg leading-snug text-balance">{bottomItem.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={bottomItem.primaryButton.text} href={bottomItem.primaryButton.href} variant="primary" />
|
||||
{bottomItem.avatarsSrc && bottomItem.avatarsSrc.length > 0 && (
|
||||
<AvatarGroup avatarsSrc={bottomItem.avatarsSrc} size="md" label={bottomItem.avatarsLabel} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={bottomItem.imageSrc} videoSrc={bottomItem.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesGridSplit;
|
||||
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HoverPattern from "@/components/ui/HoverPattern";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
interface FeaturesIconCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesIconCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: FeaturesIconCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features icon cards section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => {
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
return (
|
||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||
<HoverPattern className="flex items-center justify-center aspect-square rounded bg-foreground/5 shadow shadow-foreground/5">
|
||||
<div className="relative z-10 flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded shadow">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</HoverPattern>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||
<p className="text-base leading-snug">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesIconCards;
|
||||
112
src/components/sections/features/FeaturesImageBento.tsx
Normal file
112
src/components/sections/features/FeaturesImageBento.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesImageBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesImageBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesImageBentoProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features image bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => {
|
||||
const content = (
|
||||
<div className="relative h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-5 bottom-5 xl:inset-x-6 xl:bottom-6 2xl:inset-x-7 2xl:bottom-7 flex flex-col text-background">
|
||||
<span className="text-2xl font-semibold leading-snug truncate">{item.title}</span>
|
||||
<span className="text-base leading-snug truncate">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollReveal key={index} variant="fade-blur" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
{item.href ? (
|
||||
<a href={item.href} className="block overflow-hidden rounded">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesImageBento;
|
||||
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel >
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||
<div className="aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||
<p className="text-base leading-snug">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaCards;
|
||||
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import LoopCarousel from "@/components/ui/LoopCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonIcon: string | LucideIcon;
|
||||
buttonHref?: string;
|
||||
buttonOnClick?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaCarouselProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
|
||||
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
|
||||
const Icon = resolveIcon(item.buttonIcon);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex items-center justify-between gap-5 p-4 xl:p-5 2xl:p-6 card rounded backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-2xl font-semibold leading-snug truncate">{item.title}</h3>
|
||||
<p className="text-base leading-snug truncate">{item.description}</p>
|
||||
</div>
|
||||
<a
|
||||
href={item.buttonHref}
|
||||
onClick={handleClick}
|
||||
aria-label="View more"
|
||||
className="flex items-center justify-center shrink-0 size-9 cursor-pointer primary-button rounded-full"
|
||||
>
|
||||
<Icon className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesMediaCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaCarouselProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="w-full py-20">
|
||||
<div className="flex flex-col w-full gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LoopCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureMediaCarouselCard key={item.title} item={item} />
|
||||
))}
|
||||
</LoopCarousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaCarousel;
|
||||
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaGridProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesMediaGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaGridProps) => {
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 h-full">
|
||||
<div className="aspect-square overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaGrid;
|
||||
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type ResultItem = {
|
||||
treatment: string;
|
||||
detail: string;
|
||||
beforeSrc: string;
|
||||
afterSrc: string;
|
||||
};
|
||||
|
||||
interface FeaturesResultsComparisonProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: ResultItem[];
|
||||
}
|
||||
|
||||
const ImageLabel = ({ text, side }: { text: string; side: "left" | "right" }) => (
|
||||
<div
|
||||
className={cls(
|
||||
"absolute bottom-3 xl:bottom-3.5 2xl:bottom-4 px-3 py-1 w-fit text-sm card rounded",
|
||||
side === "left" ? "left-3 xl:left-3.5 2xl:left-4" : "right-3 xl:right-3.5 2xl:right-4"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium text-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FeaturesResultsComparison = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesResultsComparisonProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section aria-label="Results section" className="pt-20 pb-10">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-80 md:w-120 2xl:w-140 mb-10 mr-3 md:mr-5 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="relative flex w-full aspect-3/2">
|
||||
<div className="relative overflow-hidden w-1/2 rounded-l-lg rounded-r-none">
|
||||
<ImageOrVideo imageSrc={item.beforeSrc} className="absolute inset-0 object-cover w-full h-full rounded-l rounded-r-none" />
|
||||
<ImageLabel text="Before" side="left" />
|
||||
</div>
|
||||
<div className="absolute z-10 left-1/2 top-0 bottom-0 w-0.5 bg-background -translate-x-1/2" />
|
||||
<div className="relative overflow-hidden w-1/2 rounded-r-lg rounded-l-none">
|
||||
<ImageOrVideo imageSrc={item.afterSrc} className="absolute inset-0 object-cover w-full h-full rounded-r rounded-l-none" />
|
||||
<ImageLabel text="After" side="right" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h4 className="truncate text-2xl font-semibold leading-snug">
|
||||
{item.treatment}
|
||||
</h4>
|
||||
<p className="text-base leading-snug">
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesResultsComparison;
|
||||
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Info } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesRevealCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesRevealCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
|
||||
<div className="absolute top-4 left-4 xl:top-6 xl:left-6 2xl:top-8 2xl:left-8 z-20 perspective-[1000px]">
|
||||
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm rounded bg-background backface-hidden text-foreground">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
|
||||
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-2 bottom-2 xl:inset-x-3 xl:bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-0 group-hover:gap-1 xl:group-hover:gap-2 2xl:group-hover:gap-3 p-2 xl:p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-white transition-colors duration-400 group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-sm leading-snug text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCards;
|
||||
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBento;
|
||||
@@ -0,0 +1,109 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoSharpProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="fade-blur" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded-none">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded-none group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBentoSharp;
|
||||
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & (
|
||||
| { leftImageSrc: string; leftVideoSrc?: never }
|
||||
| { leftVideoSrc: string; leftImageSrc?: never }
|
||||
) & (
|
||||
| { rightImageSrc: string; rightVideoSrc?: never }
|
||||
| { rightVideoSrc: string; rightImageSrc?: never }
|
||||
);
|
||||
|
||||
interface FeaturesStickyCardsProps {
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const CardFrame = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
cardRef,
|
||||
className = "",
|
||||
}: {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
cardRef: (el: HTMLDivElement | null) => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div ref={cardRef} className={cls("card rounded p-1 overflow-hidden", className)}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FeaturesStickyCards = ({
|
||||
items,
|
||||
}: FeaturesStickyCardsProps) => {
|
||||
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const triggerRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const getAnimationConfig = (itemIndex: number, isLeftCard: boolean) => {
|
||||
const isOddItem = itemIndex % 2 === 1;
|
||||
if (isLeftCard) {
|
||||
return {
|
||||
from: { xPercent: -225, rotation: -45 },
|
||||
to: { rotation: isOddItem ? 10 : -10 },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: { xPercent: 225, rotation: 45 },
|
||||
to: { rotation: isOddItem ? -10 : 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const animateCards = (isMobile: boolean) => {
|
||||
items.forEach((_, itemIndex) => {
|
||||
[0, 1].forEach((cardIndex) => {
|
||||
const refIndex = itemIndex * 2 + cardIndex;
|
||||
const element = isMobile
|
||||
? mobileImageRefs.current[refIndex]
|
||||
: imageRefs.current[refIndex];
|
||||
|
||||
if (element) {
|
||||
const isLeftCard = cardIndex === 0;
|
||||
|
||||
const fromConfig = isMobile
|
||||
? {
|
||||
xPercent: isLeftCard ? -150 : 150,
|
||||
rotation: isLeftCard ? -25 : 25,
|
||||
}
|
||||
: getAnimationConfig(itemIndex, isLeftCard).from;
|
||||
|
||||
const toConfig = isMobile
|
||||
? {
|
||||
xPercent: 0,
|
||||
rotation: 0,
|
||||
duration: 1,
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: "top 90%",
|
||||
end: "top 50%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
xPercent: 0,
|
||||
rotation: getAnimationConfig(itemIndex, isLeftCard).to.rotation,
|
||||
scrollTrigger: {
|
||||
trigger: triggerRefs.current[itemIndex],
|
||||
start: "top bottom",
|
||||
end: "top top",
|
||||
scrub: 1,
|
||||
},
|
||||
};
|
||||
|
||||
gsap.fromTo(element, fromConfig, toConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mm.add("(max-width: 767px)", () => animateCards(true));
|
||||
mm.add("(min-width: 768px)", () => animateCards(false));
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
imageRefs.current = [];
|
||||
mobileImageRefs.current = [];
|
||||
triggerRefs.current = [];
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const sectionHeightStyle = { height: `${items.length * 100}vh` };
|
||||
|
||||
return (
|
||||
<section aria-label="Features sticky cards section" className="py-20 overflow-hidden md:overflow-visible">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="hidden md:flex relative" style={sectionHeightStyle}>
|
||||
<div
|
||||
className="absolute top-0 left-0 flex flex-col w-6/10 mx-auto right-0 z-10"
|
||||
style={sectionHeightStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { triggerRefs.current[index] = el; }}
|
||||
className="w-full mx-auto h-screen flex justify-center items-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-5xl md:text-6xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="md:max-w-6/10 text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="h-screen w-full absolute top-0 left-0">
|
||||
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-29/100 h-[70vh]"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-28/100 h-[70vh]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col gap-20 w-content-width mx-auto">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{itemIndex + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 justify-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesStickyCards;
|
||||
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTaggedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesTaggedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTaggedCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4 px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{item.tag}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between flex-1 gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" className="w-full mt-2 md:mt-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTaggedCards;
|
||||
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTimelineCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesTimelineCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTimelineCardsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
|
||||
}, 50);
|
||||
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === 100) {
|
||||
setActiveIndex((i) => (i + 1) % items.length);
|
||||
}
|
||||
}, [progress, items.length]);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (index !== activeIndex) setActiveIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Features timeline section" className="py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition className="flex flex-col gap-5">
|
||||
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
|
||||
<Transition key={activeIndex} transitionType="fade" className="absolute inset-px overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
data-active={index === activeIndex}
|
||||
onClick={() => handleCardClick(index)}
|
||||
className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center size-8 primary-button rounded">
|
||||
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||
</div>
|
||||
<div className="relative w-full h-px overflow-hidden">
|
||||
<div className="absolute inset-0 bg-foreground/20" />
|
||||
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTimelineCards;
|
||||
Reference in New Issue
Block a user