Initial commit

This commit is contained in:
dk
2025-12-21 20:37:44 +02:00
commit d55255384a
308 changed files with 64836 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
"use client";
import { memo, useState, useEffect, useRef, useCallback } from "react";
import { motion } from "framer-motion";
import MediaContent from "@/components/shared/MediaContent";
import { cls } from "@/lib/utils";
interface AngledCarouselItem {
id: string;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
}
interface AngledCarouselProps {
items: AngledCarouselItem[];
className?: string;
autoPlay?: boolean;
autoPlayInterval?: number;
}
const CARD_TRANSITION_DURATION = 0.8;
const CARD_TRANSITION_EASE = [0.65, 0, 0.35, 1] as const;
const cardVariants = {
'hidden-0': { opacity: 0, y: '25px' },
'hidden-1': { scale: 0.88, opacity: 0, x: 'calc(100% + 20px)', y: '5%', rotate: 2 },
'hidden--1': { scale: 0.88, opacity: 0, x: 'calc(-100% - 20px)', y: '5%', rotate: -2 },
'0': { scale: 1, opacity: 1, x: '0%', y: '0%', rotate: 0 },
'1': { scale: 0.88, opacity: 1, x: '100%', y: '5%', rotate: 2 },
'-1': { scale: 0.88, opacity: 1, x: '-100%', y: '5%', rotate: -2 },
'2': { scale: 0.8, opacity: 0, x: '200%', y: '10%', rotate: 4 },
'-2': { scale: 0.8, opacity: 0, x: '-200%', y: '10%', rotate: -4 },
};
const AngledCarousel = ({ items, className = "", autoPlay = true, autoPlayInterval = 4000 }: AngledCarouselProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [isFirstRender, setIsFirstRender] = useState(true);
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
const n = items.length;
useEffect(() => {
if (isFirstRender) {
const timeout = setTimeout(() => {
setIsFirstRender(false);
}, CARD_TRANSITION_DURATION * 1000);
return () => clearTimeout(timeout);
}
}, [isFirstRender]);
const resetAutoPlay = useCallback(() => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
if (autoPlay) {
autoPlayRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % n);
}, autoPlayInterval);
}
}, [autoPlay, autoPlayInterval, n]);
useEffect(() => {
resetAutoPlay();
return () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
};
}, [resetAutoPlay]);
const positionFactors = [-2, -1, 0, 1, 2];
return (
<div className={cls("relative w-full flex justify-center items-center overflow-hidden", className)}>
<div className="w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] opacity-0 pointer-events-none" />
{positionFactors.map((positionFactor) => {
const itemIndex = (activeIndex + positionFactor + n) % n;
const item = items[itemIndex];
const isCenter = positionFactor === 0;
const isVisible = Math.abs(positionFactor) <= 1;
const getAnimateState = () => {
const key = positionFactor.toString() as keyof typeof cardVariants;
return cardVariants[key];
};
const getInitialState = () => {
if (isVisible && isFirstRender) {
const key = `hidden-${positionFactor}` as keyof typeof cardVariants;
return cardVariants[key];
}
return getAnimateState();
};
const getDelay = () => {
if (isVisible && isFirstRender) {
const delays: { [key: string]: number } = { '-1': 0.6, '0': 0.45, '1': 0.6 };
return delays[positionFactor.toString()] || 0;
}
return 0;
};
return (
<motion.div
key={item.id}
className="!absolute w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] card p-1 rounded-theme-capped overflow-hidden"
style={{
zIndex: positionFactor === 0 ? 10 : 5 - Math.abs(positionFactor),
}}
initial={getInitialState()}
animate={getAnimateState()}
transition={{
duration: CARD_TRANSITION_DURATION,
ease: CARD_TRANSITION_EASE,
delay: getDelay(),
}}
>
<MediaContent
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
imageAlt={item.imageAlt}
videoAriaLabel={item.videoAriaLabel}
imageClassName="w-full h-full rounded-theme-capped object-cover"
/>
<motion.div
className="absolute inset-0 bg-background/50 backdrop-blur-[1px]"
initial={{ opacity: isCenter ? 0 : 1 }}
animate={{ opacity: isCenter ? 0 : 1 }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
/>
</motion.div>
);
})}
</div>
);
};
AngledCarousel.displayName = "AngledCarousel";
export default memo(AngledCarousel);

