Initial commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import FeatureHoverPatternItem from "./FeatureHoverPatternItem";
|
||||
import { shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
ButtonConfig,
|
||||
CardAnimationType,
|
||||
TitleSegment,
|
||||
ButtonAnimationType,
|
||||
} from "@/components/cardStack/types";
|
||||
import type {
|
||||
TextboxLayout,
|
||||
InvertedBackground,
|
||||
} from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface FeatureCard {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
button?: ButtonConfig;
|
||||
}
|
||||
|
||||
interface FeatureHoverPatternProps {
|
||||
features: FeatureCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
uniformGridCustomHeightClasses?: string;
|
||||
animationType: CardAnimationType;
|
||||
title: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description: string;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
tagAnimation?: ButtonAnimationType;
|
||||
buttons?: ButtonConfig[];
|
||||
buttonAnimation?: ButtonAnimationType;
|
||||
textboxLayout: TextboxLayout;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
cardClassName?: string;
|
||||
iconContainerClassName?: string;
|
||||
iconClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardTitleClassName?: string;
|
||||
cardDescriptionClassName?: string;
|
||||
gradientClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
cardButtonClassName?: string;
|
||||
cardButtonTextClassName?: string;
|
||||
}
|
||||
|
||||
const FeatureHoverPattern = ({
|
||||
features,
|
||||
carouselMode = "buttons",
|
||||
uniformGridCustomHeightClasses = "min-h-85 2xl:min-h-95",
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Feature section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
iconContainerClassName = "",
|
||||
iconClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardTitleClassName = "",
|
||||
cardDescriptionClassName = "",
|
||||
gradientClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
cardButtonClassName = "",
|
||||
cardButtonTextClassName = "",
|
||||
}: FeatureHoverPatternProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(
|
||||
useInvertedBackground,
|
||||
theme.cardStyle
|
||||
);
|
||||
|
||||
return (
|
||||
<CardStack
|
||||
mode={carouselMode}
|
||||
gridVariant="uniform-all-items-equal"
|
||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
||||
animationType={animationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
gridClassName={gridClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
controlsClassName={controlsClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={textBoxTitleClassName}
|
||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
||||
titleImageClassName={textBoxTitleImageClassName}
|
||||
descriptionClassName={textBoxDescriptionClassName}
|
||||
tagClassName={textBoxTagClassName}
|
||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
||||
buttonClassName={textBoxButtonClassName}
|
||||
buttonTextClassName={textBoxButtonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<FeatureHoverPatternItem
|
||||
key={`${feature.title}-${index}`}
|
||||
item={feature}
|
||||
index={index}
|
||||
className={cardClassName}
|
||||
iconContainerClassName={iconContainerClassName}
|
||||
iconClassName={iconClassName}
|
||||
titleClassName={cardTitleClassName}
|
||||
descriptionClassName={cardDescriptionClassName}
|
||||
gradientClassName={gradientClassName}
|
||||
shouldUseLightText={shouldUseLightText}
|
||||
buttonClassName={cardButtonClassName}
|
||||
buttonTextClassName={cardButtonTextClassName}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
);
|
||||
};
|
||||
|
||||
FeatureHoverPattern.displayName = "FeatureHoverPattern";
|
||||
|
||||
export default FeatureHoverPattern;
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useRef } from "react";
|
||||
import { useMotionValue } from "framer-motion";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { CardPattern } from "@/components/background/CardPattern";
|
||||
import Button from "@/components/button/Button";
|
||||
import { usePatternInteraction } from "./usePatternInteraction";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig } from "@/components/cardStack/types";
|
||||
|
||||
export interface FeatureHoverPatternItemData {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
button?: ButtonConfig;
|
||||
}
|
||||
|
||||
interface FeatureHoverPatternItemProps {
|
||||
item: FeatureHoverPatternItemData;
|
||||
index: number;
|
||||
className?: string;
|
||||
iconContainerClassName?: string;
|
||||
iconClassName?: string;
|
||||
titleClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
gradientClassName?: string;
|
||||
shouldUseLightText?: boolean;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const FeatureHoverPatternItem = memo(function FeatureHoverPatternItem({
|
||||
item,
|
||||
index,
|
||||
className = "",
|
||||
iconContainerClassName = "",
|
||||
iconClassName = "",
|
||||
titleClassName = "",
|
||||
descriptionClassName = "",
|
||||
gradientClassName = "",
|
||||
shouldUseLightText = false,
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}: FeatureHoverPatternItemProps) {
|
||||
const theme = useTheme();
|
||||
const Icon = item.icon;
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getButtonConfigProps = () => {
|
||||
if (theme.defaultButtonVariant === "hover-bubble") {
|
||||
return { bgClassName: "w-full" };
|
||||
}
|
||||
if (theme.defaultButtonVariant === "icon-arrow") {
|
||||
return { className: "justify-between" };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const { state, onMouseMove } = usePatternInteraction(
|
||||
mouseX,
|
||||
mouseY,
|
||||
containerRef
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`feature-${index}`}
|
||||
className={cls(
|
||||
"card rounded-theme-capped min-h-0 h-full",
|
||||
className
|
||||
)}
|
||||
aria-label={item.title}
|
||||
>
|
||||
<div className="relative z-10 w-full h-full p-5 flex flex-col gap-5 justify-between">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls(
|
||||
"group/primary-button relative w-full h-full flex items-center justify-center",
|
||||
state.isMobile && state.isInView ? "group/primary-button-active" : ""
|
||||
)}
|
||||
onMouseMove={onMouseMove}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative z-20 h-15 w-auto aspect-square primary-button rounded-theme transition-all duration-300 flex items-center justify-center shadow",
|
||||
iconContainerClassName
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cls(
|
||||
"w-[35%] aspect-square text-primary-cta-text",
|
||||
iconClassName
|
||||
)}
|
||||
strokeWidth={1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="opacity-25">
|
||||
<CardPattern
|
||||
mouseX={mouseX}
|
||||
mouseY={mouseY}
|
||||
randomString={state.randomString}
|
||||
isActive={state.isMobile && state.isInView}
|
||||
gradientClassName={
|
||||
gradientClassName || "bg-gradient-to-r from-accent to-background-accent"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3
|
||||
className={cls(
|
||||
"text-2xl font-medium leading-tight",
|
||||
shouldUseLightText && "text-background",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p
|
||||
className={cls(
|
||||
"text-sm leading-tight",
|
||||
shouldUseLightText ? "text-background" : "text-foreground",
|
||||
descriptionClassName
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
{item.button && (
|
||||
<Button
|
||||
{...getButtonProps(
|
||||
{ ...item.button, props: { ...item.button.props, ...getButtonConfigProps() } },
|
||||
0,
|
||||
theme.defaultButtonVariant,
|
||||
cls("w-full mt-1", buttonClassName),
|
||||
buttonTextClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
FeatureHoverPatternItem.displayName = "FeatureHoverPatternItem";
|
||||
|
||||
export default FeatureHoverPatternItem;
|
||||
@@ -0,0 +1,17 @@
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
export const RANDOM_STRING_LENGTH = 1500;
|
||||
export const VIEW_CHECK_INTERVAL = 100;
|
||||
export const PATTERN_VISIBILITY_THRESHOLD = 0.2;
|
||||
export const ICON_VISIBILITY_THRESHOLD = 0.4;
|
||||
export const THROTTLE_DELAY = 16;
|
||||
export const CHARACTERS =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
export const CHARACTERS_LENGTH = CHARACTERS.length;
|
||||
|
||||
export const generateRandomString = (length: number): string => {
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARACTERS[Math.floor(Math.random() * CHARACTERS_LENGTH)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
MouseEvent,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import { MotionValue } from "framer-motion";
|
||||
import {
|
||||
MOBILE_BREAKPOINT,
|
||||
VIEW_CHECK_INTERVAL,
|
||||
PATTERN_VISIBILITY_THRESHOLD,
|
||||
ICON_VISIBILITY_THRESHOLD,
|
||||
THROTTLE_DELAY,
|
||||
RANDOM_STRING_LENGTH,
|
||||
generateRandomString,
|
||||
} from "./constants";
|
||||
|
||||
interface InteractionState {
|
||||
randomString: string;
|
||||
isMobile: boolean;
|
||||
isInView: boolean;
|
||||
isIconActive: boolean;
|
||||
}
|
||||
|
||||
export function usePatternInteraction(
|
||||
mouseX: MotionValue<number>,
|
||||
mouseY: MotionValue<number>,
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
) {
|
||||
const lastRandomUpdateRef = useRef<number>(0);
|
||||
|
||||
const [state, setState] = useState<InteractionState>({
|
||||
randomString: "",
|
||||
isMobile: false,
|
||||
isInView: false,
|
||||
isIconActive: false,
|
||||
});
|
||||
|
||||
const checkMobile = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isMobile: window.innerWidth < MOBILE_BREAKPOINT,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
randomString: generateRandomString(RANDOM_STRING_LENGTH),
|
||||
isMobile: window.innerWidth < MOBILE_BREAKPOINT,
|
||||
}));
|
||||
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, [checkMobile]);
|
||||
|
||||
const updateRandomString = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastRandomUpdateRef.current > THROTTLE_DELAY) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
randomString: generateRandomString(RANDOM_STRING_LENGTH),
|
||||
}));
|
||||
lastRandomUpdateRef.current = now;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateMobilePosition = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { left, top } = containerRef.current.getBoundingClientRect();
|
||||
const viewportCenterX = window.innerWidth / 2;
|
||||
const viewportCenterY = window.innerHeight / 2;
|
||||
|
||||
mouseX.set(viewportCenterX - left);
|
||||
mouseY.set(viewportCenterY - top);
|
||||
updateRandomString();
|
||||
}, [mouseX, mouseY, updateRandomString, containerRef]);
|
||||
|
||||
const checkInView = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const threshold = viewportHeight * PATTERN_VISIBILITY_THRESHOLD;
|
||||
const iconThreshold = viewportHeight * ICON_VISIBILITY_THRESHOLD;
|
||||
|
||||
const inView =
|
||||
rect.top < viewportHeight - threshold && rect.bottom > threshold;
|
||||
const iconActive =
|
||||
rect.top < viewportHeight - iconThreshold &&
|
||||
rect.bottom > iconThreshold;
|
||||
|
||||
setState((prev) => ({ ...prev, isInView: inView, isIconActive: iconActive }));
|
||||
|
||||
if (inView) {
|
||||
updateMobilePosition();
|
||||
}
|
||||
}, [updateMobilePosition, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isMobile) {
|
||||
setState((prev) => ({ ...prev, isInView: false, isIconActive: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
checkInView();
|
||||
const interval = setInterval(checkInView, VIEW_CHECK_INTERVAL);
|
||||
window.addEventListener("scroll", checkInView, { passive: true });
|
||||
window.addEventListener("resize", checkInView);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("scroll", checkInView);
|
||||
window.removeEventListener("resize", checkInView);
|
||||
};
|
||||
}, [state.isMobile, checkInView]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (state.isMobile) return;
|
||||
|
||||
const { left, top } = event.currentTarget.getBoundingClientRect();
|
||||
mouseX.set(event.clientX - left);
|
||||
mouseY.set(event.clientY - top);
|
||||
updateRandomString();
|
||||
},
|
||||
[state.isMobile, mouseX, mouseY, updateRandomString]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
onMouseMove,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user