Initial commit
This commit is contained in:
55
src/components/ui/Accordion.tsx
Normal file
55
src/components/ui/Accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AccordionProps {
|
||||
items: { title: string; content: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Accordion = ({ items, className = "" }: AccordionProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-3 xl:gap-3.5 2xl:gap-4", className)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setActiveIndex(activeIndex === index ? null : index)}
|
||||
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.title}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-base leading-snug">{item.content}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
||||
22
src/components/ui/ActiveBadge.tsx
Normal file
22
src/components/ui/ActiveBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ActiveBadgeProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ActiveBadge = ({ text, className }: ActiveBadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"card backdrop-blur flex items-center gap-2 px-3 py-1 rounded",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="size-2 rounded-full bg-accent animate-pulsate" />
|
||||
<p className="text-sm leading-snug font-medium text-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveBadge;
|
||||
46
src/components/ui/AnimatedBarChart.tsx
Normal file
46
src/components/ui/AnimatedBarChart.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const BARS = [
|
||||
{ height: 100, hoverHeight: 40 },
|
||||
{ height: 84, hoverHeight: 100 },
|
||||
{ height: 62, hoverHeight: 75 },
|
||||
{ height: 90, hoverHeight: 50 },
|
||||
{ height: 70, hoverHeight: 90 },
|
||||
{ height: 50, hoverHeight: 60 },
|
||||
{ height: 75, hoverHeight: 85 },
|
||||
{ height: 80, hoverHeight: 70 },
|
||||
];
|
||||
|
||||
const AnimatedBarChart = () => {
|
||||
const [active, setActive] = useState(2);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setActive((p) => (p + 1) % BARS.length), 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:block h-full w-full"
|
||||
style={{ maskImage: "linear-gradient(to bottom, black 40%, transparent)" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-end gap-4 h-full w-full">
|
||||
{BARS.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-full rounded bg-background-accent transition-all duration-500"
|
||||
style={{ height: `${isHovered ? bar.hoverHeight : bar.height}%` }}
|
||||
>
|
||||
<div className={cls("absolute inset-0 rounded primary-button transition-opacity duration-500", active === i ? "opacity-100" : "opacity-0")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedBarChart;
|
||||
31
src/components/ui/ArrowButton.tsx
Normal file
31
src/components/ui/ArrowButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
interface ArrowButtonProps {
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ArrowButton = ({ href = "#", onClick, className }: ArrowButtonProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls(
|
||||
"group/arrow flex items-center justify-center shrink-0 size-9 primary-button rounded-full cursor-pointer transition-transform duration-300 hover:scale-110",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight
|
||||
className="size-4 text-primary-cta-text transition-transform duration-300 group-hover/arrow:rotate-45"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrowButton;
|
||||
20
src/components/ui/AuroraBackground.tsx
Normal file
20
src/components/ui/AuroraBackground.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type AuroraBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const AuroraBackground = ({ position }: AuroraBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 w-full h-full overflow-hidden bg-background pointer-events-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")}>
|
||||
<div className="absolute top-0 left-1/2 -translate-y-1/2 -translate-x-[120%] w-[9vw] h-[110vh] bg-background-accent/15 -rotate-[52.5deg] rounded-[100%]" />
|
||||
<div className="absolute top-[-20vh] right-[2.5vw] w-[12.5vw] h-screen bg-background-accent/15 -rotate-60 rounded-[100%]" />
|
||||
<div className="absolute top-[-20vh] left-[2vw] w-[15vw] h-[150vh] bg-background-accent/20 -rotate-45 rounded-[100%]" />
|
||||
<div className="absolute top-[-30vh] left-0 w-[10vw] h-[70vh] bg-background-accent/15 -rotate-45 rounded-[100%]" />
|
||||
<div className="absolute bottom-[-40vh] left-0 w-[120vw] h-[50vh] bg-background-accent/10 -rotate-20 rounded-[100%]" />
|
||||
<div className="absolute inset-0 backdrop-blur-3xl"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuroraBackground;
|
||||
67
src/components/ui/AutoFillText.tsx
Normal file
67
src/components/ui/AutoFillText.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const AutoFillText = ({
|
||||
children,
|
||||
className = "",
|
||||
paddingY = "py-10",
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
paddingY?: string;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLHeadingElement>(null);
|
||||
const [fontSize, setFontSize] = useState<number | null>(null);
|
||||
|
||||
const hasDescenders = /[gjpqy]/.test(children);
|
||||
const lineHeight = hasDescenders ? 1.2 : 0.8;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
const calculateSize = () => {
|
||||
const containerWidth = container.offsetWidth;
|
||||
if (containerWidth === 0) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const styles = getComputedStyle(text);
|
||||
ctx.font = `${styles.fontWeight} 100px ${styles.fontFamily}`;
|
||||
const textWidth = ctx.measureText(children).width;
|
||||
|
||||
if (textWidth > 0) {
|
||||
setFontSize((containerWidth / textWidth) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
calculateSize();
|
||||
|
||||
const observer = new ResizeObserver(calculateSize);
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cls("w-full min-w-0 flex-1", !hasDescenders && paddingY)}>
|
||||
<h2
|
||||
ref={textRef}
|
||||
className={cls(
|
||||
"whitespace-nowrap transition-opacity duration-150",
|
||||
fontSize ? "opacity-100" : "opacity-0",
|
||||
className
|
||||
)}
|
||||
style={{ fontSize: fontSize ? `${fontSize}px` : undefined, lineHeight }}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoFillText;
|
||||
26
src/components/ui/AvatarAuthor.tsx
Normal file
26
src/components/ui/AvatarAuthor.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
interface AvatarAuthorProps {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
name: string;
|
||||
role: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AvatarAuthor = ({ imageSrc, videoSrc, name, role, className }: AvatarAuthorProps) => (
|
||||
<div className={cls("flex items-center gap-3", className)}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AvatarAuthor;
|
||||
48
src/components/ui/AvatarGroup.tsx
Normal file
48
src/components/ui/AvatarGroup.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AvatarGroupProps {
|
||||
avatarsSrc: string[];
|
||||
max?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: "size-8 text-xs",
|
||||
md: "size-10 text-sm",
|
||||
lg: "size-12 text-base",
|
||||
};
|
||||
|
||||
const AvatarGroup = ({ avatarsSrc, max = 5, size = "md", label, labelClassName, className = "" }: AvatarGroupProps) => {
|
||||
const visible = avatarsSrc.slice(0, max);
|
||||
const remaining = avatarsSrc.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={cls("flex items-center gap-3", className)}>
|
||||
<div className="flex items-center">
|
||||
{visible.map((src, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"relative shrink-0 overflow-hidden rounded-full border-2 border-background",
|
||||
SIZES[size],
|
||||
index > 0 && "-ml-3"
|
||||
)}
|
||||
>
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div className={cls("flex items-center justify-center shrink-0 rounded-full border-2 border-background secondary-button font-medium text-secondary-cta-text -ml-3", SIZES[size])}>
|
||||
+{remaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{label && <span className={cls("text-base font-medium text-foreground", labelClassName)}>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarGroup;
|
||||
81
src/components/ui/BorderGlow.tsx
Normal file
81
src/components/ui/BorderGlow.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { animate } from "motion/react";
|
||||
|
||||
const spread = 40;
|
||||
const proximity = 64;
|
||||
const borderWidth = 1.5;
|
||||
|
||||
const BorderGlow = ({ className }: { className?: string }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const { left, top, width, height } = el.getBoundingClientRect();
|
||||
const { clientX: x, clientY: y } = e;
|
||||
|
||||
const isActive =
|
||||
x > left - proximity &&
|
||||
x < left + width + proximity &&
|
||||
y > top - proximity &&
|
||||
y < top + height + proximity;
|
||||
|
||||
el.style.setProperty("--active", isActive ? "1" : "0");
|
||||
if (!isActive) return;
|
||||
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
const currentAngle = parseFloat(el.style.getPropertyValue("--start")) || 0;
|
||||
const targetAngle = (Math.atan2(y - centerY, x - centerX) * 180) / Math.PI + 90;
|
||||
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
|
||||
|
||||
animate(currentAngle, currentAngle + angleDiff, {
|
||||
duration: 2,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
onUpdate: (v) => el.style.setProperty("--start", String(v)),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
document.body.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
document.body.removeEventListener("pointermove", onPointerMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const gradient = `radial-gradient(circle, var(--color-accent) 10%, transparent 20%),
|
||||
radial-gradient(circle at 40% 40%, var(--color-background-accent) 5%, transparent 15%),
|
||||
repeating-conic-gradient(from 236.84deg at 50% 50%, var(--color-accent) 0%, var(--color-background-accent) 5%, var(--color-accent) 10%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ "--spread": spread, "--start": 0, "--active": 0, "--border-width": `${borderWidth}px`, "--gradient": gradient } as React.CSSProperties}
|
||||
className={cls("pointer-events-none absolute inset-0 rounded-[inherit]", className)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"rounded-[inherit]",
|
||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--border-width))]',
|
||||
"after:[border:var(--border-width)_solid_transparent]",
|
||||
"after:[background:var(--gradient)] after:bg-fixed",
|
||||
"after:opacity-(--active) after:transition-opacity after:duration-300",
|
||||
"after:[mask-clip:padding-box,border-box] after:mask-intersect",
|
||||
"after:mask-[linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BorderGlow;
|
||||
87
src/components/ui/Button.tsx
Normal file
87
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useStyle } from "@/components/ui/useStyle";
|
||||
import ButtonArrow from "@/components/ui/ButtonArrow";
|
||||
import ButtonBounce from "@/components/ui/ButtonBounce";
|
||||
import ButtonBubble from "@/components/ui/ButtonBubble";
|
||||
import ButtonElastic from "@/components/ui/ButtonElastic";
|
||||
import ButtonExpand from "@/components/ui/ButtonExpand";
|
||||
import ButtonFlip from "@/components/ui/ButtonFlip";
|
||||
import ButtonMagnetic from "@/components/ui/ButtonMagnetic";
|
||||
import ButtonPill from "@/components/ui/ButtonPill";
|
||||
import ButtonShift from "@/components/ui/ButtonShift";
|
||||
import ButtonSlide from "@/components/ui/ButtonSlide";
|
||||
import ButtonStagger from "@/components/ui/ButtonStagger";
|
||||
|
||||
interface ButtonProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DefaultButton = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const Button = (props: ButtonProps) => {
|
||||
const { buttonVariant } = useStyle();
|
||||
|
||||
switch (buttonVariant) {
|
||||
case "arrow":
|
||||
return <ButtonArrow {...props} />;
|
||||
case "bounce":
|
||||
return <ButtonBounce {...props} />;
|
||||
case "bubble":
|
||||
return <ButtonBubble {...props} />;
|
||||
case "elastic":
|
||||
return <ButtonElastic {...props} />;
|
||||
case "expand":
|
||||
return <ButtonExpand {...props} />;
|
||||
case "flip":
|
||||
return <ButtonFlip {...props} />;
|
||||
case "magnetic":
|
||||
return <ButtonMagnetic {...props} />;
|
||||
case "pill":
|
||||
return <ButtonPill {...props} />;
|
||||
case "shift":
|
||||
return <ButtonShift {...props} />;
|
||||
case "slide":
|
||||
return <ButtonSlide {...props} />;
|
||||
case "stagger":
|
||||
return <ButtonStagger {...props} />;
|
||||
default:
|
||||
return <DefaultButton {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default Button;
|
||||
50
src/components/ui/ButtonArrow.tsx
Normal file
50
src/components/ui/ButtonArrow.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonArrowProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonArrow = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonArrowProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group flex items-center justify-between gap-2 h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<span className="truncate md:transition-transform md:duration-300 md:ease-out md:group-hover:translate-x-2">
|
||||
{text}
|
||||
</span>
|
||||
<div className={cls("size-5 flex items-center justify-center rounded md:transition-all md:duration-300 md:ease-out md:group-hover:scale-[0.2] md:group-hover:rotate-90", variant === "primary" ? "secondary-button text-secondary-cta-text" : "primary-button text-primary-cta-text")}>
|
||||
<ArrowRight className="size-3 md:transition-opacity md:duration-700 md:group-hover:opacity-0" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonArrow;
|
||||
54
src/components/ui/ButtonBounce.tsx
Normal file
54
src/components/ui/ButtonBounce.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonBounceProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonBounce = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonBounceProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer transition-transform duration-300 ease-out md:hover:scale-90 md:hover:-rotate-3", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<span className="truncate overflow-hidden">
|
||||
{[...text].map((char, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block transition-transform duration-300 ease-out md:group-hover:-translate-y-[1.25em] md:group-hover:rotate-3"
|
||||
style={{ textShadow: "0 1.25em currentColor", whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonBounce;
|
||||
53
src/components/ui/ButtonBubble.tsx
Normal file
53
src/components/ui/ButtonBubble.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowDownRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonBubbleProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonBubble = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonBubbleProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group relative flex items-center min-w-0 max-w-full text-sm rounded cursor-pointer", className)}
|
||||
>
|
||||
<div className={cls("flex items-center justify-center size-10 rounded relative scale-0 md:transition-transform md:duration-500 md:ease-out md:origin-left md:group-hover:scale-100", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text")}>
|
||||
<ArrowDownRight className="size-3.5 md:transition-transform md:duration-500 md:group-hover:-rotate-45" />
|
||||
</div>
|
||||
<div className={cls("flex items-center justify-between flex-1 h-10 px-4 min-w-0 max-w-full rounded relative -translate-x-10 md:transition-transform md:duration-500 md:ease-out md:group-hover:translate-x-0", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text")}>
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
<div className={cls("flex items-center justify-center size-10 rounded absolute right-0 z-20 scale-100 md:transition-transform md:duration-500 md:ease-out md:origin-right md:group-hover:scale-0", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text")}>
|
||||
<ArrowDownRight className="size-3.5 md:transition-transform md:duration-500 md:group-hover:-rotate-45" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonBubble;
|
||||
56
src/components/ui/ButtonElastic.tsx
Normal file
56
src/components/ui/ButtonElastic.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonElasticProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonElastic = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonElasticProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const scale = useSpring(1, { stiffness: 300, damping: 10 });
|
||||
const scaleX = useTransform(scale, [1, 1.08], [1, 1.08]);
|
||||
const scaleY = useTransform(scale, [1, 1.08], [1, 0.95]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (window.innerWidth < 768) return;
|
||||
scale.set(1.08);
|
||||
setTimeout(() => scale.set(1), 100);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
style={{ scaleX, scaleY }}
|
||||
className={cls("flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
{text}
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonElastic;
|
||||
51
src/components/ui/ButtonExpand.tsx
Normal file
51
src/components/ui/ButtonExpand.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonExpandProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonExpand = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonExpandProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group relative flex items-center gap-3 min-w-0 w-fit max-w-full py-0.5 pl-5 pr-0.5 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<span className={cls("relative z-10 flex-1 truncate md:transition-colors md:duration-500 md:ease-out", variant === "primary" ? "text-primary-cta-text md:group-hover:text-secondary-cta-text" : "text-secondary-cta-text md:group-hover:text-primary-cta-text")}>{text}</span>
|
||||
<div className={cls("relative z-10 flex items-center justify-center size-8 rounded", variant === "primary" ? "text-secondary-cta-text" : "text-primary-cta-text")}>
|
||||
<ArrowUpRight className="size-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="absolute inset-0.5 z-0 overflow-hidden rounded pointer-events-none">
|
||||
<div className={cls("h-full w-full rounded -translate-x-[calc(-100%+2rem)] md:transition-transform md:duration-500 md:ease-out md:group-hover:translate-x-0", variant === "primary" ? "secondary-button" : "primary-button")} />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonExpand;
|
||||
132
src/components/ui/ButtonFlip.tsx
Normal file
132
src/components/ui/ButtonFlip.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ButtonFlipProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonFlip = ({ text, variant = "primary", href = "#", onClick, animate: shouldAnimate = true, animationDelay = 0, className = "" }: ButtonFlipProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const maxIndex = Math.max(text.length - 1, 1);
|
||||
|
||||
const getCharValues = (index: number) => {
|
||||
const t = index / maxIndex;
|
||||
const signedIndex = index - maxIndex / 2;
|
||||
const curve = Math.sin(t * 1.5 * (Math.PI / 180));
|
||||
const rotCurve = Math.sin(t * 30 * (Math.PI / 180));
|
||||
const rotSign = Math.max(-1, Math.min(1, signedIndex));
|
||||
const delay = 0.05 + curve * 2.9;
|
||||
const rotateZ = rotSign * rotCurve * 36 * -1;
|
||||
const translateX = signedIndex * 0.125;
|
||||
return { delay, rotateZ, translateX };
|
||||
};
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cls("group flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer active:scale-[0.96]", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<span className="grid">
|
||||
<span className="col-start-1 row-start-1 perspective-[10em] transform-3d">
|
||||
{[...text].map((char, index) => {
|
||||
const { delay, rotateZ, translateX } = getCharValues(index);
|
||||
return (
|
||||
<motion.span
|
||||
key={index}
|
||||
className="inline-block"
|
||||
initial={false}
|
||||
animate={isHovered ? {
|
||||
x: `${translateX}em`,
|
||||
y: "-1.25em",
|
||||
rotateX: 72,
|
||||
rotateZ,
|
||||
scale: 0.65,
|
||||
opacity: 0,
|
||||
} : {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
rotateZ: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.35,
|
||||
delay: isHovered ? delay : 0,
|
||||
ease: [0.675, 0.15, 0.1, 1],
|
||||
}}
|
||||
style={{ whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span aria-hidden="true" className="col-start-1 row-start-1 perspective-[10em] transform-3d">
|
||||
{[...text].map((char, index) => {
|
||||
const { delay, rotateZ, translateX } = getCharValues(index);
|
||||
return (
|
||||
<motion.span
|
||||
key={index}
|
||||
className="inline-block"
|
||||
initial={false}
|
||||
animate={isHovered ? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
rotateZ: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
} : {
|
||||
x: `${translateX}em`,
|
||||
y: "1.25em",
|
||||
rotateX: -72,
|
||||
rotateZ,
|
||||
scale: 0.65,
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.35,
|
||||
delay: isHovered ? delay + 0.05 : 0,
|
||||
ease: [0.675, 0.15, 0.1, 1],
|
||||
}}
|
||||
style={{ whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!shouldAnimate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonFlip;
|
||||
69
src/components/ui/ButtonMagnetic.tsx
Normal file
69
src/components/ui/ButtonMagnetic.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useMotionValue, useSpring } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonMagneticProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonMagnetic = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonMagneticProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const springX = useSpring(x, { stiffness: 150, damping: 15 });
|
||||
const springY = useSpring(y, { stiffness: 150, damping: 15 });
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!ref.current || window.innerWidth < 768) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const offsetX = (e.clientX - rect.left - rect.width / 2) * 0.15;
|
||||
const offsetY = (e.clientY - rect.top - rect.height / 2) * 0.15;
|
||||
x.set(offsetX);
|
||||
y.set(offsetY);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<motion.a
|
||||
ref={ref}
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ x: springX, y: springY }}
|
||||
className={cls("flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
{text}
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonMagnetic;
|
||||
48
src/components/ui/ButtonPill.tsx
Normal file
48
src/components/ui/ButtonPill.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonPillProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonPill = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonPillProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls(
|
||||
"flex items-center justify-center h-10 px-6 text-sm rounded-[0.5rem] cursor-pointer md:transition-[border-radius] md:duration-300 md:ease-out md:hover:rounded-[5rem]",
|
||||
variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonPill;
|
||||
54
src/components/ui/ButtonShift.tsx
Normal file
54
src/components/ui/ButtonShift.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonShiftProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonShift = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonShiftProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<span className="truncate overflow-hidden">
|
||||
{[...text].map((char, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block transition-transform duration-300 ease-out md:group-hover:-translate-y-[1.25em]"
|
||||
style={{ textShadow: "0 1.25em currentColor", whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonShift;
|
||||
49
src/components/ui/ButtonSlide.tsx
Normal file
49
src/components/ui/ButtonSlide.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonSlideProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonSlide = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonSlideProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group relative flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer overflow-clip", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<ArrowRight className="absolute left-5 size-3.5 opacity-0 -translate-x-6 md:transition-all md:duration-500 md:ease-[cubic-bezier(0.32,0.72,0,1)] md:group-hover:opacity-100 md:group-hover:translate-x-0 md:group-hover:delay-75" />
|
||||
<span className="flex items-center gap-1.5 md:transition-transform md:duration-500 md:ease-[cubic-bezier(0.32,0.72,0,1)] md:group-hover:translate-x-4 md:group-hover:delay-75">
|
||||
<span className="truncate">{text}</span>
|
||||
<ArrowRight className="size-3.5 md:transition-all md:duration-500 md:ease-[cubic-bezier(0.32,0.72,0,1)] md:group-hover:opacity-0 md:group-hover:translate-x-6 md:group-hover:delay-50" />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonSlide;
|
||||
54
src/components/ui/ButtonStagger.tsx
Normal file
54
src/components/ui/ButtonStagger.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonStaggerProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
animationDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonStagger = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonStaggerProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const button = (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls("group flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonStagger;
|
||||
40
src/components/ui/Calendar.tsx
Normal file
40
src/components/ui/Calendar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import "react-day-picker/style.css";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface CalendarProps {
|
||||
selected?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Calendar = ({ selected, onSelect, className = "" }: CalendarProps) => (
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
showOutsideDays
|
||||
className={cls("p-3 secondary-button rounded", className)}
|
||||
classNames={{
|
||||
month_caption: "flex items-center h-8 mb-2",
|
||||
nav: "absolute top-0 right-0 flex items-center h-8",
|
||||
caption_label: "relative z-[1] inline-flex items-center whitespace-nowrap border-0 text-base font-medium text-secondary-cta-text",
|
||||
button_previous: "relative inline-flex items-center justify-center size-8 border-0 bg-transparent p-0 m-0 cursor-pointer text-secondary-cta-text hover:bg-foreground/10 rounded transition-colors duration-300",
|
||||
button_next: "relative inline-flex items-center justify-center size-8 border-0 bg-transparent p-0 m-0 cursor-pointer text-secondary-cta-text hover:bg-foreground/10 rounded transition-colors duration-300",
|
||||
chevron: "size-4 fill-secondary-cta-text",
|
||||
weekdays: "flex",
|
||||
weekday: "w-8 text-sm font-medium text-secondary-cta-text/50 text-center",
|
||||
week: "flex mt-1",
|
||||
day: "size-8 text-center p-0",
|
||||
day_button: "size-8 rounded text-sm text-secondary-cta-text hover:bg-foreground/10 cursor-pointer transition-colors duration-300",
|
||||
selected: "[&>button]:bg-foreground [&>button]:text-background [&>button]:hover:bg-foreground/90",
|
||||
today: "[&>button]:font-bold",
|
||||
outside: "[&>button]:opacity-30",
|
||||
disabled: "[&>button]:opacity-30 [&>button]:cursor-not-allowed",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Calendar;
|
||||
15
src/components/ui/Card.tsx
Normal file
15
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Card = ({ children, className = "" }: CardProps) => (
|
||||
<div className={cls("p-6 xl:p-7 2xl:p-8 card rounded", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
51
src/components/ui/Carousel.tsx
Normal file
51
src/components/ui/Carousel.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Children, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useCarouselControls } from "@/hooks/useCarouselControls";
|
||||
|
||||
interface CarouselProps {
|
||||
children: ReactNode;
|
||||
itemClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Carousel = ({ children, itemClassName = "", className = "" }: CarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true, containScroll: "trimSnaps" });
|
||||
const { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress } = useCarouselControls(emblaApi);
|
||||
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-5 w-full", className)}>
|
||||
<div ref={emblaRef} className="overflow-hidden w-full cursor-grab">
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
{Children.map(children, (child, i) => (
|
||||
<div key={i} className={cls("shrink-0", itemClassName)}>{child}</div>
|
||||
))}
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="relative h-2 w-1/2 card rounded overflow-hidden">
|
||||
<div className="absolute top-0 bottom-0 -left-full w-full primary-button rounded" style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={scrollPrev} disabled={prevDisabled} type="button" aria-label="Previous" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50 transition-opacity duration-300">
|
||||
<ChevronLeft className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
<button onClick={scrollNext} disabled={nextDisabled} type="button" aria-label="Next" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50 transition-opacity duration-300">
|
||||
<ChevronRight className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
||||
46
src/components/ui/ChatMarquee.tsx
Normal file
46
src/components/ui/ChatMarquee.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Exchange = { userMessage: string; aiResponse: string };
|
||||
|
||||
const ChatMarquee = ({ aiIcon, userIcon, exchanges, placeholder }: { aiIcon: string | LucideIcon; userIcon: string | LucideIcon; exchanges: Exchange[]; placeholder: string }) => {
|
||||
const AiIcon = resolveIcon(aiIcon);
|
||||
const UserIcon = resolveIcon(userIcon);
|
||||
const messages = exchanges.flatMap((e) => [{ content: e.userMessage, isUser: true }, { content: e.aiResponse, isUser: false }]);
|
||||
const duplicated = [...messages, ...messages];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col px-4 animate-marquee-vertical">
|
||||
{duplicated.map((msg, i) => (
|
||||
<div key={i} className={cls("flex items-end gap-2 mb-4 shrink-0", msg.isUser ? "flex-row-reverse" : "flex-row")}>
|
||||
{msg.isUser ? (
|
||||
<div className="flex items-center justify-center size-8 primary-button rounded shrink-0">
|
||||
<UserIcon className="size-3 text-primary-cta-text" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center size-8 card rounded shrink-0">
|
||||
<AiIcon className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cls("max-w-3/4 px-4 py-3 text-sm leading-snug", msg.isUser ? "primary-button rounded-2xl rounded-br-none text-primary-cta-text" : "card rounded-2xl rounded-bl-none")}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 pl-4 card rounded">
|
||||
<p className="flex-1 text-sm text-foreground/75 truncate">{placeholder}</p>
|
||||
<div className="flex items-center justify-center size-7 primary-button rounded">
|
||||
<Send className="size-3 text-primary-cta-text" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMarquee;
|
||||
29
src/components/ui/CheckList.tsx
Normal file
29
src/components/ui/CheckList.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface CheckListProps {
|
||||
items: string[];
|
||||
size?: "sm" | "base" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
};
|
||||
|
||||
const CheckList = ({ items, size = "sm", className }: CheckListProps) => (
|
||||
<div className={cls("flex flex-col gap-3", className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item} className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className={cls("leading-snug", textSizes[size])}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CheckList;
|
||||
46
src/components/ui/Checkbox.tsx
Normal file
46
src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface CheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { box: "size-4", icon: "size-2" },
|
||||
md: { box: "size-5", icon: "size-2.5" },
|
||||
lg: { box: "size-6", icon: "size-3" },
|
||||
};
|
||||
|
||||
const Checkbox = ({ checked, onChange, label, size = "md", className = "" }: CheckboxProps) => (
|
||||
<label className={cls("flex items-center gap-2 cursor-pointer", className)}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cls(
|
||||
"flex items-center justify-center rounded border transition-colors duration-200 cursor-pointer",
|
||||
SIZES[size].box,
|
||||
checked ? "primary-button" : "secondary-button"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ opacity: checked ? 1 : 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check className={cls(SIZES[size].icon, "text-primary-cta-text")} />
|
||||
</motion.div>
|
||||
</button>
|
||||
{label && <span className="text-sm text-foreground">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
export default Checkbox;
|
||||
47
src/components/ui/ChecklistTimeline.tsx
Normal file
47
src/components/ui/ChecklistTimeline.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Check, Loader } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Item = { label: string; detail: string };
|
||||
|
||||
const DELAYS = [
|
||||
["delay-150", "delay-200", "delay-[250ms]"],
|
||||
["delay-[350ms]", "delay-[400ms]", "delay-[450ms]"],
|
||||
["delay-[550ms]", "delay-[600ms]", "delay-[650ms]"],
|
||||
];
|
||||
|
||||
const ChecklistTimeline = ({ heading, subheading, items, completedLabel }: { heading: string; subheading: string; items: [Item, Item, Item]; completedLabel: string }) => (
|
||||
<div className="group relative flex items-center justify-center h-full w-full overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{[1, 0.8, 0.6].map((s) => <div key={s} className="absolute h-full aspect-square rounded-full border border-background-accent/30" style={{ transform: `scale(${s})` }} />)}
|
||||
</div>
|
||||
<div className="relative flex flex-col gap-3 p-4 max-w-full w-8/10 mask-fade-y">
|
||||
<div className="flex items-center gap-2 p-3 card shadow rounded">
|
||||
<Loader className="size-4 text-primary transition-transform duration-1000 group-hover:rotate-360" strokeWidth={1.5} />
|
||||
<p className="text-xs truncate">{heading}</p>
|
||||
<p className="text-xs text-foreground/75 ml-auto whitespace-nowrap">{subheading}</p>
|
||||
</div>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2 card shadow rounded">
|
||||
<div className="relative flex items-center justify-center size-6 card shadow rounded">
|
||||
<div className="absolute size-2 primary-button rounded transition-opacity duration-300 group-hover:opacity-0" />
|
||||
<div className={cls("absolute inset-0 flex items-center justify-center primary-button rounded opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100", DELAYS[i][0])}>
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between gap-4 min-w-0">
|
||||
<p className={cls("text-xs truncate opacity-0 transition-opacity duration-300 group-hover:opacity-100", DELAYS[i][1])}>{item.label}</p>
|
||||
<p className={cls("text-xs text-foreground/75 whitespace-nowrap opacity-0 translate-y-1 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0", DELAYS[i][2])}>{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="relative flex items-center justify-center p-3 primary-button rounded">
|
||||
<div className="absolute flex gap-2 transition-opacity duration-500 delay-900 group-hover:opacity-0">
|
||||
{[0, 1, 2].map((j) => <div key={j} className="size-2 rounded bg-primary-cta-text" />)}
|
||||
</div>
|
||||
<p className="text-xs text-primary-cta-text truncate opacity-0 transition-opacity duration-500 delay-900 group-hover:opacity-100">{completedLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ChecklistTimeline;
|
||||
148
src/components/ui/ColumnWaveBackground.tsx
Normal file
148
src/components/ui/ColumnWaveBackground.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type ColumnWaveBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const ColumnWaveBackground = ({ position }: ColumnWaveBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden flex items-end justify-between pointer-events-none select-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")} aria-hidden="true">
|
||||
<div className="relative flex flex-col gap-1 h-full">
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "0s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "0.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "0.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "0.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "0.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.6s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col gap-1 h-full">
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.25s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.45s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.65s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "1.85s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.05s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.25s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.45s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.65s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.85s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.05s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.25s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.45s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.65s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.85s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.05s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.25s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.45s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.65s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.85s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.05s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.25s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.45s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.65s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.85s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col gap-1 h-full">
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "2.9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.1s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.3s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.1s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.3s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.1s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.3s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.1s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.3s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col gap-1 h-full">
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.75s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "3.95s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.15s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.35s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.55s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.75s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "4.95s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.15s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.35s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.55s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.75s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.95s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.15s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.35s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.55s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.75s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.95s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.15s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.35s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.55s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.75s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.95s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.15s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.35s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col gap-1 h-full">
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "5.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "6.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "7.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.6s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "8.8s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "9s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "9.2s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "9.4s" }} />
|
||||
<div className="opacity-0 h-8 w-2 bg-background-accent [box-shadow:0px_0px_50px_16px_color-mix(in_srgb,var(--color-background-accent)_12%,transparent),0px_0px_7px_1px_color-mix(in_srgb,var(--color-background-accent)_31%,transparent)] animate-[cell-wave-pulse_4s_ease_infinite]" style={{ animationDelay: "9.6s" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnWaveBackground;
|
||||
20
src/components/ui/CornerGlowBackground.tsx
Normal file
20
src/components/ui/CornerGlowBackground.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type CornerGlowBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const CornerGlowBackground = ({ position }: CornerGlowBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")} aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-9/10 md:w-6/10 aspect-square rounded-full opacity-20 [background:radial-gradient(circle_at_center,var(--color-background-accent)_35%,transparent_70%)]"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 w-9/10 md:w-6/10 aspect-square rounded-full opacity-20 [background:radial-gradient(circle_at_center,var(--color-background-accent)_35%,transparent_70%)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CornerGlowBackground;
|
||||
54
src/components/ui/Dropdown.tsx
Normal file
54
src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface DropdownProps {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Dropdown = ({ children, label, className = "" }: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cls("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 h-9 px-3 rounded secondary-button text-secondary-cta-text text-sm cursor-pointer"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className={cls("h-(--text-sm) w-auto transition-transform duration-300 ease-in-out", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="absolute z-50 mt-2 rounded secondary-button overflow-hidden"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
68
src/components/ui/DropdownMenu.tsx
Normal file
68
src/components/ui/DropdownMenu.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface DropdownMenuProps {
|
||||
options: { label: string; value: string }[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DropdownMenu = ({ options, value, onChange, placeholder = "Select...", className = "" }: DropdownMenuProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cls("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between h-9 px-3 rounded secondary-button text-secondary-cta-text text-sm cursor-pointer"
|
||||
>
|
||||
<span className={selected ? "font-medium" : ""}>{selected?.label || placeholder}</span>
|
||||
<ChevronDown className={cls("h-(--text-sm) w-auto transition-transform duration-300 ease-in-out", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="absolute z-50 mt-2 w-full rounded secondary-button overflow-hidden"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onChange?.(option.value); setIsOpen(false); }}
|
||||
className={cls(
|
||||
"w-full px-3 py-2 text-left text-sm text-secondary-cta-text hover:bg-foreground/5 transition-colors duration-500 ease-in-out cursor-pointer",
|
||||
option.value === value && "bg-foreground/10"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
||||
82
src/components/ui/FloatingGradientBackground.tsx
Normal file
82
src/components/ui/FloatingGradientBackground.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FloatingGradientBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const FloatingGradientBackground = ({ position }: FloatingGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none blur-[40px] mask-[linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]")}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute opacity-[0.075]"
|
||||
style={{
|
||||
background: "radial-gradient(circle at center, var(--color-background-accent) 0, transparent 50%)",
|
||||
mixBlendMode: "hard-light",
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
top: "calc(50% - 30%)",
|
||||
left: "calc(50% - 30%)",
|
||||
transformOrigin: "center center",
|
||||
animation: "floating-move-vertical 20s ease infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute opacity-[0.125]"
|
||||
style={{
|
||||
background: "radial-gradient(circle at center, var(--color-accent) 0, transparent 50%)",
|
||||
mixBlendMode: "hard-light",
|
||||
width: "80%",
|
||||
height: "80%",
|
||||
top: "calc(50% - 30%)",
|
||||
left: "calc(50% - 30%)",
|
||||
transformOrigin: "calc(50% - 400px)",
|
||||
animation: "floating-move-in-circle 20s reverse infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute opacity-[0.125]"
|
||||
style={{
|
||||
background: "radial-gradient(circle at center, var(--color-primary-cta) 0, transparent 50%)",
|
||||
mixBlendMode: "hard-light",
|
||||
width: "60%",
|
||||
height: "60%",
|
||||
top: "calc(50% - 40% + 200px)",
|
||||
left: "calc(50% - 40% - 500px)",
|
||||
transformOrigin: "calc(50% + 400px)",
|
||||
animation: "floating-move-in-circle 30s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute opacity-[0.15]"
|
||||
style={{
|
||||
background: "radial-gradient(circle at center, var(--color-background-accent) 0, transparent 50%)",
|
||||
mixBlendMode: "hard-light",
|
||||
width: "60%",
|
||||
height: "60%",
|
||||
top: "calc(50% - 40%)",
|
||||
left: "calc(50% - 40%)",
|
||||
transformOrigin: "calc(50% - 200px)",
|
||||
animation: "floating-move-horizontal 30s ease infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute opacity-[0.075]"
|
||||
style={{
|
||||
background: "radial-gradient(circle at center, var(--color-primary-cta) 0, transparent 50%)",
|
||||
mixBlendMode: "hard-light",
|
||||
width: "120%",
|
||||
height: "120%",
|
||||
top: "calc(50% - 80%)",
|
||||
left: "calc(50% - 80%)",
|
||||
transformOrigin: "calc(50% - 800px) calc(50% + 200px)",
|
||||
animation: "floating-move-in-circle 20s ease infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingGradientBackground;
|
||||
37
src/components/ui/GradientBarsBackground.tsx
Normal file
37
src/components/ui/GradientBarsBackground.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GradientBarsBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const GradientBarsBackground = ({ position }: GradientBarsBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none")} aria-hidden="true">
|
||||
<div className="flex h-4/5 w-full justify-between mask-[linear-gradient(0deg,transparent_0%,black_100%)]">
|
||||
<div className="flex h-full w-[35%] overflow-hidden mask-[linear-gradient(270deg,transparent_0%,black_100%)]">
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(90deg,var(--color-primary-cta),transparent)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-[35%] justify-end overflow-hidden mask-[linear-gradient(90deg,transparent_0%,black_100%)]">
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
<div className="h-full flex-1 min-w-[30px] max-w-[82px] opacity-[0.075] bg-[linear-gradient(270deg,var(--color-primary-cta),transparent)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradientBarsBackground;
|
||||
16
src/components/ui/GridLinesBackground.tsx
Normal file
16
src/components/ui/GridLinesBackground.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridLinesBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const GridLinesBackground = ({ position }: GridLinesBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(position, "inset-0 -z-10 overflow-hidden bg-background pointer-events-none select-none mask-[radial-gradient(circle_at_center,white_0%,transparent_90%)] bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px)] bg-size-[10vw_10vw]")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridLinesBackground;
|
||||
64
src/components/ui/GridOrCarousel.tsx
Normal file
64
src/components/ui/GridOrCarousel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Children, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useCarouselControls } from "@/hooks/useCarouselControls";
|
||||
|
||||
interface GridOrCarouselProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const GridOrCarousel = ({ children }: GridOrCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true, containScroll: "trimSnaps" });
|
||||
const { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress } = useCarouselControls(emblaApi);
|
||||
const items = Children.toArray(children);
|
||||
const count = items.length;
|
||||
|
||||
if (count <= 3) {
|
||||
return (
|
||||
<div className={cls("grid grid-cols-1 gap-5 mx-auto w-content-width", count === 2 && "md:grid-cols-2", count === 3 && "md:grid-cols-3")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveSwitch = count === 4;
|
||||
|
||||
return (
|
||||
<>
|
||||
{responsiveSwitch && (
|
||||
<div className="hidden 2xl:grid grid-cols-4 gap-5 mx-auto w-content-width">{children}</div>
|
||||
)}
|
||||
<div className={cls("flex flex-col gap-5 w-full overflow-hidden", responsiveSwitch && "2xl:hidden")}>
|
||||
<div ref={emblaRef} className="w-full cursor-grab">
|
||||
<div className="flex gap-4 items-stretch">
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
{items.map((child, i) => (
|
||||
<div key={i} className="shrink-0 *:h-full w-carousel-item-3 2xl:w-carousel-item-4">{child}</div>
|
||||
))}
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="relative h-2 w-1/2 card rounded overflow-hidden">
|
||||
<div className="absolute top-0 bottom-0 -left-full w-full primary-button rounded" style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={scrollPrev} disabled={prevDisabled} type="button" aria-label="Previous" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
|
||||
<ChevronLeft className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
<button onClick={scrollNext} disabled={nextDisabled} type="button" aria-label="Next" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
|
||||
<ChevronRight className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridOrCarousel;
|
||||
32
src/components/ui/HeroBackgroundSlot.tsx
Normal file
32
src/components/ui/HeroBackgroundSlot.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useStyle } from "@/components/ui/useStyle";
|
||||
import CornerGlowBackground from "@/components/ui/CornerGlowBackground";
|
||||
import GradientBarsBackground from "@/components/ui/GradientBarsBackground";
|
||||
import HorizonGlowBackground from "@/components/ui/HorizonGlowBackground";
|
||||
import LightRaysCenterBackground from "@/components/ui/LightRaysCenterBackground";
|
||||
import LightRaysCornerBackground from "@/components/ui/LightRaysCornerBackground";
|
||||
import RadialGradientBackground from "@/components/ui/RadialGradientBackground";
|
||||
|
||||
const HeroBackgroundSlot = () => {
|
||||
const { heroBackground } = useStyle();
|
||||
|
||||
switch (heroBackground) {
|
||||
case "cornerGlow":
|
||||
return <CornerGlowBackground position="absolute" />;
|
||||
case "gradientBars":
|
||||
return <GradientBarsBackground position="absolute" />;
|
||||
case "horizonGlow":
|
||||
return <HorizonGlowBackground position="absolute" />;
|
||||
case "lightRaysCenter":
|
||||
return <LightRaysCenterBackground position="absolute" />;
|
||||
case "lightRaysCorner":
|
||||
return <LightRaysCornerBackground position="absolute" />;
|
||||
case "radialGradient":
|
||||
return <RadialGradientBackground position="absolute" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default HeroBackgroundSlot;
|
||||
19
src/components/ui/HorizonGlowBackground.tsx
Normal file
19
src/components/ui/HorizonGlowBackground.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type HorizonGlowBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const HorizonGlowBackground = ({ position }: HorizonGlowBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none mask-[linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]")} aria-hidden="true">
|
||||
<div className="absolute left-1/2 -translate-x-1/2 w-full h-full -bottom-[9vh] overflow-hidden z-0">
|
||||
<div className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden blur-[57px] [background:radial-gradient(50%_50%_at_50%_50%,color-mix(in_srgb,var(--color-primary-cta)_25%,transparent),transparent)]" />
|
||||
<div className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-screen rounded-[100%] [background:linear-gradient(180deg,color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),transparent)]" />
|
||||
<div className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%] bg-background [box-shadow:inset_0_2px_20px_color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),0_-10px_50px_1px_color-mix(in_srgb,var(--color-primary-cta)_25%,transparent)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizonGlowBackground;
|
||||
61
src/components/ui/HoverPattern.tsx
Normal file
61
src/components/ui/HoverPattern.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { motion, useMotionValue, useMotionTemplate } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const randomChars = () => Array.from({ length: 1500 }, () => CHARS[Math.floor(Math.random() * 62)]).join("");
|
||||
|
||||
interface HoverPatternProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HoverPattern = ({ children, className = "" }: HoverPatternProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const [chars, setChars] = useState(randomChars);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && ref.current) {
|
||||
x.set(ref.current.offsetWidth / 2);
|
||||
y.set(ref.current.offsetHeight / 2);
|
||||
}
|
||||
}, [isMobile, x, y]);
|
||||
|
||||
const mask = useMotionTemplate`radial-gradient(${isMobile ? 110 : 250}px at ${x}px ${y}px, white, transparent)`;
|
||||
const base = "absolute inset-0 rounded-[inherit] transition-opacity duration-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cls("group/pattern relative", className)}
|
||||
onMouseMove={isMobile ? undefined : (e) => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
x.set(e.clientX - rect.left);
|
||||
y.set(e.clientY - rect.top);
|
||||
setChars(randomChars());
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit]">
|
||||
<div className={cls(base, isMobile ? "opacity-25" : "opacity-0 group-hover/pattern:opacity-25")} style={{ background: "linear-gradient(var(--foreground), transparent)" }} />
|
||||
<motion.div className={cls(base, "bg-linear-to-r from-accent to-accent/50 backdrop-blur-xl", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }} />
|
||||
<motion.div className={cls(base, "mix-blend-overlay", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }}>
|
||||
<p className="absolute inset-0 h-full whitespace-pre-wrap wrap-break-word font-mono text-xs font-bold text-foreground">{chars}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverPattern;
|
||||
42
src/components/ui/IconBadge.tsx
Normal file
42
src/components/ui/IconBadge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface IconBadgeProps {
|
||||
icon: string | LucideIcon;
|
||||
size?: "sm" | "base" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "size-10",
|
||||
base: "size-12",
|
||||
lg: "size-14",
|
||||
};
|
||||
|
||||
const iconSizeStyles = {
|
||||
sm: "size-3.5",
|
||||
base: "size-4",
|
||||
lg: "size-5",
|
||||
};
|
||||
|
||||
const IconBadge = ({ icon, size = "base", className }: IconBadgeProps) => {
|
||||
const IconComponent = resolveIcon(icon);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"flex items-center justify-center primary-button rounded shadow",
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconComponent
|
||||
className={cls("text-primary-cta-text", iconSizeStyles[size])}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconBadge;
|
||||
29
src/components/ui/IconTextMarquee.tsx
Normal file
29
src/components/ui/IconTextMarquee.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
const IconTextMarquee = ({ centerIcon, texts }: { centerIcon: string | LucideIcon; texts: string[] }) => {
|
||||
const CenterIcon = resolveIcon(centerIcon);
|
||||
const items = [...texts, ...texts];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden" style={{ maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)" }}>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 w-full opacity-60">
|
||||
{Array.from({ length: 10 }).map((_, row) => (
|
||||
<div key={row} className={cls("flex gap-2", row % 2 === 0 ? "animate-marquee-horizontal" : "animate-marquee-horizontal-reverse")}>
|
||||
{items.map((text, i) => (
|
||||
<div key={i} className="flex items-center justify-center px-4 py-2 card rounded">
|
||||
<p className="text-sm leading-snug">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-16 primary-button backdrop-blur-sm rounded">
|
||||
<CenterIcon className="size-6 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconTextMarquee;
|
||||
41
src/components/ui/ImageOrVideo.tsx
Normal file
41
src/components/ui/ImageOrVideo.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ImageOrVideoProps {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ImageOrVideo = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
className = "",
|
||||
}: ImageOrVideoProps) => {
|
||||
if (videoSrc) {
|
||||
return (
|
||||
<video
|
||||
src={videoSrc}
|
||||
aria-label={videoSrc}
|
||||
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (imageSrc) {
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageSrc}
|
||||
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ImageOrVideo;
|
||||
28
src/components/ui/InfoCardMarquee.tsx
Normal file
28
src/components/ui/InfoCardMarquee.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Item = { icon: string | LucideIcon; label: string; value: string };
|
||||
|
||||
const InfoCardMarquee = ({ items }: { items: Item[] }) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col animate-marquee-vertical">
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4 p-3 mb-4 card rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 secondary-button rounded">
|
||||
{(() => { const Icon = resolveIcon(item.icon); return <Icon className="size-4 text-secondary-cta-text" strokeWidth={1.5} />; })()}
|
||||
</div>
|
||||
<p className="text-base truncate">{item.label}</p>
|
||||
</div>
|
||||
<p className="text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoCardMarquee;
|
||||
18
src/components/ui/Input.tsx
Normal file
18
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Input = ({ className = "", ...props }: InputProps) => (
|
||||
<input
|
||||
className={cls(
|
||||
"w-full h-9 px-3 rounded secondary-button text-secondary-cta-text text-sm",
|
||||
"placeholder:text-secondary-cta-text/50 focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Input;
|
||||
17
src/components/ui/Label.tsx
Normal file
17
src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Label = ({ className = "", ...props }: LabelProps) => (
|
||||
<label
|
||||
className={cls(
|
||||
"text-sm font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Label;
|
||||
58
src/components/ui/LightRaysCenterBackground.tsx
Normal file
58
src/components/ui/LightRaysCenterBackground.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type LightRaysCenterBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const LightRaysCenterBackground = ({ position }: LightRaysCenterBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none")} aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-background mask-[radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)] bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-background-accent)_20%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_srgb,var(--color-background-accent)_10%,transparent)_1px,transparent_1px)] bg-size-[10vw_10vw]" />
|
||||
|
||||
<div className="absolute -top-[400px] left-1/2 -translate-x-1/2 w-[1142px] h-[129vh] overflow-hidden blur-lg mask-[radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]">
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 1, transform: "rotate(-20deg)", animation: "ray-pulse 4s ease-in-out 0s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.6, transform: "rotate(-12deg)", animation: "ray-pulse 3.5s ease-in-out 0.5s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-10px)] w-[20px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.45, transform: "scale(0.9) rotate(-5deg)", animation: "ray-pulse 5s ease-in-out 1.2s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-7.5px)] w-[15px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.625, transform: "rotate(-3deg)", animation: "ray-pulse 3s ease-in-out 0.3s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-20px)] w-[40px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.1, transform: "scale(0.79) rotate(0deg)", animation: "ray-pulse 4.5s ease-in-out 0.8s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-10px)] w-[20px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.525, transform: "rotate(3deg)", animation: "ray-pulse 3.2s ease-in-out 1.5s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-7.5px)] w-[15px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.725, transform: "scale(0.9) rotate(5deg)", animation: "ray-pulse 4.2s ease-in-out 0.2s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.6, transform: "rotate(12deg)", animation: "ray-pulse 3.8s ease-in-out 1s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 1, transform: "rotate(20deg)", animation: "ray-pulse 4s ease-in-out 0.7s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div className="absolute left-[calc(50%-599px)] -top-[352px] -bottom-[46px] w-[1198px] opacity-[0.025] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
<div className="absolute left-[calc(50%-432.5px)] -top-[252px] w-[865px] h-[929px] opacity-10 overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
<div className="absolute left-[calc(50%-432.5px)] -top-[252px] w-[865px] h-[929px] opacity-10 overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LightRaysCenterBackground;
|
||||
54
src/components/ui/LightRaysCornerBackground.tsx
Normal file
54
src/components/ui/LightRaysCornerBackground.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type LightRaysCornerBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const LightRaysCornerBackground = ({ position }: LightRaysCornerBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none")} aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-background mask-[radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)] bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-background-accent)_20%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_srgb,var(--color-background-accent)_10%,transparent)_1px,transparent_1px)] bg-size-[10vw_10vw]" />
|
||||
|
||||
<div className="absolute -top-[571px] -left-[373px] w-[1142px] h-[179vh] -rotate-[38deg] overflow-hidden blur-lg mask-[radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]">
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.85, transform: "rotate(-18deg)", animation: "rotated-ray-pulse 4s ease-in-out 0s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.775, transform: "rotate(-12deg)", animation: "rotated-ray-pulse 3.5s ease-in-out 0.5s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-10px)] w-[20px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.65, transform: "scale(0.9) rotate(-5deg)", animation: "rotated-ray-pulse 5s ease-in-out 1.2s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-7.5px)] w-[15px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.25, transform: "rotate(-3deg)", animation: "rotated-ray-pulse 3s ease-in-out 0.3s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-20px)] w-[40px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.45, transform: "scale(0.79) rotate(0deg)", animation: "rotated-ray-pulse 4.5s ease-in-out 0.8s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-10px)] w-[20px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.45, transform: "rotate(6deg)", animation: "rotated-ray-pulse 3.2s ease-in-out 1.5s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 0.65, transform: "scale(0.83) rotate(9deg)", animation: "rotated-ray-pulse 4.2s ease-in-out 0.2s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-[352px] -bottom-[920px] left-[calc(50%-17.5px)] w-[35px] origin-top-right overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{ "--ray-opacity": 1, transform: "scale(0.9) rotate(14deg)", animation: "rotated-ray-pulse 3.8s ease-in-out 1s infinite both" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div className="absolute left-[calc(50%-599px)] -top-[352px] -bottom-[46px] w-[1198px] opacity-[0.05] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
<div className="absolute left-[calc(50%-432.5px)] -top-[252px] w-[865px] h-[929px] opacity-15 overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
<div className="absolute left-[calc(50%-432.5px)] -top-[252px] w-[865px] h-[929px] opacity-15 overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LightRaysCornerBackground;
|
||||
71
src/components/ui/LoaderReveal.tsx
Normal file
71
src/components/ui/LoaderReveal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface LoaderRevealProps {
|
||||
imageSrc: string;
|
||||
title: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const LoaderReveal = ({ imageSrc, title, onComplete }: LoaderRevealProps) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [introComplete, setIntroComplete] = useState(false);
|
||||
|
||||
return (
|
||||
<AnimatePresence onExitComplete={onComplete}>
|
||||
{isVisible && (
|
||||
<motion.div className="fixed inset-0 z-100 text-primary-cta-text">
|
||||
<motion.div
|
||||
className="absolute inset-0 w-full h-full bg-background-accent"
|
||||
exit={{ y: "-101%" }}
|
||||
transition={{ duration: 1, ease: [0.76, 0, 0.24, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-2 bg-primary-cta-text"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: introComplete ? 0 : 1 }}
|
||||
style={{ originX: introComplete ? 1 : 0 }}
|
||||
transition={{ duration: introComplete ? 0.5 : 3, ease: [0.76, 0, 0.24, 1] }}
|
||||
onAnimationComplete={() => {
|
||||
if (!introComplete) {
|
||||
setIntroComplete(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative z-2 flex flex-col justify-center items-center w-full h-full gap-4 md:gap-5"
|
||||
animate={introComplete ? { opacity: 0 } : { opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="card p-px rounded-full">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
className="size-12 md:size-16 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex justify-center items-center">
|
||||
<span className="absolute text-center text-2xl md:text-4xl font-medium tracking-tight opacity-20">
|
||||
{title}
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-2xl md:text-4xl font-medium tracking-tight"
|
||||
initial={{ clipPath: "inset(0% 100% 0% 0%)" }}
|
||||
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
|
||||
transition={{ duration: 3, ease: [0.76, 0, 0.24, 1] }}
|
||||
>
|
||||
{title}
|
||||
</motion.span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoaderReveal;
|
||||
76
src/components/ui/LoopCarousel.tsx
Normal file
76
src/components/ui/LoopCarousel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Children, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import type { EmblaCarouselType } from "embla-carousel";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface LoopCarouselProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const LoopCarousel = ({ children }: LoopCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center", containScroll: "trimSnaps" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const items = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((api: EmblaCarouselType) => {
|
||||
setSelectedIndex(api.selectedScrollSnap());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
|
||||
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("select", onSelect).on("reInit", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect).off("reInit", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full md:w-content-width mx-auto">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full mask-fade-x-medium">
|
||||
<div className="flex w-full">
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className="shrink-0 w-content-width md:w-[clamp(18rem,50vw,48rem)] mr-3 md:mr-6">
|
||||
<div
|
||||
className={cls(
|
||||
"transition-all duration-500 ease-out",
|
||||
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between w-content-width mx-auto pointer-events-none">
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronLeft className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronRight className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoopCarousel;
|
||||
32
src/components/ui/MediaStack.tsx
Normal file
32
src/components/ui/MediaStack.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Item = { imageSrc?: string; videoSrc?: string };
|
||||
|
||||
const MediaStack = ({ items }: { items: [Item, Item, Item] }) => (
|
||||
<div className="group/stack relative flex items-center justify-center h-full w-full rounded select-none card">
|
||||
<div className={cls(
|
||||
"absolute z-1 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"translate-x-[12%] -translate-y-[8%] rotate-8 transition-all duration-500",
|
||||
"group-hover/stack:translate-x-[22%] group-hover/stack:-translate-y-[14%] group-hover/stack:rotate-12"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[2].imageSrc} videoSrc={items[2].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
<div className={cls(
|
||||
"absolute z-2 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"-translate-x-[12%] -translate-y-[8%] -rotate-8 transition-all duration-500",
|
||||
"group-hover/stack:-translate-x-[22%] group-hover/stack:-translate-y-[14%] group-hover/stack:-rotate-12"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[1].imageSrc} videoSrc={items[1].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
<div className={cls(
|
||||
"absolute z-30 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"translate-y-[10%] transition-all duration-500",
|
||||
"group-hover/stack:translate-y-[20%]"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[0].imageSrc} videoSrc={items[0].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MediaStack;
|
||||
69
src/components/ui/Modal.tsx
Normal file
69
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { X } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Modal = ({ trigger, title, description, children, className = "" }: ModalProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
if (isOpen) document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)} className="cursor-pointer">{trigger}</div>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-background/30 backdrop-blur-[1px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className={cls("relative card rounded p-3 xl:p-4 2xl:p-5", className)}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 mb-3 xl:mb-4 2xl:mb-5">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-medium text-foreground truncate">{title}</h2>
|
||||
{description && <p className="text-sm text-foreground truncate">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="shrink-0 flex items-center justify-center size-9 rounded secondary-button text-secondary-cta-text cursor-pointer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
141
src/components/ui/NavbarCentered.tsx
Normal file
141
src/components/ui/NavbarCentered.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus, ArrowRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarCenteredProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarCentered = ({ logo, navItems, ctaButton }: NavbarCenteredProps) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsScrolled(window.scrollY > 50);
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
||||
};
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={cls(
|
||||
"fixed z-1000 top-0 left-0 w-full transition-all duration-500 ease-in-out",
|
||||
isScrolled ? "h-15 bg-background/80 backdrop-blur-sm" : "h-20 bg-background/0 backdrop-blur-0"
|
||||
)}
|
||||
>
|
||||
<div className="relative mx-auto flex items-center justify-between h-full w-content-width">
|
||||
<a href="/" className="text-xl font-medium text-foreground">{logo}</a>
|
||||
|
||||
<div className="hidden md:flex absolute left-1/2 -translate-x-1/2 items-center gap-6">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href)}
|
||||
className="text-base text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 xl:gap-3 2xl:gap-4">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
|
||||
|
||||
<button
|
||||
className="flex md:hidden items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Plus
|
||||
className={cls("w-1/2 h-1/2 text-primary-cta-text transition-transform duration-300", menuOpen ? "rotate-45" : "rotate-0")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial={{ y: "-135%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "-135%" }}
|
||||
transition={{ type: "spring", damping: 26, stiffness: 170 }}
|
||||
className="md:hidden fixed z-1000 top-3 left-3 right-3 p-6 rounded card"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-xl text-foreground">Menu</p>
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<Plus className="w-1/2 h-1/2 text-primary-cta-text rotate-45" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="flex items-center justify-between py-2 text-base font-medium text-foreground"
|
||||
>
|
||||
{item.name}
|
||||
<ArrowRight className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-linear-to-r from-transparent via-foreground/20 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} className="w-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarCentered;
|
||||
109
src/components/ui/NavbarDropdown.tsx
Normal file
109
src/components/ui/NavbarDropdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarDropdownProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarDropdown = ({ logo, navItems, ctaButton }: NavbarDropdownProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
||||
};
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuOpen && navRef.current && !navRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<nav ref={navRef} className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
||||
<div className="flex items-center justify-between p-2 xl:p-3 2xl:p-4 rounded backdrop-blur-sm card">
|
||||
<a href="/" className="pl-2 text-xl font-medium text-foreground">{logo}</a>
|
||||
|
||||
<div className="flex items-center gap-2 xl:gap-3 2xl:gap-4">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
|
||||
|
||||
<button
|
||||
className="relative flex items-center justify-center size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span
|
||||
className={cls(
|
||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
||||
menuOpen ? "rotate-45" : "-translate-y-1"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cls(
|
||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
||||
menuOpen ? "-rotate-45" : "translate-y-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute top-full right-2 xl:right-3 2xl:right-4 -mt-1 px-4 py-1 w-3/4 md:w-3/10 2xl:w-25/100 rounded primary-button"
|
||||
>
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="group flex items-center justify-between py-3 w-full"
|
||||
>
|
||||
<span className="text-base text-primary-cta-text group-hover:ml-2 transition-[margin] duration-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-(--text-base) w-auto text-primary-cta-text group-hover:rotate-45 group-hover:mr-2 transition-all duration-300"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-primary-cta-text/20" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarDropdown;
|
||||
113
src/components/ui/NavbarFloating.tsx
Normal file
113
src/components/ui/NavbarFloating.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus, ArrowUpRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarFloatingProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarFloating = ({ logo, navItems, ctaButton }: NavbarFloatingProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.15 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 z-999 bg-foreground"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<nav className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
||||
<div className="mx-auto w-full md:w-1/2 overflow-hidden rounded backdrop-blur-sm card">
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5">
|
||||
<a href="/" className="text-xl font-medium text-foreground">{logo}</a>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Plus
|
||||
className={cls(
|
||||
"w-1/2 h-1/2 text-primary-cta-text transition-transform duration-300",
|
||||
menuOpen ? "rotate-45" : "rotate-0"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.625, 0.05, 0, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 pt-0 xl:pt-0 2xl:pt-0">
|
||||
<div className="px-3 xl:px-4 2xl:px-5 py-0 md:py-1 2xl:py-2 rounded card">
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="group flex items-center justify-between py-3 w-full"
|
||||
>
|
||||
<span className="text-xl md:text-2xl font-medium text-foreground group-hover:ml-3 transition-[margin] duration-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-(--text-xl) md:h-(--text-2xl) w-auto text-foreground group-hover:rotate-45 group-hover:mr-3 transition-all duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-accent/50" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} className="w-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarFloating;
|
||||
117
src/components/ui/NavbarFloatingLogo.tsx
Normal file
117
src/components/ui/NavbarFloatingLogo.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus, ArrowUpRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarFloatingLogoProps {
|
||||
logo: string;
|
||||
logoImageSrc: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarFloatingLogo = ({ logo, logoImageSrc, navItems, ctaButton }: NavbarFloatingLogoProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.15 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 z-999 bg-foreground"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<nav className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
||||
<div className="mx-auto w-full md:w-1/2 overflow-hidden rounded backdrop-blur-sm card">
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5">
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<img src={logoImageSrc} alt={logo} className="h-8 w-8 rounded-full object-cover" />
|
||||
<span className="text-xl font-medium text-foreground">{logo}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Plus
|
||||
className={cls(
|
||||
"w-1/2 h-1/2 text-primary-cta-text transition-transform duration-300",
|
||||
menuOpen ? "rotate-45" : "rotate-0"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.625, 0.05, 0, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 pt-0 xl:pt-0 2xl:pt-0">
|
||||
<div className="px-3 xl:px-4 2xl:px-5 py-0 md:py-1 2xl:py-2 rounded card">
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="group flex items-center justify-between py-3 w-full"
|
||||
>
|
||||
<span className="text-xl md:text-2xl font-medium text-foreground group-hover:ml-3 transition-[margin] duration-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-(--text-xl) md:h-(--text-2xl) w-auto text-foreground group-hover:rotate-45 group-hover:mr-3 transition-all duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-accent/50" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} className="w-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarFloatingLogo;
|
||||
123
src/components/ui/NavbarFullscreen.tsx
Normal file
123
src/components/ui/NavbarFullscreen.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarFullscreenProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const NavbarFullscreen = ({ logo, navItems, ctaButton }: NavbarFullscreenProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-0 z-1000 pointer-events-none">
|
||||
<div className="absolute z-10 top-5 left-1/2 -translate-x-1/2 flex items-center justify-between w-content-width pointer-events-auto">
|
||||
<a
|
||||
href="/"
|
||||
className={cls(
|
||||
"text-xl font-medium transition-colors duration-500",
|
||||
menuOpen ? "text-background" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{logo}
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-2 xl:gap-3 2xl:gap-4">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
|
||||
|
||||
<button
|
||||
className="relative flex items-center justify-center size-9 rounded cursor-pointer primary-button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span
|
||||
className={cls(
|
||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
||||
menuOpen ? "rotate-45" : "-translate-y-1"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cls(
|
||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
||||
menuOpen ? "-rotate-45" : "translate-y-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center bg-foreground pointer-events-auto transition-[clip-path] duration-700 ease-[cubic-bezier(0.9,0,0.1,1)]"
|
||||
style={{
|
||||
clipPath: menuOpen
|
||||
? "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"
|
||||
: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)"
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name} className="overflow-hidden">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="group flex items-center gap-4 py-4"
|
||||
style={{
|
||||
transform: menuOpen ? "translateY(0%)" : "translateY(100%)",
|
||||
transition: "transform 0.5s cubic-bezier(0.7, 0, 0.3, 1)",
|
||||
transitionDelay: menuOpen
|
||||
? `${0.3 + index * 0.05}s`
|
||||
: `${(navItems.length - 1 - index) * 0.05}s`
|
||||
}}
|
||||
>
|
||||
<span className="text-7xl md:text-9xl font-medium text-background group-hover:ml-4 transition-[margin] duration-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-(--text-7xl) md:h-(--text-9xl) w-auto text-background group-hover:rotate-45 group-hover:mr-4 transition-all duration-300"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-background/20" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarFullscreen;
|
||||
43
src/components/ui/NavbarInline.tsx
Normal file
43
src/components/ui/NavbarInline.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarInlineProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarInline = ({ logo, navItems, ctaButton }: NavbarInlineProps) => {
|
||||
return (
|
||||
<nav className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
||||
<div className="flex items-center justify-between p-2 xl:p-3 2xl:p-4 rounded backdrop-blur-sm card">
|
||||
<a href="/" className="pl-2 text-xl font-medium text-foreground">{logo}</a>
|
||||
|
||||
<div className="hidden md:flex absolute left-1/2 -translate-x-1/2 items-center gap-6">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href)}
|
||||
className="relative text-base text-foreground after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-current after:scale-x-0 after:origin-right after:transition-transform after:duration-300 hover:after:scale-x-100 hover:after:origin-left"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarInline;
|
||||
15
src/components/ui/NoiseBackground.tsx
Normal file
15
src/components/ui/NoiseBackground.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type NoiseBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const NoiseBackground = ({ position }: NoiseBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden bg-background-accent/10 pointer-events-none select-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")} aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10 bg-[url(https://storage.googleapis.com/webild/default/noise.webp)] bg-size-[512px]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoiseBackground;
|
||||
16
src/components/ui/NoiseGradientBackground.tsx
Normal file
16
src/components/ui/NoiseGradientBackground.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type NoiseGradientBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const NoiseGradientBackground = ({ position }: NoiseGradientBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden bg-background-accent/10 pointer-events-none select-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")} aria-hidden="true">
|
||||
<div className="absolute inset-0 overflow-hidden bg-gradient-to-br from-background via-background-accent/10 to-background-accent/20" />
|
||||
<div className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10 bg-[url(https://storage.googleapis.com/webild/default/noise.webp)] bg-size-[512px]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoiseGradientBackground;
|
||||
37
src/components/ui/OrbitingIcons.tsx
Normal file
37
src/components/ui/OrbitingIcons.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
const OrbitingIcons = ({ centerIcon, items }: { centerIcon: string | LucideIcon; items: (string | LucideIcon)[] }) => {
|
||||
const CenterIcon = resolveIcon(centerIcon);
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-center h-full overflow-hidden"
|
||||
style={{ perspective: "2000px", maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, transparent, black 10%, black 90%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full" style={{ transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)" }}>
|
||||
<div className="absolute size-60 opacity-85 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute size-80 opacity-75 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute size-100 opacity-65 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute flex items-center justify-center size-40 border border-background-accent shadow rounded-full">
|
||||
<div className="flex items-center justify-center size-20 primary-button rounded-full">
|
||||
<CenterIcon className="size-10 text-primary-cta-text" strokeWidth={1.25} />
|
||||
</div>
|
||||
{items.map((iconInput, i) => {
|
||||
const Icon = resolveIcon(iconInput);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute flex items-center justify-center size-10 rounded shadow card -ml-5 -mt-5"
|
||||
style={{ top: "50%", left: "50%", animation: "orbit 12s linear infinite", "--initial-position": `${(360 / items.length) * i}deg`, "--translate-position": "160px" } as React.CSSProperties}
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrbitingIcons;
|
||||
35
src/components/ui/PriceDisplay.tsx
Normal file
35
src/components/ui/PriceDisplay.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
currency?: string;
|
||||
period?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PriceDisplay = ({
|
||||
price,
|
||||
originalPrice,
|
||||
currency = "$",
|
||||
period,
|
||||
className,
|
||||
}: PriceDisplayProps) => (
|
||||
<div className={cls("flex flex-col gap-1", className)}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl md:text-6xl font-semibold">
|
||||
{currency}
|
||||
{price}
|
||||
</span>
|
||||
{originalPrice && (
|
||||
<span className="text-xl text-foreground/50 line-through">
|
||||
{currency}
|
||||
{originalPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{period && <span className="text-base font-medium">{period}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PriceDisplay;
|
||||
15
src/components/ui/RadialGradientBackground.tsx
Normal file
15
src/components/ui/RadialGradientBackground.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type RadialGradientBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const RadialGradientBackground = ({ position }: RadialGradientBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none", position === "absolute" && "mask-[linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]")} aria-hidden="true">
|
||||
<div className="relative w-full h-full bg-[radial-gradient(130%_130%_at_50%_15%,var(--background)_40%,var(--color-background-accent)_100%)] mask-[linear-gradient(180deg,transparent_0%,transparent_15%,black_55%,black_100%)]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadialGradientBackground;
|
||||
24
src/components/ui/RatingStars.tsx
Normal file
24
src/components/ui/RatingStars.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Star } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface RatingStarsProps {
|
||||
rating: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RatingStars = ({ rating, className }: RatingStarsProps) => (
|
||||
<div className={cls("flex gap-1.5", className)}>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls(
|
||||
"size-5 text-accent",
|
||||
index < rating ? "fill-accent" : "fill-transparent"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RatingStars;
|
||||
44
src/components/ui/ScrollReveal.tsx
Normal file
44
src/components/ui/ScrollReveal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
type Variant = "slide-up" | "fade-blur" | "fade";
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
variant: Variant;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
"slide-up": {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
},
|
||||
"fade-blur": {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)" },
|
||||
},
|
||||
"fade": {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const ScrollReveal = ({ children, variant, delay = 0, className = "" }: ScrollRevealProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
variants={VARIANTS[variant]}
|
||||
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollReveal;
|
||||
90
src/components/ui/SectionErrorBoundary.tsx
Normal file
90
src/components/ui/SectionErrorBoundary.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Per-section error boundary inserted around every assembled section by the
|
||||
* backend's page-assembler. Goal: a single section that throws at runtime
|
||||
* (missing required prop, broken `.map`, etc.) shows a small placeholder
|
||||
* instead of taking down the entire page with a white screen.
|
||||
*
|
||||
* Also reports the failure via the `/__webild/render-status` probe channel
|
||||
* so Bob-AI's post-commit poll picks up the section name + error message and
|
||||
* the model gets the signal to fix the right section on the next loop turn.
|
||||
*
|
||||
* The probe POST is best-effort and silent — sandbox-only (gated by
|
||||
* `window.parent !== window`), so production deploys never call it.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** Section slug — same value the wrapping `<div data-section="…">` uses. */
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const RENDER_STATUS_URL = "/__webild/render-status";
|
||||
|
||||
export default class SectionErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): State {
|
||||
return {
|
||||
hasError: true,
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : String(error ?? "unknown"),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.parent === window) return;
|
||||
try {
|
||||
fetch(RENDER_STATUS_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
reason: "section_error_boundary",
|
||||
section: this.props.name,
|
||||
error: String(error?.message || error || "unknown"),
|
||||
stack: String(error?.stack || "").slice(0, 4000),
|
||||
componentStack: String(info?.componentStack || "").slice(0, 4000),
|
||||
t: Date.now(),
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Section render error placeholder"
|
||||
className="w-content-width mx-auto my-8 p-6 card rounded text-center"
|
||||
>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
This section failed to render.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Section: <code className="font-mono">{this.props.name}</code>
|
||||
</p>
|
||||
{this.state.errorMessage ? (
|
||||
<p className="mt-2 text-xs text-foreground/50 max-w-xl mx-auto break-words">
|
||||
{this.state.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-xs text-foreground/40">
|
||||
Tell Bob exactly what's wrong (e.g. "fix the {this.props.name} section") to retry.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
73
src/components/ui/SelectorButton.tsx
Normal file
73
src/components/ui/SelectorButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
interface SelectorButtonProps {
|
||||
options: Option[];
|
||||
activeValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SelectorButton = ({ options, activeValue, onValueChange, className }: SelectorButtonProps) => {
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const hoverElement = hoverRef.current;
|
||||
|
||||
if (!container || !hoverElement) return;
|
||||
|
||||
const moveHoverBlock = (target: HTMLElement) => {
|
||||
if (!target) return;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
hoverElement.style.width = `${targetRect.width}px`;
|
||||
hoverElement.style.transform = `translateX(${targetRect.left - containerRect.left}px)`;
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
const activeButton = container.querySelector(`[data-value="${activeValue}"]`) as HTMLElement;
|
||||
if (activeButton) moveHoverBlock(activeButton);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updatePosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [activeValue]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cls("relative inline-flex gap-1 p-1 card rounded-full", className)}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
data-value={option.value}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
className={cls(
|
||||
"relative z-1 px-5 py-2 text-sm font-medium rounded-full cursor-pointer transition-colors duration-300",
|
||||
activeValue === option.value ? "text-primary-cta-text" : "text-foreground hover:text-foreground/80"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="absolute z-0 inset-y-1 left-0 rounded-full primary-button pointer-events-none transition-all duration-300 ease-out"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectorButton;
|
||||
11
src/components/ui/Separator.tsx
Normal file
11
src/components/ui/Separator.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface SeparatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Separator = ({ className = "" }: SeparatorProps) => (
|
||||
<div className={cls("h-px w-full bg-foreground/10", className)} />
|
||||
);
|
||||
|
||||
export default Separator;
|
||||
69
src/components/ui/Sheet.tsx
Normal file
69
src/components/ui/Sheet.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { X } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface SheetProps {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Sheet = ({ trigger, title, description, children, className = "" }: SheetProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
if (isOpen) document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)} className="cursor-pointer">{trigger}</div>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-background/30 backdrop-blur-[1px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className={cls("fixed z-50 inset-y-0 right-0 card p-3 xl:p-4 2xl:p-5", className)}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-4 2xl:gap-5 mb-3 xl:mb-4 2xl:mb-5">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-medium text-foreground truncate">{title}</h2>
|
||||
{description && <p className="text-sm text-foreground truncate">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="shrink-0 flex items-center justify-center size-9 rounded secondary-button text-secondary-cta-text cursor-pointer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sheet;
|
||||
32
src/components/ui/SiteBackgroundSlot.tsx
Normal file
32
src/components/ui/SiteBackgroundSlot.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useStyle } from "@/components/ui/useStyle";
|
||||
import AuroraBackground from "@/components/ui/AuroraBackground";
|
||||
import CornerGlowBackground from "@/components/ui/CornerGlowBackground";
|
||||
import FloatingGradientBackground from "@/components/ui/FloatingGradientBackground";
|
||||
import GridLinesBackground from "@/components/ui/GridLinesBackground";
|
||||
import NoiseBackground from "@/components/ui/NoiseBackground";
|
||||
import NoiseGradientBackground from "@/components/ui/NoiseGradientBackground";
|
||||
|
||||
const SiteBackgroundSlot = () => {
|
||||
const { siteBackground } = useStyle();
|
||||
|
||||
switch (siteBackground) {
|
||||
case "aurora":
|
||||
return <AuroraBackground position="fixed" />;
|
||||
case "cornerGlow":
|
||||
return <CornerGlowBackground position="fixed" />;
|
||||
case "floatingGradient":
|
||||
return <FloatingGradientBackground position="fixed" />;
|
||||
case "gridLines":
|
||||
return <GridLinesBackground position="fixed" />;
|
||||
case "noise":
|
||||
return <NoiseBackground position="fixed" />;
|
||||
case "noiseGradient":
|
||||
return <NoiseGradientBackground position="fixed" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SiteBackgroundSlot;
|
||||
47
src/components/ui/SocialLinks.tsx
Normal file
47
src/components/ui/SocialLinks.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
href: string;
|
||||
};
|
||||
|
||||
interface SocialLinksProps {
|
||||
links: SocialLink[];
|
||||
size?: "sm" | "base" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "size-8",
|
||||
base: "size-9",
|
||||
lg: "size-10",
|
||||
};
|
||||
|
||||
const SocialLinks = ({ links, size = "base", className }: SocialLinksProps) => (
|
||||
<div className={cls("flex gap-3", className)}>
|
||||
{links.map((link, index) => {
|
||||
const IconComponent = resolveIcon(link.icon);
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cls(
|
||||
"flex items-center justify-center primary-button rounded",
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
<IconComponent
|
||||
className="h-2/5 w-2/5 text-primary-cta-text"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SocialLinks;
|
||||
24
src/components/ui/Spinner.tsx
Normal file
24
src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: "size-4 border-1",
|
||||
md: "size-6 border-2",
|
||||
lg: "size-8 border-2",
|
||||
};
|
||||
|
||||
const Spinner = ({ size = "md", className = "" }: SpinnerProps) => (
|
||||
<div
|
||||
className={cls(
|
||||
"animate-spin rounded-full border-foreground/10 border-t-foreground",
|
||||
SIZES[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Spinner;
|
||||
32
src/components/ui/StyleProvider.tsx
Normal file
32
src/components/ui/StyleProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
StyleContext,
|
||||
type ButtonVariant,
|
||||
type HeroBackgroundVariant,
|
||||
type SiteBackgroundVariant,
|
||||
} from "@/components/ui/useStyle";
|
||||
|
||||
interface StyleProviderProps {
|
||||
buttonVariant?: ButtonVariant;
|
||||
siteBackground?: SiteBackgroundVariant;
|
||||
heroBackground?: HeroBackgroundVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StyleProvider({
|
||||
buttonVariant = "default",
|
||||
siteBackground = "none",
|
||||
heroBackground = "none",
|
||||
children,
|
||||
}: StyleProviderProps) {
|
||||
return (
|
||||
<StyleContext.Provider value={{ buttonVariant, siteBackground, heroBackground }}>
|
||||
{children}
|
||||
</StyleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default StyleProvider;
|
||||
30
src/components/ui/Switch.tsx
Normal file
30
src/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Switch = ({ checked, onChange, disabled = false, className = "" }: SwitchProps) => (
|
||||
<div
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={cls(
|
||||
"relative flex items-center h-5 aspect-2/1 secondary-button rounded-full cursor-pointer",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 h-full aspect-square rounded-full primary-button transition-all duration-300",
|
||||
checked ? "translate-x-5 opacity-100" : "opacity-50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Switch;
|
||||
17
src/components/ui/Tag.tsx
Normal file
17
src/components/ui/Tag.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface TagProps {
|
||||
text: string;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Tag = ({ text, icon: Icon, className = "" }: TagProps) => (
|
||||
<div className={cls("flex items-center gap-1 px-3 py-1 text-sm card rounded w-fit", className)}>
|
||||
{Icon && <Icon className="h-(--text-sm) w-auto" />}
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Tag;
|
||||
96
src/components/ui/TextAnimation.tsx
Normal file
96
src/components/ui/TextAnimation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Variant = "slide-up" | "fade-blur" | "fade";
|
||||
|
||||
interface TextAnimationProps {
|
||||
text: string;
|
||||
variant: Variant;
|
||||
gradientText: boolean;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
"slide-up": {
|
||||
hidden: { opacity: 0, y: "50%" },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
},
|
||||
"fade-blur": {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
visible: { opacity: 1, filter: "none" },
|
||||
},
|
||||
"fade": {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const EASING: Record<Variant, [number, number, number, number]> = {
|
||||
"slide-up": [0.25, 0.46, 0.45, 0.94],
|
||||
"fade-blur": [0.45, 0, 0.55, 1],
|
||||
"fade": [0.45, 0, 0.55, 1],
|
||||
};
|
||||
|
||||
const TextAnimation = ({ text, variant, gradientText, tag = "p", className = "" }: TextAnimationProps) => {
|
||||
const Tag = motion[tag] as typeof motion.p;
|
||||
const words = text.split(" ");
|
||||
const [animationComplete, setAnimationComplete] = useState(false);
|
||||
const [reverted, setReverted] = useState(false);
|
||||
|
||||
const gradientClass = gradientText
|
||||
? "bg-gradient-to-r from-foreground to-primary-cta bg-clip-text text-transparent pb-[0.1em] -mb-[0.1em]"
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (animationComplete && !reverted) {
|
||||
const delay = variant === "fade-blur" && gradientText ? 0 : 700;
|
||||
const timer = setTimeout(() => {
|
||||
setReverted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [animationComplete, reverted, variant, gradientText]);
|
||||
|
||||
if (reverted) {
|
||||
return (
|
||||
<Tag
|
||||
className={cls("leading-[1.2]", gradientClass, className)}
|
||||
initial={false}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cls(
|
||||
"leading-[1.2] transition-all duration-700",
|
||||
animationComplete && gradientClass,
|
||||
className
|
||||
)}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
transition={{ staggerChildren: 0.04 }}
|
||||
onAnimationComplete={() => setAnimationComplete(true)}
|
||||
>
|
||||
{words.map((word, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && " "}
|
||||
<motion.span
|
||||
className="inline-block"
|
||||
variants={VARIANTS[variant]}
|
||||
transition={{ duration: 0.6, ease: EASING[variant] }}
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextAnimation;
|
||||
53
src/components/ui/TextBox.tsx
Normal file
53
src/components/ui/TextBox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
|
||||
interface TextBoxProps {
|
||||
tag?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextBox = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
className,
|
||||
}: TextBoxProps) => (
|
||||
<div className={`flex flex-col items-center gap-2 w-content-width mx-auto ${className || ""}`}>
|
||||
{tag && (
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance text-center"
|
||||
/>
|
||||
|
||||
<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 text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TextBox;
|
||||
33
src/components/ui/TextLink.tsx
Normal file
33
src/components/ui/TextLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface TextLinkProps {
|
||||
text: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextLink = ({ text, href = "#", onClick, className = "" }: TextLinkProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls(
|
||||
"relative text-sm text-foreground cursor-pointer",
|
||||
"after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-current",
|
||||
"after:scale-x-0 after:origin-right after:transition-transform after:duration-300",
|
||||
"hover:after:scale-x-100 hover:after:origin-left",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextLink;
|
||||
18
src/components/ui/Textarea.tsx
Normal file
18
src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Textarea = ({ className = "", ...props }: TextareaProps) => (
|
||||
<textarea
|
||||
className={cls(
|
||||
"w-full min-h-24 px-3 py-2 rounded secondary-button text-secondary-cta-text text-sm",
|
||||
"placeholder:text-secondary-cta-text/50 focus:outline-none resize-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Textarea;
|
||||
85
src/components/ui/TiltedCarousel.tsx
Normal file
85
src/components/ui/TiltedCarousel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type TiltedCarouselProps = {
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
autoPlayInterval?: number;
|
||||
};
|
||||
|
||||
const TiltedCarousel = ({ items, autoPlayInterval = 4000 }: TiltedCarouselProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isFirstRender, setIsFirstRender] = useState(true);
|
||||
const autoPlayRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const itemCount = items.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender) {
|
||||
const timeout = setTimeout(() => setIsFirstRender(false), 800);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isFirstRender]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlayRef.current) clearInterval(autoPlayRef.current);
|
||||
autoPlayRef.current = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % itemCount);
|
||||
}, autoPlayInterval);
|
||||
return () => {
|
||||
if (autoPlayRef.current) clearInterval(autoPlayRef.current);
|
||||
};
|
||||
}, [autoPlayInterval, itemCount]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center w-full overflow-hidden">
|
||||
<div className="w-[70%] md:w-[40%] aspect-square md:aspect-video opacity-0" />
|
||||
{[-2, -1, 0, 1, 2].map((position) => {
|
||||
const itemIndex = (activeIndex + position + itemCount) % itemCount;
|
||||
const item = items[itemIndex];
|
||||
const isCenter = position === 0;
|
||||
const distance = Math.abs(position);
|
||||
|
||||
const scale = distance === 0 ? 1 : distance === 1 ? 0.88 : 0.8;
|
||||
const opacity = distance <= 1 ? 1 : 0;
|
||||
const xPercent = position * 100;
|
||||
const yPercent = distance === 0 ? 0 : distance === 1 ? 5 : 10;
|
||||
const rotate = position * 2;
|
||||
|
||||
const initialState = distance <= 1 && isFirstRender
|
||||
? isCenter
|
||||
? { opacity: 0, y: "25px", scale: 1, x: "0%", rotate: 0 }
|
||||
: { opacity: 0, scale: 0.88, x: `calc(${xPercent}% + ${position > 0 ? 20 : -20}px)`, y: "5%", rotate }
|
||||
: { scale, opacity, x: `${xPercent}%`, y: `${yPercent}%`, rotate };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={itemIndex}
|
||||
className="absolute w-[70%] md:w-[40%] aspect-square md:aspect-video p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden"
|
||||
style={{ zIndex: isCenter ? 10 : 5 - distance }}
|
||||
initial={initialState}
|
||||
animate={{ scale, opacity, x: `${xPercent}%`, y: `${yPercent}%`, rotate }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: [0.65, 0, 0.35, 1],
|
||||
delay: distance <= 1 && isFirstRender ? (isCenter ? 0.45 : 0.6) : 0,
|
||||
}}
|
||||
>
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-background/50 backdrop-blur-[1px] pointer-events-none"
|
||||
initial={{ opacity: isCenter ? 0 : 1 }}
|
||||
animate={{ opacity: isCenter ? 0 : 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TiltedCarousel;
|
||||
29
src/components/ui/TiltedStackCards.tsx
Normal file
29
src/components/ui/TiltedStackCards.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Item = { icon: string | LucideIcon; title: string; subtitle: string; detail: string };
|
||||
|
||||
const POS = ["-translate-y-14 hover:-translate-y-20", "translate-x-16 hover:-translate-y-4", "translate-x-32 translate-y-16 hover:translate-y-10"];
|
||||
|
||||
const TiltedStackCards = ({ items }: { items: [Item, Item, Item] }) => (
|
||||
<div
|
||||
className="h-full grid place-items-center [grid-template-areas:'stack']"
|
||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, black, black 80%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={cls("flex flex-col justify-between gap-2 p-6 w-80 h-36 card rounded transition-all duration-500 -skew-y-[8deg] [grid-area:stack] 2xl:w-90", POS[i])}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5 rounded primary-button">
|
||||
{(() => { const Icon = resolveIcon(item.icon); return <Icon className="size-3 text-primary-cta-text" strokeWidth={1.5} />; })()}
|
||||
</div>
|
||||
<p className="text-base">{item.title}</p>
|
||||
</div>
|
||||
<p className="text-lg whitespace-nowrap">{item.subtitle}</p>
|
||||
<p className="text-base">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TiltedStackCards;
|
||||
53
src/components/ui/Tooltip.tsx
Normal file
53
src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const POSITIONS = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
const Tooltip = ({ content, position = "top", children, className = "" }: TooltipProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className={cls(
|
||||
"absolute z-50 whitespace-nowrap rounded px-2 py-1 text-xs pointer-events-none",
|
||||
"secondary-button text-secondary-cta-text",
|
||||
POSITIONS[position],
|
||||
className
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: position === "bottom" ? -4 : position === "top" ? 4 : 0 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
37
src/components/ui/Transition.tsx
Normal file
37
src/components/ui/Transition.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface TransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
transitionType?: "full" | "fade";
|
||||
whileInView?: boolean;
|
||||
}
|
||||
|
||||
const Transition = ({
|
||||
children,
|
||||
className = "flex flex-col w-full gap-6",
|
||||
transitionType = "full",
|
||||
whileInView = true,
|
||||
}: TransitionProps) => {
|
||||
const initial = transitionType === "full"
|
||||
? { opacity: 0, y: 20 }
|
||||
: { opacity: 0 };
|
||||
|
||||
const target = transitionType === "full"
|
||||
? { opacity: 1, y: 0 }
|
||||
: { opacity: 1 };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={initial}
|
||||
{...(whileInView ? { whileInView: target, viewport: { once: true, margin: "-15%" } } : { animate: target })}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transition;
|
||||
14
src/components/ui/backgrounds.md
Normal file
14
src/components/ui/backgrounds.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Background Components
|
||||
|
||||
AuroraBackground
|
||||
ColumnWaveBackground
|
||||
CornerGlowBackground
|
||||
FloatingGradientBackground
|
||||
GradientBarsBackground
|
||||
GridLinesBackground
|
||||
HorizonGlowBackground
|
||||
LightRaysCenterBackground
|
||||
LightRaysCornerBackground
|
||||
NoiseBackground
|
||||
NoiseGradientBackground
|
||||
RadialGradientBackground
|
||||
54
src/components/ui/useStyle.ts
Normal file
54
src/components/ui/useStyle.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "default"
|
||||
| "arrow"
|
||||
| "bounce"
|
||||
| "bubble"
|
||||
| "elastic"
|
||||
| "expand"
|
||||
| "flip"
|
||||
| "magnetic"
|
||||
| "pill"
|
||||
| "shift"
|
||||
| "slide"
|
||||
| "stagger";
|
||||
|
||||
export type SiteBackgroundVariant =
|
||||
| "none"
|
||||
| "aurora"
|
||||
| "cornerGlow"
|
||||
| "floatingGradient"
|
||||
| "gridDots"
|
||||
| "gridLines"
|
||||
| "noise"
|
||||
| "noiseGradient";
|
||||
|
||||
export type HeroBackgroundVariant =
|
||||
| "none"
|
||||
| "lightRaysCenter"
|
||||
| "lightRaysCorner"
|
||||
| "gradientBars"
|
||||
| "radialGradient"
|
||||
| "cornerGlow"
|
||||
| "horizonGlow";
|
||||
|
||||
export interface StyleContextValue {
|
||||
buttonVariant: ButtonVariant;
|
||||
siteBackground: SiteBackgroundVariant;
|
||||
heroBackground: HeroBackgroundVariant;
|
||||
}
|
||||
|
||||
export const DEFAULT_STYLE_VALUE: StyleContextValue = {
|
||||
buttonVariant: "default",
|
||||
siteBackground: "none",
|
||||
heroBackground: "none",
|
||||
};
|
||||
|
||||
export const StyleContext = createContext<StyleContextValue>(DEFAULT_STYLE_VALUE);
|
||||
|
||||
export function useStyle(): StyleContextValue {
|
||||
return useContext(StyleContext);
|
||||
}
|
||||
Reference in New Issue
Block a user