View File

@@ -0,0 +1,140 @@
"use client";
import { memo, Children } from "react";
import Marquee from "react-fast-marquee";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { AutoCarouselProps } from "../../types";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const AutoCarousel = ({
children,
uniformGridCustomHeightClasses,
animationType,
containerStyle = "default",
speed = 50,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
itemClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
showTextBox = true,
dualMarquee = false,
topMarqueeDirection = "left",
bottomCarouselClassName = "",
marqueeGapClassName = "",
}: AutoCarouselProps) => {
const childrenArray = Children.toArray(children);
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
// Bottom marquee direction is opposite of top
const bottomMarqueeDirection = topMarqueeDirection === "left" ? "right" : "left";
// Reverse order for bottom marquee to avoid alignment with top
const bottomChildren = dualMarquee ? [...childrenArray].reverse() : [];
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
aria-live="off"
>
<div className={cls("w-full md:w-content-width mx-auto", containerClassName)}>
<div className="w-full flex flex-col items-center">
<div className="w-full flex flex-col gap-6">
{showTextBox && (title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
)}
<div
className={cls(
"w-full flex flex-col",
containerStyle === "default" && (marqueeGapClassName || "gap-6"),
containerStyle === "card" && "primary-button p-6 rounded-theme-capped",
containerStyle === "card" && (marqueeGapClassName || "gap-6")
)}
>
{/* Top/Single Marquee */}
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", carouselClassName)}>
<Marquee gradient={false} speed={speed} direction={topMarqueeDirection}>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
</Marquee>
</div>
{/* Bottom Marquee (only if dualMarquee is true) - Reversed order, opposite direction */}
{dualMarquee && (
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", bottomCarouselClassName || carouselClassName)}>
<Marquee gradient={false} speed={speed} direction={bottomMarqueeDirection}>
{Children.map(bottomChildren, (child, index) => (
<div
key={`bottom-${index}`}
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
>
{child}
</div>
))}
</Marquee>
</div>
)}
</div>
</div>
</div>
</div>
</section>
);
};
AutoCarousel.displayName = "AutoCarousel";
export default memo(AutoCarousel);

View File

@@ -0,0 +1,174 @@
"use client";
import { memo, Children } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { ButtonCarouselProps } from "../../types";
import { usePrevNextButtons } from "../../hooks/usePrevNextButtons";
import { useScrollProgress } from "../../hooks/useScrollProgress";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const ButtonCarousel = ({
children,
uniformGridCustomHeightClasses,
animationType,
containerStyle = "default",
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
carouselItemClassName = "",
controlsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
}: ButtonCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(emblaApi);
const scrollProgress = useScrollProgress(emblaApi);
const childrenArray = Children.toArray(children);
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
return (
<section
className={cls(
"relative px-[var(--width-0)] py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto", containerClassName)}>
<div className="w-full flex flex-col items-center">
<div className="w-full flex flex-col gap-6">
{(title || titleSegments || description) && (
<div className="w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
</div>
)}
<div
className={cls(
"w-full flex flex-col",
containerStyle === "default" && "gap-6",
containerStyle === "card" && "primary-button p-6 rounded-theme-capped gap-6"
)}
>
<div
className={cls(
"overflow-hidden w-full relative z-10 flex cursor-grab",
carouselClassName
)}
ref={emblaRef}
>
<div className="flex gap-6 w-full">
<div className="flex-shrink-0 w-carousel-padding" />
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("flex-none select-none w-carousel-item-3 xl:w-carousel-item-4 mb-6", heightClasses, carouselItemClassName)}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
<div className="flex-shrink-0 w-carousel-padding" />
</div>
</div>
<div className={cls("w-full flex", controlsClassName)}>
<div className="flex-shrink-0 w-carousel-padding-controls" />
<div className="flex justify-between items-center w-full">
<div
className="rounded-full card relative h-2 w-50 overflow-hidden"
role="progressbar"
aria-label="Carousel progress"
aria-valuenow={Math.round(scrollProgress)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="bg-foreground primary-button absolute w-full top-0 bottom-0 -left-full rounded-full"
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
/>
</div>
<div className="flex items-center gap-3">
<button
onClick={onPrevButtonClick}
disabled={prevBtnDisabled}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="Previous slide"
>
<ChevronLeft className="h-[40%] w-auto aspect-square text-foreground" />
</button>
<button
onClick={onNextButtonClick}
disabled={nextBtnDisabled}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="Next slide"
>
<ChevronRight className="h-[40%] w-auto aspect-square text-foreground" />
</button>
</div>
</div>
<div className="flex-shrink-0 w-carousel-padding-controls" />
</div>
</div>
</div>
</div>
</div>
</section>
);
};
ButtonCarousel.displayName = "ButtonCarousel";
export default memo(ButtonCarousel);

