Initial commit
This commit is contained in:
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroBillboardProps = {
|
||||
tag?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboard = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 md:gap-15 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
{avatarsSrc && avatarsSrc.length > 0 ? (
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} className="mb-1" />
|
||||
) : tag ? (
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboard;
|
||||
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroBillboardBrandProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboardBrand = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardBrandProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 md:gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-end gap-5">
|
||||
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug text-balance text-right"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardBrand;
|
||||
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardCarouselProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<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="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCarousel;
|
||||
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type CreatorVideo = {
|
||||
videoSrc: string;
|
||||
name: string;
|
||||
followers: string;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
type HeroBillboardCreatorProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
titleHighlight?: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
note: string;
|
||||
videos: CreatorVideo[];
|
||||
badgeText: string;
|
||||
};
|
||||
|
||||
const HeroBillboardCreator = ({
|
||||
tag,
|
||||
title,
|
||||
titleHighlight,
|
||||
description,
|
||||
primaryButton,
|
||||
note,
|
||||
videos,
|
||||
badgeText,
|
||||
}: HeroBillboardCreatorProps) => {
|
||||
const duplicated = [...videos, ...videos, ...videos, ...videos];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<motion.h1
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl font-semibold leading-[1.15] text-balance"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
transition={{ staggerChildren: 0.04 }}
|
||||
>
|
||||
<motion.span
|
||||
className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent"
|
||||
variants={{ hidden: { opacity: 0, y: "50%" }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{title}{" "}
|
||||
{titleHighlight && (
|
||||
<span className="italic" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
{titleHighlight}
|
||||
</span>
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.h1>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||
<Check className="size-1/2 text-primary-cta-text" />
|
||||
</div>
|
||||
{note}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((video, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative shrink-0 mr-3 xl:mr-4 2xl:mr-5 w-60 md:w-75 2xl:w-80 aspect-4/5 overflow-hidden rounded-lg"
|
||||
>
|
||||
<div className="absolute z-10 top-3 left-3 xl:top-4 xl:left-4 2xl:top-5 2xl:left-5 px-2 py-1 xl:px-2.5 xl:py-1.5 2xl:px-3 2xl:py-2 text-xs font-medium rounded-sm border border-background/30 bg-background/50 backdrop-blur-md">
|
||||
{badgeText}
|
||||
</div>
|
||||
<ImageOrVideo
|
||||
videoSrc={video.videoSrc}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-x-px -bottom-px h-1/3 bg-background-accent/50 backdrop-blur-xl"
|
||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute flex items-center inset-x-3 bottom-3 xl:inset-x-4 xl:bottom-4 2xl:inset-x-5 2xl:bottom-5 gap-2 xl:gap-2.5 2xl:gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={video.imageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="flex items-center gap-1 text-base text-background font-semibold leading-snug truncate">
|
||||
{video.name}
|
||||
<img src="https://storage.googleapis.com/webild/default/templates/ai-ugc/verified-badge.webp" alt="Verified" className="shrink-0 h-[calc(var(--text-base)*1.25)] w-auto" />
|
||||
</span>
|
||||
<span className="text-base text-background/75 leading-snug truncate">{video.followers}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCreator;
|
||||
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import ActiveBadge from "@/components/ui/ActiveBadge";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type HeroBillboardFeaturesProps = {
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardFeatures = ({
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
features,
|
||||
}: HeroBillboardFeaturesProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (features.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % features.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [features.length]);
|
||||
|
||||
const feature = features[currentIndex];
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<ActiveBadge text={badge} className="mb-1" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
|
||||
>
|
||||
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
|
||||
<p className="text-base font-medium leading-snug">{feature.title}</p>
|
||||
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardFeatures;
|
||||
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useRef } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
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 FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
|
||||
|
||||
type HeroBillboardFloatingCardsProps = {
|
||||
avatarsSrc: string[];
|
||||
avatarsLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
note?: string;
|
||||
floatingCardsSrc: [string, string, string, string];
|
||||
logosSrc?: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
|
||||
|
||||
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
|
||||
position: string;
|
||||
rotation: string;
|
||||
size: string;
|
||||
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
|
||||
}> = {
|
||||
"top-left": {
|
||||
position: "top-8 left-0",
|
||||
rotation: "-rotate-8",
|
||||
size: "size-20 xl:size-22 2xl:size-24",
|
||||
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
|
||||
},
|
||||
"top-right": {
|
||||
position: "top-4 right-4",
|
||||
rotation: "rotate-10",
|
||||
size: "size-18 xl:size-20 2xl:size-22",
|
||||
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
|
||||
},
|
||||
"middle-left": {
|
||||
position: "top-1/2 left-2",
|
||||
rotation: "rotate-6",
|
||||
size: "size-18 xl:size-20 2xl:size-22",
|
||||
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
|
||||
},
|
||||
"middle-right": {
|
||||
position: "top-1/2 right-0",
|
||||
rotation: "-rotate-6",
|
||||
size: "size-20 xl:size-22 2xl:size-24",
|
||||
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
|
||||
},
|
||||
};
|
||||
|
||||
const HeroBillboardFloatingCards = ({
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
note,
|
||||
floatingCardsSrc,
|
||||
logosSrc,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardFloatingCardsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||
|
||||
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative">
|
||||
<HeroBackgroundSlot />
|
||||
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
|
||||
<div className="relative w-content-width mx-auto">
|
||||
{POSITIONS.map((position, index) => {
|
||||
const config = FLOATING_CARD_CONFIG[position];
|
||||
const src = floatingCardsSrc[index];
|
||||
if (!src) return null;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={cls("absolute z-10 hidden md:block", config.position)}
|
||||
animate={{ y: [0, config.animation.yOffset, 0] }}
|
||||
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
|
||||
>
|
||||
<motion.div
|
||||
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
|
||||
>
|
||||
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
|
||||
<div className="p-0.5 pr-3 mb-1 card rounded-full">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<motion.div
|
||||
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||
<Check className="size-1/2 text-primary-cta-text" />
|
||||
</div>
|
||||
{note}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{ rotateX: rotate, scale }}
|
||||
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||
</motion.div>
|
||||
|
||||
{logosSrc && logosSrc.length > 0 && (
|
||||
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
|
||||
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
|
||||
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
|
||||
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardFloatingCards;
|
||||
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useRef } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardScrollProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboardScroll = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardScrollProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||
|
||||
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative">
|
||||
<HeroBackgroundSlot />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pt-25 pb-20 md:pt-30 perspective-distant"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<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="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{ rotateX: rotate, scale }}
|
||||
className="w-content-width mx-auto mt-12 2xl:mt-8 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardScroll;
|
||||
126
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
126
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroBillboardTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroBillboardTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<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="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-4 left-4 right-4 xl:left-6 xl:bottom-6 xl:right-auto 2xl:left-8 2xl:bottom-8 max-w-sm p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTestimonial;
|
||||
61
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
61
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import TiltedCarousel from "@/components/ui/TiltedCarousel";
|
||||
|
||||
type HeroBillboardTiltedCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardTiltedCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardTiltedCarouselProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<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="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TiltedCarousel items={items} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTiltedCarousel;
|
||||
65
src/components/sections/hero/HeroBrand.tsx
Normal file
65
src/components/sections/hero/HeroBrand.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
|
||||
type HeroBrandProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBrand = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBrandProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-snug"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrand;
|
||||
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type HeroBrandCarouselProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const INTERVAL = 4000;
|
||||
|
||||
const HeroBrandCarousel = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBrandCarouselProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, items.length]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"absolute inset-0 transition-opacity duration-500",
|
||||
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
aria-hidden={currentIndex !== index}
|
||||
>
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-snug"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
|
||||
<div className="flex gap-3 pb-5">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative h-1 w-full rounded overflow-hidden bg-primary-cta-text/20 cursor-pointer"
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
aria-label="Slide"
|
||||
aria-current={currentIndex === index}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 bg-primary-cta-text rounded origin-left",
|
||||
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
|
||||
)}
|
||||
style={{ animationDuration: `${INTERVAL}ms` }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrandCarousel;
|
||||
80
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
80
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroCenteredLogosProps = {
|
||||
avatarsSrc: string[];
|
||||
avatarText: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
logos: string[];
|
||||
hideMedia?: boolean;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroCenteredLogos = ({
|
||||
avatarsSrc,
|
||||
avatarText,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
logos,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
hideMedia = false,
|
||||
}: HeroCenteredLogosProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative h-svh flex flex-col mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
{!hideMedia && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover" />
|
||||
<div className="absolute inset-0 bg-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarText} size="lg" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-8 overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
|
||||
{[...logos, ...logos, ...logos, ...logos].map((logo, index) => (
|
||||
<div key={index} className="shrink-0 mx-3 px-4 py-2 card rounded">
|
||||
<span className="text-xl font-semibold whitespace-nowrap text-foreground/75">{logo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroCenteredLogos;
|
||||
83
src/components/sections/hero/HeroOverlay.tsx
Normal file
83
src/components/sections/hero/HeroOverlay.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroOverlayProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroOverlay = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
}: HeroOverlayProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{avatarsSrc && avatarsSrc.length > 0 && (
|
||||
<div className="mt-3 md:mt-4">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlay;
|
||||
98
src/components/sections/hero/HeroOverlayMarquee.tsx
Normal file
98
src/components/sections/hero/HeroOverlayMarquee.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroOverlayMarqueeProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
items: { text: string; icon: LucideIcon }[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroOverlayMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
items,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroOverlayMarqueeProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative overflow-hidden flex flex-col justify-between mb-20 w-full h-svh"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 object-cover w-full h-full rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 left-0 top-0 w-[150vw] h-[150vw] -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 mx-auto pt-35 w-content-width">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{avatarsSrc && avatarsSrc.length > 0 && (
|
||||
<div className="mt-3 md:mt-4">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 overflow-hidden mx-auto pb-8 w-content-width mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
|
||||
{[...items, ...items, ...items, ...items].map((item, index) => (
|
||||
<div key={index} className="flex items-center shrink-0 gap-1 mx-3 pl-2 pr-4 py-2 card rounded">
|
||||
<item.icon className="h-(--text-base) text-foreground" />
|
||||
<span className="whitespace-nowrap text-base font-medium text-foreground">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlayMarquee;
|
||||
135
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
135
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroOverlayTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroOverlayTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroOverlayTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-start mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pt-35">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute z-10 bottom-4 left-4 right-4 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5 md:left-auto md:bottom-6 md:right-6 md:max-w-25/100 2xl:max-w-2/10"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlayTestimonial;
|
||||
65
src/components/sections/hero/HeroSplit.tsx
Normal file
65
src/components/sections/hero/HeroSplit.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<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="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplit;
|
||||
131
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
131
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type KpiItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type HeroSplitKpiProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
kpis: [KpiItem, KpiItem, KpiItem];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
|
||||
|
||||
const HeroSplitKpi = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
kpis,
|
||||
}: HeroSplitKpiProps) => {
|
||||
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth <= 768) return;
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
|
||||
const multipliers = [-0.25, -0.5, 0.25];
|
||||
let animationId: number;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
offsets.forEach((offset, i) => {
|
||||
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
|
||||
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
|
||||
|
||||
const el = kpiRefs.current[i];
|
||||
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
|
||||
});
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<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="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full h-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden scale-85">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
|
||||
{kpis.map((kpi, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
ref={(el) => { kpiRefs.current[index] = el; }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
|
||||
className={cls(
|
||||
"absolute flex flex-col items-center p-3 xl:p-4 2xl:p-5 card backdrop-blur-sm rounded",
|
||||
KPI_POSITIONS[index]
|
||||
)}
|
||||
>
|
||||
<p className="text-2xl md:text-4xl text-foreground font-medium">{kpi.value}</p>
|
||||
<p className="text-sm md:text-base text-foreground/75">{kpi.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitKpi;
|
||||
72
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
72
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitMediaGridProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: [
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never },
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
|
||||
];
|
||||
};
|
||||
|
||||
const HeroSplitMediaGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroSplitMediaGridProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<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="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full md:w-1/2 grid grid-cols-2 gap-2 xl:gap-3 2xl:gap-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="h-80 md:h-[55vh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitMediaGrid;
|
||||
128
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
128
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroSplitTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroSplitTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroSplitTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<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="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-4 left-4 right-4 xl:bottom-6 xl:right-6 2xl:bottom-8 2xl:right-8 md:left-auto md:max-w-5/10 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitTestimonial;
|
||||
87
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
87
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroSplitVerticalMarqueeProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroSplitVerticalMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
leftItems,
|
||||
rightItems,
|
||||
}: HeroSplitVerticalMarqueeProps) => {
|
||||
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
|
||||
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<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="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-2 xl:gap-3 2xl:gap-4 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden mask-fade-y-medium">
|
||||
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical">
|
||||
{duplicatedLeft.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden mask-fade-y-medium">
|
||||
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical-reverse">
|
||||
{duplicatedRight.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitVerticalMarquee;
|
||||
95
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
95
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface HeroTiltedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
}
|
||||
|
||||
const HeroTiltedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroTiltedCardsProps) => {
|
||||
const marqueeItems = [...items, ...items];
|
||||
const galleryStyles = [
|
||||
"-rotate-6 z-10 -translate-y-5",
|
||||
"rotate-6 z-20 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-30 -translate-y-5 -ml-15",
|
||||
"rotate-6 z-40 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-50 -translate-y-5 -ml-15",
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-12 md:gap-15 w-full md:w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<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="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl 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-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="block md:hidden w-full overflow-hidden mask-padding-x">
|
||||
<div className="flex w-max animate-marquee-horizontal">
|
||||
{marqueeItems.map((item, index) => (
|
||||
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="hidden md:flex justify-center items-center w-full">
|
||||
<div className="flex items-center justify-center">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"relative w-[23%] aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
|
||||
galleryStyles[index]
|
||||
)}
|
||||
>
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroTiltedCards;
|
||||
146
src/components/sections/hero/HeroVideoExpand.tsx
Normal file
146
src/components/sections/hero/HeroVideoExpand.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
const StaggerText = ({ text }: { text: string }) => (
|
||||
<span className="truncate overflow-hidden">
|
||||
{[...text].map((char, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
|
||||
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
type HeroVideoExpandProps = {
|
||||
title: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
onComplete?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroVideoExpand = ({
|
||||
title,
|
||||
videoSrc,
|
||||
imageSrc,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onComplete,
|
||||
}: HeroVideoExpandProps) => {
|
||||
const [showLoader, setShowLoader] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
||||
const handleSecondaryClick = useButtonClick(secondaryButton.href);
|
||||
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
|
||||
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
|
||||
|
||||
useEffect(() => {
|
||||
const expandTimer = setTimeout(() => setExpanded(true), 600);
|
||||
const hideTimer = setTimeout(() => {
|
||||
setShowLoader(false);
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => {
|
||||
clearTimeout(expandTimer);
|
||||
clearTimeout(hideTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{showLoader && (
|
||||
<motion.div
|
||||
key="loader"
|
||||
className="fixed inset-0 z-100 bg-background"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
|
||||
animate={
|
||||
expanded
|
||||
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
|
||||
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
|
||||
}
|
||||
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
|
||||
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
|
||||
|
||||
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
|
||||
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
className="w-full"
|
||||
>
|
||||
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
|
||||
className="w-1/2 md:w-auto"
|
||||
>
|
||||
<a
|
||||
href={primaryButton.href}
|
||||
onClick={handlePrimaryClick}
|
||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
|
||||
>
|
||||
<StaggerText text={primaryButton.text} />
|
||||
</a>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
|
||||
className="w-1/2 md:w-auto"
|
||||
>
|
||||
<a
|
||||
href={secondaryButton.href}
|
||||
onClick={handleSecondaryClick}
|
||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
|
||||
>
|
||||
<StaggerText text={secondaryButton.text} />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroVideoExpand;
|
||||
283
src/components/sections/hero/HeroWorkScrollStack.tsx
Normal file
283
src/components/sections/hero/HeroWorkScrollStack.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface HeroWorkScrollStackProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
description: string;
|
||||
descriptionMuted: string;
|
||||
primaryButton: { text: string; href: string; avatarSrc: string; avatarLabel: string };
|
||||
sectionTag: string;
|
||||
sectionTitle: string;
|
||||
sectionDescription: string;
|
||||
items: [
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string }
|
||||
];
|
||||
secondaryButton?: { text: string; href: string };
|
||||
heroAnimationDelay?: number;
|
||||
}
|
||||
|
||||
const HeroWorkScrollStack = ({
|
||||
tag,
|
||||
title,
|
||||
titleHighlight,
|
||||
description,
|
||||
descriptionMuted,
|
||||
primaryButton,
|
||||
sectionTag,
|
||||
sectionTitle,
|
||||
sectionDescription,
|
||||
items,
|
||||
secondaryButton,
|
||||
heroAnimationDelay,
|
||||
}: HeroWorkScrollStackProps) => {
|
||||
const animationRef = useRef<HTMLDivElement>(null);
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const card1Ref = useRef<HTMLDivElement>(null);
|
||||
const card2Ref = useRef<HTMLDivElement>(null);
|
||||
const card3Ref = useRef<HTMLDivElement>(null);
|
||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
||||
const handleSecondaryClick = useButtonClick(secondaryButton?.href || "#");
|
||||
|
||||
useEffect(() => {
|
||||
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const cardRefs = [card1Ref.current, card2Ref.current, card3Ref.current];
|
||||
const placeholder = placeholderRef.current;
|
||||
if (!placeholder) return;
|
||||
|
||||
const placeholderRect = placeholder.getBoundingClientRect();
|
||||
const placeholderCenterY = placeholderRect.top + placeholderRect.height / 2;
|
||||
|
||||
if (isDesktop) {
|
||||
// DESKTOP: Scrub animation tied to scroll position
|
||||
const xOffsets = ["32rem", "14.5rem", "-1.8rem"];
|
||||
const yAdjustments = [0, -48, 0];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [1.35, 1.3, 1.25];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: animationRef.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
tl.to(card, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.4, ease: "none" }, 0);
|
||||
tl.to(card, { zIndex: 1, duration: 0.1, ease: "none" }, 0.3);
|
||||
});
|
||||
} else {
|
||||
// MOBILE: Toggle animation - play/reverse on scroll
|
||||
const xOffsets = ["2.5rem", "0.5rem", "-1rem"];
|
||||
const yAdjustments = [-10, -30, 10];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [0.65, 0.7, 0.75];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
gsap.to(card, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
duration: 1.2,
|
||||
ease: "power2.inOut",
|
||||
scrollTrigger: {
|
||||
trigger: placeholder,
|
||||
start: "top 35%",
|
||||
toggleActions: "play none none reverse",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}, animationRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={animationRef}>
|
||||
<div id="hero" data-section="hero">
|
||||
<section aria-label="Hero section" className="relative h-fit md:h-svh pt-30 pb-20 md:py-0 flex items-center overflow-hidden md:overflow-visible">
|
||||
<HeroBackgroundSlot />
|
||||
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-full">
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 1.8, ease: [0.16, 1, 0.3, 1], delay: heroAnimationDelay ?? 0 }}
|
||||
className="w-full md:w-[46%] flex flex-col items-center md:items-start gap-3"
|
||||
>
|
||||
<div className="card backdrop-blur flex items-center gap-2 px-3 py-1 rounded">
|
||||
<span className="size-2 rounded-full bg-green-500 animate-pulsate [--accent:#22c55e]" />
|
||||
<p className="text-sm leading-snug font-medium text-foreground">{tag}</p>
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-7xl 2xl:text-8xl font-medium leading-[1.05] tracking-tight text-center md:text-left">
|
||||
<span className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent">
|
||||
{title}{" "}
|
||||
<span className="font-bold">{titleHighlight}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-base md:text-lg font-medium leading-snug text-center md:text-left max-w-[95%]">
|
||||
{description}{" "}
|
||||
<span className="text-foreground/50">{descriptionMuted}</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={primaryButton.href}
|
||||
onClick={handlePrimaryClick}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||
<img
|
||||
src={primaryButton.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-medium 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">{primaryButton.avatarLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-medium whitespace-nowrap">{primaryButton.text}</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<div ref={placeholderRef} className="w-full md:w-[54%] relative h-80 md:h-96">
|
||||
<div className="absolute inset-0 card rounded-2xl md:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="work" data-section="work">
|
||||
<section aria-label="Work section" className="py-20 md:pt-0">
|
||||
<div className="flex flex-col gap-8 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{sectionTag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={sectionTitle}
|
||||
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={sectionDescription}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-5">
|
||||
{items.map((item, index) => {
|
||||
const cardRef = index === 0 ? card1Ref : index === 1 ? card2Ref : card3Ref;
|
||||
return (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5">
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="aspect-4/3 rounded-2xl shadow-2xl relative card p-2 xl:p-3 2xl:p-4"
|
||||
>
|
||||
<div className="w-full h-full rounded-xl overflow-hidden relative">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="w-full h-full object-cover" />
|
||||
<span className="absolute bottom-2 left-2 xl:bottom-3 xl:left-3 2xl:bottom-4 2xl:left-4 px-3 py-1.5 text-xs font-medium text-primary-cta-text rounded-full backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20">
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg md:text-xl lg:text-2xl leading-snug">
|
||||
<span className="font-semibold text-foreground">{item.title}. </span>
|
||||
<span className="text-foreground/50">{item.description}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{secondaryButton && (
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={secondaryButton.href}
|
||||
onClick={handleSecondaryClick}
|
||||
className="group flex items-center gap-2 px-6 py-3 text-base font-medium rounded-full secondary-button text-secondary-cta-text transition-all duration-300"
|
||||
>
|
||||
<span>{secondaryButton.text}</span>
|
||||
<ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroWorkScrollStack;
|
||||
Reference in New Issue
Block a user