View File

@@ -0,0 +1,154 @@
"use client";
import { memo, Children, cloneElement, isValidElement, useCallback, useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { EmblaCarouselType } from "embla-carousel";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { FullWidthCarouselProps } from "../../types";
const FullWidthCarousel = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
dotsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Carousel section",
}: FullWidthCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
const [selectedIndex, setSelectedIndex] = useState(0);
const childrenArray = Children.toArray(children);
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setSelectedIndex(emblaApi.selectedScrollSnap());
}, []);
const scrollTo = useCallback(
(index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index);
},
[emblaApi]
);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
useEffect(() => {
if (!emblaApi) return;
const autoplay = setInterval(() => {
emblaApi.scrollNext();
}, 5000);
return () => clearInterval(autoplay);
}, [emblaApi]);
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
<div className="w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
</div>
<div className="w-full">
<div
className={cls(
"overflow-hidden w-full relative z-10",
carouselClassName
)}
ref={emblaRef}
>
<div className="flex w-full">
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className="flex-none w-70 mr-6"
>
{isValidElement(child)
? cloneElement(child, { isActive: selectedIndex === index } as Record<string, unknown>)
: child}
</div>
))}
</div>
</div>
</div>
<div className={cls("flex items-center justify-center gap-2", dotsClassName)}>
{childrenArray.map((_, index) => (
<button
key={index}
type="button"
onClick={() => scrollTo(index)}
className={cls(
"relative cursor-pointer h-2 rounded-full bg-accent transition-all duration-300",
selectedIndex === index
? "w-8 opacity-100"
: "w-2 opacity-20"
)}
aria-label={`Go to slide ${index + 1}`}
aria-current={selectedIndex === index}
/>
))}
</div>
</div>
</section>
);
};
FullWidthCarousel.displayName = "FullWidthCarousel";
export default memo(FullWidthCarousel);

View File

@@ -0,0 +1,137 @@
"use client";
import { memo, Children } from "react";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { GridLayoutProps } from "../../types";
import { gridConfigs } from "./gridConfigs";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const GridLayout = ({
children,
itemCount,
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
containerStyle = "default",
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
gridClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
}: GridLayoutProps) => {
// Get config for this variant and item count
const config = gridConfigs[gridVariant]?.[itemCount];
// Fallback to default uniform grid if no config
const gridColsMap = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const defaultGridCols = gridColsMap[itemCount as keyof typeof gridColsMap] || "md:grid-cols-4";
// Use config values or fallback
const gridCols = config?.gridCols || defaultGridCols;
const gridRows = gridRowsClassName || config?.gridRows || "";
const itemClasses = config?.itemClasses || [];
const itemHeightClasses = itemHeightClassesOverride || config?.itemHeightClasses || [];
const heightClasses = uniformGridCustomHeightClasses || config?.heightClasses || "";
const itemWrapperClass = config?.itemWrapperClass || "";
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
{(title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
)}
<div
className={cls(
"grid grid-cols-1",
containerStyle === "default" && "gap-6",
containerStyle === "card" && "primary-button p-6 rounded-theme-capped gap-6",
gridCols,
gridRows,
gridClassName
)}
>
{childrenArray.map((child, index) => {
const itemClass = itemClasses[index] || "";
const itemHeightClass = itemHeightClasses[index] || "";
const combinedClass = cls(itemWrapperClass, itemClass, itemHeightClass, heightClasses);
return combinedClass ? (
<div
key={index}
className={combinedClass}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
) : (
<div
key={index}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
);
})}
</div>
</div>
</section>
);
};
GridLayout.displayName = "GridLayout";
export default memo(GridLayout);

View File

@@ -0,0 +1,464 @@
type GridConfig = {
gridCols: string;
gridRows?: string;
itemClasses: string[];
itemHeightClasses?: string[];
heightClasses?: string;
itemWrapperClass?: string;
} | null;
type GridVariantConfig = {
[key: number]: GridConfig;
};
export const gridConfigs: Record<string, GridVariantConfig> = {
"uniform-all-items-equal": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"uniform-staggered-items": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-3",
itemClasses: [
"",
"md:translate-y-20",
"",
],
heightClasses: "min-h-80 2xl:min-h-90"
},
4: {
gridCols: "md:grid-cols-4",
itemClasses: [
"",
"md:translate-y-20",
"",
"md:translate-y-20",
],
heightClasses: "min-h-80 2xl:min-h-90"
},
},
"uniform-alternating-heights": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-3 md:items-start",
itemClasses: [
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-110",
"min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-4 md:items-start",
itemClasses: [
"min-h-80 md:min-h-100 2xl:min-h-110",
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-110",
"min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"uniform-alternating-heights-inverted": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-3 md:items-start",
itemClasses: [
"min-h-80 md:min-h-100 2xl:min-h-110",
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-4 md:items-start",
itemClasses: [
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-110",
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"uniform-alternating-sizes": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10 md:items-start",
itemClasses: [
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-14 md:items-start",
itemClasses: [
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"uniform-alternating-sizes-inverted": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10 md:items-start",
itemClasses: [
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-2 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-14 md:items-start",
itemClasses: [
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"two-items-tall-short": {
1: null,
2: {
gridCols: "md:grid-cols-2 md:items-start",
itemClasses: [
"min-h-80 md:min-h-100 2xl:min-h-120",
"min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"two-items-short-tall": {
1: null,
2: {
gridCols: "md:grid-cols-2 md:items-start",
itemClasses: [
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-120",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"bento-grid": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
4: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
},
"bento-grid-inverted": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
4: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-2 md:row-start-1 md:col-start-3 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
},
"two-columns-alternating-heights": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[13rem_13rem_0.5rem_0.5rem_13rem_13rem] 2xl:grid-rows-[16rem_16rem_0.5rem_0.5rem_16rem_16rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-4 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-4 md:row-start-3 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-2 md:row-start-5 md:min-h-0 md:overflow-hidden",
]
},
},
"asymmetric-60-wide-40-narrow": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-10 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-10",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"three-columns-all-equal-width": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[21rem_21rem] 2xl:grid-rows-[24rem_24rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[21rem_21rem] 2xl:grid-rows-[24rem_24rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"four-items-2x2-equal-grid": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[26.5rem_26.5rem] 2xl:grid-rows-[32.5rem_32.5rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"four-items-2x2-alternating-heights": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: {
gridCols: "md:grid-cols-2 md:grid-rows-2 md:items-start",
itemClasses: [
"md:col-start-1 md:row-start-1",
"md:col-start-2 md:row-start-1",
"md:col-start-1 md:row-start-2",
"md:col-start-2 md:row-start-2",
],
itemHeightClasses: [
"min-h-80 md:min-h-140 2xl:min-h-160",
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-140 2xl:min-h-160",
"min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
},
"four-items-2x2-alternating-heights-inverted": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: {
gridCols: "md:grid-cols-2 md:grid-rows-2 md:items-start",
itemClasses: [
"md:col-start-1 md:row-start-1",
"md:col-start-2 md:row-start-1",
"md:col-start-1 md:row-start-2",
"md:col-start-2 md:row-start-2",
],
itemHeightClasses: [
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-140 2xl:min-h-160",
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-140 2xl:min-h-160",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
},
"four-items-2x2-staggered-grid": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
itemClasses: [
"",
"md:translate-y-20",
"",
"md:translate-y-20",
],
heightClasses: "min-h-80 2xl:min-h-90"
},
},
"four-items-2x2-staggered-grid-inverted": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
itemClasses: [
"md:translate-y-20",
"",
"md:translate-y-20",
"",
],
heightClasses: "min-h-80 2xl:min-h-90"
},
},
"one-large-right-three-stacked-left": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[17.5rem_17.5rem_17.5rem] 2xl:grid-rows-[21rem_21rem_21rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-3 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-3 md:row-start-1 md:min-h-0 md:overflow-hidden",
]
},
},
"items-top-row-full-width-bottom": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-3 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"full-width-top-items-bottom-row": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-3 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"one-large-left-three-stacked-right": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-4 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[17.5rem_17.5rem_17.5rem] 2xl:grid-rows-[21rem_21rem_21rem]",
itemClasses: [
"md:col-span-4 md:row-span-3 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-3 md:min-h-0 md:overflow-hidden",
]
},
},
};

View File

@@ -0,0 +1,158 @@
"use client";
import React, { Children, useCallback } from "react";
import { cls } from "@/lib/utils";
import CardStackTextBox from "../../CardStackTextBox";
import { useCardAnimation } from "../../hooks/useCardAnimation";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, CardAnimationType, ContainerStyle, TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
type TimelineVariant = "timeline" | "timeline-three-columns";
interface TimelineBaseProps {
children: React.ReactNode;
variant?: TimelineVariant;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
containerStyle?: ContainerStyle;
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout?: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
ariaLabel?: string;
}
const TimelineBase = ({
children,
variant = "timeline",
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
animationType,
containerStyle = "default",
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Timeline section",
}: TimelineBaseProps) => {
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
const getItemClasses = useCallback((index: number) => {
if (variant === "timeline-three-columns") {
// Pattern: start (0) → center (1) → end (2) → center (3) → start (4) → center (5) ...
const position = index % 4;
const alignmentClasses = cls(
position === 0 && "self-start md:self-start",
position === 1 && "self-end md:self-center",
position === 2 && "self-start md:self-end",
position === 3 && "self-end md:self-center"
);
return alignmentClasses;
}
// Default timeline variant - scattered/organic pattern
const alignmentClass =
index % 2 === 0 ? "self-start ml-0" : "self-end mr-0";
const marginClasses = cls(
index % 4 === 0 && "md:ml-0",
index % 4 === 1 && "md:mr-20",
index % 4 === 2 && "md:ml-15",
index % 4 === 3 && "md:mr-30"
);
return cls(alignmentClass, marginClasses);
}, [variant]);
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div
className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}
>
{(title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
)}
<div
className={cls(
"relative z-10 flex flex-col",
containerStyle === "default" && "gap-6 md:gap-15",
containerStyle === "card" && "primary-button p-6 rounded-theme-capped gap-6 md:gap-15"
)}
>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("w-65 md:w-25", uniformGridCustomHeightClasses, getItemClasses(index))}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
</div>
</div>
</section>
);
};
TimelineBase.displayName = "TimelineBase";
export default React.memo(TimelineBase);

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useEffect, useRef, memo, Children } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
gsap.registerPlugin(ScrollTrigger);
interface TimelineCardStackProps {
children: React.ReactNode;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
ariaLabel?: string;
}
const TimelineCardStack = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Timeline section",
}: TimelineCardStackProps) => {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const childrenArray = Children.toArray(children);
useEffect(() => {
const ctx = gsap.context(() => {
itemRefs.current.forEach((ref, position) => {
if (!ref) return;
const isLast = position === itemRefs.current.length - 1;
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ref,
start: "center center",
end: "+=100%",
scrub: true,
},
});
timeline.set(ref, { willChange: "opacity" }).to(ref, {
ease: "none",
opacity: isLast ? 1 : 0,
});
});
});
return () => {
ctx.revert();
};
}, [childrenArray.length]);
return (
<section
className={cls(
"relative overflow-visible h-fit py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
<div className="w-full flex flex-col gap-[var(--width-25)] md:gap-[6.25vh]">
{Children.map(childrenArray, (child, index) => (
<div
key={index}
ref={(el) => {
itemRefs.current[index] = el;
}}
className="!sticky w-full card rounded-theme-capped h-[140vw] md:h-[75vh] top-[25vw] md:top-[12.5vh]"
>
{child}
</div>
))}
</div>
</div>
</section>
);
};
TimelineCardStack.displayName = "TimelineCardStack";
export default memo(TimelineCardStack);

View File

@@ -0,0 +1,172 @@
"use client";
import React, { Children, useCallback } from "react";
import { cls } from "@/lib/utils";
import CardStackTextBox from "../../CardStackTextBox";
import { useTimelineHorizontal, type MediaItem } from "../../hooks/useTimelineHorizontal";
import MediaContent from "@/components/shared/MediaContent";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment, TextboxLayout, InvertedBackground } from "../../types";
interface TimelineHorizontalCardStackProps {
children: React.ReactNode;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
mediaItems?: MediaItem[];
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
cardClassName?: string;
progressBarClassName?: string;
mediaContainerClassName?: string;
mediaClassName?: string;
ariaLabel?: string;
}
const TimelineHorizontalCardStack = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
mediaItems,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
cardClassName = "",
progressBarClassName = "",
mediaContainerClassName = "",
mediaClassName = "",
ariaLabel = "Timeline section",
}: TimelineHorizontalCardStackProps) => {
const childrenArray = Children.toArray(children);
const itemCount = childrenArray.length;
const { activeIndex, progressRefs, handleItemClick, imageOpacity, currentMediaSrc } = useTimelineHorizontal({
itemCount,
mediaItems,
});
const getGridColumns = useCallback(() => {
if (itemCount === 2) return "md:grid-cols-2";
if (itemCount === 3) return "md:grid-cols-3";
return "md:grid-cols-4";
}, [itemCount]);
const getItemOpacity = useCallback(
(index: number) => {
return index <= activeIndex ? "opacity-100" : "opacity-50";
},
[activeIndex]
);
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
{mediaItems && mediaItems.length > 0 && (
<div className={cls("relative card rounded-theme-capped overflow-hidden aspect-square md:aspect-[17/9]", mediaContainerClassName)}>
<div
className="absolute inset-6 z-1 transition-opacity duration-300 overflow-hidden"
style={{ opacity: imageOpacity }}
>
<MediaContent
imageSrc={currentMediaSrc.imageSrc}
videoSrc={currentMediaSrc.videoSrc}
imageAlt={mediaItems[activeIndex]?.imageAlt}
videoAriaLabel={mediaItems[activeIndex]?.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
</div>
</div>
)}
<div className={cls("relative grid grid-cols-1 gap-6 md:gap-6", getGridColumns())}>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls(
"card rounded-theme-capped p-6 flex flex-col justify-between gap-6 transition-all duration-300",
index === activeIndex ? "cursor-default" : "cursor-pointer hover:shadow-lg",
getItemOpacity(index),
cardClassName
)}
onClick={() => handleItemClick(index)}
>
{child}
<div className="relative w-full h-px overflow-hidden">
<div className="absolute z-0 w-full h-full bg-foreground/20" />
<div
ref={(el) => {
if (el !== null) {
progressRefs.current[index] = el;
}
}}
className={cls("absolute z-10 h-full w-full bg-foreground origin-left", progressBarClassName)}
style={{ transform: "scaleX(0)" }}
/>
</div>
</div>
))}
</div>
</div>
</section>
);
};
TimelineHorizontalCardStack.displayName = "TimelineHorizontalCardStack";
export default React.memo(TimelineHorizontalCardStack);

View File

@@ -0,0 +1,261 @@
"use client";
import React, { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import CardStackTextBox from "../../CardStackTextBox";
import { usePhoneAnimations, type TimelinePhoneViewItem } from "../../hooks/usePhoneAnimations";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "../../types";
import type { TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface PhoneFrameProps {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
phoneRef: (el: HTMLDivElement | null) => void;
className?: string;
}
const PhoneFrame = memo(({
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
phoneRef,
className = "",
}: PhoneFrameProps) => (
<div
ref={phoneRef}
className={cls("card rounded-theme-capped p-1 overflow-hidden", className)}
>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName="w-full h-full object-cover rounded-theme-capped"
/>
</div>
));
PhoneFrame.displayName = "PhoneFrame";
interface TimelinePhoneViewProps {
items: TimelinePhoneViewItem[];
showTextBox?: boolean;
showDivider?: boolean;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
desktopContainerClassName?: string;
mobileContainerClassName?: string;
desktopContentClassName?: string;
desktopWrapperClassName?: string;
mobileWrapperClassName?: string;
phoneFrameClassName?: string;
mobilePhoneFrameClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
ariaLabel?: string;
}
const TimelinePhoneView = ({
items,
showTextBox = true,
showDivider = false,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
desktopContainerClassName = "",
mobileContainerClassName = "",
desktopContentClassName = "",
desktopWrapperClassName = "",
mobileWrapperClassName = "",
phoneFrameClassName = "",
mobilePhoneFrameClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
ariaLabel = "Timeline phone view section",
}: TimelinePhoneViewProps) => {
const { imageRefs, mobileImageRefs } = usePhoneAnimations(items);
const sectionHeightStyle = { height: `${items.length * 100}vh` };
return (
<section
className={cls(
"relative py-20 overflow-hidden md:overflow-visible",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
{showTextBox && (
<div className="relative w-content-width mx-auto" >
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
/>
</div>
)}
{showDivider && (
<div className="relative w-content-width mx-auto h-px bg-accent md:hidden" />
)}
<div className="hidden md:flex relative" style={sectionHeightStyle}>
<div
className={cls(
"absolute top-0 left-0 flex flex-col w-[calc(var(--width-content-width)-var(--width-20)*2)] 2xl:w-[calc(var(--width-content-width)-var(--width-25)*2)] mx-auto right-0 z-10",
desktopContainerClassName
)}
style={sectionHeightStyle}
>
{items.map((item, index) => (
<div
key={`content-${index}`}
className={cls(
item.trigger,
"w-full mx-auto h-screen flex justify-center items-center",
desktopContentClassName
)}
>
<div className={desktopWrapperClassName}>
{item.content}
</div>
</div>
))}
</div>
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
{items.map((item, itemIndex) => (
<div
key={`phones-${itemIndex}`}
className="h-screen w-full absolute top-0 left-0"
>
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
<PhoneFrame
key={`phone-${itemIndex}-1`}
imageSrc={item.imageOne}
videoSrc={item.videoOne}
imageAlt={item.imageAltOne}
videoAriaLabel={item.videoAriaLabelOne}
phoneRef={(el) => {
if (imageRefs.current) {
imageRefs.current[itemIndex * 2] = el;
}
}}
className={cls("w-20 2xl:w-25 h-[70vh]", phoneFrameClassName)}
/>
<PhoneFrame
key={`phone-${itemIndex}-2`}
imageSrc={item.imageTwo}
videoSrc={item.videoTwo}
imageAlt={item.imageAltTwo}
videoAriaLabel={item.videoAriaLabelTwo}
phoneRef={(el) => {
if (imageRefs.current) {
imageRefs.current[itemIndex * 2 + 1] = el;
}
}}
className={cls("w-20 2xl:w-25 h-[70vh]", phoneFrameClassName)}
/>
</div>
</div>
))}
</div>
</div>
<div className={cls("md:hidden flex flex-col gap-20", mobileContainerClassName)}>
{items.map((item, itemIndex) => (
<div
key={`mobile-item-${itemIndex}`}
className="flex flex-col gap-10"
>
<div className={mobileWrapperClassName}>
{item.content}
</div>
<div className="flex flex-row gap-6 justify-center">
<PhoneFrame
key={`mobile-phone-${itemIndex}-1`}
imageSrc={item.imageOne}
videoSrc={item.videoOne}
imageAlt={item.imageAltOne}
videoAriaLabel={item.videoAriaLabelOne}
phoneRef={(el) => {
if (mobileImageRefs.current) {
mobileImageRefs.current[itemIndex * 2] = el;
}
}}
className={cls("w-40 h-80", mobilePhoneFrameClassName)}
/>
<PhoneFrame
key={`mobile-phone-${itemIndex}-2`}
imageSrc={item.imageTwo}
videoSrc={item.videoTwo}
imageAlt={item.imageAltTwo}
videoAriaLabel={item.videoAriaLabelTwo}
phoneRef={(el) => {
if (mobileImageRefs.current) {
mobileImageRefs.current[itemIndex * 2 + 1] = el;
}
}}
className={cls("w-40 h-80", mobilePhoneFrameClassName)}
/>
</div>
</div>
))}
</div>
</div>
</section>
);
};
TimelinePhoneView.displayName = "TimelinePhoneView";
export default memo(TimelinePhoneView);

View File

@@ -0,0 +1,187 @@
"use client";
import React, { useEffect, useRef, memo } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import CardStackTextBox from "../../CardStackTextBox";
import { useCardAnimation } from "../../hooks/useCardAnimation";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "../../types";
import type { CardAnimationType } from "../../types";
import type { TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
gsap.registerPlugin(ScrollTrigger);
interface TimelineProcessFlowItem {
id: string;
content: React.ReactNode;
media: React.ReactNode;
reverse: boolean;
}
interface TimelineProcessFlowProps {
items: TimelineProcessFlowItem[];
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
animationType: CardAnimationType;
useInvertedBackground?: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
textBoxTitleClassName?: string;
textBoxDescriptionClassName?: string;
textBoxTagClassName?: string;
textBoxButtonContainerClassName?: string;
textBoxButtonClassName?: string;
textBoxButtonTextClassName?: string;
itemClassName?: string;
mediaWrapperClassName?: string;
numberClassName?: string;
contentWrapperClassName?: string;
gapClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
}
const TimelineProcessFlow = ({
items,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
animationType,
useInvertedBackground,
ariaLabel = "Timeline process flow section",
className = "",
containerClassName = "",
textBoxClassName = "",
textBoxTitleClassName = "",
textBoxDescriptionClassName = "",
textBoxTagClassName = "",
textBoxButtonContainerClassName = "",
textBoxButtonClassName = "",
textBoxButtonTextClassName = "",
itemClassName = "",
mediaWrapperClassName = "",
numberClassName = "",
contentWrapperClassName = "",
gapClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
}: TimelineProcessFlowProps) => {
const processLineRef = useRef<HTMLDivElement>(null);
const { itemRefs } = useCardAnimation({ animationType, itemCount: items.length });
useEffect(() => {
if (!processLineRef.current) return;
gsap.fromTo(
processLineRef.current,
{ yPercent: -100 },
{
yPercent: 0,
ease: "none",
scrollTrigger: {
trigger: ".timeline-line",
start: "top center",
end: "bottom center",
scrub: true,
},
}
);
return () => {
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
}, []);
return (
<section
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full flex flex-col gap-6", containerClassName)}>
<div className="relative w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={textBoxTitleClassName}
descriptionClassName={textBoxDescriptionClassName}
tagClassName={textBoxTagClassName}
buttonContainerClassName={textBoxButtonContainerClassName}
buttonClassName={textBoxButtonClassName}
buttonTextClassName={textBoxButtonTextClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
/>
</div>
<div className="relative w-full">
<div className="timeline-line pointer-events-none absolute top-0 right-[var(--width-10)] md:right-auto md:left-1/2 md:-translate-x-1/2 w-px h-full z-0 overflow-hidden bg-foreground">
<div className="w-full h-full bg-accent" ref={processLineRef} />
</div>
<ol className={cls("relative flex flex-col gap-10 md:gap-20 w-content-width mx-auto", gapClassName)}>
{items.map((item, index) => (
<li
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cls(
"relative z-10 w-full flex flex-col gap-6 md:gap-0 md:flex-row justify-between",
item.reverse && "flex-col md:flex-row-reverse",
itemClassName
)}
>
<div
className={cls("relative w-70 md:w-30", mediaWrapperClassName)}
>
{item.media}
</div>
<div
className={cls(
"absolute top-1/2 right-[calc(var(--height-8)/-2)] md:right-auto md:left-1/2 md:-translate-x-1/2 -translate-y-1/2 h-8 aspect-square rounded-full flex items-center justify-center z-10 primary-button",
numberClassName
)}
>
<p className="text-sm text-background">{item.id}</p>
</div>
<div className={cls("relative w-70 md:w-30", contentWrapperClassName)}>
{item.content}
</div>
</li>
))}
</ol>
</div>
</div>
</section>
);
};
TimelineProcessFlow.displayName = "TimelineProcessFlow";
export default memo(TimelineProcessFlow);