Initial commit
This commit is contained in:
101
src/app/blog/page.tsx
Normal file
101
src/app/blog/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import ReactLenis from "lenis/react";
|
||||
import BlogCardTwo from '@/components/sections/blog/BlogCardTwo';
|
||||
import FooterLogoEmphasis from '@/components/sections/footer/FooterLogoEmphasis';
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import { useBlogPosts } from "@/hooks/useBlogPosts";
|
||||
|
||||
export default function BlogPage() {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Order Now", href: "products" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="w-content-width mx-auto py-20 text-center">
|
||||
<p className="text-foreground">Loading posts...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div id="blog" data-section="blog">
|
||||
<BlogCardTwo
|
||||
blogs={posts}
|
||||
title="Latest Articles"
|
||||
description="Discover insights and updates from our team"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground="noInvert"
|
||||
carouselMode="buttons"
|
||||
animationType="slide-up"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "hero" },
|
||||
{ label: "Shop", href: "products" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
5
src/app/globals.css
Normal file
5
src/app/globals.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/variables.css";
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/utilities.css";
|
||||
@import "./styles/base.css";
|
||||
1268
src/app/layout.tsx
Normal file
1268
src/app/layout.tsx
Normal file
File diff suppressed because it is too large
Load Diff
232
src/app/page.tsx
Normal file
232
src/app/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import HeroBillboardGallery from '@/components/sections/hero/HeroBillboardGallery';
|
||||
import AboutMetric from '@/components/sections/about/AboutMetric';
|
||||
import ProductCardTwo from '@/components/sections/product/ProductCardTwo';
|
||||
import FeatureHoverPattern from '@/components/sections/feature/featureHoverPattern/FeatureHoverPattern';
|
||||
import TestimonialCardTwo from '@/components/sections/testimonial/TestimonialCardTwo';
|
||||
import ContactCenter from '@/components/sections/contact/ContactCenter';
|
||||
import FooterLogoEmphasis from '@/components/sections/footer/FooterLogoEmphasis';
|
||||
import { Award, Flower, Flower2, Gift, Heart, Leaf, Mail, Shield, Sparkles, Truck } from 'lucide-react';
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{
|
||||
text: "Order Now", href: "products"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="hero" data-section="hero">
|
||||
<HeroBillboardGallery
|
||||
title="Discover Fresh & Beautiful Flowers"
|
||||
description="Handpicked blooms delivered fresh to your doorstep. Celebrate every moment with nature's finest arrangements."
|
||||
tag="Premium Florals"
|
||||
tagIcon={Flower2}
|
||||
background={{ variant: "aurora" }}
|
||||
mediaItems={[
|
||||
{
|
||||
imageSrc: "https://img.b2bpic.net/free-photo/bouquet-pink-roses-single-branch-rose-with-green-leaves-inside-vase_114579-1440.jpg", imageAlt: "Elegant rose bouquet arrangement"
|
||||
},
|
||||
{
|
||||
imageSrc: "https://img.b2bpic.net/free-photo/closeup-pink-rose-bouquet_53876-153449.jpg", imageAlt: "Colorful spring tulips"
|
||||
},
|
||||
{
|
||||
imageSrc: "https://img.b2bpic.net/free-photo/wedding-roses-bouquet-stands-chair-around-are-scattered-petals-roses_8353-9798.jpg", imageAlt: "Bright sunflower bunch"
|
||||
},
|
||||
{
|
||||
imageSrc: "https://img.b2bpic.net/free-photo/floral-decor-light-pink-roses-vase-scissors-rope-rose-petals-black_140725-10930.jpg", imageAlt: "Romantic peony arrangement"
|
||||
},
|
||||
{
|
||||
imageSrc: "https://img.b2bpic.net/free-photo/beautiful-bouquet-with-pink-roses-green-leaves_181624-28053.jpg", imageAlt: "Delicate cherry blossom display"
|
||||
}
|
||||
]}
|
||||
buttons={[
|
||||
{ text: "Shop Flowers", href: "products" },
|
||||
{ text: "Learn More", href: "about" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="about" data-section="about">
|
||||
<AboutMetric
|
||||
title="We believe every flower tells a story of beauty, care, and connection. Since 2010, Bloom has been crafting memorable moments through the art of floristry."
|
||||
useInvertedBackground="noInvert"
|
||||
metrics={[
|
||||
{ icon: Heart, label: "Happy Customers", value: "50K+" },
|
||||
{ icon: Flower, label: "Bouquets Created", value: "100K+" },
|
||||
{ icon: Truck, label: "Same-Day Deliveries", value: "95%" },
|
||||
{ icon: Award, label: "Industry Awards", value: "15+" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="products" data-section="products">
|
||||
<ProductCardTwo
|
||||
title="Signature Collections"
|
||||
description="Browse our curated selection of premium floral arrangements perfect for any occasion."
|
||||
textboxLayout="default"
|
||||
animationType="slide-up"
|
||||
useInvertedBackground="noInvert"
|
||||
gridVariant="uniform-all-items-equal"
|
||||
products={[
|
||||
{
|
||||
id: "1", brand: "Bloom Classic", name: "Red Romance Roses", price: "$75.00", rating: 5,
|
||||
reviewCount: "328", imageSrc: "https://img.b2bpic.net/free-photo/portrait-woman-holding-collection-red-roses_23-2148488575.jpg", imageAlt: "Dozen red roses bouquet"
|
||||
},
|
||||
{
|
||||
id: "2", brand: "Bloom Spring", name: "Vibrant Tulip Ensemble", price: "$65.00", rating: 5,
|
||||
reviewCount: "245", imageSrc: "https://img.b2bpic.net/free-photo/side-view-bouquet-red-color-roses-flowers_140725-12105.jpg", imageAlt: "Colorful mixed tulips arrangement"
|
||||
},
|
||||
{
|
||||
id: "3", brand: "Bloom Sunshine", name: "Golden Sunflower Radiance", price: "$55.00", rating: 4,
|
||||
reviewCount: "189", imageSrc: "https://img.b2bpic.net/free-photo/flat-lay-beautifully-bloomed-colorful-rose-flowers_23-2149005546.jpg", imageAlt: "Bright sunflower bouquet"
|
||||
},
|
||||
{
|
||||
id: "4", brand: "Bloom Garden", name: "Wild Meadow Mix", price: "$70.00", rating: 5,
|
||||
reviewCount: "412", imageSrc: "https://img.b2bpic.net/free-photo/rich-bouquet-made-red-roses-stands-outside_8353-8470.jpg", imageAlt: "Natural mixed wildflowers arrangement"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="features" data-section="features">
|
||||
<FeatureHoverPattern
|
||||
title="Why Choose Bloom"
|
||||
description="Experience the difference of premium floristry with our commitment to quality and care."
|
||||
textboxLayout="default"
|
||||
animationType="slide-up"
|
||||
useInvertedBackground="noInvert"
|
||||
features={[
|
||||
{
|
||||
icon: Leaf,
|
||||
title: "Fresh Daily", description: "We source the finest blooms daily from trusted growers worldwide to ensure peak freshness and vibrancy in every arrangement."
|
||||
},
|
||||
{
|
||||
icon: Truck,
|
||||
title: "Fast Delivery", description: "Same-day delivery available for orders placed before noon. We guarantee your flowers arrive fresh and beautiful."
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Quality Guaranteed", description: "Every arrangement is handcrafted by expert florists and backed by our 100% satisfaction guarantee."
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Custom Designs", description: "Work with our designers to create a one-of-a-kind arrangement tailored perfectly to your vision and budget."
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: "Eco-Friendly", description: "We use sustainable practices, recyclable packaging, and support local farms to protect our environment."
|
||||
},
|
||||
{
|
||||
icon: Gift,
|
||||
title: "Perfect Gifts", description: "Surprise your loved ones with our beautifully presented bouquets and personalized gift cards for any occasion."
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="testimonials" data-section="testimonials">
|
||||
<TestimonialCardTwo
|
||||
title="Customer Love"
|
||||
description="See what our happy customers have to say about their Bloom experience."
|
||||
textboxLayout="default"
|
||||
animationType="slide-up"
|
||||
useInvertedBackground="noInvert"
|
||||
testimonials={[
|
||||
{
|
||||
id: "1", name: "Sarah Mitchell", role: "Wedding Planner", testimonial: "Bloom created the most stunning wedding centerpieces for our big day. The attention to detail and freshness was absolutely incredible. Every guest complimented the flowers!", imageSrc: "https://img.b2bpic.net/free-photo/headshot-charismatic-pleasant-friendly-european-woman-short-chestnut-haircut-smiling-positive-feeling-happy-upbeat-enjoying-lifes-casually-talking-friends-amused-cheerful-standing-white-background_176420-34680.jpg", imageAlt: "Sarah Mitchell portrait"
|
||||
},
|
||||
{
|
||||
id: "2", name: "James Chen", role: "Business Owner", testimonial: "I order from Bloom every week for our office. Their arrangements instantly brighten our workspace, and their same-day delivery is reliable and fast. Highly recommended!", imageSrc: "https://img.b2bpic.net/free-photo/caucasion-woman-casual-confident-concept_53876-137678.jpg", imageAlt: "James Chen portrait"
|
||||
},
|
||||
{
|
||||
id: "3", name: "Emily Rodriguez", role: "Event Coordinator", testimonial: "Working with Bloom for our corporate events has been a game-changer. Their custom designs are always on-brand, fresh, and delivered perfectly on time every single time.", imageSrc: "https://img.b2bpic.net/free-photo/smiling-blonde-woman-white_181624-25676.jpg", imageAlt: "Emily Rodriguez portrait"
|
||||
},
|
||||
{
|
||||
id: "4", name: "Michael Thompson", role: "Customer", testimonial: "I sent flowers to my mom on Mother's Day and her reaction was priceless. They arrived the next morning looking absolutely perfect. Bloom is now my go-to for all occasions!", imageSrc: "https://img.b2bpic.net/free-photo/close-up-picture-smiling-woman-black-dress-looking-camera-gray_171337-13347.jpg", imageAlt: "Michael Thompson portrait"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="contact" data-section="contact">
|
||||
<ContactCenter
|
||||
tag="Stay Updated"
|
||||
title="Get Special Offers & Seasonal Collections"
|
||||
description="Subscribe to our newsletter to receive exclusive discounts, seasonal flower arrangements, and florist tips delivered to your inbox."
|
||||
tagIcon={Mail}
|
||||
background={{ variant: "aurora" }}
|
||||
useInvertedBackground="noInvert"
|
||||
inputPlaceholder="Enter your email"
|
||||
buttonText="Subscribe"
|
||||
termsText="We respect your privacy. Unsubscribe at any time. We send updates about 2-3 times per month."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "hero" },
|
||||
{ label: "Shop", href: "products" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
332
src/app/shop/[id]/page.tsx
Normal file
332
src/app/shop/[id]/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ReactLenis from "lenis/react";
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import FooterLogoEmphasis from '@/components/sections/footer/FooterLogoEmphasis';
|
||||
import ProductDetailCard from "@/components/ecommerce/productDetail/ProductDetailCard";
|
||||
import ProductCart from "@/components/ecommerce/cart/ProductCart";
|
||||
import { useProductDetail } from "@/hooks/useProductDetail";
|
||||
import { useCart } from "@/hooks/useCart";
|
||||
import { useCheckout } from "@/hooks/useCheckout";
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ProductPage({ params }: ProductPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
product,
|
||||
isLoading,
|
||||
images,
|
||||
meta,
|
||||
variants,
|
||||
quantityVariant,
|
||||
selectedQuantity,
|
||||
createCartItem,
|
||||
} = useProductDetail(id);
|
||||
|
||||
const {
|
||||
items: cartItems,
|
||||
isOpen: cartOpen,
|
||||
setIsOpen: setCartOpen,
|
||||
addItem,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
total: cartTotal,
|
||||
getCheckoutItems,
|
||||
} = useCart();
|
||||
|
||||
const { buyNow, checkout, isLoading: isCheckoutLoading } = useCheckout();
|
||||
|
||||
const handleAddToCart = useCallback(() => {
|
||||
const item = createCartItem();
|
||||
if (item) {
|
||||
addItem(item);
|
||||
}
|
||||
}, [createCartItem, addItem]);
|
||||
|
||||
const handleBuyNow = useCallback(() => {
|
||||
if (product) {
|
||||
buyNow(product, selectedQuantity);
|
||||
}
|
||||
}, [product, selectedQuantity, buyNow]);
|
||||
|
||||
const handleCheckout = useCallback(async () => {
|
||||
if (cartItems.length === 0) return;
|
||||
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("success", "true");
|
||||
|
||||
await checkout(getCheckoutItems(), { successUrl: currentUrl.toString() });
|
||||
}, [cartItems, checkout, getCheckoutItems]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="navbar" data-section="navbar">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Cart", onClick: () => setCartOpen(true) }}
|
||||
/>
|
||||
</div>
|
||||
<main className="min-h-screen flex items-center justify-center pt-20">
|
||||
<p className="text-foreground">Loading product...</p>
|
||||
</main>
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="navbar" data-section="navbar">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Cart", onClick: () => setCartOpen(true) }}
|
||||
/>
|
||||
</div>
|
||||
<main className="min-h-screen flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground mb-4">Product not found</p>
|
||||
<button
|
||||
onClick={() => router.push("/shop")}
|
||||
className="primary-button px-6 py-2 rounded-theme"
|
||||
>
|
||||
Back to Shop
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="navbar" data-section="navbar">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Cart", onClick: () => setCartOpen(true) }}
|
||||
/>
|
||||
</div>
|
||||
<div id="productDetailCard" data-section="productDetailCard">
|
||||
<ProductDetailCard
|
||||
layout="page"
|
||||
name={product.name}
|
||||
price={product.price}
|
||||
salePrice={meta.salePrice}
|
||||
rating={product.rating || 0}
|
||||
description={product.description}
|
||||
images={images}
|
||||
variants={variants.length > 0 ? variants : undefined}
|
||||
quantity={quantityVariant}
|
||||
ribbon={meta.ribbon}
|
||||
inventoryStatus={meta.inventoryStatus}
|
||||
inventoryQuantity={meta.inventoryQuantity}
|
||||
sku={meta.sku}
|
||||
buttons={[
|
||||
{ text: "Add To Cart", onClick: handleAddToCart },
|
||||
{ text: "Buy Now", onClick: handleBuyNow },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div id="productCart" data-section="productCart">
|
||||
<ProductCart
|
||||
isOpen={cartOpen}
|
||||
onClose={() => setCartOpen(false)}
|
||||
items={cartItems}
|
||||
onQuantityChange={updateQuantity}
|
||||
onRemove={removeItem}
|
||||
total={`$${cartTotal}`}
|
||||
buttons={[
|
||||
{
|
||||
text: isCheckoutLoading ? "Processing..." : "Check Out", onClick: handleCheckout,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
215
src/app/shop/page.tsx
Normal file
215
src/app/shop/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import ReactLenis from "lenis/react";
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import FooterLogoEmphasis from '@/components/sections/footer/FooterLogoEmphasis';
|
||||
import ProductCatalog from "@/components/ecommerce/productCatalog/ProductCatalog";
|
||||
import { useProductCatalog } from "@/hooks/useProductCatalog";
|
||||
import { useState } from "react";
|
||||
import ProductCart from "@/components/ecommerce/cart/ProductCart";
|
||||
import { useCart } from "@/hooks/useCart";
|
||||
import { useCheckout } from "@/hooks/useCheckout";
|
||||
|
||||
export default function ShopPage() {
|
||||
const {
|
||||
products,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
filters,
|
||||
} = useProductCatalog({ basePath: "/shop" });
|
||||
|
||||
const {
|
||||
items: cartItems,
|
||||
isOpen: cartOpen,
|
||||
setIsOpen: setCartOpen,
|
||||
addItem,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
total: cartTotal,
|
||||
getCheckoutItems,
|
||||
} = useCart();
|
||||
|
||||
const { checkout, isLoading: isCheckoutLoading } = useCheckout();
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (cartItems.length === 0) return;
|
||||
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("success", "true");
|
||||
|
||||
await checkout(getCheckoutItems(), { successUrl: currentUrl.toString() });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="navbar" data-section="navbar">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Cart", onClick: () => setCartOpen(true) }}
|
||||
/>
|
||||
</div>
|
||||
<main className="min-h-screen flex items-center justify-center pt-20">
|
||||
<p className="text-foreground">Loading products...</p>
|
||||
</main>
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="text-shift"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLargeSizeMediumTitles"
|
||||
background="aurora"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="radial-glow"
|
||||
headingFontWeight="medium"
|
||||
>
|
||||
<ReactLenis root>
|
||||
<div id="navbar" data-section="navbar">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="Bloom"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "Contact", id: "contact" }
|
||||
]}
|
||||
button={{ text: "Cart", onClick: () => setCartOpen(true) }}
|
||||
/>
|
||||
</div>
|
||||
<div id="productCatalog" data-section="productCatalog">
|
||||
<ProductCatalog
|
||||
layout="page"
|
||||
products={products}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search products..."
|
||||
filters={filters}
|
||||
emptyMessage="No products found"
|
||||
/>
|
||||
</div>
|
||||
<div id="productCart" data-section="productCart">
|
||||
<ProductCart
|
||||
isOpen={cartOpen}
|
||||
onClose={() => setCartOpen(false)}
|
||||
items={cartItems}
|
||||
onQuantityChange={updateQuantity}
|
||||
onRemove={removeItem}
|
||||
total={`$${cartTotal}`}
|
||||
buttons={[
|
||||
{
|
||||
text: isCheckoutLoading ? "Processing..." : "Check Out", onClick: handleCheckout,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterLogoEmphasis
|
||||
logoText="Bloom"
|
||||
columns={[
|
||||
{
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
{ label: "About", href: "about" },
|
||||
{ label: "Contact", href: "contact" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Delivery Info", href: "#" },
|
||||
{ label: "Care Guide", href: "#" },
|
||||
{ label: "Gift Cards", href: "#" },
|
||||
{ label: "Custom Orders", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms & Conditions", href: "#" },
|
||||
{ label: "Shipping Policy", href: "#" },
|
||||
{ label: "Returns", href: "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: "Instagram", href: "https://instagram.com" },
|
||||
{ label: "Facebook", href: "https://facebook.com" },
|
||||
{ label: "Pinterest", href: "https://pinterest.com" },
|
||||
{ label: "TikTok", href: "https://tiktok.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ReactLenis>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
28
src/app/styles/base.css
Normal file
28
src/app/styles/base.css
Normal file
@@ -0,0 +1,28 @@
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 1) rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-open-sans), sans-serif;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-inter), sans-serif;
|
||||
}
|
||||
174
src/app/styles/theme.css
Normal file
174
src/app/styles/theme.css
Normal file
@@ -0,0 +1,174 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-primary-cta: var(--primary-cta);
|
||||
--color-secondary-cta: var(--secondary-cta);
|
||||
--color-accent: var(--accent);
|
||||
--color-background-accent: var(--background-accent);
|
||||
|
||||
/* theme border radius */
|
||||
--radius-theme: var(--theme-border-radius);
|
||||
--radius-theme-capped: var(--theme-border-radius-capped);
|
||||
|
||||
/* text */
|
||||
--text-2xs: var(--text-2xs);
|
||||
--text-xs: var(--text-xs);
|
||||
--text-sm: var(--text-sm);
|
||||
--text-base: var(--text-base);
|
||||
--text-lg: var(--text-lg);
|
||||
--text-xl: var(--text-xl);
|
||||
--text-2xl: var(--text-2xl);
|
||||
--text-3xl: var(--text-3xl);
|
||||
--text-4xl: var(--text-4xl);
|
||||
--text-5xl: var(--text-5xl);
|
||||
--text-6xl: var(--text-6xl);
|
||||
--text-7xl: var(--text-7xl);
|
||||
--text-8xl: var(--text-8xl);
|
||||
--text-9xl: var(--text-9xl);
|
||||
|
||||
/* height */
|
||||
--height-4: var(--height-4);
|
||||
--height-5: var(--height-5);
|
||||
--height-6: var(--height-6);
|
||||
--height-7: var(--height-7);
|
||||
--height-8: var(--height-8);
|
||||
--height-9: var(--height-9);
|
||||
--height-11: var(--height-11);
|
||||
--height-12: var(--height-12);
|
||||
|
||||
--height-10: var(--height-10);
|
||||
--height-30: var(--height-30);
|
||||
--height-90: var(--height-90);
|
||||
--height-100: var(--height-100);
|
||||
--height-110: var(--height-110);
|
||||
--height-120: var(--height-120);
|
||||
--height-130: var(--height-130);
|
||||
--height-140: var(--height-140);
|
||||
--height-150: var(--height-150);
|
||||
|
||||
--height-page-padding: calc(2.25rem+var(--vw-1_5)+var(--vw-1_5));
|
||||
|
||||
/* width */
|
||||
--width-5: var(--width-5);
|
||||
--width-7_5: var(--width-7_5);
|
||||
--width-10: var(--width-10);
|
||||
--width-12_5: var(--width-12_5);
|
||||
--width-15: var(--width-15);
|
||||
--width-17: var(--width-17);
|
||||
--width-17_5: var(--width-17_5);
|
||||
--width-20: var(--width-20);
|
||||
--width-21: var(--width-21);
|
||||
--width-22_5: var(--width-22_5);
|
||||
--width-25: var(--width-25);
|
||||
--width-26: var(--width-26);
|
||||
--width-27_5: var(--width-27_5);
|
||||
--width-30: var(--width-30);
|
||||
--width-32_5: var(--width-32_5);
|
||||
--width-35: var(--width-35);
|
||||
--width-37_5: var(--width-37_5);
|
||||
--width-40: var(--width-40);
|
||||
--width-42_5: var(--width-42_5);
|
||||
--width-45: var(--width-45);
|
||||
--width-47_5: var(--width-47_5);
|
||||
--width-50: var(--width-50);
|
||||
--width-52_5: var(--width-52_5);
|
||||
--width-55: var(--width-55);
|
||||
--width-57_5: var(--width-57_5);
|
||||
--width-60: var(--width-60);
|
||||
--width-62_5: var(--width-62_5);
|
||||
--width-65: var(--width-65);
|
||||
--width-67_5: var(--width-67_5);
|
||||
--width-70: var(--width-70);
|
||||
--width-72_5: var(--width-72_5);
|
||||
--width-75: var(--width-75);
|
||||
--width-77_5: var(--width-77_5);
|
||||
--width-80: var(--width-80);
|
||||
--width-82_5: var(--width-82_5);
|
||||
--width-85: var(--width-85);
|
||||
--width-87_5: var(--width-87_5);
|
||||
--width-90: var(--width-90);
|
||||
--width-92_5: var(--width-92_5);
|
||||
--width-95: var(--width-95);
|
||||
--width-97_5: var(--width-97_5);
|
||||
--width-100: var(--width-100);
|
||||
--width-content-width: var(--width-content-width);
|
||||
--width-carousel-padding: var(--width-carousel-padding);
|
||||
--width-carousel-padding-controls: var(--width-carousel-padding-controls);
|
||||
--width-carousel-padding-expanded: var(--width-carousel-padding-expanded);
|
||||
--width-carousel-padding-controls-expanded: var(--width-carousel-padding-controls-expanded);
|
||||
--width-carousel-item-3: var(--width-carousel-item-3);
|
||||
--width-carousel-item-4: var(--width-carousel-item-4);
|
||||
--width-x-padding-mask-fade: var(--width-x-padding-mask-fade);
|
||||
--width-content-width-expanded: var(--width-content-width-expanded);
|
||||
|
||||
/* gap */
|
||||
--spacing-1: var(--vw-0_25);
|
||||
--spacing-2: var(--vw-0_5);
|
||||
--spacing-3: var(--vw-0_75);
|
||||
--spacing-4: var(--vw-1);
|
||||
--spacing-5: var(--vw-1_25);
|
||||
--spacing-6: var(--vw-1_5);
|
||||
--spacing-7: var(--vw-1_75);
|
||||
--spacing-8: var(--vw-2);
|
||||
|
||||
--spacing-x-1: var(--vw-0_25);
|
||||
--spacing-x-2: var(--vw-0_5);
|
||||
--spacing-x-3: var(--vw-0_75);
|
||||
--spacing-x-4: var(--vw-1);
|
||||
--spacing-x-5: var(--vw-1_25);
|
||||
--spacing-x-6: var(--vw-1_5);
|
||||
|
||||
/* border radius */
|
||||
--radius-none: 0;
|
||||
--radius-sm: var(--vw-0_5);
|
||||
--radius: var(--vw-0_75);
|
||||
--radius-md: var(--vw-1);
|
||||
--radius-lg: var(--vw-1_25);
|
||||
--radius-xl: var(--vw-1_75);
|
||||
--radius-full: 999px;
|
||||
|
||||
/* padding */
|
||||
--padding-1: var(--vw-0_25);
|
||||
--padding-2: var(--vw-0_5);
|
||||
--padding-2.5: var(--vw-0_625);
|
||||
--padding-3: var(--vw-0_75);
|
||||
--padding-4: var(--vw-1);
|
||||
--padding-5: var(--vw-1_25);
|
||||
--padding-6: var(--vw-1_5);
|
||||
--padding-7: var(--vw-1_75);
|
||||
--padding-8: var(--vw-2);
|
||||
|
||||
--padding-x-1: var(--vw-0_25);
|
||||
--padding-x-2: var(--vw-0_5);
|
||||
--padding-x-3: var(--vw-0_75);
|
||||
--padding-x-4: var(--vw-1);
|
||||
--padding-x-5: var(--vw-1_25);
|
||||
--padding-x-6: var(--vw-1_5);
|
||||
--padding-x-7: var(--vw-1_75);
|
||||
--padding-x-8: var(--vw-2);
|
||||
|
||||
--padding-hero-page-padding-half: var(--padding-hero-page-padding-half);
|
||||
--padding-hero-page-padding: var(--padding-hero-page-padding);
|
||||
--padding-hero-page-padding-1_5: var(--padding-hero-page-padding-1_5);
|
||||
--padding-hero-page-padding-double: var(--padding-hero-page-padding-double);
|
||||
|
||||
/* margin */
|
||||
--margin-1: var(--vw-0_25);
|
||||
--margin-2: var(--vw-0_5);
|
||||
--margin-3: var(--vw-0_75);
|
||||
--margin-4: var(--vw-1);
|
||||
--margin-5: var(--vw-1_25);
|
||||
--margin-6: var(--vw-1_5);
|
||||
--margin-7: var(--vw-1_75);
|
||||
--margin-8: var(--vw-2);
|
||||
|
||||
--margin-x-1: var(--vw-0_25);
|
||||
--margin-x-2: var(--vw-0_5);
|
||||
--margin-x-3: var(--vw-0_75);
|
||||
--margin-x-4: var(--vw-1);
|
||||
--margin-x-5: var(--vw-1_25);
|
||||
--margin-x-6: var(--vw-1_5);
|
||||
--margin-x-7: var(--vw-1_75);
|
||||
--margin-x-8: var(--vw-2);
|
||||
}
|
||||
178
src/app/styles/utilities.css
Normal file
178
src/app/styles/utilities.css
Normal file
@@ -0,0 +1,178 @@
|
||||
@layer components {}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Card, primary-button, and secondary-button styles are now dynamically injected via ThemeProvider */
|
||||
|
||||
/* .card {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply bg-gradient-to-b from-primary-cta/83 to-primary-cta;
|
||||
box-shadow:
|
||||
color-mix(in srgb, var(--color-background) 25%, transparent) 0px 1px 1px 0px inset,
|
||||
color-mix(in srgb, var(--color-primary-cta) 15%, transparent) 3px 3px 3px 0px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-secondary-cta/80 to-secondary-cta shadow-sm border border-secondary-cta;
|
||||
} */
|
||||
|
||||
.tag-card {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
|
||||
}
|
||||
|
||||
.mask-fade-x {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
|
||||
}
|
||||
|
||||
.mask-padding-x {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black var(--width-x-padding-mask-fade), black calc(100% - var(--width-x-padding-mask-fade)), transparent 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0%, black var(--width-x-padding-mask-fade), black calc(100% - var(--width-x-padding-mask-fade)), transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-y {
|
||||
mask-image: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
black var(--vw-1_5),
|
||||
black calc(100% - var(--vw-1_5)),
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom-large {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom-long {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-top-long {
|
||||
-webkit-mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
|
||||
mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-xy {
|
||||
-webkit-mask-image:
|
||||
linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
|
||||
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
mask-image:
|
||||
linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
|
||||
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
/* ANIMATION */
|
||||
|
||||
.animation-container {
|
||||
animation:
|
||||
fadeInOpacity 0.8s ease-in-out forwards,
|
||||
fadeInTranslate 0.6s forwards;
|
||||
}
|
||||
|
||||
.animation-container-fade {
|
||||
animation: fadeInOpacity 0.8s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInOpacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInTranslate {
|
||||
from {
|
||||
transform: translateY(0.75vh);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
from {
|
||||
background-position: 50% 50%, 50% 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 350% 50%, 350% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee-vertical {
|
||||
animation: marquee-vertical 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from {
|
||||
transform: rotate(var(--initial-position, 0deg)) translateX(var(--translate-position, 120px)) rotate(calc(-1 * var(--initial-position, 0deg)));
|
||||
}
|
||||
to {
|
||||
transform: rotate(calc(var(--initial-position, 0deg) + 360deg)) translateX(var(--translate-position, 120px)) rotate(calc(-1 * (var(--initial-position, 0deg) + 360deg)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-dot-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
215
src/app/styles/variables.css
Normal file
215
src/app/styles/variables.css
Normal file
@@ -0,0 +1,215 @@
|
||||
:root {
|
||||
/* Base units */
|
||||
/* --vw is set by ThemeProvider */
|
||||
|
||||
/* --background: #f7f6f7;;
|
||||
--card: #ffffff;;
|
||||
--foreground: #032419;;
|
||||
--primary-cta: #2bb889;;
|
||||
--secondary-cta: #ffffff;;
|
||||
--accent: #09b97e;;
|
||||
--background-accent: #a8e8c3;; */
|
||||
|
||||
--background: #f7f6f7;;
|
||||
--card: #ffffff;;
|
||||
--foreground: #032419;;
|
||||
--primary-cta: #2bb889;;
|
||||
--secondary-cta: #ffffff;;
|
||||
--accent: #09b97e;;
|
||||
--background-accent: #a8e8c3;;
|
||||
|
||||
/* text sizing - set by ThemeProvider */
|
||||
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
||||
--text-xs: clamp(0.54rem, 0.72vw, 0.72rem);
|
||||
--text-sm: clamp(0.615rem, 0.82vw, 0.82rem);
|
||||
--text-base: clamp(0.69rem, 0.92vw, 0.92rem);
|
||||
--text-lg: clamp(0.75rem, 1vw, 1rem);
|
||||
--text-xl: clamp(0.825rem, 1.1vw, 1.1rem);
|
||||
--text-2xl: clamp(0.975rem, 1.3vw, 1.3rem);
|
||||
--text-3xl: clamp(1.2rem, 1.6vw, 1.6rem);
|
||||
--text-4xl: clamp(1.5rem, 2vw, 2rem);
|
||||
--text-5xl: clamp(2.025rem, 2.75vw, 2.75rem);
|
||||
--text-6xl: clamp(2.475rem, 3.3vw, 3.3rem);
|
||||
--text-7xl: clamp(3rem, 4vw, 4rem);
|
||||
--text-8xl: clamp(3.5rem, 4.5vw, 4.5rem);
|
||||
--text-9xl: clamp(5.25rem, 7vw, 7rem); */
|
||||
|
||||
/* Base spacing units */
|
||||
--vw-0_25: calc(var(--vw) * 0.25);
|
||||
--vw-0_5: calc(var(--vw) * 0.5);
|
||||
--vw-0_625: calc(var(--vw) * 0.625);
|
||||
--vw-0_75: calc(var(--vw) * 0.75);
|
||||
--vw-1: calc(var(--vw) * 1);
|
||||
--vw-1_25: calc(var(--vw) * 1.25);
|
||||
--vw-1_5: calc(var(--vw) * 1.5);
|
||||
--vw-1_75: calc(var(--vw) * 1.75);
|
||||
--vw-2: calc(var(--vw) * 2);
|
||||
--vw-2_25: calc(var(--vw) * 2.25);
|
||||
--vw-2_5: calc(var(--vw) * 2.5);
|
||||
--vw-2_75: calc(var(--vw) * 2.75);
|
||||
--vw-3: calc(var(--vw) * 3);
|
||||
|
||||
/* width */
|
||||
--width-5: clamp(4rem, 5vw, 6rem);
|
||||
--width-7_5: clamp(5.625rem, 7.5vw, 7.5rem);
|
||||
--width-10: clamp(7.5rem, 10vw, 10rem);
|
||||
--width-12_5: clamp(9.375rem, 12.5vw, 12.5rem);
|
||||
--width-15: clamp(11.25rem, 15vw, 15rem);
|
||||
--width-17: clamp(12.75rem, 17vw, 17rem);
|
||||
--width-17_5: clamp(13.125rem, 17.5vw, 17.5rem);
|
||||
--width-20: clamp(15rem, 20vw, 20rem);
|
||||
--width-21: clamp(15.75rem, 21vw, 21rem);
|
||||
--width-22_5: clamp(16.875rem, 22.5vw, 22.5rem);
|
||||
--width-25: clamp(18.75rem, 25vw, 25rem);
|
||||
--width-26: clamp(19.5rem, 26vw, 26rem);
|
||||
--width-27_5: clamp(20.625rem, 27.5vw, 27.5rem);
|
||||
--width-30: clamp(22.5rem, 30vw, 30rem);
|
||||
--width-32_5: clamp(24.375rem, 32.5vw, 32.5rem);
|
||||
--width-35: clamp(26.25rem, 35vw, 35rem);
|
||||
--width-37_5: clamp(28.125rem, 37.5vw, 37.5rem);
|
||||
--width-40: clamp(30rem, 40vw, 40rem);
|
||||
--width-42_5: clamp(31.875rem, 42.5vw, 42.5rem);
|
||||
--width-45: clamp(33.75rem, 45vw, 45rem);
|
||||
--width-47_5: clamp(35.625rem, 47.5vw, 47.5rem);
|
||||
--width-50: clamp(37.5rem, 50vw, 50rem);
|
||||
--width-52_5: clamp(39.375rem, 52.5vw, 52.5rem);
|
||||
--width-55: clamp(41.25rem, 55vw, 55rem);
|
||||
--width-57_5: clamp(43.125rem, 57.5vw, 57.5rem);
|
||||
--width-60: clamp(45rem, 60vw, 60rem);
|
||||
--width-62_5: clamp(46.875rem, 62.5vw, 62.5rem);
|
||||
--width-65: clamp(48.75rem, 65vw, 65rem);
|
||||
--width-67_5: clamp(50.625rem, 67.5vw, 67.5rem);
|
||||
--width-70: clamp(52.5rem, 70vw, 70rem);
|
||||
--width-72_5: clamp(54.375rem, 72.5vw, 72.5rem);
|
||||
--width-75: clamp(56.25rem, 75vw, 75rem);
|
||||
--width-77_5: clamp(58.125rem, 77.5vw, 77.5rem);
|
||||
--width-80: clamp(60rem, 80vw, 80rem);
|
||||
--width-82_5: clamp(61.875rem, 82.5vw, 82.5rem);
|
||||
--width-85: clamp(63.75rem, 85vw, 85rem);
|
||||
--width-87_5: clamp(65.625rem, 87.5vw, 87.5rem);
|
||||
--width-90: clamp(67.5rem, 90vw, 90rem);
|
||||
--width-92_5: clamp(69.375rem, 92.5vw, 92.5rem);
|
||||
--width-95: clamp(71.25rem, 95vw, 95rem);
|
||||
--width-97_5: clamp(73.125rem, 97.5vw, 97.5rem);
|
||||
--width-100: clamp(75rem, 100vw, 100rem);
|
||||
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
|
||||
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-item-3: calc(var(--width-content-width) / 3 - var(--vw-1_5) / 3 * 2);
|
||||
--width-carousel-item-4: calc(var(--width-content-width) / 4 - var(--vw-1_5) / 4 * 3);
|
||||
--width-x-padding-mask-fade: clamp(1.5rem, 4vw, 4rem);
|
||||
|
||||
--height-4: 1rem;
|
||||
--height-5: 1.25rem;
|
||||
--height-6: 1.5rem;
|
||||
--height-7: 1.75rem;
|
||||
--height-8: 2rem;
|
||||
--height-9: 2.25rem;
|
||||
--height-10: 2.5rem;
|
||||
--height-11: 2.75rem;
|
||||
--height-12: 3rem;
|
||||
--height-30: 7.5rem;
|
||||
--height-90: 22.5rem;
|
||||
--height-100: 25rem;
|
||||
--height-110: 27.5rem;
|
||||
--height-120: 30rem;
|
||||
--height-130: 32.5rem;
|
||||
--height-140: 35rem;
|
||||
--height-150: 37.5rem;
|
||||
|
||||
/* hero page padding */
|
||||
--padding-hero-page-padding-half: calc((var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)) / 2);
|
||||
--padding-hero-page-padding: calc(var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10));
|
||||
--padding-hero-page-padding-1_5: calc(1.5 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
|
||||
--padding-hero-page-padding-double: calc(2 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
/* --vw and text sizing are set by ThemeProvider */
|
||||
/* --vw: 3vw;
|
||||
|
||||
--text-2xs: 2.5vw;
|
||||
--text-xs: 2.75vw;
|
||||
--text-sm: 3vw;
|
||||
--text-base: 3.25vw;
|
||||
--text-lg: 3.5vw;
|
||||
--text-xl: 4.25vw;
|
||||
--text-2xl: 5vw;
|
||||
--text-3xl: 6vw;
|
||||
--text-4xl: 7vw;
|
||||
--text-5xl: 7.5vw;
|
||||
--text-6xl: 8.5vw;
|
||||
--text-7xl: 10vw;
|
||||
--text-8xl: 12vw;
|
||||
--text-9xl: 14vw; */
|
||||
|
||||
--width-5: 5vw;
|
||||
--width-7_5: 7.5vw;
|
||||
--width-10: 10vw;
|
||||
--width-12_5: 12.5vw;
|
||||
--width-15: 15vw;
|
||||
--width-17_5: 17.5vw;
|
||||
--width-20: 20vw;
|
||||
--width-22_5: 22.5vw;
|
||||
--width-25: 25vw;
|
||||
--width-27_5: 27.5vw;
|
||||
--width-30: 30vw;
|
||||
--width-32_5: 32.5vw;
|
||||
--width-35: 35vw;
|
||||
--width-37_5: 37.5vw;
|
||||
--width-40: 40vw;
|
||||
--width-42_5: 42.5vw;
|
||||
--width-45: 45vw;
|
||||
--width-47_5: 47.5vw;
|
||||
--width-50: 50vw;
|
||||
--width-52_5: 52.5vw;
|
||||
--width-55: 55vw;
|
||||
--width-57_5: 57.5vw;
|
||||
--width-60: 60vw;
|
||||
--width-62_5: 62.5vw;
|
||||
--width-65: 65vw;
|
||||
--width-67_5: 67.5vw;
|
||||
--width-70: 70vw;
|
||||
--width-72_5: 72.5vw;
|
||||
--width-75: 75vw;
|
||||
--width-77_5: 77.5vw;
|
||||
--width-80: 80vw;
|
||||
--width-82_5: 82.5vw;
|
||||
--width-85: 85vw;
|
||||
--width-87_5: 87.5vw;
|
||||
--width-90: 90vw;
|
||||
--width-92_5: 92.5vw;
|
||||
--width-95: 95vw;
|
||||
--width-97_5: 97.5vw;
|
||||
--width-100: 100vw;
|
||||
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
|
||||
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-item-3: var(--width-content-width);
|
||||
--width-carousel-item-4: var(--width-content-width);
|
||||
--width-x-padding-mask-fade: 10vw;
|
||||
|
||||
--height-4: 3.5vw;
|
||||
--height-5: 4.5vw;
|
||||
--height-6: 5.5vw;
|
||||
--height-7: 6.5vw;
|
||||
--height-8: 7.5vw;
|
||||
--height-9: 8.5vw;
|
||||
--height-10: 9vw;
|
||||
--height-11: 10vw;
|
||||
--height-12: 11vw;
|
||||
--height-30: 25vw;
|
||||
--height-90: 81vw;
|
||||
--height-100: 90vw;
|
||||
--height-110: 99vw;
|
||||
--height-120: 108vw;
|
||||
--height-130: 117vw;
|
||||
--height-140: 126vw;
|
||||
--height-150: 135vw;
|
||||
}
|
||||
}
|
||||
146
src/components/Accordion.tsx
Normal file
146
src/components/Accordion.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
|
||||
interface AccordionProps {
|
||||
index: number;
|
||||
isActive?: boolean;
|
||||
onToggle?: (index: number) => void;
|
||||
title: string;
|
||||
content: string;
|
||||
animationType?: "smooth" | "instant";
|
||||
showCard?: boolean;
|
||||
useInvertedBackground?: "noInvert" | "invertDefault";
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
iconContainerClassName?: string;
|
||||
iconClassName?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const Accordion = ({
|
||||
index,
|
||||
isActive: controlledIsActive,
|
||||
onToggle,
|
||||
title,
|
||||
content,
|
||||
animationType = "smooth",
|
||||
showCard = true,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
iconContainerClassName = "",
|
||||
iconClassName = "",
|
||||
contentClassName = "",
|
||||
}: AccordionProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState("0px");
|
||||
const [internalIsActive, setInternalIsActive] = useState(false);
|
||||
|
||||
const isActive = controlledIsActive !== undefined ? controlledIsActive : internalIsActive;
|
||||
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = showCard
|
||||
? shouldUseInvertedText(useInvertedBackground, theme.cardStyle)
|
||||
: useInvertedBackground;
|
||||
|
||||
useEffect(() => {
|
||||
if (animationType === "smooth") {
|
||||
setHeight(isActive ? `${contentRef.current?.scrollHeight}px` : "0px");
|
||||
}
|
||||
}, [isActive, animationType]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (controlledIsActive === undefined) {
|
||||
setInternalIsActive(!internalIsActive);
|
||||
}
|
||||
if (onToggle) {
|
||||
onToggle(index);
|
||||
}
|
||||
}, [controlledIsActive, internalIsActive, onToggle, index]);
|
||||
|
||||
const headerContent = (
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<h2
|
||||
className={cls(
|
||||
"text-base md:text-xl font-medium",
|
||||
shouldUseLightText ? "text-background" : "text-foreground",
|
||||
animationType === "instant" && "text-left",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className={cls(
|
||||
"h-8 aspect-square flex items-center justify-center rounded-theme primary-button transition-all duration-300",
|
||||
iconContainerClassName
|
||||
)}
|
||||
>
|
||||
<Plus
|
||||
className={cls(
|
||||
"w-4/10 aspect-square text-background",
|
||||
animationType === "smooth" ? "transition-transform duration-500" : "transition-transform duration-300",
|
||||
isActive && "rotate-45",
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const contentElement = (
|
||||
<div
|
||||
className={cls(
|
||||
"text-base",
|
||||
shouldUseLightText ? "text-background" : "text-foreground",
|
||||
animationType === "smooth" && "pt-2",
|
||||
contentClassName
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (animationType === "instant") {
|
||||
return (
|
||||
<div className={cls(showCard && "card rounded-theme", className)}>
|
||||
<button
|
||||
className={cls("cursor-pointer flex flex-row items-center justify-between w-full transition-all duration-300 group", showCard && "p-4")}
|
||||
onClick={handleClick}
|
||||
aria-expanded={isActive}
|
||||
>
|
||||
{headerContent}
|
||||
</button>
|
||||
{isActive && <div className={cls(showCard && "px-4 pb-4")}>{contentElement}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
showCard ? "card p-4 rounded-theme-capped" : "",
|
||||
"cursor-pointer flex flex-col items-center justify-between transition-all duration-500 group",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
aria-expanded={isActive}
|
||||
>
|
||||
{headerContent}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{ maxHeight: height }}
|
||||
className="overflow-hidden transition-[max-height] duration-500 w-full flex flex-col"
|
||||
>
|
||||
{contentElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Accordion.displayName = "Accordion";
|
||||
|
||||
export default memo(Accordion);
|
||||
22
src/components/ServiceWrapper.tsx
Normal file
22
src/components/ServiceWrapper.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
|
||||
export function ServiceWrapper({ children }: { children: React.ReactNode }) {
|
||||
const websiteId = process.env.NEXT_PUBLIC_WEBSITE_ANALYTICS_ID;
|
||||
|
||||
return (
|
||||
<>
|
||||
{websiteId && (
|
||||
<Script
|
||||
async
|
||||
defer
|
||||
data-website-id={websiteId}
|
||||
src="https://analytics.webild.io/script.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
294
src/components/Textbox.tsx
Normal file
294
src/components/Textbox.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import TextAnimation from "./text/TextAnimation";
|
||||
import Button from "./button/Button";
|
||||
import Tag from "./shared/Tag";
|
||||
import AvatarGroup from "./shared/AvatarGroup";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import type { AnimationType } from "./text/types";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig } from "@/types/button";
|
||||
import type { Avatar } from "./shared/AvatarGroup";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type TitleSegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "image"; src: string; alt?: string };
|
||||
|
||||
interface TextBoxProps {
|
||||
title: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description: string;
|
||||
type?: AnimationType;
|
||||
textboxLayout?: TextboxLayout;
|
||||
useInvertedBackground?: InvertedBackground;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
titleImageWrapperClassName?: string;
|
||||
titleImageClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
duration?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
gradientColors?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
center?: boolean;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
tagClassName?: string;
|
||||
buttons?: ButtonConfig[];
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
avatars?: Avatar[];
|
||||
avatarText?: string;
|
||||
avatarGroupClassName?: string;
|
||||
avatarsAbove?: boolean;
|
||||
}
|
||||
|
||||
const TextBox = ({
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
type,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
duration = 1,
|
||||
start = "top 80%",
|
||||
end = "top 20%",
|
||||
gradientColors,
|
||||
children,
|
||||
center = false,
|
||||
tag,
|
||||
tagIcon: TagIcon,
|
||||
tagClassName = "",
|
||||
buttons,
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
avatars,
|
||||
avatarText,
|
||||
avatarGroupClassName = "",
|
||||
avatarsAbove = false,
|
||||
}: TextBoxProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Shared tag component
|
||||
const tagElement = useMemo(() => tag && (
|
||||
<Tag
|
||||
text={tag}
|
||||
icon={TagIcon}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={cls(textboxLayout === "default" && "mb-3", tagClassName)}
|
||||
/>
|
||||
), [tag, TagIcon, useInvertedBackground, textboxLayout, tagClassName]);
|
||||
|
||||
// Shared title component
|
||||
const titleElement = useMemo(() => (
|
||||
<TextAnimation
|
||||
type={type || theme.defaultTextAnimation}
|
||||
text={title}
|
||||
variant="trigger"
|
||||
as="h2"
|
||||
className={cls(
|
||||
textboxLayout === "split" || textboxLayout === "split-actions" || textboxLayout === "split-description" ? "text-7xl font-medium text-balance" : "text-6xl font-medium",
|
||||
center && textboxLayout === "default" && "text-center",
|
||||
useInvertedBackground === "invertDefault" && "text-background",
|
||||
titleClassName
|
||||
)}
|
||||
duration={duration}
|
||||
start={start}
|
||||
end={end}
|
||||
gradientColors={gradientColors}
|
||||
/>
|
||||
), [type, theme.defaultTextAnimation, title, textboxLayout, center, useInvertedBackground, titleClassName, duration, start, end, gradientColors]);
|
||||
|
||||
// Inline image title component (used when textboxLayout === "inline-image")
|
||||
const inlineImageTitleElement = useMemo(() => titleSegments && titleSegments.length > 0 ? (
|
||||
<h2
|
||||
className={cls(
|
||||
"text-4xl md:text-5xl font-medium text-center leading-[1.15] text-balance",
|
||||
useInvertedBackground === "invertDefault" && "text-background",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{titleSegments.map((segment, index) => {
|
||||
const imageIndex = titleSegments
|
||||
.slice(0, index + 1)
|
||||
.filter(s => s.type === "image").length - 1;
|
||||
|
||||
const element = segment.type === "text" ? (
|
||||
<span key={index}>{segment.content}</span>
|
||||
) : (
|
||||
<span
|
||||
key={index}
|
||||
className={cls(
|
||||
"inline-block relative primary-button -mt-[0.2em] h-[1.1em] w-auto aspect-square align-middle mx-1 p-0.5 rounded-theme",
|
||||
imageIndex % 2 === 0 ? "-rotate-12" : "rotate-12",
|
||||
titleImageWrapperClassName
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={segment.src}
|
||||
alt={segment.alt || ""}
|
||||
width={24}
|
||||
height={24}
|
||||
className={cls(
|
||||
"absolute inset-0 m-auto h-full w-full rounded-theme",
|
||||
titleImageClassName
|
||||
)}
|
||||
unoptimized={segment.src.startsWith("http") || segment.src.startsWith("//")}
|
||||
aria-hidden={!segment.alt || segment.alt === ""}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span key={index}>
|
||||
{index > 0 && " "}
|
||||
{element}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</h2>
|
||||
) : null, [titleSegments, useInvertedBackground, titleClassName, titleImageWrapperClassName, titleImageClassName]);
|
||||
|
||||
// Shared description component
|
||||
const descriptionElement = useMemo(() => (
|
||||
<TextAnimation
|
||||
type={type || theme.defaultTextAnimation}
|
||||
text={description}
|
||||
variant="words-trigger"
|
||||
as="p"
|
||||
className={cls(
|
||||
"text-lg leading-[1.2]",
|
||||
center && textboxLayout === "default" && "text-center",
|
||||
(textboxLayout === "split" || textboxLayout === "split-description") && "text-balance",
|
||||
useInvertedBackground === "invertDefault" && "text-background",
|
||||
descriptionClassName
|
||||
)}
|
||||
duration={duration}
|
||||
start={start}
|
||||
end={end}
|
||||
gradientColors={gradientColors}
|
||||
/>
|
||||
), [type, theme.defaultTextAnimation, description, center, textboxLayout, useInvertedBackground, descriptionClassName, duration, start, end, gradientColors]);
|
||||
|
||||
// Shared avatars component
|
||||
const avatarsElement = useMemo(() => avatars && avatars.length > 0 ? (
|
||||
<AvatarGroup
|
||||
avatars={avatars}
|
||||
text={avatarText}
|
||||
className={cls(
|
||||
textboxLayout === "default" && !avatarsAbove && "mt-3",
|
||||
textboxLayout === "default" && avatarsAbove && "mb-3",
|
||||
center && textboxLayout === "default" && "justify-center",
|
||||
avatarGroupClassName
|
||||
)}
|
||||
/>
|
||||
) : null, [avatars, avatarText, textboxLayout, center, avatarGroupClassName, avatarsAbove]);
|
||||
|
||||
// Shared buttons/children component
|
||||
const actionsElement = useMemo(() => buttons && buttons.length > 0 ? (
|
||||
<div className={cls(
|
||||
"flex flex-wrap gap-4",
|
||||
textboxLayout === "default" && "w-full mt-3",
|
||||
(textboxLayout === "split" || textboxLayout === "split-actions") && "w-fit",
|
||||
center && textboxLayout === "default" && "justify-center",
|
||||
buttonContainerClassName
|
||||
)}>
|
||||
{/* Limit to 2 buttons for optimal layout */}
|
||||
{buttons.slice(0, 2).map((button, index) => (
|
||||
<Button key={`${button.text}-${index}`} {...getButtonProps(button, index, theme.defaultButtonVariant, buttonClassName, buttonTextClassName)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
), [buttons, textboxLayout, center, buttonContainerClassName, theme.defaultButtonVariant, buttonClassName, buttonTextClassName, children]);
|
||||
|
||||
// Split layout
|
||||
if (textboxLayout === "split") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{actionsElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split actions layout - tag and buttons required, no description
|
||||
if (textboxLayout === "split-actions") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{actionsElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split description layout - tag + title left, description only right (no buttons)
|
||||
if (textboxLayout === "split-description") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{descriptionElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline image layout - centered heading with inline images and optional buttons
|
||||
if (textboxLayout === "inline-image") {
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
|
||||
{inlineImageTitleElement}
|
||||
{actionsElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default layout
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
|
||||
{avatarsAbove && avatarsElement}
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
{actionsElement}
|
||||
{!avatarsAbove && avatarsElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TextBox.displayName = "TextBox";
|
||||
|
||||
export default memo(TextBox);
|
||||
44
src/components/background/AnimatedAuroraBackground.tsx
Normal file
44
src/components/background/AnimatedAuroraBackground.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AnimatedAuroraBackgroundProps {
|
||||
className?: string;
|
||||
showRadialGradient?: boolean;
|
||||
/**
|
||||
* Inverts the aurora colors for better visibility.
|
||||
* Use `true` for light backgrounds (makes aurora darker/inverted)
|
||||
* Use `false` for dark backgrounds (keeps aurora colors vibrant)
|
||||
*/
|
||||
invertColors: boolean;
|
||||
}
|
||||
|
||||
const AnimatedAuroraBackground = ({
|
||||
className,
|
||||
showRadialGradient = true,
|
||||
invertColors,
|
||||
}: AnimatedAuroraBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden opacity-30">
|
||||
<div
|
||||
className={cls(
|
||||
"[--base-gradient:repeating-linear-gradient(100deg,var(--background)_0%,var(--background)_7%,transparent_10%,transparent_12%,var(--background)_16%)] [--aurora:repeating-linear-gradient(100deg,var(--color-primary-cta)_10%,var(--color-accent)_15%,var(--color-secondary-cta)_20%,var(--color-accent)_25%,var(--color-primary-cta)_30%)] [background-image:var(--base-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] filter blur-[10px] after:content-[''] after:absolute after:inset-0 after:[background-image:var(--base-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[animation:aurora_60s_linear_infinite] after:[background-attachment:fixed] after:mix-blend-difference pointer-events-none absolute -inset-[10px] opacity-30 will-change-transform",
|
||||
invertColors && "invert",
|
||||
showRadialGradient && "[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]"
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AnimatedAuroraBackground.displayName = "AnimatedAuroraBackground";
|
||||
|
||||
export default memo(AnimatedAuroraBackground);
|
||||
112
src/components/background/AnimatedGridBackground.tsx
Normal file
112
src/components/background/AnimatedGridBackground.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AnimatedGridBackgroundProps {
|
||||
className?: string;
|
||||
squareSize?: number;
|
||||
numSquares?: number;
|
||||
maxOpacity?: number;
|
||||
}
|
||||
|
||||
const AnimatedGridBackground = ({
|
||||
className = "",
|
||||
squareSize = 100,
|
||||
numSquares = 50,
|
||||
maxOpacity = 0.15,
|
||||
}: AnimatedGridBackgroundProps) => {
|
||||
const id = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [squares, setSquares] = useState<Array<{ id: number; pos: [number, number] }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dimensions.width && dimensions.height) {
|
||||
const cols = Math.ceil(dimensions.width / squareSize);
|
||||
const rows = Math.ceil(dimensions.height / squareSize);
|
||||
|
||||
const newSquares = Array.from({ length: numSquares }, (_, i) => ({
|
||||
id: i,
|
||||
pos: [
|
||||
Math.floor(Math.random() * cols),
|
||||
Math.floor(Math.random() * rows),
|
||||
] as [number, number],
|
||||
}));
|
||||
|
||||
setSquares(newSquares);
|
||||
}
|
||||
}, [dimensions, squareSize, numSquares]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls(
|
||||
"absolute inset-0 z-0 pointer-events-none select-none overflow-hidden inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
} as React.CSSProperties}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={`grid-${id}`}
|
||||
width={squareSize}
|
||||
height={squareSize}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d={`M ${squareSize} 0 L 0 0 0 ${squareSize}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-background-accent/50"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#grid-${id})`} />
|
||||
{squares.map(({ id, pos: [x, y] }) => (
|
||||
<motion.rect
|
||||
key={id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: [0, maxOpacity, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 2 + 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
x={x * squareSize}
|
||||
y={y * squareSize}
|
||||
width={squareSize}
|
||||
height={squareSize}
|
||||
fill="var(--color-background-accent)"
|
||||
strokeWidth="0"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AnimatedGridBackground.displayName = "AnimatedGridBackground";
|
||||
|
||||
export default memo(AnimatedGridBackground);
|
||||
32
src/components/background/AuroraBackground.tsx
Normal file
32
src/components/background/AuroraBackground.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AuroraBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AuroraBackground = ({
|
||||
className = "",
|
||||
}: AuroraBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls("fixed inset-0 z-0 w-full h-full bg-background", className)}>
|
||||
<div className="absolute top-0 left-0 w-full h-full z-10 backdrop-blur-3xl" ></div>
|
||||
{/* top center */}
|
||||
<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%]" />
|
||||
{/* top right */}
|
||||
<div className="absolute top-[-20vh] right-[2.5vw] -translate-x-[0%] w-[12.5vw] h-[100vh] bg-background-accent/15 -rotate-[60deg] rounded-[100%]" />
|
||||
{/* center left */}
|
||||
<div className="absolute top-[-20vh] left-[2vw] -translate-x-[0%] w-[15vw] h-[150vh] bg-background-accent/20 -rotate-[45deg] rounded-[100%]" />
|
||||
{/* top left */}
|
||||
<div className="absolute top-[-30vh] left-0 -translate-x-[0%] w-[10vw] h-[70vh] bg-background-accent/15 -rotate-[45deg] rounded-[100%]" />
|
||||
{/* bottom center */}
|
||||
<div className="absolute bottom-[-40vh] left-0 -translate-x-[0%] w-[120vw] h-[50vh] bg-background-accent/10 -rotate-[20deg] rounded-[100%]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AuroraBackground.displayName = "AuroraBackground";
|
||||
|
||||
export default memo(AuroraBackground);
|
||||
58
src/components/background/BlurBottomBackground.tsx
Normal file
58
src/components/background/BlurBottomBackground.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useCallback } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const MASK_GRADIENT = "linear-gradient(to bottom, transparent, black 60%)";
|
||||
const BOTTOM_THRESHOLD = 50;
|
||||
const TOP_THRESHOLD = 50;
|
||||
|
||||
interface BlurBottomBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BlurBottomBackground = ({
|
||||
className = ""
|
||||
}: BlurBottomBackgroundProps) => {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(true);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
|
||||
|
||||
setIsAtTop(scrollTop <= TOP_THRESHOLD);
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed pointer-events-none backdrop-blur-xl w-full h-50 left-0 bottom-0 z-[500] transition-opacity duration-500 ease-out",
|
||||
isAtTop || isAtBottom ? "opacity-0" : "opacity-100",
|
||||
className
|
||||
)}
|
||||
style={{ maskImage: MASK_GRADIENT }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BlurBottomBackground.displayName = "BlurBottomBackground";
|
||||
|
||||
export default memo(BlurBottomBackground);
|
||||
74
src/components/background/CanvasRevealBackground.tsx
Normal file
74
src/components/background/CanvasRevealBackground.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import CanvasRevealEffect from './CanvasRevealEffect';
|
||||
|
||||
interface CanvasRevealBackgroundProps {
|
||||
className?: string;
|
||||
animationSpeed?: number;
|
||||
dotSize?: number;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const hexToRgb = (hex: string): number[] => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
|
||||
: [0, 255, 255];
|
||||
};
|
||||
|
||||
const CanvasRevealBackground = ({
|
||||
className = "",
|
||||
animationSpeed = 5,
|
||||
dotSize = 3,
|
||||
height = "30%",
|
||||
}: CanvasRevealBackgroundProps) => {
|
||||
const [colors, setColors] = useState<number[][]>([[0, 255, 255]]);
|
||||
|
||||
useEffect(() => {
|
||||
const primaryCta = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-background-accent')
|
||||
.trim();
|
||||
|
||||
if (primaryCta) {
|
||||
setColors([hexToRgb(primaryCta)]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 w-full"
|
||||
style={{
|
||||
height: height,
|
||||
mask: `
|
||||
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
|
||||
`,
|
||||
maskComposite: 'intersect',
|
||||
WebkitMask: `
|
||||
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
|
||||
`,
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<CanvasRevealEffect
|
||||
animationSpeed={animationSpeed}
|
||||
colors={colors}
|
||||
dotSize={dotSize}
|
||||
showGradient={false}
|
||||
containerClassName="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CanvasRevealBackground.displayName = 'CanvasRevealBackground';
|
||||
|
||||
export default memo(CanvasRevealBackground);
|
||||
304
src/components/background/CanvasRevealEffect.tsx
Normal file
304
src/components/background/CanvasRevealEffect.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { useMemo, useRef, useCallback, memo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface CanvasRevealEffectProps {
|
||||
animationSpeed?: number;
|
||||
opacities?: number[];
|
||||
colors?: number[][];
|
||||
containerClassName?: string;
|
||||
dotSize?: number;
|
||||
showGradient?: boolean;
|
||||
}
|
||||
|
||||
const CanvasRevealEffect = ({
|
||||
animationSpeed = 0.4,
|
||||
opacities = [0.2, 0.2, 0.2, 0.4, 0.4, 0.4, 0.7, 0.6, 0.6, 0.9],
|
||||
colors = [[0, 255, 255]],
|
||||
containerClassName = "",
|
||||
dotSize = 3,
|
||||
showGradient = true,
|
||||
}: CanvasRevealEffectProps) => {
|
||||
return (
|
||||
<div className={cls('h-full relative bg-white w-full', containerClassName)}>
|
||||
<div className="h-full w-full">
|
||||
<DotMatrix
|
||||
colors={colors}
|
||||
dotSize={dotSize}
|
||||
opacities={opacities}
|
||||
shader={`
|
||||
float animation_speed_factor = ${animationSpeed.toFixed(1)};
|
||||
float intro_offset = distance(u_resolution / 2.0 / u_total_size, st2) * 0.01 + (random(st2) * 0.15);
|
||||
opacity *= step(intro_offset, u_time * animation_speed_factor);
|
||||
opacity *= clamp((1.0 - step(intro_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
|
||||
`}
|
||||
center={['x', 'y']}
|
||||
/>
|
||||
</div>
|
||||
{showGradient && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-[84%]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DotMatrixProps {
|
||||
colors?: number[][];
|
||||
opacities?: number[];
|
||||
totalSize?: number;
|
||||
dotSize?: number;
|
||||
shader?: string;
|
||||
center?: ('x' | 'y')[];
|
||||
}
|
||||
|
||||
const DotMatrix = ({
|
||||
colors = [[0, 0, 0]],
|
||||
opacities = [0.04, 0.04, 0.04, 0.04, 0.04, 0.08, 0.08, 0.08, 0.08, 0.14],
|
||||
totalSize = 4,
|
||||
dotSize = 2,
|
||||
shader = '',
|
||||
center = ['x', 'y'],
|
||||
}: DotMatrixProps) => {
|
||||
const uniforms = useMemo(() => {
|
||||
let colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
];
|
||||
if (colors.length === 2) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[1],
|
||||
colors[1],
|
||||
colors[1],
|
||||
];
|
||||
} else if (colors.length === 3) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[1],
|
||||
colors[1],
|
||||
colors[2],
|
||||
colors[2],
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
u_colors: {
|
||||
value: colorsArray.map((color) => [
|
||||
color[0] / 255,
|
||||
color[1] / 255,
|
||||
color[2] / 255,
|
||||
]),
|
||||
type: 'uniform3fv',
|
||||
},
|
||||
u_opacities: {
|
||||
value: opacities,
|
||||
type: 'uniform1fv',
|
||||
},
|
||||
u_total_size: {
|
||||
value: totalSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
u_dot_size: {
|
||||
value: dotSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
};
|
||||
}, [colors, opacities, totalSize, dotSize]);
|
||||
|
||||
return (
|
||||
<Shader
|
||||
source={`
|
||||
precision mediump float;
|
||||
in vec2 fragCoord;
|
||||
|
||||
uniform float u_time;
|
||||
uniform float u_opacities[10];
|
||||
uniform vec3 u_colors[6];
|
||||
uniform float u_total_size;
|
||||
uniform float u_dot_size;
|
||||
uniform vec2 u_resolution;
|
||||
out vec4 fragColor;
|
||||
float PHI = 1.61803398874989484820459;
|
||||
float random(vec2 xy) {
|
||||
return fract(tan(distance(xy * PHI, xy) * 0.5) * xy.x);
|
||||
}
|
||||
float map(float value, float min1, float max1, float min2, float max2) {
|
||||
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
|
||||
}
|
||||
void main() {
|
||||
vec2 st = fragCoord.xy;
|
||||
${
|
||||
center.includes('x')
|
||||
? 'st.x -= abs(floor((mod(u_resolution.x, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
${
|
||||
center.includes('y')
|
||||
? 'st.y -= abs(floor((mod(u_resolution.y, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
float opacity = step(0.0, st.x);
|
||||
opacity *= step(0.0, st.y);
|
||||
|
||||
vec2 st2 = vec2(int(st.x / u_total_size), int(st.y / u_total_size));
|
||||
|
||||
float frequency = 5.0;
|
||||
float show_offset = random(st2);
|
||||
float rand = random(st2 * floor((u_time / frequency) + show_offset + frequency) + 1.0);
|
||||
opacity *= u_opacities[int(rand * 10.0)];
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.x / u_total_size));
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.y / u_total_size));
|
||||
|
||||
vec3 color = u_colors[int(show_offset * 6.0)];
|
||||
|
||||
${shader}
|
||||
|
||||
fragColor = vec4(color, opacity);
|
||||
fragColor.rgb *= fragColor.a;
|
||||
}`}
|
||||
uniforms={uniforms}
|
||||
maxFps={60}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type Uniforms = {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ShaderMaterial = ({
|
||||
source,
|
||||
uniforms,
|
||||
maxFps = 60,
|
||||
}: {
|
||||
source: string;
|
||||
maxFps?: number;
|
||||
uniforms: Uniforms;
|
||||
}) => {
|
||||
const { size } = useThree();
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
let lastFrameTime = 0;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const timestamp = clock.getElapsedTime();
|
||||
if (timestamp - lastFrameTime < 1 / maxFps) {
|
||||
return;
|
||||
}
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
const material = ref.current.material as THREE.ShaderMaterial;
|
||||
const timeLocation = material.uniforms.u_time;
|
||||
timeLocation.value = timestamp;
|
||||
});
|
||||
|
||||
const getUniforms = useCallback(() => {
|
||||
const preparedUniforms: Record<string, { value: unknown; type?: string }> = {};
|
||||
|
||||
for (const uniformName in uniforms) {
|
||||
const uniform = uniforms[uniformName] as { type: string; value: number | number[] | number[][] };
|
||||
|
||||
switch (uniform.type) {
|
||||
case 'uniform1f':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1f' };
|
||||
break;
|
||||
case 'uniform3f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector3().fromArray(uniform.value as number[]),
|
||||
type: '3f',
|
||||
};
|
||||
break;
|
||||
case 'uniform1fv':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1fv' };
|
||||
break;
|
||||
case 'uniform3fv':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: (uniform.value as number[][]).map((v: number[]) =>
|
||||
new THREE.Vector3().fromArray(v)
|
||||
),
|
||||
type: '3fv',
|
||||
};
|
||||
break;
|
||||
case 'uniform2f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector2().fromArray(uniform.value as number[]),
|
||||
type: '2f',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
console.error(`Invalid uniform type for '${uniformName}'.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
preparedUniforms['u_time'] = { value: 0, type: '1f' };
|
||||
preparedUniforms['u_resolution'] = {
|
||||
value: new THREE.Vector2(size.width * 2, size.height * 2),
|
||||
};
|
||||
return preparedUniforms;
|
||||
}, [uniforms, size.width, size.height]);
|
||||
|
||||
const material = useMemo(() => {
|
||||
const materialObject = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
precision mediump float;
|
||||
in vec2 coordinates;
|
||||
uniform vec2 u_resolution;
|
||||
out vec2 fragCoord;
|
||||
void main(){
|
||||
float x = position.x;
|
||||
float y = position.y;
|
||||
gl_Position = vec4(x, y, 0.0, 1.0);
|
||||
fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
|
||||
fragCoord.y = u_resolution.y - fragCoord.y;
|
||||
}
|
||||
`,
|
||||
fragmentShader: source,
|
||||
uniforms: getUniforms(),
|
||||
glslVersion: THREE.GLSL3,
|
||||
blending: THREE.CustomBlending,
|
||||
blendSrc: THREE.SrcAlphaFactor,
|
||||
blendDst: THREE.OneFactor,
|
||||
});
|
||||
|
||||
return materialObject;
|
||||
}, [source, getUniforms]);
|
||||
|
||||
return (
|
||||
<mesh ref={ref as React.Ref<THREE.Mesh>}>
|
||||
<planeGeometry args={[2, 2]} />
|
||||
<primitive object={material} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
interface ShaderProps {
|
||||
source: string;
|
||||
uniforms: Uniforms;
|
||||
maxFps?: number;
|
||||
}
|
||||
|
||||
const Shader = ({ source, uniforms, maxFps = 60 }: ShaderProps) => {
|
||||
return (
|
||||
<Canvas className="absolute inset-0 h-full w-full">
|
||||
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
CanvasRevealEffect.displayName = 'CanvasRevealEffect';
|
||||
|
||||
export default memo(CanvasRevealEffect);
|
||||
56
src/components/background/CardPattern.tsx
Normal file
56
src/components/background/CardPattern.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { motion, useMotionTemplate, type MotionValue } from "framer-motion";
|
||||
|
||||
const GRADIENT_SIZE = 250;
|
||||
|
||||
interface CardPatternProps {
|
||||
mouseX: MotionValue<number>;
|
||||
mouseY: MotionValue<number>;
|
||||
randomString: string;
|
||||
isActive?: boolean;
|
||||
gradientClassName?: string;
|
||||
}
|
||||
|
||||
function CardPatternComponent({
|
||||
mouseX,
|
||||
mouseY,
|
||||
randomString,
|
||||
isActive = false,
|
||||
gradientClassName,
|
||||
}: CardPatternProps) {
|
||||
const maskImage = useMotionTemplate`radial-gradient(${GRADIENT_SIZE}px at ${mouseX}px ${mouseY}px, white, transparent)`;
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
maskImage,
|
||||
WebkitMaskImage: maskImage,
|
||||
}),
|
||||
[maskImage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-theme-capped [mask-image:linear-gradient(white,transparent)] ${isActive ? "opacity-50" : "group-hover/primary-button:opacity-50"}`}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute inset-0 rounded-theme-capped ${gradientClassName} backdrop-blur-xl transition duration-500 ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
|
||||
style={style}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute inset-0 rounded-theme-capped mix-blend-overlay ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
|
||||
style={style}
|
||||
>
|
||||
<p className="absolute inset-x-0 text-xs h-full break-words whitespace-pre-wrap text-white font-mono font-bold transition duration-500">
|
||||
{randomString}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CardPatternComponent.displayName = "CardPattern";
|
||||
|
||||
export const CardPattern = memo(CardPatternComponent);
|
||||
103
src/components/background/CellWaveBackground.tsx
Normal file
103
src/components/background/CellWaveBackground.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface CellWaveBackgroundProps {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
cellColor?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CellWaveBackground = ({
|
||||
columns = 5,
|
||||
rows = 24,
|
||||
cellColor = 'var(--color-background-accent)',
|
||||
duration = 0.25,
|
||||
delay = 1.25,
|
||||
className = ''
|
||||
}: CellWaveBackgroundProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cellRefs = useRef<(HTMLDivElement | null)[][]>([]);
|
||||
const timelinesRef = useRef<gsap.core.Timeline[]>([]);
|
||||
|
||||
const setCellRef = (colIndex: number, cellIndex: number) => (el: HTMLDivElement | null) => {
|
||||
if (!cellRefs.current[colIndex]) {
|
||||
cellRefs.current[colIndex] = [];
|
||||
}
|
||||
cellRefs.current[colIndex][cellIndex] = el;
|
||||
};
|
||||
|
||||
const cellStyles = {
|
||||
backgroundColor: cellColor,
|
||||
boxShadow: `0px 0px 50px 16px color-mix(in srgb, ${cellColor} 12%, transparent), 0px 0px 7px 1px color-mix(in srgb, ${cellColor} 31%, transparent)`
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
timelinesRef.current.forEach(tl => tl.kill());
|
||||
timelinesRef.current = [];
|
||||
|
||||
cellRefs.current.forEach((column, colIndex) => {
|
||||
const cells = [...column].filter(Boolean).reverse();
|
||||
const timeline = gsap.timeline({
|
||||
delay: delay * colIndex,
|
||||
repeat: -1,
|
||||
repeatDelay: 2
|
||||
});
|
||||
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
if (cell) {
|
||||
timeline.to(cell, {
|
||||
keyframes: [
|
||||
{ opacity: 0, duration: 0 },
|
||||
{ opacity: 0.05, duration: duration },
|
||||
{ opacity: 0.15, duration: duration },
|
||||
{ opacity: 0.25, duration: duration },
|
||||
{ opacity: 0.5, duration: duration },
|
||||
{ opacity: 0.25, duration: duration },
|
||||
{ opacity: 0.15, duration: duration },
|
||||
{ opacity: 0.05, duration: duration },
|
||||
{ opacity: 0, duration: duration }
|
||||
],
|
||||
ease: 'none'
|
||||
}, cellIndex * duration);
|
||||
}
|
||||
});
|
||||
|
||||
timelinesRef.current.push(timeline);
|
||||
});
|
||||
|
||||
return () => {
|
||||
timelinesRef.current.forEach(tl => tl.kill());
|
||||
};
|
||||
}, [duration, delay, columns, rows]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls("absolute inset-0 z-0 flex items-end justify-between pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div className="relative flex flex-col gap-1 h-full" key={colIndex}>
|
||||
{Array.from({ length: rows }).map((_, cellIndex) => (
|
||||
<div
|
||||
ref={setCellRef(colIndex, cellIndex)}
|
||||
className="opacity-0 h-8 w-2"
|
||||
key={cellIndex}
|
||||
style={cellStyles}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CellWaveBackground.displayName = 'CellWaveBackground';
|
||||
|
||||
export default memo(CellWaveBackground);
|
||||
48
src/components/background/CircleGradientBackground.tsx
Normal file
48
src/components/background/CircleGradientBackground.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type DiagonalVariant = "primary" | "secondary";
|
||||
|
||||
interface CircleGradientBackgroundProps {
|
||||
className?: string;
|
||||
diagonal?: DiagonalVariant;
|
||||
}
|
||||
|
||||
const CircleGradientBackground = ({
|
||||
className = "",
|
||||
diagonal = "primary",
|
||||
}: CircleGradientBackgroundProps) => {
|
||||
const isPrimary = diagonal === "primary";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed top-0 left-0 right-0 bottom-0 h-screen w-full -z-10 overflow-hidden", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
|
||||
isPrimary ? "top-0 right-0 translate-x-1/2 -translate-y-1/2" : "top-0 left-0 -translate-x-1/2 -translate-y-1/2"
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cls(
|
||||
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
|
||||
isPrimary ? "bottom-0 left-0 -translate-x-1/2 translate-y-1/2" : "bottom-0 right-0 translate-x-1/2 translate-y-1/2"
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CircleGradientBackground.displayName = "CircleGradientBackground";
|
||||
|
||||
export default memo(CircleGradientBackground);
|
||||
45
src/components/background/DotGridBackground.tsx
Normal file
45
src/components/background/DotGridBackground.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridSize = "small" | "medium" | "large";
|
||||
|
||||
interface DotGridBackgroundProps {
|
||||
size?: GridSize;
|
||||
className?: string;
|
||||
perspectiveThreeD?: boolean;
|
||||
}
|
||||
|
||||
const GRID_SIZES: Record<GridSize, string> = {
|
||||
small: "1vw 1vw",
|
||||
medium: "2vw 2vw",
|
||||
large: "4vw 4vw",
|
||||
};
|
||||
|
||||
const DotGridBackground = ({
|
||||
size = "medium",
|
||||
className = "",
|
||||
perspectiveThreeD = false
|
||||
}: DotGridBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
|
||||
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--background-accent) 30%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: GRID_SIZES[size],
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DotGridBackground.displayName = "DotGridBackground";
|
||||
|
||||
export default memo(DotGridBackground);
|
||||
130
src/components/background/DownwardRaysBackground.tsx
Normal file
130
src/components/background/DownwardRaysBackground.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface RayConfig {
|
||||
width: number;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scale?: number;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
}
|
||||
|
||||
interface LightSourceConfig {
|
||||
width: number;
|
||||
height?: number;
|
||||
opacity: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface DownwardRaysBackgroundProps {
|
||||
animated: boolean;
|
||||
showGrid: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const rays: RayConfig[] = [
|
||||
{ width: 35, opacity: 1, rotation: -20, animationDuration: 4, animationDelay: 0 },
|
||||
{ width: 35, opacity: 0.6, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
|
||||
{ width: 20, opacity: 0.45, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
|
||||
{ width: 15, opacity: 0.625, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
|
||||
{ width: 40, opacity: 0.1, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
|
||||
{ width: 20, opacity: 0.525, rotation: 3, animationDuration: 3.2, animationDelay: 1.5 },
|
||||
{ width: 15, opacity: 0.725, rotation: 5, scale: 0.90, animationDuration: 4.2, animationDelay: 0.2 },
|
||||
{ width: 35, opacity: 0.6, rotation: 12, animationDuration: 3.8, animationDelay: 1 },
|
||||
{ width: 35, opacity: 1, rotation: 20, animationDuration: 4, animationDelay: 0.7 },
|
||||
];
|
||||
|
||||
const lightSources: LightSourceConfig[] = [
|
||||
{ width: 1198, opacity: 0.025, top: -352 },
|
||||
{ width: 865, height: 929, opacity: 0.1, top: -252 },
|
||||
{ width: 865, height: 929, opacity: 0.1, top: -252 },
|
||||
];
|
||||
|
||||
const DownwardRaysBackground = ({
|
||||
animated,
|
||||
showGrid,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
}: DownwardRaysBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{animated && (
|
||||
<style>
|
||||
{`
|
||||
@keyframes rayPulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: var(--target-opacity); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{showGrid && (
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"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)",
|
||||
backgroundSize: "10vw 10vw",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"absolute overflow-hidden w-[1142px] h-[129vh] -top-[400px] left-1/2 -translate-x-1/2",
|
||||
"blur-[16px]",
|
||||
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{rays.map((ray, index) => (
|
||||
<div
|
||||
key={`ray-${index}`}
|
||||
className="absolute overflow-hidden origin-top -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${ray.width}px`,
|
||||
left: `calc(50% - ${ray.width / 2}px)`,
|
||||
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
|
||||
...(animated
|
||||
? {
|
||||
"--target-opacity": ray.opacity,
|
||||
animation: `rayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
|
||||
}
|
||||
: {
|
||||
opacity: ray.opacity,
|
||||
}),
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{lightSources.map((source, index) => (
|
||||
<div
|
||||
key={`light-source-${index}`}
|
||||
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${source.width}px`,
|
||||
height: source.height ? `${source.height}px` : undefined,
|
||||
top: `${source.top}px`,
|
||||
bottom: source.height ? undefined : "-46px",
|
||||
left: `calc(50% - ${source.width / 2}px)`,
|
||||
opacity: source.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DownwardRaysBackground.displayName = "DownwardRaysBackground";
|
||||
|
||||
export default memo(DownwardRaysBackground);
|
||||
277
src/components/background/FluidBackground.tsx
Normal file
277
src/components/background/FluidBackground.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useMemo, memo, useEffect, useState } from 'react';
|
||||
import { Canvas, useFrame, extend, useThree } from '@react-three/fiber';
|
||||
import { shaderMaterial } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
const getComputedColor = (varName: string): THREE.Color => {
|
||||
if (typeof window === 'undefined') return new THREE.Color(0x000000);
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colorString = styles.getPropertyValue(varName).trim();
|
||||
return new THREE.Color(colorString || '#000000');
|
||||
};
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
#ifdef GL_ES
|
||||
precision lowp float;
|
||||
#endif
|
||||
uniform float iTime;
|
||||
uniform vec2 iResolution;
|
||||
uniform vec3 uBackgroundColor;
|
||||
uniform vec3 uPrimaryCta;
|
||||
uniform vec3 uAccent;
|
||||
uniform vec3 uSecondaryCta;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec4 buf[8];
|
||||
|
||||
vec4 sigmoid(vec4 x) { return 1. / (1. + exp(-x)); }
|
||||
|
||||
vec4 cppn_fn(vec2 coordinate, float in0, float in1, float in2) {
|
||||
buf[6] = vec4(coordinate.x, coordinate.y, 0.3948333106474662 + in0, 0.36 + in1);
|
||||
buf[7] = vec4(0.14 + in2, sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y), 0., 0.);
|
||||
|
||||
buf[0] = mat4(vec4(6.5404263, -3.6126034, 0.7590882, -1.13613), vec4(2.4582713, 3.1660357, 1.2219609, 0.06276096), vec4(-5.478085, -6.159632, 1.8701609, -4.7742867), vec4(6.039214, -5.542865, -0.90925294, 3.251348))
|
||||
* buf[6]
|
||||
+ mat4(vec4(0.8473259, -5.722911, 3.975766, 1.6522468), vec4(-0.24321538, 0.5839259, -1.7661959, -5.350116), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(0.21808943, 1.1243913, -1.7969975, 5.0294676);
|
||||
|
||||
buf[1] = mat4(vec4(-3.3522482, -6.0612736, 0.55641043, -4.4719114), vec4(0.8631464, 1.7432913, 5.643898, 1.6106541), vec4(2.4941394, -3.5012043, 1.7184316, 6.357333), vec4(3.310376, 8.209261, 1.1355612, -1.165539))
|
||||
* buf[6]
|
||||
+ mat4(vec4(5.24046, -13.034365, 0.009859298, 15.870829), vec4(2.987511, 3.129433, -0.89023495, -1.6822904), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-5.9457836, -6.573602, -0.8812491, 1.5436668);
|
||||
|
||||
buf[0] = sigmoid(buf[0]);
|
||||
buf[1] = sigmoid(buf[1]);
|
||||
|
||||
buf[2] = mat4(vec4(-15.219568, 8.095543, -2.429353, -1.9381982), vec4(-5.951362, 4.3115187, 2.6393783, 1.274315), vec4(-7.3145227, 6.7297835, 5.2473326, 5.9411426), vec4(5.0796127, 8.979051, -1.7278991, -1.158976))
|
||||
* buf[6]
|
||||
+ mat4(vec4(-11.967154, -11.608155, 6.1486754, 11.237008), vec4(2.124141, -6.263192, -1.7050359, -0.7021966), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-4.17164, -3.2281182, -4.576417, -3.6401186);
|
||||
|
||||
buf[3] = mat4(vec4(3.1832156, -13.738922, 1.879223, 3.233465), vec4(0.64300746, 12.768129, 1.9141049, 0.50990224), vec4(-0.049295485, 4.4807224, 1.4733979, 1.801449), vec4(5.0039253, 13.000481, 3.3991797, -4.5561905))
|
||||
* buf[6]
|
||||
+ mat4(vec4(-0.1285731, 7.720628, -3.1425676, 4.742367), vec4(0.6393625, 3.714393, -0.8108378, -0.39174938), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-1.1811101, -21.621881, 0.7851888, 1.2329718);
|
||||
|
||||
buf[2] = sigmoid(buf[2]);
|
||||
buf[3] = sigmoid(buf[3]);
|
||||
|
||||
buf[4] = mat4(vec4(5.214916, -7.183024, 2.7228765, 2.6592617), vec4(-5.601878, -25.3591, 4.067988, 0.4602802), vec4(-10.57759, 24.286327, 21.102104, 37.546658), vec4(4.3024497, -1.9625226, 2.3458803, -1.372816))
|
||||
* buf[0]
|
||||
+ mat4(vec4(-17.6526, -10.507558, 2.2587414, 12.462782), vec4(6.265566, -502.75443, -12.642513, 0.9112289), vec4(-10.983244, 20.741234, -9.701768, -0.7635988), vec4(5.383626, 1.4819539, -4.1911616, -4.8444734))
|
||||
* buf[1]
|
||||
+ mat4(vec4(12.785233, -16.345072, -0.39901125, 1.7955981), vec4(-30.48365, -1.8345358, 1.4542528, -1.1118771), vec4(19.872723, -7.337935, -42.941723, -98.52709), vec4(8.337645, -2.7312303, -2.2927687, -36.142323))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-16.298317, 3.5471997, -0.44300047, -9.444417), vec4(57.5077, -35.609753, 16.163465, -4.1534753), vec4(-0.07470326, -3.8656476, -7.0901804, 3.1523974), vec4(-12.559385, -7.077619, 1.490437, -0.8211543))
|
||||
* buf[3]
|
||||
+ vec4(-7.67914, 15.927437, 1.3207729, -1.6686112);
|
||||
|
||||
buf[5] = mat4(vec4(-1.4109162, -0.372762, -3.770383, -21.367174), vec4(-6.2103205, -9.35908, 0.92529047, 8.82561), vec4(11.460242, -22.348068, 13.625772, -18.693201), vec4(-0.3429052, -3.9905605, -2.4626114, -0.45033523))
|
||||
* buf[0]
|
||||
+ mat4(vec4(7.3481627, -4.3661838, -6.3037653, -3.868115), vec4(1.5462853, 6.5488915, 1.9701879, -0.58291394), vec4(6.5858274, -2.2180402, 3.7127688, -1.3730392), vec4(-5.7973905, 10.134961, -2.3395722, -5.965605))
|
||||
* buf[1]
|
||||
+ mat4(vec4(-2.5132585, -6.6685553, -1.4029363, -0.16285264), vec4(-0.37908727, 0.53738135, 4.389061, -1.3024765), vec4(-0.70647055, 2.0111287, -5.1659346, -3.728635), vec4(-13.562562, 10.487719, -0.9173751, -2.6487076))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-8.645013, 6.5546675, -6.3944063, -5.5933375), vec4(-0.57783127, -1.077275, 36.91025, 5.736769), vec4(14.283112, 3.7146652, 7.1452246, -4.5958776), vec4(2.7192075, 3.6021907, -4.366337, -2.3653464))
|
||||
* buf[3]
|
||||
+ vec4(-5.9000807, -4.329569, 1.2427121, 8.59503);
|
||||
|
||||
buf[4] = sigmoid(buf[4]);
|
||||
buf[5] = sigmoid(buf[5]);
|
||||
|
||||
buf[6] = mat4(vec4(-1.61102, 0.7970257, 1.4675229, 0.20917463), vec4(-28.793737, -7.1390953, 1.5025433, 4.656581), vec4(-10.94861, 39.66238, 0.74318546, -10.095605), vec4(-0.7229728, -1.5483948, 0.7301322, 2.1687684))
|
||||
* buf[0]
|
||||
+ mat4(vec4(3.2547753, 21.489103, -1.0194173, -3.3100595), vec4(-3.7316632, -3.3792162, -7.223193, -0.23685838), vec4(13.1804495, 0.7916005, 5.338587, 5.687114), vec4(-4.167605, -17.798311, -6.815736, -1.6451967))
|
||||
* buf[1]
|
||||
+ mat4(vec4(0.604885, -7.800309, -7.213122, -2.741014), vec4(-3.522382, -0.12359311, -0.5258442, 0.43852118), vec4(9.6752825, -22.853785, 2.062431, 0.099892326), vec4(-4.3196306, -17.730087, 2.5184598, 5.30267))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-6.545563, -15.790176, -6.0438633, -5.415399), vec4(-43.591583, 28.551912, -16.00161, 18.84728), vec4(4.212382, 8.394307, 3.0958717, 8.657522), vec4(-5.0237565, -4.450633, -4.4768, -5.5010443))
|
||||
* buf[3]
|
||||
+ mat4(vec4(1.6985557, -67.05806, 6.897715, 1.9004834), vec4(1.8680354, 2.3915145, 2.5231109, 4.081538), vec4(11.158006, 1.7294737, 2.0738268, 7.386411), vec4(-4.256034, -306.24686, 8.258898, -17.132736))
|
||||
* buf[4]
|
||||
+ mat4(vec4(1.6889864, -4.5852966, 3.8534803, -6.3482175), vec4(1.3543309, -1.2640043, 9.932754, 2.9079645), vec4(-5.2770967, 0.07150358, -0.13962056, 3.3269649), vec4(28.34703, -4.918278, 6.1044083, 4.085355))
|
||||
* buf[5]
|
||||
+ vec4(6.6818056, 12.522166, -3.7075126, -4.104386);
|
||||
|
||||
buf[7] = mat4(vec4(-8.265602, -4.7027016, 5.098234, 0.7509808), vec4(8.6507845, -17.15949, 16.51939, -8.884479), vec4(-4.036479, -2.3946867, -2.6055532, -1.9866527), vec4(-2.2167742, -1.8135649, -5.9759874, 4.8846445))
|
||||
* buf[0]
|
||||
+ mat4(vec4(6.7790847, 3.5076547, -2.8191125, -2.7028968), vec4(-5.743024, -0.27844876, 1.4958696, -5.0517144), vec4(13.122226, 15.735168, -2.9397483, -4.101023), vec4(-14.375265, -5.030483, -6.2599335, 2.9848232))
|
||||
* buf[1]
|
||||
+ mat4(vec4(4.0950394, -0.94011575, -5.674733, 4.755022), vec4(4.3809423, 4.8310084, 1.7425908, -3.437416), vec4(2.117492, 0.16342592, -104.56341, 16.949184), vec4(-5.22543, -2.994248, 3.8350096, -1.9364246))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-5.900337, 1.7946124, -13.604192, -3.8060522), vec4(6.6583457, 31.911177, 25.164474, 91.81147), vec4(11.840538, 4.1503043, -0.7314397, 6.768467), vec4(-6.3967767, 4.034772, 6.1714606, -0.32874924))
|
||||
* buf[3]
|
||||
+ mat4(vec4(3.4992442, -196.91893, -8.923708, 2.8142626), vec4(3.4806502, -3.1846354, 5.1725626, 5.1804223), vec4(-2.4009497, 15.585794, 1.2863957, 2.0252278), vec4(-71.25271, -62.441242, -8.138444, 0.50670296))
|
||||
* buf[4]
|
||||
+ mat4(vec4(-12.291733, -11.176166, -7.3474145, 4.390294), vec4(10.805477, 5.6337385, -0.9385842, -4.7348723), vec4(-12.869276, -7.039391, 5.3029537, 7.5436664), vec4(1.4593618, 8.91898, 3.5101583, 5.840625))
|
||||
* buf[5]
|
||||
+ vec4(2.2415268, -6.705987, -0.98861027, -2.117676);
|
||||
|
||||
buf[6] = sigmoid(buf[6]);
|
||||
buf[7] = sigmoid(buf[7]);
|
||||
|
||||
buf[0] = mat4(vec4(1.6794263, 1.3817469, 2.9625452, 0.0), vec4(-1.8834411, -1.4806935, -3.5924516, 0.0), vec4(-1.3279216, -1.0918057, -2.3124623, 0.0), vec4(0.2662234, 0.23235129, 0.44178495, 0.0))
|
||||
* buf[0]
|
||||
+ mat4(vec4(-0.6299101, -0.5945583, -0.9125601, 0.0), vec4(0.17828953, 0.18300213, 0.18182953, 0.0), vec4(-2.96544, -2.5819945, -4.9001055, 0.0), vec4(1.4195864, 1.1868085, 2.5176322, 0.0))
|
||||
* buf[1]
|
||||
+ mat4(vec4(-1.2584374, -1.0552157, -2.1688404, 0.0), vec4(-0.7200217, -0.52666044, -1.438251, 0.0), vec4(0.15345335, 0.15196142, 0.272854, 0.0), vec4(0.945728, 0.8861938, 1.2766753, 0.0))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-2.4218085, -1.968602, -4.35166, 0.0), vec4(-22.683098, -18.0544, -41.954372, 0.0), vec4(0.63792, 0.5470648, 1.1078634, 0.0), vec4(-1.5489894, -1.3075932, -2.6444845, 0.0))
|
||||
* buf[3]
|
||||
+ mat4(vec4(-0.49252132, -0.39877754, -0.91366625, 0.0), vec4(0.95609266, 0.7923952, 1.640221, 0.0), vec4(0.30616966, 0.15693925, 0.8639857, 0.0), vec4(1.1825981, 0.94504964, 2.176963, 0.0))
|
||||
* buf[4]
|
||||
+ mat4(vec4(0.35446745, 0.3293795, 0.59547555, 0.0), vec4(-0.58784515, -0.48177817, -1.0614829, 0.0), vec4(2.5271258, 1.9991658, 4.6846647, 0.0), vec4(0.13042648, 0.08864098, 0.30187556, 0.0))
|
||||
* buf[5]
|
||||
+ mat4(vec4(-1.7718065, -1.4033192, -3.3355875, 0.0), vec4(3.1664357, 2.638297, 5.378702, 0.0), vec4(-3.1724713, -2.6107926, -5.549295, 0.0), vec4(-2.851368, -2.249092, -5.3013067, 0.0))
|
||||
* buf[6]
|
||||
+ mat4(vec4(1.5203838, 1.2212278, 2.8404984, 0.0), vec4(1.5210563, 1.2651345, 2.683903, 0.0), vec4(2.9789467, 2.4364579, 5.2347264, 0.0), vec4(2.2270417, 1.8825914, 3.8028636, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-1.5468478, -3.6171484, 0.24762098, 0.0);
|
||||
|
||||
buf[0] = sigmoid(buf[0]);
|
||||
return vec4(buf[0].x , buf[0].y , buf[0].z, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv * 2.0 - 1.0; uv.y *= -1.0;
|
||||
vec4 pattern = cppn_fn(uv, 0.1 * sin(0.3 * iTime), 0.1 * sin(0.69 * iTime), 0.1 * sin(0.44 * iTime));
|
||||
|
||||
vec3 color1 = mix(uBackgroundColor, uPrimaryCta, pattern.x);
|
||||
vec3 color2 = mix(uBackgroundColor, uAccent, pattern.y);
|
||||
vec3 color3 = mix(uBackgroundColor, uSecondaryCta, pattern.z);
|
||||
|
||||
vec3 finalColor = (color1 + color2 + color3) / 3.0;
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const CPPNShaderMaterial = shaderMaterial(
|
||||
{
|
||||
iTime: 0,
|
||||
iResolution: new THREE.Vector2(1, 1),
|
||||
uBackgroundColor: new THREE.Color(0x000000),
|
||||
uPrimaryCta: new THREE.Color(0xff0000),
|
||||
uAccent: new THREE.Color(0x00ff00),
|
||||
uSecondaryCta: new THREE.Color(0x0000ff),
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader
|
||||
);
|
||||
|
||||
extend({ CPPNShaderMaterial });
|
||||
|
||||
interface ShaderPlaneProps {
|
||||
backgroundColor: THREE.Color;
|
||||
primaryCta: THREE.Color;
|
||||
accent: THREE.Color;
|
||||
secondaryCta: THREE.Color;
|
||||
}
|
||||
|
||||
const ShaderPlane = memo(({ backgroundColor, primaryCta, accent, secondaryCta }: ShaderPlaneProps) => {
|
||||
const meshRef = useRef<THREE.Mesh>(null!);
|
||||
const materialRef = useRef<THREE.ShaderMaterial & {
|
||||
iTime: number;
|
||||
iResolution: THREE.Vector2;
|
||||
uBackgroundColor: THREE.Color;
|
||||
uPrimaryCta: THREE.Color;
|
||||
uAccent: THREE.Color;
|
||||
uSecondaryCta: THREE.Color;
|
||||
}>(null!);
|
||||
const { viewport } = useThree();
|
||||
|
||||
useFrame((state) => {
|
||||
if (!materialRef.current) return;
|
||||
materialRef.current.iTime = state.clock.elapsedTime;
|
||||
const { width, height } = state.size;
|
||||
materialRef.current.iResolution.set(width, height);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!materialRef.current) return;
|
||||
materialRef.current.uBackgroundColor = backgroundColor;
|
||||
materialRef.current.uPrimaryCta = primaryCta;
|
||||
materialRef.current.uAccent = accent;
|
||||
materialRef.current.uSecondaryCta = secondaryCta;
|
||||
}, [backgroundColor, primaryCta, accent, secondaryCta]);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[viewport.width, viewport.height]} />
|
||||
<cPPNShaderMaterial ref={materialRef} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
});
|
||||
|
||||
ShaderPlane.displayName = 'ShaderPlane';
|
||||
|
||||
interface FluidBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FluidBackground = ({ className = "" }: FluidBackgroundProps) => {
|
||||
const camera = useMemo(() => ({ position: [0, 0, 5] as [number, number, number], fov: 75, near: 0.1, far: 1000 }), []);
|
||||
|
||||
const [colors, setColors] = useState({
|
||||
background: new THREE.Color(0x000000),
|
||||
primaryCta: new THREE.Color(0xff0000),
|
||||
accent: new THREE.Color(0x00ff00),
|
||||
secondaryCta: new THREE.Color(0x0000ff),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateColors = () => {
|
||||
setColors({
|
||||
background: getComputedColor('--background'),
|
||||
primaryCta: getComputedColor('--color-primary-cta'),
|
||||
accent: getComputedColor('--color-accent'),
|
||||
secondaryCta: getComputedColor('--color-secondary-cta'),
|
||||
});
|
||||
};
|
||||
|
||||
updateColors();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cls("bg-background fixed inset-0 -z-10 w-full h-full", className)} aria-hidden="true">
|
||||
<Canvas
|
||||
camera={camera}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
dpr={[1, 2]}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<ShaderPlane
|
||||
backgroundColor={colors.background}
|
||||
primaryCta={colors.primaryCta}
|
||||
accent={colors.accent}
|
||||
secondaryCta={colors.secondaryCta}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FluidBackground.displayName = 'FluidBackground';
|
||||
|
||||
export default memo(FluidBackground);
|
||||
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements {
|
||||
cPPNShaderMaterial: unknown;
|
||||
}
|
||||
}
|
||||
272
src/components/background/GlowingEffect.tsx
Normal file
272
src/components/background/GlowingEffect.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { animate } from "motion/react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const INACTIVE_ZONE_MULTIPLIER = 0.5;
|
||||
const CENTER_MULTIPLIER = 0.5;
|
||||
const ANGLE_CONVERSION_FACTOR = 180 / Math.PI;
|
||||
const ANGLE_OFFSET = 90;
|
||||
const ANGLE_NORMALIZATION = 180;
|
||||
const FULL_CIRCLE = 360;
|
||||
const REPEATING_GRADIENT_TIMES = 5;
|
||||
const GRADIENT_DIVISION = 25;
|
||||
|
||||
const ANIMATION_EASING = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
interface GlowingEffectProps {
|
||||
blur?: number;
|
||||
inactiveZone?: number;
|
||||
proximity?: number;
|
||||
spread?: number;
|
||||
glow?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
movementDuration?: number;
|
||||
borderWidth?: number;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type MouseEventLike = MouseEvent | Position;
|
||||
const getIsSSR = () => typeof window === "undefined";
|
||||
|
||||
const getViewportCenter = (): Position => {
|
||||
if (getIsSSR()) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const getIsMobileDevice = (): boolean => {
|
||||
if (getIsSSR()) return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
};
|
||||
|
||||
const calculateAngleDiff = (current: number, target: number): number => {
|
||||
return ((target - current + ANGLE_NORMALIZATION) % FULL_CIRCLE) - ANGLE_NORMALIZATION;
|
||||
};
|
||||
|
||||
const GlowingEffect = memo(
|
||||
({
|
||||
blur = 0,
|
||||
inactiveZone = 0.7,
|
||||
proximity = 0,
|
||||
spread = 20,
|
||||
glow = false,
|
||||
className,
|
||||
movementDuration = 2,
|
||||
borderWidth = 1,
|
||||
disabled = true,
|
||||
}: GlowingEffectProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastPosition = useRef<Position>({ x: 0, y: 0 });
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const [isMobile, setIsMobile] = useState(() => getIsMobileDevice());
|
||||
|
||||
const updateElementStyles = useCallback(
|
||||
(element: HTMLElement, property: string, value: string) => {
|
||||
element.style.setProperty(property, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const calculateMousePosition = useCallback(
|
||||
(e?: MouseEventLike): Position => {
|
||||
if (isMobile) {
|
||||
return getViewportCenter();
|
||||
}
|
||||
return {
|
||||
x: e?.x ?? lastPosition.current.x,
|
||||
y: e?.y ?? lastPosition.current.y,
|
||||
};
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const animateAngleTransition = useCallback(
|
||||
(element: HTMLElement, currentAngle: number, targetAngle: number) => {
|
||||
const angleDiff = calculateAngleDiff(currentAngle, targetAngle);
|
||||
const newAngle = currentAngle + angleDiff;
|
||||
|
||||
animate(currentAngle, newAngle, {
|
||||
duration: movementDuration,
|
||||
ease: ANIMATION_EASING,
|
||||
onUpdate: (value) => {
|
||||
updateElementStyles(element, "--start", String(value));
|
||||
},
|
||||
});
|
||||
},
|
||||
[movementDuration, updateElementStyles]
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(e?: MouseEventLike) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
const mousePosition = calculateMousePosition(e);
|
||||
|
||||
if (e) {
|
||||
lastPosition.current = mousePosition;
|
||||
}
|
||||
|
||||
const centerX = left + width * CENTER_MULTIPLIER;
|
||||
const centerY = top + height * CENTER_MULTIPLIER;
|
||||
const distanceFromCenter = Math.hypot(
|
||||
mousePosition.x - centerX,
|
||||
mousePosition.y - centerY
|
||||
);
|
||||
const inactiveRadius = INACTIVE_ZONE_MULTIPLIER * Math.min(width, height) * inactiveZone;
|
||||
|
||||
if (distanceFromCenter < inactiveRadius) {
|
||||
updateElementStyles(element, "--active", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive =
|
||||
mousePosition.x > left - proximity &&
|
||||
mousePosition.x < left + width + proximity &&
|
||||
mousePosition.y > top - proximity &&
|
||||
mousePosition.y < top + height + proximity;
|
||||
|
||||
updateElementStyles(element, "--active", isActive ? "1" : "0");
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
const currentAngle =
|
||||
parseFloat(element.style.getPropertyValue("--start")) || 0;
|
||||
const targetAngle =
|
||||
ANGLE_CONVERSION_FACTOR * Math.atan2(mousePosition.y - centerY, mousePosition.x - centerX) +
|
||||
ANGLE_OFFSET;
|
||||
|
||||
animateAngleTransition(element, currentAngle, targetAngle);
|
||||
});
|
||||
},
|
||||
[inactiveZone, proximity, calculateMousePosition, updateElementStyles, animateAngleTransition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsSSR()) return;
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(getIsMobileDevice());
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || getIsSSR()) return;
|
||||
|
||||
const handleScroll = () => handleMove();
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isMobile) {
|
||||
handleMove(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
handleMove();
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.body.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.body.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
}, [handleMove, disabled, isMobile]);
|
||||
|
||||
const gradient = useMemo(
|
||||
() => `radial-gradient(circle, var(--accent) 10%, transparent 20%),
|
||||
radial-gradient(circle at 40% 40%, var(--background-accent) 5%, transparent 15%),
|
||||
repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
var(--accent) 0%,
|
||||
var(--background-accent) calc(${GRADIENT_DIVISION}% / var(--repeating-conic-gradient-times)),
|
||||
var(--accent) calc(${GRADIENT_DIVISION * 2}% / var(--repeating-conic-gradient-times))
|
||||
)`,
|
||||
[]
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
"--blur": `${blur}px`,
|
||||
"--spread": spread,
|
||||
"--start": "0",
|
||||
"--active": "0",
|
||||
"--glowingeffect-border-width": `${borderWidth}px`,
|
||||
"--repeating-conic-gradient-times": String(REPEATING_GRADIENT_TIMES),
|
||||
"--gradient": gradient,
|
||||
} as React.CSSProperties),
|
||||
[blur, spread, borderWidth, gradient]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cls(
|
||||
"pointer-events-none absolute inset-0 hidden rounded-[inherit] border opacity-0 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
disabled && "!block"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={containerStyle}
|
||||
className={cls(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
blur > 0 && "blur-[var(--blur)] ",
|
||||
className,
|
||||
disabled && "!hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"glow",
|
||||
"rounded-[inherit]",
|
||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
||||
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
|
||||
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
|
||||
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
|
||||
"after:[mask-clip:padding-box,border-box]",
|
||||
"after:[mask-composite:intersect]",
|
||||
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GlowingEffect.displayName = "GlowingEffect";
|
||||
|
||||
export { GlowingEffect };
|
||||
52
src/components/background/GlowingOrbBackground.tsx
Normal file
52
src/components/background/GlowingOrbBackground.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface GlowingOrbBackgroundProps {
|
||||
className?: string;
|
||||
blurAmount?: string;
|
||||
glowColor?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const GlowingOrbBackground = ({
|
||||
className = "",
|
||||
blurAmount = "57px",
|
||||
glowColor = "var(--color-primary-cta)",
|
||||
backgroundColor = "var(--background)",
|
||||
}: GlowingOrbBackgroundProps) => {
|
||||
return (
|
||||
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
|
||||
<div
|
||||
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
|
||||
style={{
|
||||
backgroundColor,
|
||||
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlowingOrbBackground.displayName = 'GlowingOrbBackground';
|
||||
|
||||
export default memo(GlowingOrbBackground);
|
||||
82
src/components/background/GlowingOrbSparklesBackground.tsx
Normal file
82
src/components/background/GlowingOrbSparklesBackground.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface GlowingOrbSparklesBackgroundProps {
|
||||
className?: string;
|
||||
blurAmount?: string;
|
||||
glowColor?: string;
|
||||
backgroundColor?: string;
|
||||
particleColor?: string;
|
||||
particleDensity?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
const GlowingOrbSparklesBackground = ({
|
||||
className = "",
|
||||
blurAmount = "57px",
|
||||
glowColor = "var(--color-primary-cta)",
|
||||
backgroundColor = "var(--background)",
|
||||
particleColor = "var(--color-primary-cta)",
|
||||
particleDensity = 80,
|
||||
minSize = 0.5,
|
||||
maxSize = 1.5,
|
||||
speed = 4,
|
||||
}: GlowingOrbSparklesBackgroundProps) => {
|
||||
return (
|
||||
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
|
||||
{/* Sparkles layer with radial mask */}
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
|
||||
}}
|
||||
>
|
||||
<Sparkles
|
||||
className="absolute inset-0"
|
||||
particleColor={particleColor}
|
||||
particleDensity={particleDensity}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
speed={speed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Glowing orb layer */}
|
||||
<div
|
||||
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
|
||||
style={{
|
||||
backgroundColor,
|
||||
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlowingOrbSparklesBackground.displayName = 'GlowingOrbSparklesBackground';
|
||||
|
||||
export default memo(GlowingOrbSparklesBackground);
|
||||
74
src/components/background/GradientBarsBackground.tsx
Normal file
74
src/components/background/GradientBarsBackground.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface GradientBarsBackgroundProps {
|
||||
className?: string;
|
||||
numBarsPerSide?: number;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
opacity?: number;
|
||||
sideWidth?: string;
|
||||
}
|
||||
|
||||
const GradientBarsBackground = ({
|
||||
className = "",
|
||||
numBarsPerSide = 8,
|
||||
gradientFrom = "var(--color-primary-cta)",
|
||||
gradientTo = "transparent",
|
||||
opacity = 0.075,
|
||||
sideWidth = "35%",
|
||||
}: GradientBarsBackgroundProps) => {
|
||||
const getBarStyle = (side: 'left' | 'right') => ({
|
||||
flex: '1 0 0',
|
||||
minWidth: '30px',
|
||||
maxWidth: '82px',
|
||||
background: `linear-gradient(${side === 'left' ? '90deg' : '270deg'}, ${gradientFrom}, ${gradientTo})`,
|
||||
opacity: opacity,
|
||||
});
|
||||
|
||||
const renderBars = (side: 'left' | 'right') =>
|
||||
Array.from({ length: numBarsPerSide }).map((_, index) => (
|
||||
<div key={`${side}-${index}`} className="h-full" style={getBarStyle(side)} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="flex h-8/10 w-full justify-between backface-hidden antialiased"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
mask: 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-full overflow-hidden"
|
||||
style={{
|
||||
width: sideWidth,
|
||||
mask: 'linear-gradient(270deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
{renderBars('left')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-full justify-end overflow-hidden"
|
||||
style={{
|
||||
width: sideWidth,
|
||||
mask: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
{renderBars('right')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GradientBarsBackground.displayName = 'GradientBarsBackground';
|
||||
|
||||
export default memo(GradientBarsBackground);
|
||||
45
src/components/background/GridBackround.tsx
Normal file
45
src/components/background/GridBackround.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridSize = "small" | "medium" | "large";
|
||||
|
||||
interface GridBackroundProps {
|
||||
size?: GridSize;
|
||||
className?: string;
|
||||
perspectiveThreeD?: boolean;
|
||||
}
|
||||
|
||||
const GRID_SIZES: Record<GridSize, string> = {
|
||||
small: "6.25vw 6.25vw",
|
||||
medium: "10vw 10vw",
|
||||
large: "20vw 20vw",
|
||||
};
|
||||
|
||||
const GridBackround = ({
|
||||
size = "medium",
|
||||
className = "",
|
||||
perspectiveThreeD = false
|
||||
}: GridBackroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
|
||||
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: GRID_SIZES[size],
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
GridBackround.displayName = "GridBackround";
|
||||
|
||||
export default memo(GridBackround);
|
||||
121
src/components/background/HeroBackgrounds.tsx
Normal file
121
src/components/background/HeroBackgrounds.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import AnimatedGridBackground from "./AnimatedGridBackground";
|
||||
import CanvasRevealBackground from "./CanvasRevealBackground";
|
||||
import CellWaveBackground from "./CellWaveBackground";
|
||||
import DownwardRaysBackground from "./DownwardRaysBackground";
|
||||
import GlowingOrbBackground from "./GlowingOrbBackground";
|
||||
import GlowingOrbSparklesBackground from "./GlowingOrbSparklesBackground";
|
||||
import GradientBarsBackground from "./GradientBarsBackground";
|
||||
import RadialGradientBackground from "./RadialGradientBackground";
|
||||
import RotatedRaysBackground from "./RotatedRaysBackground";
|
||||
import RotatingGradientBackground from "./RotatingGradientBackground";
|
||||
import SparklesGradientBackground from "./SparklesGradientBackground";
|
||||
|
||||
export type HeroBackgroundVariant =
|
||||
| "plain"
|
||||
| "animated-grid"
|
||||
| "canvas-reveal"
|
||||
| "cell-wave"
|
||||
| "downward-rays-animated"
|
||||
| "downward-rays-animated-grid"
|
||||
| "downward-rays-static"
|
||||
| "downward-rays-static-grid"
|
||||
| "glowing-orb"
|
||||
| "glowing-orb-sparkles"
|
||||
| "gradient-bars"
|
||||
| "radial-gradient"
|
||||
| "rotated-rays-animated"
|
||||
| "rotated-rays-animated-grid"
|
||||
| "rotated-rays-static"
|
||||
| "rotated-rays-static-grid"
|
||||
| "rotating-gradient"
|
||||
| "sparkles-gradient";
|
||||
|
||||
type AnimatedGridProps = React.ComponentProps<typeof AnimatedGridBackground>;
|
||||
type CanvasRevealProps = React.ComponentProps<typeof CanvasRevealBackground>;
|
||||
type CellWaveProps = React.ComponentProps<typeof CellWaveBackground>;
|
||||
type GlowingOrbProps = React.ComponentProps<typeof GlowingOrbBackground>;
|
||||
type GlowingOrbSparklesProps = React.ComponentProps<typeof GlowingOrbSparklesBackground>;
|
||||
type GradientBarsProps = React.ComponentProps<typeof GradientBarsBackground>;
|
||||
type RadialGradientProps = React.ComponentProps<typeof RadialGradientBackground>;
|
||||
type RotatingGradientProps = React.ComponentProps<typeof RotatingGradientBackground>;
|
||||
type SparklesGradientProps = React.ComponentProps<typeof SparklesGradientBackground>;
|
||||
|
||||
export type HeroBackgroundVariantProps =
|
||||
| { variant: "plain" }
|
||||
| ({ variant: "animated-grid" } & AnimatedGridProps)
|
||||
| ({ variant: "canvas-reveal" } & CanvasRevealProps)
|
||||
| ({ variant: "cell-wave" } & CellWaveProps)
|
||||
| { variant: "downward-rays-animated" }
|
||||
| { variant: "downward-rays-animated-grid" }
|
||||
| { variant: "downward-rays-static" }
|
||||
| { variant: "downward-rays-static-grid" }
|
||||
| ({ variant: "glowing-orb" } & GlowingOrbProps)
|
||||
| ({ variant: "glowing-orb-sparkles" } & GlowingOrbSparklesProps)
|
||||
| ({ variant: "gradient-bars" } & GradientBarsProps)
|
||||
| ({ variant: "radial-gradient" } & RadialGradientProps)
|
||||
| { variant: "rotated-rays-animated" }
|
||||
| { variant: "rotated-rays-animated-grid" }
|
||||
| { variant: "rotated-rays-static" }
|
||||
| { variant: "rotated-rays-static-grid" }
|
||||
| ({ variant: "rotating-gradient" } & RotatingGradientProps)
|
||||
| ({ variant: "sparkles-gradient" } & SparklesGradientProps);
|
||||
|
||||
const heroBackgroundComponents = {
|
||||
"animated-grid": AnimatedGridBackground,
|
||||
"canvas-reveal": CanvasRevealBackground,
|
||||
"cell-wave": CellWaveBackground,
|
||||
"downward-rays": DownwardRaysBackground,
|
||||
"glowing-orb": GlowingOrbBackground,
|
||||
"glowing-orb-sparkles": GlowingOrbSparklesBackground,
|
||||
"gradient-bars": GradientBarsBackground,
|
||||
"radial-gradient": RadialGradientBackground,
|
||||
"rotated-rays": RotatedRaysBackground,
|
||||
"rotating-gradient": RotatingGradientBackground,
|
||||
"sparkles-gradient": SparklesGradientBackground,
|
||||
} as const;
|
||||
|
||||
const HeroBackgrounds = (props: HeroBackgroundVariantProps) => {
|
||||
if (props.variant === "plain") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { variant, ...restProps } = props;
|
||||
|
||||
// Handle rotated-rays preset variants
|
||||
if (variant === "rotated-rays-animated") {
|
||||
return <RotatedRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-animated-grid") {
|
||||
return <RotatedRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-static") {
|
||||
return <RotatedRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-static-grid") {
|
||||
return <RotatedRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
|
||||
// Handle downward-rays preset variants
|
||||
if (variant === "downward-rays-animated") {
|
||||
return <DownwardRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-animated-grid") {
|
||||
return <DownwardRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-static") {
|
||||
return <DownwardRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-static-grid") {
|
||||
return <DownwardRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
|
||||
const BackgroundComponent = heroBackgroundComponents[variant];
|
||||
return <BackgroundComponent {...(restProps as any)} />;
|
||||
};
|
||||
|
||||
HeroBackgrounds.displayName = "HeroBackgrounds";
|
||||
|
||||
export default memo(HeroBackgrounds);
|
||||
31
src/components/background/NoiseBackground.tsx
Normal file
31
src/components/background/NoiseBackground.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseBackground = ({ className = "" }: NoiseBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
|
||||
style={{
|
||||
backgroundImage: "url(/images/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseBackground.displayName = "NoiseBackground";
|
||||
|
||||
export default memo(NoiseBackground);
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseDiagonalGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseDiagonalGradientBackground = ({ className = "" }: NoiseDiagonalGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-br from-background via-accent/20 to-primary-cta/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
|
||||
style={{
|
||||
backgroundImage: "url(/images/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseDiagonalGradientBackground.displayName = "NoiseDiagonalGradientBackground";
|
||||
|
||||
export default memo(NoiseDiagonalGradientBackground);
|
||||
35
src/components/background/NoiseGradientBackground.tsx
Normal file
35
src/components/background/NoiseGradientBackground.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseGradientBackground = ({ className = "" }: NoiseGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-r from-background via-accent/20 to-primary-cta/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
|
||||
style={{
|
||||
backgroundImage: "url(/images/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseGradientBackground.displayName = "NoiseGradientBackground";
|
||||
|
||||
export default memo(NoiseGradientBackground);
|
||||
21
src/components/background/PlainBackground.tsx
Normal file
21
src/components/background/PlainBackground.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface PlainBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PlainBackground = ({ className = "" }: PlainBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-background", className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PlainBackground.displayName = "PlainBackground";
|
||||
|
||||
export default memo(PlainBackground);
|
||||
40
src/components/background/RadialGradientBackground.tsx
Normal file
40
src/components/background/RadialGradientBackground.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface RadialGradientBackgroundProps {
|
||||
className?: string;
|
||||
centerColor?: string;
|
||||
edgeColor?: string;
|
||||
size?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
const RadialGradientBackground = ({
|
||||
className = "",
|
||||
centerColor = "var(--background)",
|
||||
edgeColor = "var(--color-background-accent)",
|
||||
size = "130% 130%",
|
||||
position = "50% 15%",
|
||||
}: RadialGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 pointer-events-none select-none md:px-5 md:pb-5", className)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full rounded-b-theme-capped"
|
||||
style={{
|
||||
background: `radial-gradient(${size} at ${position}, ${centerColor} 40%, ${edgeColor} 100%)`,
|
||||
mask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
|
||||
WebkitMask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RadialGradientBackground.displayName = 'RadialGradientBackground';
|
||||
|
||||
export default memo(RadialGradientBackground);
|
||||
130
src/components/background/RotatedRaysBackground.tsx
Normal file
130
src/components/background/RotatedRaysBackground.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface RayConfig {
|
||||
width: number;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scale?: number;
|
||||
left?: string;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
}
|
||||
|
||||
interface LightSourceConfig {
|
||||
width: number;
|
||||
height?: number;
|
||||
opacity: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface RotatedRaysBackgroundProps {
|
||||
animated: boolean;
|
||||
showGrid: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const rays: RayConfig[] = [
|
||||
{ width: 35, opacity: 0.85, rotation: -18, animationDuration: 4, animationDelay: 0 },
|
||||
{ width: 35, opacity: 0.775, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
|
||||
{ width: 20, opacity: 0.65, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
|
||||
{ width: 15, opacity: 0.25, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
|
||||
{ width: 40, opacity: 0.45, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
|
||||
{ width: 20, opacity: 0.45, rotation: 6, animationDuration: 3.2, animationDelay: 1.5 },
|
||||
{ width: 35, opacity: 0.65, rotation: 9, scale: 0.83, animationDuration: 4.2, animationDelay: 0.2 },
|
||||
{ width: 35, opacity: 1, rotation: 14, scale: 0.9, animationDuration: 3.8, animationDelay: 1 },
|
||||
];
|
||||
|
||||
const lightSources: LightSourceConfig[] = [
|
||||
{ width: 1198, opacity: 0.05, top: -352 },
|
||||
{ width: 865, height: 929, opacity: 0.15, top: -252 },
|
||||
{ width: 865, height: 929, opacity: 0.15, top: -252 },
|
||||
];
|
||||
|
||||
const RotatedRaysBackground = ({
|
||||
animated,
|
||||
showGrid,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
}: RotatedRaysBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{animated && (
|
||||
<style>
|
||||
{`
|
||||
@keyframes rotatedRayPulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: var(--target-opacity); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{showGrid && (
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"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)",
|
||||
backgroundSize: "10vw 10vw",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"absolute overflow-hidden w-[1142px] h-[179vh] -top-[571px] -left-[373px]",
|
||||
"-rotate-[38deg] blur-[16px]",
|
||||
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{rays.map((ray, index) => (
|
||||
<div
|
||||
key={`ray-${index}`}
|
||||
className="absolute overflow-hidden origin-top-right -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${ray.width}px`,
|
||||
left: ray.left || `calc(50% - ${ray.width / 2}px)`,
|
||||
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
|
||||
...(animated
|
||||
? {
|
||||
"--target-opacity": ray.opacity,
|
||||
animation: `rotatedRayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
|
||||
}
|
||||
: {
|
||||
opacity: ray.opacity,
|
||||
}),
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{lightSources.map((source, index) => (
|
||||
<div
|
||||
key={`light-source-${index}`}
|
||||
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${source.width}px`,
|
||||
height: source.height ? `${source.height}px` : undefined,
|
||||
top: `${source.top}px`,
|
||||
bottom: source.height ? undefined : "-46px",
|
||||
left: `calc(50% - ${source.width / 2}px)`,
|
||||
opacity: source.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RotatedRaysBackground.displayName = "RotatedRaysBackground";
|
||||
|
||||
export default memo(RotatedRaysBackground);
|
||||
77
src/components/background/RotatingGradientBackground.tsx
Normal file
77
src/components/background/RotatingGradientBackground.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface RotatingGradientBackgroundProps {
|
||||
className?: string;
|
||||
gradientColorStart?: string;
|
||||
gradientColorEnd?: string;
|
||||
bigCircleSize?: string;
|
||||
smallCircleSize?: string;
|
||||
blurAmount?: string;
|
||||
opacity?: number;
|
||||
showSparkles?: boolean;
|
||||
}
|
||||
|
||||
const RotatingGradientBackground = ({
|
||||
className = "",
|
||||
gradientColorStart = "var(--color-background-accent)",
|
||||
gradientColorEnd = "var(--color-background-accent)",
|
||||
bigCircleSize = "28vw",
|
||||
smallCircleSize = "21vw",
|
||||
blurAmount = "10px",
|
||||
opacity = 0.6,
|
||||
showSparkles = true,
|
||||
}: RotatingGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-slow opacity-75"
|
||||
style={{
|
||||
width: bigCircleSize,
|
||||
height: bigCircleSize,
|
||||
background: `linear-gradient(229deg, ${gradientColorStart} 10%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 40%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-reverse opacity-75"
|
||||
style={{
|
||||
width: smallCircleSize,
|
||||
height: smallCircleSize,
|
||||
background: `linear-gradient(141deg, ${gradientColorStart} 13%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 37.5%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showSparkles && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
mask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<Sparkles particleDensity={60} minSize={0.3} maxSize={0.8} speed={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RotatingGradientBackground.displayName = 'RotatingGradientBackground';
|
||||
|
||||
export default memo(RotatingGradientBackground);
|
||||
460
src/components/background/Sparkles.tsx
Normal file
460
src/components/background/Sparkles.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useEffect, useState } from "react";
|
||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||
import type { Container, SingleOrMultiple } from "@tsparticles/engine";
|
||||
import { loadSlim } from "@tsparticles/slim";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { motion, useAnimation } from "framer-motion";
|
||||
|
||||
type SparklesProps = {
|
||||
id?: string;
|
||||
className?: string;
|
||||
background?: string;
|
||||
particleSize?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
speed?: number;
|
||||
particleColor?: string;
|
||||
particleDensity?: number;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
minSize: 0.5,
|
||||
maxSize: 1,
|
||||
speed: 4,
|
||||
particleDensity: 100,
|
||||
particleColor: "var(--color-primary-cta)",
|
||||
background: "transparent",
|
||||
};
|
||||
|
||||
export const Sparkles = (props: SparklesProps) => {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
background = defaultProps.background,
|
||||
minSize = defaultProps.minSize,
|
||||
maxSize = defaultProps.maxSize,
|
||||
speed = defaultProps.speed,
|
||||
particleColor = defaultProps.particleColor,
|
||||
particleDensity = defaultProps.particleDensity,
|
||||
} = props;
|
||||
const [init, setInit] = useState(false);
|
||||
const [resolvedColor, setResolvedColor] = useState("#ffffff");
|
||||
|
||||
useEffect(() => {
|
||||
if (particleColor?.startsWith('var(')) {
|
||||
const varName = particleColor.match(/var\((.*?)\)/)?.[1];
|
||||
if (varName) {
|
||||
const computed = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (computed) {
|
||||
setResolvedColor(computed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setResolvedColor(particleColor || "#ffffff");
|
||||
}
|
||||
}, [particleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
initParticlesEngine(async (engine) => {
|
||||
await loadSlim(engine);
|
||||
}).then(() => {
|
||||
setInit(true);
|
||||
});
|
||||
}, []);
|
||||
const controls = useAnimation();
|
||||
|
||||
const particlesLoaded = async (container?: Container) => {
|
||||
if (container) {
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const generatedId = useId();
|
||||
return (
|
||||
<motion.div animate={controls} className={cls("absolute inset-0 opacity-0", className)}>
|
||||
{init && (
|
||||
<Particles
|
||||
id={id || generatedId}
|
||||
className={cls("h-full w-full")}
|
||||
particlesLoaded={particlesLoaded}
|
||||
options={{
|
||||
background: {
|
||||
color: {
|
||||
value: background || "transparent",
|
||||
},
|
||||
},
|
||||
fullScreen: {
|
||||
enable: false,
|
||||
zIndex: 1,
|
||||
},
|
||||
|
||||
fpsLimit: 120,
|
||||
interactivity: {
|
||||
events: {
|
||||
onClick: {
|
||||
enable: true,
|
||||
mode: "push",
|
||||
},
|
||||
onHover: {
|
||||
enable: false,
|
||||
mode: "repulse",
|
||||
},
|
||||
resize: true as any,
|
||||
},
|
||||
modes: {
|
||||
push: {
|
||||
quantity: 4,
|
||||
},
|
||||
repulse: {
|
||||
distance: 200,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
particles: {
|
||||
bounce: {
|
||||
horizontal: {
|
||||
value: 1,
|
||||
},
|
||||
vertical: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
collisions: {
|
||||
absorb: {
|
||||
speed: 2,
|
||||
},
|
||||
bounce: {
|
||||
horizontal: {
|
||||
value: 1,
|
||||
},
|
||||
vertical: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
enable: false,
|
||||
maxSpeed: 50,
|
||||
mode: "bounce",
|
||||
overlap: {
|
||||
enable: true,
|
||||
retries: 0,
|
||||
},
|
||||
},
|
||||
color: {
|
||||
value: resolvedColor,
|
||||
animation: {
|
||||
h: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
s: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
l: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
effect: {
|
||||
close: true,
|
||||
fill: true,
|
||||
options: {},
|
||||
type: {} as SingleOrMultiple<string> | undefined,
|
||||
},
|
||||
groups: {},
|
||||
move: {
|
||||
angle: {
|
||||
offset: 0,
|
||||
value: 90,
|
||||
},
|
||||
attract: {
|
||||
distance: 200,
|
||||
enable: false,
|
||||
rotate: {
|
||||
x: 3000,
|
||||
y: 3000,
|
||||
},
|
||||
},
|
||||
center: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
mode: "percent",
|
||||
radius: 0,
|
||||
},
|
||||
decay: 0,
|
||||
distance: {},
|
||||
direction: "none",
|
||||
drift: 0,
|
||||
enable: true,
|
||||
gravity: {
|
||||
acceleration: 9.81,
|
||||
enable: false,
|
||||
inverse: false,
|
||||
maxSpeed: 50,
|
||||
},
|
||||
path: {
|
||||
clamp: true,
|
||||
delay: {
|
||||
value: 0,
|
||||
},
|
||||
enable: false,
|
||||
options: {},
|
||||
},
|
||||
outModes: {
|
||||
default: "out",
|
||||
},
|
||||
random: false,
|
||||
size: false,
|
||||
speed: {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
},
|
||||
spin: {
|
||||
acceleration: 0,
|
||||
enable: false,
|
||||
},
|
||||
straight: false,
|
||||
trail: {
|
||||
enable: false,
|
||||
length: 10,
|
||||
fill: {},
|
||||
},
|
||||
vibrate: false,
|
||||
warp: false,
|
||||
},
|
||||
number: {
|
||||
density: {
|
||||
enable: true,
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
limit: {
|
||||
mode: "delete",
|
||||
value: 0,
|
||||
},
|
||||
value: particleDensity || 120,
|
||||
},
|
||||
opacity: {
|
||||
value: {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
},
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: true,
|
||||
speed: speed || 4,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
mode: "auto",
|
||||
startValue: "random",
|
||||
destroy: "none",
|
||||
},
|
||||
},
|
||||
reduceDuplicates: false,
|
||||
shadow: {
|
||||
blur: 0,
|
||||
color: {
|
||||
value: "#000",
|
||||
},
|
||||
enable: false,
|
||||
offset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
close: true,
|
||||
fill: true,
|
||||
options: {},
|
||||
type: "circle",
|
||||
},
|
||||
size: {
|
||||
value: {
|
||||
min: minSize || 1,
|
||||
max: maxSize || 3,
|
||||
},
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 5,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
mode: "auto",
|
||||
startValue: "random",
|
||||
destroy: "none",
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
width: 0,
|
||||
},
|
||||
zIndex: {
|
||||
value: 0,
|
||||
opacityRate: 1,
|
||||
sizeRate: 1,
|
||||
velocityRate: 1,
|
||||
},
|
||||
destroy: {
|
||||
bounds: {},
|
||||
mode: "none",
|
||||
split: {
|
||||
count: 1,
|
||||
factor: {
|
||||
value: 3,
|
||||
},
|
||||
rate: {
|
||||
value: {
|
||||
min: 4,
|
||||
max: 9,
|
||||
},
|
||||
},
|
||||
sizeOffset: true,
|
||||
},
|
||||
},
|
||||
roll: {
|
||||
darken: {
|
||||
enable: false,
|
||||
value: 0,
|
||||
},
|
||||
enable: false,
|
||||
enlighten: {
|
||||
enable: false,
|
||||
value: 0,
|
||||
},
|
||||
mode: "vertical",
|
||||
speed: 25,
|
||||
},
|
||||
tilt: {
|
||||
value: 0,
|
||||
animation: {
|
||||
enable: false,
|
||||
speed: 0,
|
||||
decay: 0,
|
||||
sync: false,
|
||||
},
|
||||
direction: "clockwise",
|
||||
enable: false,
|
||||
},
|
||||
twinkle: {
|
||||
lines: {
|
||||
enable: false,
|
||||
frequency: 0.05,
|
||||
opacity: 1,
|
||||
},
|
||||
particles: {
|
||||
enable: false,
|
||||
frequency: 0.05,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
wobble: {
|
||||
distance: 5,
|
||||
enable: false,
|
||||
speed: {
|
||||
angle: 50,
|
||||
move: 10,
|
||||
},
|
||||
},
|
||||
life: {
|
||||
count: 0,
|
||||
delay: {
|
||||
value: 0,
|
||||
sync: false,
|
||||
},
|
||||
duration: {
|
||||
value: 0,
|
||||
sync: false,
|
||||
},
|
||||
},
|
||||
rotate: {
|
||||
value: 0,
|
||||
animation: {
|
||||
enable: false,
|
||||
speed: 0,
|
||||
decay: 0,
|
||||
sync: false,
|
||||
},
|
||||
direction: "clockwise",
|
||||
path: false,
|
||||
},
|
||||
orbit: {
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
},
|
||||
enable: false,
|
||||
opacity: 1,
|
||||
rotation: {
|
||||
value: 45,
|
||||
},
|
||||
width: 1,
|
||||
},
|
||||
links: {
|
||||
blink: false,
|
||||
color: {
|
||||
value: "#fff",
|
||||
},
|
||||
consent: false,
|
||||
distance: 100,
|
||||
enable: false,
|
||||
frequency: 1,
|
||||
opacity: 1,
|
||||
shadow: {
|
||||
blur: 5,
|
||||
color: {
|
||||
value: "#000",
|
||||
},
|
||||
enable: false,
|
||||
},
|
||||
triangles: {
|
||||
enable: false,
|
||||
frequency: 1,
|
||||
},
|
||||
width: 1,
|
||||
warp: false,
|
||||
},
|
||||
repulse: {
|
||||
value: 0,
|
||||
enabled: false,
|
||||
distance: 1,
|
||||
duration: 1,
|
||||
factor: 1,
|
||||
speed: 1,
|
||||
},
|
||||
},
|
||||
detectRetina: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
56
src/components/background/SparklesGradientBackground.tsx
Normal file
56
src/components/background/SparklesGradientBackground.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface SparklesGradientBackgroundProps {
|
||||
className?: string;
|
||||
gradientColor?: string;
|
||||
accentColor?: string;
|
||||
blurAmount?: string;
|
||||
}
|
||||
|
||||
const SparklesGradientBackground = ({
|
||||
className = "",
|
||||
gradientColor = "var(--color-background-accent)",
|
||||
accentColor = "var(--color-background-accent)",
|
||||
blurAmount = "6vw",
|
||||
}: SparklesGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
style={{
|
||||
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[65vw] h-[88vh] -top-[59vh] overflow-visible z-0"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[100%] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, ${gradientColor}, color-mix(in srgb, ${gradientColor} 25%, transparent) 41%, color-mix(in srgb, ${gradientColor} 20%, transparent))`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[33vw] h-[53vh] rounded-[100%] overflow-hidden"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${accentColor} 30%, transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Sparkles />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SparklesGradientBackground.displayName = 'SparklesGradientBackground';
|
||||
|
||||
export default memo(SparklesGradientBackground);
|
||||
@@ -0,0 +1,102 @@
|
||||
.floating-gradient-background-container {
|
||||
--circle-size: 80%;
|
||||
--circle-size-small: 60%;
|
||||
--blending: hard-light;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-one {
|
||||
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size);
|
||||
height: var(--circle-size);
|
||||
top: calc(50% - var(--circle-size-small) / 2);
|
||||
left: calc(50% - var(--circle-size-small) / 2);
|
||||
transform-origin: center center;
|
||||
animation: moveVertical 20s ease infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-two {
|
||||
background: radial-gradient(circle at center, var(--color-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size);
|
||||
height: var(--circle-size);
|
||||
top: calc(50% - var(--circle-size-small) / 2);
|
||||
left: calc(50% - var(--circle-size-small) / 2);
|
||||
transform-origin: calc(50% - 400px);
|
||||
animation: moveInCircle 20s reverse infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-three {
|
||||
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size-small);
|
||||
height: var(--circle-size-small);
|
||||
top: calc(50% - var(--circle-size) / 2 + 200px);
|
||||
left: calc(50% - var(--circle-size) / 2 - 500px);
|
||||
transform-origin: calc(50% + 400px);
|
||||
animation: moveInCircle 30s linear infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-four {
|
||||
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size-small);
|
||||
height: var(--circle-size-small);
|
||||
top: calc(50% - var(--circle-size) / 2);
|
||||
left: calc(50% - var(--circle-size) / 2);
|
||||
transform-origin: calc(50% - 200px);
|
||||
animation: moveHorizontal 30s ease infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-five {
|
||||
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: calc(var(--circle-size-small) * 2);
|
||||
height: calc(var(--circle-size-small) * 2);
|
||||
top: calc(50% - var(--circle-size));
|
||||
left: calc(50% - var(--circle-size));
|
||||
transform-origin: calc(50% - 800px) calc(50% + 200px);
|
||||
animation: moveInCircle 20s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes moveInCircle {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveVertical {
|
||||
0% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveHorizontal {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(-10%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(50%) translateY(10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(-10%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./FloatingGradientBackground.css";
|
||||
|
||||
interface FloatingGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FloatingGradientBackground = ({
|
||||
className = "",
|
||||
}: FloatingGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed top-0 bottom-0 left-0 right-0 w-full h-full z-0 pointer-events-none blur-[40px]",
|
||||
"[mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
|
||||
"[mask-composite:intersect]",
|
||||
"[-webkit-mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
|
||||
"[-webkit-mask-composite:destination-in]",
|
||||
"floating-gradient-background-container",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute opacity-[0.075] floating-gradient-background-circle-one" />
|
||||
<div className="absolute opacity-[0.125] floating-gradient-background-circle-two" />
|
||||
<div className="absolute opacity-[0.125] floating-gradient-background-circle-three" />
|
||||
<div className="absolute opacity-[0.15] floating-gradient-background-circle-four" />
|
||||
<div className="absolute opacity-[0.075] floating-gradient-background-circle-five" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FloatingGradientBackground.displayName = "FloatingGradientBackground";
|
||||
|
||||
export default memo(FloatingGradientBackground);
|
||||
122
src/components/bento/Bento3DCardGrid.tsx
Normal file
122
src/components/bento/Bento3DCardGrid.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type GridCardItem = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
interface Bento3DCardGridProps {
|
||||
useInvertedBackground: InvertedBackground;
|
||||
items: [GridCardItem, GridCardItem, GridCardItem, GridCardItem];
|
||||
centerIcon: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const gridItemStyle = {
|
||||
perspective: '1000px',
|
||||
transformStyle: 'preserve-3d' as const,
|
||||
};
|
||||
|
||||
const EmptyCell = () => (
|
||||
<div
|
||||
className="relative aspect-square card shadow rounded-theme-capped opacity-50"
|
||||
style={gridItemStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTranslateZ = [
|
||||
'group-hover:[transform:translateZ(10px)]',
|
||||
'group-hover:[transform:translateZ(14px)]',
|
||||
'group-hover:[transform:translateZ(18px)]',
|
||||
'group-hover:[transform:translateZ(22px)]',
|
||||
] as const;
|
||||
|
||||
const CardCell = ({ name, Icon, cardIndex }: { name: string; Icon: LucideIcon; cardIndex: number }) => (
|
||||
<div
|
||||
className={cls(
|
||||
"relative card shadow aspect-square rounded-theme-capped flex flex-col justify-between p-3 transition-transform duration-500",
|
||||
cardTranslateZ[cardIndex]
|
||||
)}
|
||||
style={gridItemStyle}
|
||||
>
|
||||
<div className="h-6 w-[var(--height-6)] aspect-square rounded-theme primary-button flex items-center justify-center">
|
||||
<Icon className="h-4/10 w-4/10 text-background" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-xs text-foreground leading-tight line-clamp-4">{name}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CenterCell = ({ Icon }: { Icon: LucideIcon }) => (
|
||||
<div
|
||||
className="aspect-square flex items-center justify-center bg-transparent border-none overflow-visible"
|
||||
style={gridItemStyle}
|
||||
>
|
||||
<div className="card shadow rounded-full h-6/10 aspect-square flex items-center justify-center">
|
||||
<Icon className="h-4/10 w-4/10 text-foreground" strokeWidth={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bento3DCardGrid = ({
|
||||
useInvertedBackground,
|
||||
items,
|
||||
centerIcon: CenterIcon,
|
||||
className = "",
|
||||
}: Bento3DCardGridProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
const gridPositions = [
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 0 },
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 1 },
|
||||
{ type: 'center' },
|
||||
{ type: 'card', index: 2 },
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 3 },
|
||||
{ type: 'empty' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("group w-full h-full", className)}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMaskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full grid grid-cols-3 gap-4 -translate-y-9 -translate-x-8"
|
||||
style={{
|
||||
gridAutoRows: '1fr',
|
||||
perspective: '5000px',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: 'rotateX(45deg) rotateY(20deg) rotate(-25deg) scale(1.1)',
|
||||
}}
|
||||
>
|
||||
{gridPositions.map((pos, index) => {
|
||||
switch (pos.type) {
|
||||
case 'card':
|
||||
const item = items[pos.index];
|
||||
return <CardCell key={index} name={item.name} Icon={item.icon} cardIndex={pos.index} />;
|
||||
case 'center':
|
||||
return <CenterCell key={index} Icon={CenterIcon} />;
|
||||
default:
|
||||
return <EmptyCell key={index} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DCardGrid.displayName = "Bento3DCardGrid";
|
||||
|
||||
export default memo(Bento3DCardGrid);
|
||||
117
src/components/bento/Bento3DStackCards.tsx
Normal file
117
src/components/bento/Bento3DStackCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface StackCardProps {
|
||||
Icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
detail: string;
|
||||
iconClassName?: string;
|
||||
titleClassName?: string;
|
||||
subtitleClassName?: string;
|
||||
detailClassName?: string;
|
||||
}
|
||||
|
||||
interface Bento3DStackCardProps extends StackCardProps {
|
||||
className?: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
}
|
||||
|
||||
const StackCard = memo(({
|
||||
className = "",
|
||||
Icon,
|
||||
title,
|
||||
subtitle,
|
||||
detail,
|
||||
iconClassName = "",
|
||||
titleClassName = "",
|
||||
subtitleClassName = "",
|
||||
detailClassName = "",
|
||||
useInvertedBackground,
|
||||
}: Bento3DStackCardProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"relative flex h-35 w-80 md:w-25 p-6 -skew-y-[8deg] card rounded-theme-capped flex-col justify-between transition-all duration-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-5 aspect-square primary-button rounded-theme flex items-center justify-center",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<Icon className="h-1/2 w-auto aspect-square text-background" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("whitespace-nowrap text-lg", shouldUseLightText ? "text-background" : "text-foreground", subtitleClassName)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", detailClassName)}>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StackCard.displayName = "StackCard";
|
||||
|
||||
interface Bento3DStackCardsProps {
|
||||
cards: StackCardProps[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Bento3DStackCards = ({
|
||||
cards,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: Bento3DStackCardsProps) => {
|
||||
const baseClassNames = [
|
||||
"[grid-area:stack] -translate-y-14 hover:-translate-y-20",
|
||||
"[grid-area:stack] translate-x-15 translate-y-0 hover:-translate-y-5",
|
||||
"[grid-area:stack] translate-x-31 translate-y-15 hover:translate-y-10",
|
||||
];
|
||||
|
||||
const displayCards = cards.slice(0, 3).map((card, index) => ({
|
||||
...card,
|
||||
className: `${baseClassNames[index]} ${card.iconClassName || ""}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("h-full grid [grid-template-areas:'stack'] place-items-center opacity-100 animate-in fade-in-0 duration-700", className)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
{displayCards.map((cardProps, index) => (
|
||||
<StackCard
|
||||
key={index}
|
||||
{...cardProps}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DStackCards.displayName = "Bento3DStackCards";
|
||||
|
||||
export default memo(Bento3DStackCards);
|
||||
97
src/components/bento/Bento3DTaskList.tsx
Normal file
97
src/components/bento/Bento3DTaskList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Fragment } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type TaskItem = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
interface Bento3DTaskListProps {
|
||||
title: string;
|
||||
items: TaskItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Bento3DTaskList = ({
|
||||
title,
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: Bento3DTaskListProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("h-full w-full flex items-center justify-center", className)}
|
||||
style={{
|
||||
perspective: "1200px",
|
||||
transformStyle: "preserve-3d",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative w-80 md:w-25 p-6 card rounded-theme-capped flex flex-col gap-3 translate-x-4 -translate-y-5"
|
||||
)}
|
||||
style={{
|
||||
transform: "rotateX(30deg) rotateY(30deg) rotateZ(-30deg)",
|
||||
transformStyle: "preserve-3d"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[var(--text-base)] w-auto aspect-square rounded-theme primary-button" />
|
||||
<h3 className={cls("text-base leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full min-w-0 secondary-button rounded-theme-capped flex flex-col p-5 gap-3">
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div
|
||||
className={cls(
|
||||
"w-full min-w-0 flex items-center justify-between gap-3"
|
||||
)}
|
||||
>
|
||||
<div className="w-full min-w-0 flex items-center gap-3">
|
||||
<div
|
||||
className="h-6 w-auto aspect-square rounded-theme flex items-center justify-center primary-button"
|
||||
>
|
||||
<Icon className="h-4/10 w-4/10 aspect-square text-background" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-sm truncate", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("text-xs text-nowrap", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
{index !== items.length - 1 && (
|
||||
<div className="h-px bg-background-accent/50" />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DTaskList.displayName = "Bento3DTaskList";
|
||||
|
||||
export default memo(Bento3DTaskList);
|
||||
77
src/components/bento/BentoAnimatedBarChart.tsx
Normal file
77
src/components/bento/BentoAnimatedBarChart.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type BarData = {
|
||||
defaultHeight: number;
|
||||
hoverHeight: number;
|
||||
};
|
||||
|
||||
interface BentoAnimatedBarChartProps {
|
||||
bars?: BarData[];
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
}
|
||||
|
||||
const defaultBars: BarData[] = [
|
||||
{ defaultHeight: 100, hoverHeight: 40 },
|
||||
{ defaultHeight: 84, hoverHeight: 100 },
|
||||
{ defaultHeight: 62, hoverHeight: 75 },
|
||||
{ defaultHeight: 90, hoverHeight: 50 },
|
||||
{ defaultHeight: 70, hoverHeight: 90 },
|
||||
{ defaultHeight: 50, hoverHeight: 60 },
|
||||
{ defaultHeight: 75, hoverHeight: 85 },
|
||||
{ defaultHeight: 80, hoverHeight: 70 },
|
||||
];
|
||||
|
||||
const BentoAnimatedBarChart = ({
|
||||
bars = defaultBars,
|
||||
className = "",
|
||||
barClassName = "",
|
||||
}: BentoAnimatedBarChartProps) => {
|
||||
const [activeBar, setActiveBar] = useState(2); // Start at third bar (index 2)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveBar((prev) => (prev + 1) % bars.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [bars.length]);
|
||||
|
||||
return (
|
||||
<div className={cls("group w-full h-full [mask-image:linear-gradient(to_bottom,black_40%,transparent_100%)]", className)}>
|
||||
<style>{`
|
||||
.bento-bar {
|
||||
height: var(--default-height);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.group:hover .bento-bar {
|
||||
height: var(--hover-height) !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="w-full h-full flex items-end gap-5">
|
||||
{bars.map((bar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("relative bento-bar w-full rounded-theme transition-all duration-500 ease bg-background-accent", barClassName)}
|
||||
style={
|
||||
{
|
||||
"--default-height": `${bar.defaultHeight}%`,
|
||||
"--hover-height": `${bar.hoverHeight}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={cls("absolute! inset-0 primary-button rounded-theme transition-opacity ease-in-out duration-500", activeBar === index ? "opacity-100" : "opacity-0")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoAnimatedBarChart.displayName = "BentoAnimatedBarChart";
|
||||
|
||||
export default memo(BentoAnimatedBarChart);
|
||||
96
src/components/bento/BentoChatAnimation.tsx
Normal file
96
src/components/bento/BentoChatAnimation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type ChatExchange = {
|
||||
userMessage: string;
|
||||
aiResponse: string;
|
||||
};
|
||||
|
||||
interface BentoChatAnimationProps {
|
||||
aiIcon: LucideIcon;
|
||||
userIcon: LucideIcon;
|
||||
exchanges: ChatExchange[];
|
||||
placeholder: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoChatAnimation = ({
|
||||
aiIcon: AiIcon,
|
||||
userIcon: UserIcon,
|
||||
exchanges,
|
||||
placeholder,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoChatAnimationProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const messages = exchanges.flatMap((exchange) => [
|
||||
{ content: exchange.userMessage, isUser: true },
|
||||
{ content: exchange.aiResponse, isUser: false },
|
||||
]);
|
||||
const duplicatedMessages = [...messages, ...messages];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-full w-full flex flex-col overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col animate-marquee-vertical px-4">
|
||||
{duplicatedMessages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"flex items-end gap-2 shrink-0 mb-4",
|
||||
message.isUser ? "flex-row-reverse" : "flex-row"
|
||||
)}
|
||||
>
|
||||
{message.isUser ? (
|
||||
<div className="shrink-0 h-8 aspect-square rounded-theme primary-button flex items-center justify-center">
|
||||
<UserIcon className="h-4/10 w-auto text-background" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 h-8 aspect-square rounded-theme card shadow flex items-center justify-center">
|
||||
<AiIcon className={cls("h-4/10 w-auto", shouldUseLightText ? "text-background" : "text-foreground")} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cls(
|
||||
"max-w-75/100 px-4 py-3 text-sm leading-tight",
|
||||
message.isUser
|
||||
? "primary-button rounded-theme-capped rounded-br-none text-background"
|
||||
: "card rounded-theme-capped rounded-bl-none",
|
||||
!message.isUser && (shouldUseLightText ? "text-background" : "text-foreground")
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow rounded-theme p-2 pl-5 flex items-center gap-2">
|
||||
<p className={cls("flex-1 text-sm truncate", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
|
||||
{placeholder}
|
||||
</p>
|
||||
<div className="h-7 w-auto aspect-square primary-button rounded-theme flex items-center justify-center">
|
||||
<Send className="h-4/10 w-auto text-background" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoChatAnimation.displayName = "BentoChatAnimation";
|
||||
|
||||
export default memo(BentoChatAnimation);
|
||||
204
src/components/bento/BentoGlobe.tsx
Normal file
204
src/components/bento/BentoGlobe.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import createGlobe, { COBEOptions } from "cobe";
|
||||
|
||||
// Helper function to convert CSS color to RGB array
|
||||
const getRGBFromCSSVar = (varName: string): [number, number, number] => {
|
||||
if (typeof window === "undefined") return [0.5, 0.5, 0.5];
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
|
||||
// Handle CSS named colors by creating a temporary element to get computed RGB
|
||||
if (value && !value.startsWith("#") && !value.startsWith("rgb") && !value.includes("%") && !value.match(/^\d+\s+\d+\s+\d+$/)) {
|
||||
const temp = document.createElement("div");
|
||||
temp.style.color = value;
|
||||
document.body.appendChild(temp);
|
||||
const computed = getComputedStyle(temp).color;
|
||||
document.body.removeChild(temp);
|
||||
|
||||
if (computed && computed.startsWith("rgb")) {
|
||||
const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
const r = parseInt(match[1]) / 255;
|
||||
const g = parseInt(match[2]) / 255;
|
||||
const b = parseInt(match[3]) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rgba/rgb format (e.g., "rgba(18, 0, 6, .9)" or "rgb(255, 255, 255)")
|
||||
if (value.startsWith("rgb")) {
|
||||
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
const r = parseInt(match[1]) / 255;
|
||||
const g = parseInt(match[2]) / 255;
|
||||
const b = parseInt(match[3]) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hex format (e.g., "#ffffff", "#ffffffaa", or shorthand "#fff", "#f0f")
|
||||
if (value.startsWith("#")) {
|
||||
let hex = value.replace("#", "");
|
||||
// Expand shorthand hex (e.g., "93f" -> "9933ff")
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = hex.split("").map(c => c + c).join("").substring(0, 6);
|
||||
}
|
||||
// Take only first 6 characters (ignore alpha channel if present)
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// Handle HSL format (e.g., "0 0% 100%")
|
||||
if (value.includes("%")) {
|
||||
const [h, s, l] = value.split(/\s+/).map(v => parseFloat(v));
|
||||
// Convert HSL to RGB
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
return [(r + m), (g + m), (b + m)];
|
||||
}
|
||||
|
||||
// Handle RGB format (e.g., "255 255 255")
|
||||
const [r, g, b] = value.split(/\s+/).map(v => parseFloat(v) / 255);
|
||||
return [r || 0.5, g || 0.5, b || 0.5];
|
||||
};
|
||||
|
||||
const getGlobeConfig = (): COBEOptions => ({
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: 0,
|
||||
diffuse: 0.4,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 1.2,
|
||||
baseColor: getRGBFromCSSVar("--secondary-cta"),
|
||||
markerColor: getRGBFromCSSVar("--primary-cta"),
|
||||
glowColor: getRGBFromCSSVar("--card"),
|
||||
markers: [
|
||||
{ location: [14.5995, 120.9842], size: 0.03 },
|
||||
{ location: [19.076, 72.8777], size: 0.1 },
|
||||
{ location: [23.8103, 90.4125], size: 0.05 },
|
||||
{ location: [30.0444, 31.2357], size: 0.07 },
|
||||
{ location: [39.9042, 116.4074], size: 0.08 },
|
||||
{ location: [-23.5505, -46.6333], size: 0.1 },
|
||||
{ location: [19.4326, -99.1332], size: 0.1 },
|
||||
{ location: [40.7128, -74.006], size: 0.1 },
|
||||
{ location: [34.6937, 135.5022], size: 0.05 },
|
||||
{ location: [41.0082, 28.9784], size: 0.06 },
|
||||
],
|
||||
});
|
||||
|
||||
interface GlobeProps {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
}
|
||||
|
||||
const GlobeComponent = ({
|
||||
className = "",
|
||||
config,
|
||||
}: GlobeProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const globeRef = useRef<{ destroy: () => void } | null>(null);
|
||||
const phiRef = useRef(0);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [globeConfig, setGlobeConfig] = useState<COBEOptions | null>(null);
|
||||
|
||||
const onRender = useCallback(
|
||||
(state: Record<string, number>) => {
|
||||
phiRef.current += 0.005;
|
||||
state.phi = phiRef.current;
|
||||
state.width = dimensions.width * 2;
|
||||
state.height = dimensions.width * 2;
|
||||
},
|
||||
[dimensions]
|
||||
);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (canvasRef.current) {
|
||||
const newWidth = canvasRef.current.offsetWidth;
|
||||
setDimensions(prev => {
|
||||
if (prev.width === newWidth) return prev;
|
||||
return { width: newWidth, height: newWidth };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [onResize]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize globe config with CSS variables
|
||||
const defaultConfig = getGlobeConfig();
|
||||
setGlobeConfig(config ? { ...defaultConfig, ...config } : defaultConfig);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || dimensions.width === 0 || !globeConfig) return;
|
||||
|
||||
if (globeRef.current) {
|
||||
globeRef.current.destroy();
|
||||
}
|
||||
|
||||
globeRef.current = createGlobe(canvasRef.current, {
|
||||
...globeConfig,
|
||||
width: dimensions.width * 2,
|
||||
height: dimensions.width * 2,
|
||||
onRender,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.destroy();
|
||||
globeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dimensions, globeConfig, onRender]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 mx-auto w-full aspect-square",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
className="size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
|
||||
ref={canvasRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlobeComponent.displayName = "BentoGlobe";
|
||||
|
||||
export const BentoGlobe = React.memo(GlobeComponent);
|
||||
72
src/components/bento/BentoIconInfoCards.tsx
Normal file
72
src/components/bento/BentoIconInfoCards.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type BentoInfoItem = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface BentoIconInfoCardsProps {
|
||||
items: BentoInfoItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
iconWrapperClassName?: string;
|
||||
iconClassName?: string;
|
||||
labelClassName?: string;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
const BentoIconInfoCards = ({
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
cardClassName = "",
|
||||
iconWrapperClassName = "",
|
||||
iconClassName = "",
|
||||
labelClassName = "",
|
||||
valueClassName = "",
|
||||
}: BentoIconInfoCardsProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const duplicatedItems = [...items, ...items];
|
||||
|
||||
return (
|
||||
<div className={cls("h-full min-h-0 overflow-hidden mask-fade-y", className)}>
|
||||
<div className="flex flex-col animate-marquee-vertical px-px">
|
||||
{duplicatedItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("card shadow rounded-theme-capped p-3 flex items-center justify-between flex-shrink-0 mb-4", cardClassName)}
|
||||
>
|
||||
<div className="w-full min-w-0 flex items-center gap-3">
|
||||
<div className={cls("h-10 w-auto aspect-square rounded-theme flex items-center justify-center secondary-button", iconWrapperClassName)}>
|
||||
<Icon className={cls("h-4/10 w-4/10 text-foreground", iconClassName)} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-base truncate", shouldUseLightText ? "text-background" : "text-foreground", labelClassName)}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", valueClassName)}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoIconInfoCards.displayName = "BentoIconInfoCards";
|
||||
|
||||
export default memo(BentoIconInfoCards);
|
||||
145
src/components/bento/BentoLineChart/BentoLineChart.tsx
Normal file
145
src/components/bento/BentoLineChart/BentoLineChart.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { formatNumber, calculateYAxisWidth, type ChartDataItem } from "./utils";
|
||||
import CustomTooltip from "./CustomTooltip";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface BentoLineChartProps {
|
||||
data?: ChartDataItem[];
|
||||
dataKey?: string;
|
||||
metricLabel?: string;
|
||||
isPercentage?: boolean;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultData: ChartDataItem[] = [
|
||||
{ value: 120 },
|
||||
{ value: 180 },
|
||||
{ value: 150 },
|
||||
{ value: 280 },
|
||||
{ value: 220 },
|
||||
{ value: 350 },
|
||||
{ value: 300 },
|
||||
{ value: 250 },
|
||||
];
|
||||
|
||||
const BentoLineChart = memo<BentoLineChartProps>(
|
||||
({
|
||||
data = defaultData,
|
||||
dataKey = "value",
|
||||
metricLabel = "Value",
|
||||
isPercentage = false,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const yAxisWidth = calculateYAxisWidth(data, isPercentage);
|
||||
|
||||
const strokeColor = "var(--primary-cta)";
|
||||
const gridColor = "color-mix(in srgb, var(--background-accent) 30%, transparent)";
|
||||
const tickColor = shouldUseLightText ? "var(--background)" : "var(--foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("w-full h-full **:outline-none **:focus:outline-none", className)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 14,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="bentoLineChartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="bentoFadeGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="black" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="black" stopOpacity={0} />
|
||||
<stop offset="15%" stopColor="white" stopOpacity={1} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={1} />
|
||||
<stop offset="100%" stopColor="black" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<mask id="bentoFadeMask">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#bentoFadeGradient)"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{
|
||||
fill: tickColor,
|
||||
fontSize: 10,
|
||||
}}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) =>
|
||||
isPercentage ? `${value}%` : formatNumber(value)
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip
|
||||
metricLabel={metricLabel}
|
||||
isPercentage={isPercentage}
|
||||
totalItems={data.length}
|
||||
/>
|
||||
}
|
||||
cursor={{
|
||||
stroke: gridColor,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
fill="url(#bentoLineChartFill)"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
mask="url(#bentoFadeMask)"
|
||||
activeDot={{
|
||||
fill: strokeColor,
|
||||
r: 5,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BentoLineChart.displayName = "BentoLineChart";
|
||||
|
||||
export default BentoLineChart;
|
||||
64
src/components/bento/BentoLineChart/CustomTooltip.tsx
Normal file
64
src/components/bento/BentoLineChart/CustomTooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { formatNumber } from "./utils";
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
label?: number;
|
||||
metricLabel?: string;
|
||||
isPercentage?: boolean;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
const CustomTooltip = memo<CustomTooltipProps>(
|
||||
({
|
||||
active,
|
||||
payload,
|
||||
label = 0,
|
||||
metricLabel = "Value",
|
||||
isPercentage = false,
|
||||
totalItems,
|
||||
}: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = isPercentage
|
||||
? `${payload[0].value}%`
|
||||
: formatNumber(payload[0].value);
|
||||
const today = new Date();
|
||||
const daysAgo = totalItems - 1 - label;
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - daysAgo);
|
||||
return (
|
||||
<div className="card rounded-theme-capped p-3">
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 aspect-square rounded-full"
|
||||
style={{
|
||||
backgroundColor: payload[0].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-foreground">
|
||||
{metricLabel}: {value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
|
||||
export default CustomTooltip;
|
||||
33
src/components/bento/BentoLineChart/utils.ts
Normal file
33
src/components/bento/BentoLineChart/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const formatNumber = (value: number): string => {
|
||||
if (value >= 100000) {
|
||||
const millions = value / 1000000;
|
||||
return `${millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
const thousands = value / 1000;
|
||||
const rounded = Math.round(thousands * 10) / 10;
|
||||
return `${rounded}K`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export interface ChartDataItem {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const calculateYAxisWidth = (
|
||||
data: ChartDataItem[],
|
||||
isPercentage: boolean
|
||||
): number => {
|
||||
const maxValue = Math.max(...data.map((item) => item.value));
|
||||
const formattedMax = isPercentage ? `${maxValue}%` : formatNumber(maxValue);
|
||||
|
||||
let multiplier = 9;
|
||||
if (formattedMax.length === 2) {
|
||||
multiplier = 11;
|
||||
} else if (formattedMax.length === 3) {
|
||||
multiplier = 13;
|
||||
}
|
||||
|
||||
return formattedMax.length * multiplier;
|
||||
};
|
||||
1460
src/components/bento/BentoMap.tsx
Normal file
1460
src/components/bento/BentoMap.tsx
Normal file
File diff suppressed because it is too large
Load Diff
71
src/components/bento/BentoMarquee.tsx
Normal file
71
src/components/bento/BentoMarquee.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Marquee from "react-fast-marquee";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type BentoMarqueeProps = {
|
||||
centerIcon: LucideIcon;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
} & (
|
||||
| { variant: "text"; texts: string[] }
|
||||
| { variant: "icon"; icons: LucideIcon[] }
|
||||
);
|
||||
|
||||
const BentoMarquee = (props: BentoMarqueeProps) => {
|
||||
const { centerIcon, useInvertedBackground, className = "" } = props;
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const CenterIcon = centerIcon;
|
||||
const items = props.variant === "text"
|
||||
? [...props.texts, ...props.texts]
|
||||
: [...props.icons, ...props.icons];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("relative h-full w-full flex flex-col overflow-hidden", className)}
|
||||
style={{
|
||||
maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)"
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-1/2 h-auto w-full flex flex-col justify-center gap-2 opacity-60">
|
||||
{Array.from({ length: 10 }).map((_, rowIndex) => (
|
||||
<Marquee
|
||||
key={rowIndex}
|
||||
gradient={false}
|
||||
speed={10}
|
||||
direction={rowIndex % 2 === 0 ? "left" : "right"}
|
||||
>
|
||||
{items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className={cls("relative mx-1 card rounded-theme flex items-center justify-center", props.variant === "icon" ? "p-2 aspect-square" : "px-4 py-2")}
|
||||
>
|
||||
{props.variant === "text" ? (
|
||||
<p className={cls("text-sm leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>{item as string}</p>
|
||||
) : (
|
||||
(() => {
|
||||
const Icon = item as LucideIcon;
|
||||
return <Icon className={cls("h-1/2 w-1/2", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute! top-1/2 left-1/2 -translate-1/2 z-10 h-18 w-auto aspect-square primary-button backdrop-blur-xs rounded-theme flex items-center justify-center">
|
||||
<CenterIcon className="h-4/10 w-4/10 text-background" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoMarquee.displayName = "BentoMarquee";
|
||||
|
||||
export default memo(BentoMarquee);
|
||||
82
src/components/bento/BentoMediaStack.tsx
Normal file
82
src/components/bento/BentoMediaStack.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type MediaStackItem = {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
|
||||
interface BentoMediaStackProps {
|
||||
items: [MediaStackItem, MediaStackItem, MediaStackItem];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoMediaStack = ({
|
||||
items,
|
||||
className = "",
|
||||
}: BentoMediaStackProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("group/stack relative w-full h-full card shadow rounded-theme-capped flex items-center justify-center select-none", className)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[1]",
|
||||
"rotate-8 translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
|
||||
"2xl:translate-x-[8%] 2xl:-translate-y-[6%]",
|
||||
"group-hover/stack:translate-x-[22%] group-hover/stack:rotate-12 group-hover/stack:-translate-y-[14%]",
|
||||
"2xl:group-hover/stack:translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[2].imageSrc}
|
||||
videoSrc={items[2].videoSrc}
|
||||
imageAlt={items[2].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[2]",
|
||||
"-rotate-8 -translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
|
||||
"2xl:-translate-x-[8%] 2xl:-translate-y-[6%]",
|
||||
"group-hover/stack:-translate-x-[22%] group-hover/stack:-rotate-12 group-hover/stack:-translate-y-[14%]",
|
||||
"2xl:group-hover/stack:-translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[1].imageSrc}
|
||||
videoSrc={items[1].videoSrc}
|
||||
imageAlt={items[1].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-30",
|
||||
"translate-y-[10%] transition-all duration-500 ease-out",
|
||||
"2xl:translate-y-[7%]",
|
||||
"group-hover/stack:translate-y-[20%]",
|
||||
"2xl:group-hover/stack:translate-y-[14%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[0].imageSrc}
|
||||
videoSrc={items[0].videoSrc}
|
||||
imageAlt={items[0].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoMediaStack.displayName = "BentoMediaStack";
|
||||
|
||||
export default memo(BentoMediaStack);
|
||||
104
src/components/bento/BentoOrbitingIcons.tsx
Normal file
104
src/components/bento/BentoOrbitingIcons.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type OrbitingItem = {
|
||||
icon: LucideIcon;
|
||||
ring?: 1 | 2 | 3; // Which ring to orbit on (1=innermost, 3=outermost), defaults to 2
|
||||
duration?: number; // Animation duration in seconds, defaults to 10
|
||||
};
|
||||
|
||||
interface BentoOrbitingIconsProps {
|
||||
centerIcon: LucideIcon;
|
||||
items: OrbitingItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoOrbitingIcons = ({
|
||||
centerIcon,
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoOrbitingIconsProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const CenterIcon = centerIcon;
|
||||
|
||||
const circleStyles = "secondary-button border border-background-accent! shadow rounded-full";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("relative h-full flex flex-col overflow-hidden", className)}
|
||||
style={{
|
||||
perspective: "2000px",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1 rounded-t-theme-capped gap-2 flex items-center justify-center w-full h-full inset-x-0 p-2 relative"
|
||||
style={{
|
||||
transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)"
|
||||
}}
|
||||
>
|
||||
{/* Background concentric circles */}
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[15rem] w-[15rem] z-[9] opacity-85", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[20rem] w-[20rem] z-[8] opacity-65", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[25rem] w-[25rem] z-[7] opacity-45", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[30rem] w-[30rem] z-[6] opacity-25", circleStyles)} />
|
||||
|
||||
{/* Center circle with icon */}
|
||||
<div className={cls("absolute! inset-0 shrink-0 h-40 w-[10rem] z-10 m-auto flex items-center justify-center", circleStyles)}>
|
||||
|
||||
<div className="absolute! primary-button h-[5rem] w-[5rem] rounded-full flex items-center justify-center" >
|
||||
<CenterIcon className="absolute h-1/2 w-1/2 text-background" strokeWidth={1.25} />
|
||||
</div>
|
||||
|
||||
{/* Orbiting items */}
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const ring = item.ring || 2;
|
||||
// Ring radii: 7.5rem=120px, 10rem=160px, 12.5rem=200px
|
||||
const radiusMap = { 1: 120, 2: 160, 3: 200 };
|
||||
const radius = radiusMap[ring];
|
||||
const duration = item.duration || 10;
|
||||
// Evenly distribute items around the circle
|
||||
const initialPosition = (360 / items.length) * index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("!absolute top-1/2 left-1/2 h-[2.5rem] w-[2.5rem] card shadow rounded-theme flex items-center justify-center")}
|
||||
style={{
|
||||
marginLeft: '-1.25rem',
|
||||
marginTop: '-1.25rem',
|
||||
animation: `orbit ${duration}s linear infinite`,
|
||||
"--initial-position": `${initialPosition}deg`,
|
||||
"--translate-position": `${radius}px`,
|
||||
"--orbit-duration": `${duration}s`,
|
||||
} as React.CSSProperties & {
|
||||
"--initial-position": string;
|
||||
"--translate-position": string;
|
||||
"--orbit-duration": string;
|
||||
}}
|
||||
>
|
||||
<Icon className={cls("h-4/10 w-4/10", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoOrbitingIcons.displayName = "BentoOrbitingIcons";
|
||||
|
||||
export default memo(BentoOrbitingIcons);
|
||||
115
src/components/bento/BentoPhoneAnimation.tsx
Normal file
115
src/components/bento/BentoPhoneAnimation.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type PhoneApp = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export type PhoneApps8 = [PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp];
|
||||
|
||||
interface BentoPhoneAnimationProps {
|
||||
statusIcon: LucideIcon;
|
||||
alertIcon: LucideIcon;
|
||||
alertTitle: string;
|
||||
alertMessage: string;
|
||||
apps: PhoneApps8;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoPhoneAnimation = ({
|
||||
statusIcon: StatusIcon,
|
||||
alertIcon: AlertIcon,
|
||||
alertTitle,
|
||||
alertMessage,
|
||||
apps,
|
||||
className = "",
|
||||
}: BentoPhoneAnimationProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group/phone relative h-full flex flex-auto items-center justify-center overflow-hidden cursor-pointer",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-x-0 top-0 h-full overflow-hidden isolate",
|
||||
"pt-8 transition-[padding] duration-500 ease-out group-hover/phone:pt-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative mx-auto card shadow h-100 w-[calc(100%-var(--vw-2)*2)] rounded-[3vw] p-2",
|
||||
)}
|
||||
>
|
||||
<div className="w-full min-w-0 relative h-full overflow-hidden secondary-button rounded-[2.6vw] p-8 pt-6" >
|
||||
<div
|
||||
className="relative z-10 mx-auto h-7 w-auto aspect-square card shadow flex items-center justify-center rounded-full"
|
||||
>
|
||||
<StatusIcon className="h-4/10 w-4/10 text-foreground transition-colors duration-300 group-hover/phone:text-primary-cta" />
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! left-8 right-8 z-2 gap-[0.5vw] p-3 card flex flex-row items-center rounded-theme-capped",
|
||||
"-translate-y-30 scale-90 blur-[2px] opacity-50",
|
||||
"transition-all duration-500 ease-out",
|
||||
"group-hover/phone:translate-y-0 group-hover/phone:scale-100 group-hover/phone:blur-none group-hover/phone:opacity-100",
|
||||
)}
|
||||
style={{ top: "calc(var(--vw-1_5) + var(--height-7) + var(--vw-1_5))" }}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-8 w-auto aspect-square primary-button flex shrink-0 items-center justify-center rounded-theme",
|
||||
)}
|
||||
>
|
||||
<AlertIcon className="h-4/10 w-4/10 text-background" />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0">
|
||||
<h3
|
||||
className={cls(
|
||||
"text-sm leading-tight text-foreground",
|
||||
)}
|
||||
>
|
||||
{alertTitle}
|
||||
</h3>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground/75 leading-tight truncate",
|
||||
)}
|
||||
>
|
||||
{alertMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full min-w-0 grid grid-cols-4 gap-6 mt-6">
|
||||
{apps.map(({ name, icon: Icon }) => (
|
||||
<div key={name} className="w-full min-w-0 flex flex-col items-center gap-2">
|
||||
<div className="aspect-square w-full primary-button rounded-theme-capped flex items-center justify-center">
|
||||
<Icon className="h-2/5 w-2/5 text-background" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="w-full text-xs text-foreground text-center truncate">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoPhoneAnimation.displayName = "BentoPhoneAnimation";
|
||||
|
||||
export default memo(BentoPhoneAnimation);
|
||||
83
src/components/bento/BentoRevealIcon.tsx
Normal file
83
src/components/bento/BentoRevealIcon.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface BentoRevealIconProps {
|
||||
icon: LucideIcon;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoRevealIcon = ({
|
||||
icon: Icon,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoRevealIconProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group relative h-full w-full flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
|
||||
WebkitMaskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-26 w-[6.5rem]">
|
||||
<div
|
||||
className="absolute right-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:-translate-x-12"
|
||||
style={{ transform: "translateX(calc(52px + 1px - 2px))" }}
|
||||
>
|
||||
<div className="relative h-96 aspect-[224/280] -scale-x-100">
|
||||
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
|
||||
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:translate-x-12"
|
||||
style={{ transform: "translateX(calc(-52px - 1px + 2px))" }}
|
||||
>
|
||||
<div className="relative h-96 aspect-[224/280]">
|
||||
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
|
||||
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-full p-2">
|
||||
<div className="relative w-full h-full primary-button rounded-theme flex items-center justify-center">
|
||||
<Icon className="relative z-10 h-4/10 w-auto text-background" strokeWidth={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-px z-10 rounded-full mix-blend-overlay"
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 z-10 transition-transform duration-500 ease-out group-hover:translate-x-0 group-hover:translate-y-0"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(to bottom right, transparent 30%, black, transparent 70%)",
|
||||
transform: "translate(-65px, -65px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoRevealIcon.displayName = "BentoRevealIcon";
|
||||
|
||||
export default memo(BentoRevealIcon);
|
||||
115
src/components/bento/BentoTimeline.tsx
Normal file
115
src/components/bento/BentoTimeline.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { Check, Loader } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type TimelineItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
interface BentoTimelineProps {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
items: [TimelineItem, TimelineItem, TimelineItem];
|
||||
completedLabel: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const itemDelays = [
|
||||
{ check: 'delay-[150ms]', label: 'delay-[200ms]', detail: 'delay-[250ms]' },
|
||||
{ check: 'delay-[350ms]', label: 'delay-[400ms]', detail: 'delay-[450ms]' },
|
||||
{ check: 'delay-[550ms]', label: 'delay-[600ms]', detail: 'delay-[650ms]' },
|
||||
] as const;
|
||||
|
||||
const BentoTimeline = ({
|
||||
heading,
|
||||
subheading,
|
||||
items,
|
||||
completedLabel,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoTimelineProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group relative h-full w-full flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-100" />
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-80" />
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-60" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-full min-w-0 flex flex-col gap-3 p-4">
|
||||
<div className="card shadow rounded-theme-capped p-3 flex items-center gap-2">
|
||||
<Loader className="h-[var(--text-sm)] w-auto text-primary transition-transform duration-1000 ease-out group-hover:rotate-[360deg]" strokeWidth={1.5} />
|
||||
<p className="text-xs text-foreground truncate">{heading}</p>
|
||||
<p className="text-xs text-foreground/75 ml-auto text-nowrap">{subheading}</p>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card shadow rounded-theme-capped px-3 py-2 flex items-center gap-2"
|
||||
>
|
||||
<div className="relative h-6 w-auto aspect-square card shadow rounded-theme flex items-center justify-center">
|
||||
<div className="absolute h-3/10 w-3/10 primary-button rounded-theme transition-opacity duration-300 group-hover:opacity-0" />
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! inset-0 rounded-theme primary-button flex items-center justify-center",
|
||||
"opacity-0 scale-75 transition-all duration-300",
|
||||
`group-hover:opacity-100 group-hover:scale-100 ${itemDelays[index].check}`
|
||||
)}
|
||||
>
|
||||
<Check className="h-1/2 w-1/2 text-background" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0 max-w-full flex-1 flex items-center gap-10 justify-between">
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground truncate opacity-0 transition-all duration-300",
|
||||
`group-hover:opacity-100 ${itemDelays[index].label}`
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground/75 text-nowrap opacity-0 translate-y-1 transition-all duration-300",
|
||||
`group-hover:opacity-100 group-hover:translate-y-0 ${itemDelays[index].detail}`
|
||||
)}
|
||||
>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="primary-button rounded-theme-capped p-3 flex items-center justify-center">
|
||||
<div className="absolute flex gap-2 transition-opacity duration-500 delay-[900ms] group-hover:opacity-0">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-2 w-auto aspect-square rounded-theme bg-background" />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs text-background truncate opacity-0 transition-opacity duration-500 delay-[900ms] group-hover:opacity-100"
|
||||
>
|
||||
{completedLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoTimeline.displayName = "BentoTimeline";
|
||||
|
||||
export default memo(BentoTimeline);
|
||||
43
src/components/button/Button.tsx
Normal file
43
src/components/button/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ButtonHoverMagnetic from "./ButtonHoverMagnetic/ButtonHoverMagnetic";
|
||||
import ButtonIconArrow from "./ButtonIconArrow";
|
||||
import ButtonShiftHover from "./ButtonShiftHover/ButtonShiftHover";
|
||||
import ButtonTextStagger from "./ButtonTextStagger/ButtonTextStagger";
|
||||
import ButtonTextUnderline from "./ButtonTextUnderline";
|
||||
import ButtonHoverBubble from "./ButtonHoverBubble";
|
||||
import ButtonExpandHover from "./ButtonExpandHover";
|
||||
import ButtonElasticEffect from "./ButtonElasticEffect/ButtonElasticEffect";
|
||||
import ButtonBounceEffect from "./ButtonBounceEffect/ButtonBounceEffect";
|
||||
import ButtonDirectionalHover from "./ButtonDirectionalHover/ButtonDirectionalHover";
|
||||
import ButtonTextShift from "./ButtonTextShift/ButtonTextShift";
|
||||
import ButtonSlideBackground from "./ButtonSlideBackground";
|
||||
import type { ButtonVariantProps } from "./types";
|
||||
|
||||
export type { ButtonVariant, ButtonVariantProps, ButtonPropsForVariant } from "./types";
|
||||
|
||||
const buttonComponents = {
|
||||
"hover-magnetic": ButtonHoverMagnetic,
|
||||
"hover-bubble": ButtonHoverBubble,
|
||||
"expand-hover": ButtonExpandHover,
|
||||
"elastic-effect": ButtonElasticEffect,
|
||||
"bounce-effect": ButtonBounceEffect,
|
||||
"icon-arrow": ButtonIconArrow,
|
||||
"shift-hover": ButtonShiftHover,
|
||||
"text-stagger": ButtonTextStagger,
|
||||
"text-shift": ButtonTextShift,
|
||||
"text-underline": ButtonTextUnderline,
|
||||
"directional-hover": ButtonDirectionalHover,
|
||||
"slide-background": ButtonSlideBackground,
|
||||
} as const;
|
||||
|
||||
const Button = (props: ButtonVariantProps) => {
|
||||
const { variant = "hover-magnetic", ...restProps } = props;
|
||||
const ButtonComponent = buttonComponents[variant];
|
||||
return <ButtonComponent {...restProps} />;
|
||||
};
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default memo(Button);
|
||||
30
src/components/button/ButtonBounceEffect/BounceButton.css
Normal file
30
src/components/button/ButtonBounceEffect/BounceButton.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.bounce-button {
|
||||
--ease-elastic: linear(0, 0.55 7.5%, 0.85 12%, 0.95 14%, 1.03 16.5%, 1.09 20%, 1.13 22%, 1.14 23%, 1.15 24.5%, 1.15 26%, 1.13 28%, 1.11 31%, 1.05 39%, 1.02 43%, 0.99 47%, 0.98 52%, 0.97 59%, 1.002 81%, 1);
|
||||
transition: transform 0.65s var(--ease-elastic);
|
||||
}
|
||||
|
||||
.bounce-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm) * 1.5) currentColor;
|
||||
transform: translateY(0) rotate(0.001deg);
|
||||
transition: transform 0.65s var(--ease-elastic);
|
||||
}
|
||||
|
||||
.bounce-button:hover {
|
||||
transform: scale(0.92) rotate(-3deg);
|
||||
}
|
||||
|
||||
.bounce-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(3deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bounce-button:hover {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
.bounce-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./BounceButton.css";
|
||||
|
||||
interface ButtonBounceEffectProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonBounceEffect = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonBounceEffectProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
data-href={href}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"bounce-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"bounce-button-bg absolute inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"bounce-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonBounceEffect.displayName = "ButtonBounceEffect";
|
||||
|
||||
export default memo(ButtonBounceEffect);
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useDirectionalHover } from "./useDirectionalHover";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./DirectionalButton.css";
|
||||
|
||||
export interface ButtonDirectionalHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
circleClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonDirectionalHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
circleClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonDirectionalHoverProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useDirectionalHover(buttonRef);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
data-href={href}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"directional-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"directional-button-bg absolute inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<div className="directional-button-circle-wrap">
|
||||
<div
|
||||
className={cls(
|
||||
"directional-button-circle bg-accent",
|
||||
circleClassName
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<span
|
||||
className={cls(
|
||||
"directional-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonDirectionalHover.displayName = "ButtonDirectionalHover";
|
||||
|
||||
export default memo(ButtonDirectionalHover);
|
||||
@@ -0,0 +1,37 @@
|
||||
.directional-button-circle-wrap {
|
||||
border-radius: inherit;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.directional-button-circle {
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transition: transform 0.7s cubic-bezier(0.625, 0.05, 0, 1);
|
||||
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.directional-button-circle::before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
|
||||
.directional-button:hover .directional-button-circle {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0.001deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.directional-button:hover .directional-button-circle {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useCallback, RefObject } from "react";
|
||||
|
||||
export const useDirectionalHover = (
|
||||
buttonRef: RefObject<HTMLButtonElement | null>,
|
||||
circleSelector: string = ".directional-button-circle"
|
||||
) => {
|
||||
const handleHover = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const buttonWidth = buttonRect.width;
|
||||
const buttonHeight = buttonRect.height;
|
||||
const buttonCenterX = buttonRect.left + buttonWidth / 2;
|
||||
|
||||
const mouseX = event.clientX;
|
||||
const mouseY = event.clientY;
|
||||
|
||||
const offsetXFromLeft = ((mouseX - buttonRect.left) / buttonWidth) * 100;
|
||||
const offsetYFromTop = ((mouseY - buttonRect.top) / buttonHeight) * 100;
|
||||
|
||||
let offsetXFromCenter = ((mouseX - buttonCenterX) / (buttonWidth / 2)) * 50;
|
||||
offsetXFromCenter = Math.abs(offsetXFromCenter);
|
||||
|
||||
const circle = button.querySelector(circleSelector) as HTMLElement;
|
||||
if (circle) {
|
||||
circle.style.left = `${offsetXFromLeft.toFixed(1)}%`;
|
||||
circle.style.top = `${offsetYFromTop.toFixed(1)}%`;
|
||||
circle.style.width = `${115 + offsetXFromCenter * 2}%`;
|
||||
}
|
||||
},
|
||||
[buttonRef, circleSelector]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener("mouseenter", handleHover);
|
||||
button.addEventListener("mouseleave", handleHover);
|
||||
|
||||
return () => {
|
||||
button.removeEventListener("mouseenter", handleHover);
|
||||
button.removeEventListener("mouseleave", handleHover);
|
||||
};
|
||||
}, [buttonRef, handleHover]);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import useElasticEffect from "./useElasticEffect";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonElasticEffectProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonElasticEffect = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonElasticEffectProps) => {
|
||||
const elasticRef = useElasticEffect<HTMLButtonElement>();
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={elasticRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 primary-button rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonElasticEffect.displayName = "ButtonElasticEffect";
|
||||
|
||||
export default memo(ButtonElasticEffect);
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import gsap from "gsap";
|
||||
|
||||
const useElasticEffect = <T extends HTMLElement>() => {
|
||||
const elementRef = useRef<T>(null);
|
||||
const hoverLockedRef = useRef(false);
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const el = elementRef.current;
|
||||
if (!el || hoverLockedRef.current) return;
|
||||
|
||||
hoverLockedRef.current = true;
|
||||
setTimeout(() => {
|
||||
hoverLockedRef.current = false;
|
||||
}, 500);
|
||||
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
const stretch = 0.75 * fs;
|
||||
const sx = (w + stretch) / w;
|
||||
const sy = (h - stretch * 0.33) / h;
|
||||
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.kill();
|
||||
}
|
||||
|
||||
timelineRef.current = gsap
|
||||
.timeline()
|
||||
.to(el, { scaleX: sx, scaleY: sy, duration: 0.1, ease: "power1.out" })
|
||||
.to(el, { scaleX: 1, scaleY: 1, duration: 1, ease: "elastic.out(1, 0.3)" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip on touch devices
|
||||
if (window.matchMedia("(hover: none) and (pointer: coarse)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener("mouseenter", handleMouseEnter);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("mouseenter", handleMouseEnter);
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.kill();
|
||||
}
|
||||
};
|
||||
}, [handleMouseEnter]);
|
||||
|
||||
return elementRef;
|
||||
};
|
||||
|
||||
export default useElasticEffect;
|
||||
92
src/components/button/ButtonExpandHover.tsx
Normal file
92
src/components/button/ButtonExpandHover.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonExpandHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
iconBgClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonExpandHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
iconBgClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonExpandHoverProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"group relative cursor-pointer h-fit min-w-0 w-fit max-w-full rounded-theme text-sm text-background pointer-events-auto outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative h-9 w-full px-5"
|
||||
style={{ paddingRight: "calc(2.25rem + 0.75rem)" }}
|
||||
>
|
||||
<div className="h-9 flex items-center" >
|
||||
<span
|
||||
className={cls(
|
||||
"relative z-10 block overflow-hidden truncate whitespace-nowrap md:transition-colors md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute overflow-hidden top-[2px] bottom-[2px] left-[2px] right-[2px] rounded-theme flex justify-end">
|
||||
<div
|
||||
className={cls(
|
||||
"relative z-10 h-full w-auto aspect-square flex items-center justify-center",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight
|
||||
className="h-1/2 w-auto aspect-square"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute z-0 h-full w-full rounded-theme",
|
||||
"md:transition-transform md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
|
||||
"-translate-x-[calc(-100%+2.25rem-4px)] md:group-hover:translate-x-0",
|
||||
iconBgClassName
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonExpandHover.displayName = "ButtonExpandHover";
|
||||
|
||||
export default memo(ButtonExpandHover);
|
||||
83
src/components/button/ButtonHoverBubble.tsx
Normal file
83
src/components/button/ButtonHoverBubble.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ArrowDownRight } from "lucide-react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonHoverBubbleProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonHoverBubble = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonHoverBubbleProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
data-href={href}
|
||||
className={cls(
|
||||
"relative group flex justify-center items-center min-w-0 w-fit max-w-full rounded-theme cursor-pointer pointer-events-auto outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 aspect-square rounded-theme relative",
|
||||
"scale-0 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-left md:group-hover:scale-100",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 px-4 min-w-0 w-fit max-w-full rounded-theme relative",
|
||||
"-translate-x-[var(--height-9)] md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:group-hover:translate-x-0",
|
||||
bgClassName
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 aspect-square rounded-theme absolute right-0 z-20",
|
||||
"scale-100 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-right md:group-hover:scale-0",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonHoverBubble.displayName = "ButtonHoverBubble";
|
||||
|
||||
export default memo(ButtonHoverBubble);
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import useMagneticEffect from "./useMagneticEffect";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonHoverMagneticProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
strengthFactor?: number;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonHoverMagnetic = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
strengthFactor = 20,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonHoverMagneticProps) => {
|
||||
const magneticRef = useMagneticEffect(strengthFactor);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={magneticRef as React.RefObject<HTMLButtonElement>}
|
||||
data-href={href}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 primary-button rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonHoverMagnetic.displayName = "ButtonHoverMagnetic";
|
||||
|
||||
export default memo(ButtonHoverMagnetic);
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const useMagneticEffect = (strengthFactor = 10) => {
|
||||
const elementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
import("gsap").then((gsap) => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element || window.innerWidth < 768) return;
|
||||
|
||||
const resetEl = (el: HTMLElement, immediate: boolean) => {
|
||||
if (!el) return;
|
||||
gsap.default.killTweensOf(el);
|
||||
(immediate ? gsap.default.set : gsap.default.to)(el, {
|
||||
x: "0vw",
|
||||
y: "0vw",
|
||||
rotate: "0deg",
|
||||
clearProps: "all",
|
||||
...(!immediate && { ease: "elastic.out(1, 0.3)", duration: 1.6 })
|
||||
});
|
||||
};
|
||||
|
||||
const resetOnEnter = () => {
|
||||
resetEl(element, true);
|
||||
};
|
||||
|
||||
const moveMagnet = (e: MouseEvent) => {
|
||||
const b = element.getBoundingClientRect();
|
||||
const strength = strengthFactor;
|
||||
|
||||
const offsetX = ((e.clientX - b.left) / element.offsetWidth - 0.5) * (strength / 16);
|
||||
const offsetY = ((e.clientY - b.top) / element.offsetHeight - 0.5) * (strength / 16);
|
||||
|
||||
gsap.default.to(element, {
|
||||
x: offsetX + "vw",
|
||||
y: offsetY + "vw",
|
||||
rotate: "0.001deg",
|
||||
ease: "power4.out",
|
||||
duration: 1.6
|
||||
});
|
||||
};
|
||||
|
||||
const resetMagnet = () => {
|
||||
gsap.default.to(element, {
|
||||
x: "0vw",
|
||||
y: "0vw",
|
||||
ease: "elastic.out(1, 0.3)",
|
||||
duration: 1.6,
|
||||
clearProps: "all"
|
||||
});
|
||||
};
|
||||
|
||||
element.addEventListener("mouseenter", resetOnEnter);
|
||||
element.addEventListener("mousemove", moveMagnet);
|
||||
element.addEventListener("mouseleave", resetMagnet);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseenter", resetOnEnter);
|
||||
element.removeEventListener("mousemove", moveMagnet);
|
||||
element.removeEventListener("mouseleave", resetMagnet);
|
||||
};
|
||||
});
|
||||
}, [strengthFactor]);
|
||||
|
||||
return elementRef;
|
||||
};
|
||||
|
||||
export default useMagneticEffect;
|
||||
66
src/components/button/ButtonIconArrow.tsx
Normal file
66
src/components/button/ButtonIconArrow.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonIconArrowProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonIconArrow = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonIconArrowProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative group cursor-pointer h-9 min-w-0 w-fit max-w-full primary-button rounded-theme px-6 text-sm text-background flex items-center gap-3",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls(
|
||||
"block overflow-hidden truncate whitespace-nowrap md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:[transform:translateX(calc(var(--height-9)/4))]",
|
||||
textClassName
|
||||
)}>
|
||||
{text}
|
||||
</span>
|
||||
<div className={cls(
|
||||
"h-5 w-[var(--height-5)] aspect-square rounded-theme flex items-center justify-center md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:scale-[0.2] md:group-hover:rotate-90",
|
||||
iconClassName || "secondary-button text-foreground"
|
||||
)}>
|
||||
<ArrowRight className="h-1/2 w-1/2 md:transition-opacity md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:opacity-0" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonIconArrow.displayName = "ButtonIconArrow";
|
||||
|
||||
export default memo(ButtonIconArrow);
|
||||
73
src/components/button/ButtonShiftHover/ButtonShiftHover.tsx
Normal file
73
src/components/button/ButtonShiftHover/ButtonShiftHover.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./ShiftButton.css";
|
||||
|
||||
interface ButtonShiftHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonShiftHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonShiftHoverProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"shift-button group relative cursor-pointer flex gap-2 items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-5 pr-4 min-w-0 w-fit max-w-full rounded-theme text-background text-sm",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
textClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"shift-button-bg absolute inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className="shift-button-text relative inline-block overflow-hidden truncate whitespace-nowrap"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<div className="relative h-[1em] w-auto aspect-square rounded-theme border border-current scale-65 transition-all duration-300 md:group-hover:bg-current md:group-hover:scale-40" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonShiftHover.displayName = "ButtonShiftHover";
|
||||
|
||||
export default memo(ButtonShiftHover);
|
||||
29
src/components/button/ButtonShiftHover/ShiftButton.css
Normal file
29
src/components/button/ButtonShiftHover/ShiftButton.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.shift-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.shift-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.shift-button:hover .shift-button-bg {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shift-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.shift-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
|
||||
.shift-button:hover .shift-button-bg {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
75
src/components/button/ButtonSlideBackground.tsx
Normal file
75
src/components/button/ButtonSlideBackground.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonSlideBackgroundProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonSlideBackground = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonSlideBackgroundProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
const cubicBezier = "cubic-bezier(0.4, 0, 0, 1)";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"group relative flex items-center justify-center h-9 min-w-0 w-fit max-w-full px-6 rounded-theme overflow-hidden cursor-pointer",
|
||||
"primary-button",
|
||||
"after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-full",
|
||||
"after:translate-y-[101%] after:rounded-t-[50%] hover:after:translate-y-0 hover:after:rounded-none",
|
||||
"after:transition-all after:duration-500",
|
||||
"after:bg-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
transform: "scaleX(1)",
|
||||
transition: `transform 0.5s ${cubicBezier}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cls(
|
||||
"inline-block text-sm overflow-hidden relative",
|
||||
"text-background",
|
||||
"after:content-[attr(data-text)] after:w-full after:h-full after:inline-block after:absolute",
|
||||
"after:left-1/2 after:bottom-0 after:z-[1] after:-translate-x-1/2 after:translate-y-full group-hover:after:translate-y-0",
|
||||
"after:transition-transform after:duration-500 after:ease-[cubic-bezier(0.2,0,0,1)]",
|
||||
"after:text-foreground",
|
||||
textClassName
|
||||
)}
|
||||
data-text={text}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonSlideBackground.displayName = "ButtonSlideBackground";
|
||||
|
||||
export default memo(ButtonSlideBackground);
|
||||
74
src/components/button/ButtonTextShift/ButtonTextShift.tsx
Normal file
74
src/components/button/ButtonTextShift/ButtonTextShift.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./TextShiftButton.css";
|
||||
|
||||
export interface ButtonTextShiftProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextShift = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextShiftProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.0);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
data-href={href}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"stagger-button-bg absolute inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextShift.displayName = "ButtonTextShift";
|
||||
|
||||
export default memo(ButtonTextShift);
|
||||
21
src/components/button/ButtonTextShift/TextShiftButton.css
Normal file
21
src/components/button/ButtonTextShift/TextShiftButton.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./StaggerButton.css";
|
||||
|
||||
export interface ButtonTextStaggerProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextStagger = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextStaggerProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.01);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
data-href={href}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"stagger-button-bg absolute inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextStagger.displayName = "ButtonTextStagger";
|
||||
|
||||
export default memo(ButtonTextStagger);
|
||||
29
src/components/button/ButtonTextStagger/StaggerButton.css
Normal file
29
src/components/button/ButtonTextStagger/StaggerButton.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.stagger-button:hover .stagger-button-bg {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
|
||||
.stagger-button:hover .stagger-button-bg {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
53
src/components/button/ButtonTextUnderline.tsx
Normal file
53
src/components/button/ButtonTextUnderline.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonTextUnderlineProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextUnderline = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextUnderlineProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative text-sm inline-block bg-transparent border-none p-0 cursor-pointer",
|
||||
"after:content-[''] after:absolute after:bottom-0 after:left-0 after:w-full after:h-[1px]",
|
||||
"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",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:after:scale-x-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextUnderline.displayName = "ButtonTextUnderline";
|
||||
|
||||
export default memo(ButtonTextUnderline);
|
||||
125
src/components/button/SelectorButton.tsx
Normal file
125
src/components/button/SelectorButton.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, memo, ReactNode } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
export interface SelectorOption {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export interface SelectorButtonProps {
|
||||
options: SelectorOption[];
|
||||
activeValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const SelectorButton = memo<SelectorButtonProps>(({
|
||||
options,
|
||||
activeValue,
|
||||
onValueChange,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
wrapperClassName = "",
|
||||
labelClassName = "",
|
||||
}) => {
|
||||
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 className={cls("relative w-fit p-1 card rounded-theme-capped", wrapperClassName)}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls("relative overflow-hidden cursor-pointer flex", className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
data-value={option.value}
|
||||
disabled={option.disabled}
|
||||
onClick={() => !option.disabled && onValueChange(option.value)}
|
||||
className={cls(
|
||||
"relative px-4 py-2 text-sm md:text-base rounded-theme transition-all duration-300 ease-in-out z-1 text-nowrap",
|
||||
option.disabled ? "opacity-50" : "cursor-pointer",
|
||||
activeValue === option.value ? "" : "bg-transparent",
|
||||
buttonClassName
|
||||
)}
|
||||
>
|
||||
{typeof option.label === "string" ? (
|
||||
<span
|
||||
className={cls(
|
||||
"transition-colors duration-300 ease-in-out",
|
||||
activeValue === option.value ? "text-background" : "text-foreground",
|
||||
option.disabled ? "" : "cursor-pointer",
|
||||
option.labelClassName || labelClassName
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={cls(
|
||||
"flex items-center justify-center transition-opacity duration-300",
|
||||
activeValue === option.value ? "opacity-100" : "opacity-50",
|
||||
option.disabled ? "" : "cursor-pointer",
|
||||
option.labelClassName || labelClassName
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="absolute top-0 left-0 h-full rounded-theme overflow-hidden pointer-events-none z-0 transition-all duration-400 ease-out"
|
||||
>
|
||||
<div className="relative primary-button w-full h-full rounded-theme" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SelectorButton.displayName = "SelectorButton";
|
||||
|
||||
export default SelectorButton;
|
||||
96
src/components/button/types.ts
Normal file
96
src/components/button/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type ButtonVariant =
|
||||
| "hover-magnetic"
|
||||
| "hover-bubble"
|
||||
| "expand-hover"
|
||||
| "elastic-effect"
|
||||
| "bounce-effect"
|
||||
| "icon-arrow"
|
||||
| "shift-hover"
|
||||
| "text-stagger"
|
||||
| "text-shift"
|
||||
| "text-underline"
|
||||
| "directional-hover"
|
||||
| "slide-background";
|
||||
|
||||
export type CTAButtonVariant = Exclude<ButtonVariant, "text-underline">;
|
||||
|
||||
export type ButtonWithBgClassName = "text-stagger" | "text-shift" | "shift-hover" | "bounce-effect" | "directional-hover";
|
||||
|
||||
export const hasBgClassName = (variant?: string): variant is ButtonWithBgClassName => {
|
||||
return variant === "text-stagger" || variant === "text-shift" || variant === "shift-hover" || variant === "bounce-effect" || variant === "directional-hover";
|
||||
};
|
||||
|
||||
export type BaseButtonProps = {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
scrollToSection?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
};
|
||||
|
||||
export type ButtonVariantProps =
|
||||
| ({
|
||||
variant?: "hover-magnetic";
|
||||
textClassName?: string;
|
||||
strengthFactor?: number;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "hover-bubble";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "expand-hover";
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
iconBgClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "elastic-effect";
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "bounce-effect";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "icon-arrow";
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "shift-hover";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-stagger";
|
||||
bgClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-shift";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-underline";
|
||||
disabled?: boolean;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "directional-hover";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
circleClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "slide-background";
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps);
|
||||
|
||||
export type ButtonPropsForVariant<V extends ButtonVariant> = Extract<
|
||||
ButtonVariantProps,
|
||||
{ variant?: V }
|
||||
>;
|
||||
74
src/components/button/useButtonClick.ts
Normal file
74
src/components/button/useButtonClick.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useLenis } from "lenis/react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useButtonClick = (
|
||||
href?: string,
|
||||
onClick?: () => void,
|
||||
scrollToSection?: boolean
|
||||
) => {
|
||||
const lenis = useLenis();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const scrollToElement = (sectionId: string, delay: number = 100) => {
|
||||
setTimeout(() => {
|
||||
if (lenis) {
|
||||
lenis.scrollTo(`#${sectionId}`, { offset: 0 });
|
||||
} else {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (href) {
|
||||
const isExternalLink = /^(https?:\/\/|www\.)/.test(href);
|
||||
|
||||
if (isExternalLink) {
|
||||
window.open(
|
||||
href.startsWith("www.") ? `https://${href}` : href,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
} else if (href.startsWith("/")) {
|
||||
const [path, hash] = href.split("#");
|
||||
|
||||
if (path !== pathname) {
|
||||
router.push(path);
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 100);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
if (hash) {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 50);
|
||||
} else if (scrollToSection) {
|
||||
const sectionId = path.replace(/^\//, "").replace(/\//g, "-");
|
||||
scrollToElement(sectionId, 50);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollToElement(href, 50);
|
||||
}
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.location.hash) {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
scrollToElement(hash, 300);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return handleClick;
|
||||
};
|
||||
31
src/components/button/useCharAnimation.ts
Normal file
31
src/components/button/useCharAnimation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect, RefObject } from "react";
|
||||
|
||||
export const useCharAnimation = (
|
||||
buttonRef: RefObject<HTMLButtonElement | null>,
|
||||
text: string | undefined,
|
||||
selector: string = "[data-button-animate-chars]",
|
||||
staggerDelay: number = 0
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const buttonElement = buttonRef.current?.querySelector(selector);
|
||||
if (!buttonElement) return;
|
||||
|
||||
const textContent = text || buttonElement.textContent || "";
|
||||
buttonElement.innerHTML = "";
|
||||
|
||||
[...textContent].forEach((char, index) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = char;
|
||||
|
||||
if (staggerDelay > 0) {
|
||||
span.style.transitionDelay = `${index * staggerDelay}s`;
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
span.style.whiteSpace = "pre";
|
||||
}
|
||||
|
||||
buttonElement.appendChild(span);
|
||||
});
|
||||
}, [buttonRef, text, selector, staggerDelay]);
|
||||
};
|
||||
117
src/components/cardStack/CardList.tsx
Normal file
117
src/components/cardStack/CardList.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
|
||||
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface CardListProps {
|
||||
children: React.ReactNode;
|
||||
animationType: CardAnimationType;
|
||||
useUncappedRounding?: boolean;
|
||||
title?: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description?: string;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
buttons?: ButtonConfig[];
|
||||
textboxLayout: TextboxLayout;
|
||||
useInvertedBackground?: InvertedBackground;
|
||||
disableCardWrapper?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
cardClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
titleClassName?: string;
|
||||
titleImageWrapperClassName?: string;
|
||||
titleImageClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
tagClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const CardList = ({
|
||||
children,
|
||||
animationType,
|
||||
useUncappedRounding = false,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
disableCardWrapper = false,
|
||||
ariaLabel = "Card list",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}: CardListProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={ariaLabel}
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{childrenArray.map((child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cls(!disableCardWrapper && "card", !disableCardWrapper && (useUncappedRounding ? "rounded-theme" : "rounded-theme-capped"), cardClassName)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
CardList.displayName = "CardList";
|
||||
|
||||
export default memo(CardList);
|
||||
219
src/components/cardStack/CardStack.tsx
Normal file
219
src/components/cardStack/CardStack.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import { CardStackProps } from "./types";
|
||||
import GridLayout from "./layouts/grid/GridLayout";
|
||||
import AutoCarousel from "./layouts/carousels/AutoCarousel";
|
||||
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
|
||||
import TimelineBase from "./layouts/timelines/TimelineBase";
|
||||
import { gridConfigs } from "./layouts/grid/gridConfigs";
|
||||
|
||||
const CardStack = ({
|
||||
children,
|
||||
mode = "buttons",
|
||||
gridVariant = "uniform-all-items-equal",
|
||||
uniformGridCustomHeightClasses,
|
||||
gridRowsClassName,
|
||||
itemHeightClassesOverride,
|
||||
animationType,
|
||||
supports3DAnimation = false,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
carouselThreshold = 5,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
carouselItemClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel = "Card stack",
|
||||
}: CardStackProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const itemCount = childrenArray.length;
|
||||
|
||||
// Check if the current grid config has gridRows defined
|
||||
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
|
||||
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
|
||||
|
||||
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
|
||||
// we need to use min-h-0 on md+ to prevent conflicts
|
||||
let adjustedHeightClasses = uniformGridCustomHeightClasses;
|
||||
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
|
||||
// Extract the mobile min-height and add md:min-h-0
|
||||
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
|
||||
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
|
||||
}
|
||||
|
||||
// Timeline layout for zigzag pattern (works best with 3-6 items)
|
||||
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
|
||||
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
|
||||
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<TimelineBase
|
||||
variant={gridVariant}
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={timelineAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</TimelineBase>
|
||||
);
|
||||
}
|
||||
|
||||
// Use grid for items below threshold, carousel for items at or above threshold
|
||||
// Timeline with 7+ items will also use carousel
|
||||
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
|
||||
|
||||
// Grid layout for 1-4 items
|
||||
if (!useCarousel) {
|
||||
return (
|
||||
<GridLayout
|
||||
itemCount={itemCount}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
gridRowsClassName={gridRowsClassName}
|
||||
itemHeightClassesOverride={itemHeightClassesOverride}
|
||||
animationType={animationType}
|
||||
supports3DAnimation={supports3DAnimation}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
gridClassName={gridClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</GridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-scroll carousel for 5+ items
|
||||
if (mode === "auto") {
|
||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<AutoCarousel
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={carouselAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</AutoCarousel>
|
||||
);
|
||||
}
|
||||
|
||||
// Button-controlled carousel for 5+ items
|
||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<ButtonCarousel
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={carouselAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
carouselItemClassName={carouselItemClassName}
|
||||
controlsClassName={controlsClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</ButtonCarousel>
|
||||
);
|
||||
};
|
||||
|
||||
CardStack.displayName = "CardStack";
|
||||
|
||||
export default memo(CardStack);
|
||||
88
src/components/cardStack/CardStackTextBox.tsx
Normal file
88
src/components/cardStack/CardStackTextBox.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import TextBox from "@/components/Textbox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { TextBoxProps } from "./types";
|
||||
|
||||
const CardStackTextBox = ({
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}: TextBoxProps) => {
|
||||
const styles = useMemo(() => {
|
||||
if (textboxLayout === "default") {
|
||||
return {
|
||||
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
|
||||
titleClassName: cls("text-6xl font-medium text-center", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight text-center md:max-w-6/10", descriptionClassName),
|
||||
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4 mt-1 md:mt-3 justify-center", buttonContainerClassName),
|
||||
center: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (textboxLayout === "inline-image") {
|
||||
return {
|
||||
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
|
||||
titleClassName: cls("text-4xl md:text-5xl font-medium text-center", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight text-center", descriptionClassName),
|
||||
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4 mt-1 md:mt-3 justify-center", buttonContainerClassName),
|
||||
center: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: textBoxClassName,
|
||||
titleClassName: cls("text-6xl font-medium", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight", descriptionClassName),
|
||||
tagClassName: cls("px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4", buttonContainerClassName),
|
||||
center: false,
|
||||
};
|
||||
}, [textboxLayout, textBoxClassName, titleClassName, descriptionClassName, tagClassName, buttonContainerClassName]);
|
||||
|
||||
if (!title && !titleSegments && !description) return null;
|
||||
|
||||
return (
|
||||
<TextBox
|
||||
title={title!}
|
||||
titleSegments={titleSegments}
|
||||
description={description!}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={styles.className}
|
||||
titleClassName={styles.titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={styles.descriptionClassName}
|
||||
tagClassName={styles.tagClassName}
|
||||
buttonContainerClassName={styles.buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
center={styles.center}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CardStackTextBox.displayName = "CardStackTextBox";
|
||||
|
||||
export default memo(CardStackTextBox);
|
||||
126
src/components/cardStack/hooks/useCardAnimation.ts
Normal file
126
src/components/cardStack/hooks/useCardAnimation.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useRef } from "react";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { CardAnimationType, GridVariant } from "../types";
|
||||
import { useDepth3DAnimation } from "./useDepth3DAnimation";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface UseCardAnimationProps {
|
||||
animationType: CardAnimationType | "depth-3d";
|
||||
itemCount: number;
|
||||
isGrid?: boolean;
|
||||
supports3DAnimation?: boolean;
|
||||
gridVariant?: GridVariant;
|
||||
}
|
||||
|
||||
export const useCardAnimation = ({
|
||||
animationType,
|
||||
itemCount,
|
||||
isGrid = true,
|
||||
supports3DAnimation = false,
|
||||
gridVariant
|
||||
}: UseCardAnimationProps) => {
|
||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const perspectiveRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Enable 3D effect only when explicitly supported and conditions are met
|
||||
const { isMobile } = useDepth3DAnimation({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
|
||||
});
|
||||
|
||||
// Use scale-rotate as fallback when depth-3d conditions aren't met
|
||||
const effectiveAnimationType =
|
||||
animationType === "depth-3d" && (isMobile || !isGrid || gridVariant !== "uniform-all-items-equal")
|
||||
? "scale-rotate"
|
||||
: animationType;
|
||||
|
||||
useGSAP(() => {
|
||||
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
|
||||
|
||||
const items = itemRefs.current.filter((el) => el !== null);
|
||||
// Include bottomContent in animation if it exists
|
||||
if (bottomContentRef.current) {
|
||||
items.push(bottomContentRef.current);
|
||||
}
|
||||
|
||||
if (effectiveAnimationType === "opacity") {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 1.25,
|
||||
stagger: 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (effectiveAnimationType === "slide-up") {
|
||||
items.forEach((item, index) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0, yPercent: 15 },
|
||||
{
|
||||
opacity: 1,
|
||||
yPercent: 0,
|
||||
duration: 1,
|
||||
delay: index * 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else if (effectiveAnimationType === "scale-rotate") {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ scaleX: 0, rotate: 10 },
|
||||
{
|
||||
scaleX: 1,
|
||||
rotate: 0,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: "power3",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (effectiveAnimationType === "blur-reveal") {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0, filter: "blur(10px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.2,
|
||||
stagger: 0.15,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [effectiveAnimationType, itemCount]);
|
||||
|
||||
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
|
||||
};
|
||||
118
src/components/cardStack/hooks/useDepth3DAnimation.ts
Normal file
118
src/components/cardStack/hooks/useDepth3DAnimation.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState, useRef, RefObject } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const ANIMATION_SPEED = 0.05;
|
||||
const ROTATION_SPEED = 0.1;
|
||||
const MOUSE_MULTIPLIER = 0.5;
|
||||
const ROTATION_MULTIPLIER = 0.25;
|
||||
|
||||
interface UseDepth3DAnimationProps {
|
||||
itemRefs: RefObject<(HTMLElement | null)[]>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
perspectiveRef?: RefObject<HTMLDivElement | null>;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const useDepth3DAnimation = ({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled,
|
||||
}: UseDepth3DAnimationProps) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3D mouse-tracking effect (desktop only)
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isMobile) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let isAnimating = true;
|
||||
|
||||
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
|
||||
const perspectiveElement = perspectiveRef?.current || containerRef.current;
|
||||
if (perspectiveElement) {
|
||||
perspectiveElement.style.perspective = "1200px";
|
||||
perspectiveElement.style.transformStyle = "preserve-3d";
|
||||
}
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let isMouseInSection = false;
|
||||
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let currentRotationX = 0;
|
||||
let currentRotationY = 0;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent): void => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
isMouseInSection =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
if (isMouseInSection) {
|
||||
mouseX = (event.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (event.clientY / window.innerHeight) * 100 - 50;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = (): void => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (isMouseInSection) {
|
||||
const distX = mouseX * MOUSE_MULTIPLIER - currentX;
|
||||
const distY = mouseY * MOUSE_MULTIPLIER - currentY;
|
||||
currentX += distX * ANIMATION_SPEED;
|
||||
currentY += distY * ANIMATION_SPEED;
|
||||
|
||||
const distRotX = -mouseY * ROTATION_MULTIPLIER - currentRotationX;
|
||||
const distRotY = mouseX * ROTATION_MULTIPLIER - currentRotationY;
|
||||
currentRotationX += distRotX * ROTATION_SPEED;
|
||||
currentRotationY += distRotY * ROTATION_SPEED;
|
||||
} else {
|
||||
currentX += -currentX * ANIMATION_SPEED;
|
||||
currentY += -currentY * ANIMATION_SPEED;
|
||||
currentRotationX += -currentRotationX * ROTATION_SPEED;
|
||||
currentRotationY += -currentRotationY * ROTATION_SPEED;
|
||||
}
|
||||
|
||||
itemRefs.current?.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
ref.style.transform = `translate(${currentX}px, ${currentY}px) rotateX(${currentRotationX}deg) rotateY(${currentRotationY}deg)`;
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
isAnimating = false;
|
||||
};
|
||||
}, [isEnabled, isMobile, itemRefs, containerRef]);
|
||||
|
||||
return { isMobile };
|
||||
};
|
||||
108
src/components/cardStack/hooks/usePhoneAnimations.ts
Normal file
108
src/components/cardStack/hooks/usePhoneAnimations.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export interface TimelinePhoneViewItem {
|
||||
trigger: string;
|
||||
content: React.ReactNode;
|
||||
imageOne?: string;
|
||||
videoOne?: string;
|
||||
imageAltOne?: string;
|
||||
videoAriaLabelOne?: string;
|
||||
imageTwo?: string;
|
||||
videoTwo?: string;
|
||||
imageAltTwo?: string;
|
||||
videoAriaLabelTwo?: string;
|
||||
}
|
||||
|
||||
const getImageAnimationConfig = (itemIndex: number, imageIndex: number) => {
|
||||
const isFirstImage = imageIndex === 0;
|
||||
const isOddItem = itemIndex % 2 === 1;
|
||||
|
||||
if (isFirstImage) {
|
||||
return {
|
||||
from: { xPercent: -200, rotation: -45 },
|
||||
to: { rotation: isOddItem ? 10 : -10 },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: { xPercent: 200, rotation: 45 },
|
||||
to: { rotation: isOddItem ? -10 : 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const usePhoneAnimations = (items: TimelinePhoneViewItem[]) => {
|
||||
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const animatePhones = (isMobile: boolean) => {
|
||||
items.forEach((item, itemIndex) => {
|
||||
const images = [item.imageOne || item.videoOne, item.imageTwo || item.videoTwo];
|
||||
|
||||
images.forEach((_, imageIndex) => {
|
||||
const refIndex = itemIndex * 2 + imageIndex;
|
||||
const element = isMobile
|
||||
? mobileImageRefs.current[refIndex]
|
||||
: imageRefs.current[refIndex];
|
||||
|
||||
if (element) {
|
||||
const isFirstImage = imageIndex === 0;
|
||||
|
||||
const fromConfig = isMobile
|
||||
? {
|
||||
xPercent: isFirstImage ? -150 : 150,
|
||||
rotation: isFirstImage ? -25 : 25,
|
||||
}
|
||||
: getImageAnimationConfig(itemIndex, imageIndex).from;
|
||||
|
||||
const toConfig = isMobile
|
||||
? {
|
||||
xPercent: 0,
|
||||
rotation: 0,
|
||||
duration: 1,
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: "top 90%",
|
||||
end: "top 50%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
xPercent: 0,
|
||||
rotation: getImageAnimationConfig(itemIndex, imageIndex).to
|
||||
.rotation,
|
||||
scrollTrigger: {
|
||||
trigger: `.${item.trigger}`,
|
||||
start: "top bottom",
|
||||
end: "top top",
|
||||
scrub: 1,
|
||||
},
|
||||
};
|
||||
|
||||
gsap.fromTo(element, fromConfig, toConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mm.add("(max-width: 767px)", () => animatePhones(true));
|
||||
mm.add("(min-width: 768px)", () => animatePhones(false));
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
imageRefs.current = [];
|
||||
mobileImageRefs.current = [];
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
imageRefs,
|
||||
mobileImageRefs,
|
||||
};
|
||||
};
|
||||
40
src/components/cardStack/hooks/usePrevNextButtons.ts
Normal file
40
src/components/cardStack/hooks/usePrevNextButtons.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
|
||||
export const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
|
||||
|
||||
const onPrevButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onNextButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev());
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("reInit", onSelect).on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("reInit", onSelect).off("select", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
};
|
||||
};
|
||||
30
src/components/cardStack/hooks/useScrollProgress.ts
Normal file
30
src/components/cardStack/hooks/useScrollProgress.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
|
||||
export const useScrollProgress = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
const onScroll = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
const progress = Math.max(0, Math.min(1, emblaApi.scrollProgress()));
|
||||
setScrollProgress(progress * 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onScroll(emblaApi);
|
||||
emblaApi
|
||||
.on("reInit", onScroll)
|
||||
.on("scroll", onScroll)
|
||||
.on("slideFocus", onScroll);
|
||||
|
||||
return () => {
|
||||
emblaApi
|
||||
.off("reInit", onScroll)
|
||||
.off("scroll", onScroll)
|
||||
.off("slideFocus", onScroll);
|
||||
};
|
||||
}, [emblaApi, onScroll]);
|
||||
|
||||
return scrollProgress;
|
||||
};
|
||||
243
src/components/cardStack/hooks/useTimelineHorizontal.ts
Normal file
243
src/components/cardStack/hooks/useTimelineHorizontal.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
PROGRESS_DURATION: 5000,
|
||||
TRANSITION_DURATION: 500,
|
||||
ANIMATION_START_DELAY: 100,
|
||||
IMAGE_TRANSITION_DELAY: 300,
|
||||
} as const;
|
||||
|
||||
export interface MediaItem {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
videoAriaLabel?: string;
|
||||
}
|
||||
|
||||
interface UseTimelineHorizontalProps {
|
||||
itemCount: number;
|
||||
mediaItems?: MediaItem[];
|
||||
}
|
||||
|
||||
export const useTimelineHorizontal = ({ itemCount, mediaItems }: UseTimelineHorizontalProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imageOpacity, setImageOpacity] = useState(1);
|
||||
const [currentMediaSrc, setCurrentMediaSrc] = useState<{ imageSrc?: string; videoSrc?: string }>(() => {
|
||||
if (mediaItems && mediaItems[0]) {
|
||||
return {
|
||||
imageSrc: mediaItems[0].imageSrc,
|
||||
videoSrc: mediaItems[0].videoSrc,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const progressRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const imageTransitionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
const resetAllProgressBars = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
progressRefs.current.forEach((bar) => {
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(0)";
|
||||
|
||||
setTimeout(() => {
|
||||
if (bar) {
|
||||
bar.style.transition = "none";
|
||||
}
|
||||
}, ANIMATION_CONFIG.TRANSITION_DURATION);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const animateProgress = useCallback(
|
||||
(index: number) => {
|
||||
if (!progressRefs.current[index]) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
const progressBar = progressRefs.current[index];
|
||||
progressBar.style.transition = "none";
|
||||
progressBar.style.transform = "scaleX(0)";
|
||||
|
||||
const easeInOut = (t: number): number => {
|
||||
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
let startTime: number | null = null;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const linearProgress = Math.min(elapsed / ANIMATION_CONFIG.PROGRESS_DURATION, 1);
|
||||
const easedProgress = easeInOut(linearProgress);
|
||||
|
||||
if (progressRefs.current[index]) {
|
||||
progressRefs.current[index]!.style.transform = `scaleX(${easedProgress})`;
|
||||
}
|
||||
|
||||
if (linearProgress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setActiveIndex((prevIndex) => {
|
||||
const nextIndex = prevIndex + 1;
|
||||
if (nextIndex >= itemCount) {
|
||||
resetAllProgressBars();
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
|
||||
},
|
||||
[itemCount, resetAllProgressBars]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = 0; i < activeIndex; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transform = "scaleX(1)";
|
||||
}
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
animateProgress(activeIndex);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeIndex, animateProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (!hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
|
||||
if (mediaItems && mediaItems[0]) {
|
||||
setCurrentMediaSrc({
|
||||
imageSrc: mediaItems[0].imageSrc,
|
||||
videoSrc: mediaItems[0].videoSrc,
|
||||
});
|
||||
setImageOpacity(1);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
animateProgress(0);
|
||||
}
|
||||
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
imageTransitionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [animateProgress, mediaItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMountedRef.current || !mediaItems) return;
|
||||
|
||||
const currentItem = mediaItems[activeIndex];
|
||||
if (!currentItem) return;
|
||||
|
||||
const newMediaSrc = {
|
||||
imageSrc: currentItem.imageSrc,
|
||||
videoSrc: currentItem.videoSrc,
|
||||
};
|
||||
|
||||
if (
|
||||
(newMediaSrc.imageSrc && newMediaSrc.imageSrc !== currentMediaSrc.imageSrc) ||
|
||||
(newMediaSrc.videoSrc && newMediaSrc.videoSrc !== currentMediaSrc.videoSrc)
|
||||
) {
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
}
|
||||
|
||||
setImageOpacity(0);
|
||||
|
||||
imageTransitionTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentMediaSrc(newMediaSrc);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setImageOpacity(1);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, ANIMATION_CONFIG.IMAGE_TRANSITION_DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [activeIndex, mediaItems, currentMediaSrc]);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageOpacity(1);
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(index: number) => {
|
||||
if (index === activeIndex) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(1)";
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = index; i < progressRefs.current.length; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(0)";
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex(index);
|
||||
},
|
||||
[activeIndex]
|
||||
);
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
progressRefs,
|
||||
handleItemClick,
|
||||
imageOpacity,
|
||||
currentMediaSrc,
|
||||
handleImageLoad,
|
||||
};
|
||||
};
|
||||
144
src/components/cardStack/layouts/carousels/AngledCarousel.tsx
Normal file
144
src/components/cardStack/layouts/carousels/AngledCarousel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AngledCarouselItem {
|
||||
id: string;
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
videoAriaLabel?: string;
|
||||
}
|
||||
|
||||
interface AngledCarouselProps {
|
||||
items: AngledCarouselItem[];
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
autoPlayInterval?: number;
|
||||
}
|
||||
|
||||
const CARD_TRANSITION_DURATION = 0.8;
|
||||
const CARD_TRANSITION_EASE = [0.65, 0, 0.35, 1] as const;
|
||||
|
||||
const cardVariants = {
|
||||
'hidden-0': { opacity: 0, y: '25px' },
|
||||
'hidden-1': { scale: 0.88, opacity: 0, x: 'calc(100% + 20px)', y: '5%', rotate: 2 },
|
||||
'hidden--1': { scale: 0.88, opacity: 0, x: 'calc(-100% - 20px)', y: '5%', rotate: -2 },
|
||||
'0': { scale: 1, opacity: 1, x: '0%', y: '0%', rotate: 0 },
|
||||
'1': { scale: 0.88, opacity: 1, x: '100%', y: '5%', rotate: 2 },
|
||||
'-1': { scale: 0.88, opacity: 1, x: '-100%', y: '5%', rotate: -2 },
|
||||
'2': { scale: 0.8, opacity: 0, x: '200%', y: '10%', rotate: 4 },
|
||||
'-2': { scale: 0.8, opacity: 0, x: '-200%', y: '10%', rotate: -4 },
|
||||
};
|
||||
|
||||
const AngledCarousel = ({ items, className = "", autoPlay = true, autoPlayInterval = 4000 }: AngledCarouselProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isFirstRender, setIsFirstRender] = useState(true);
|
||||
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const n = items.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsFirstRender(false);
|
||||
}, CARD_TRANSITION_DURATION * 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isFirstRender]);
|
||||
|
||||
const resetAutoPlay = () => {
|
||||
if (autoPlayRef.current) {
|
||||
clearInterval(autoPlayRef.current);
|
||||
}
|
||||
if (autoPlay) {
|
||||
autoPlayRef.current = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % n);
|
||||
}, autoPlayInterval);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetAutoPlay();
|
||||
return () => {
|
||||
if (autoPlayRef.current) {
|
||||
clearInterval(autoPlayRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoPlay, autoPlayInterval]);
|
||||
|
||||
const positionFactors = [-2, -1, 0, 1, 2];
|
||||
|
||||
return (
|
||||
<div className={cls("relative w-full flex justify-center items-center overflow-hidden", className)}>
|
||||
<div className="w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] opacity-0" />
|
||||
{positionFactors.map((positionFactor) => {
|
||||
const itemIndex = (activeIndex + positionFactor + n) % n;
|
||||
const item = items[itemIndex];
|
||||
const isCenter = positionFactor === 0;
|
||||
const isVisible = Math.abs(positionFactor) <= 1;
|
||||
|
||||
const getAnimateState = () => {
|
||||
const key = positionFactor.toString() as keyof typeof cardVariants;
|
||||
return cardVariants[key];
|
||||
};
|
||||
|
||||
const getInitialState = () => {
|
||||
if (isVisible && isFirstRender) {
|
||||
const key = `hidden-${positionFactor}` as keyof typeof cardVariants;
|
||||
return cardVariants[key];
|
||||
}
|
||||
return getAnimateState();
|
||||
};
|
||||
|
||||
const getDelay = () => {
|
||||
if (isVisible && isFirstRender) {
|
||||
const delays: { [key: string]: number } = { '-1': 0.6, '0': 0.45, '1': 0.6 };
|
||||
return delays[positionFactor.toString()] || 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className="!absolute w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] card p-1 rounded-theme-capped overflow-hidden"
|
||||
style={{
|
||||
zIndex: positionFactor === 0 ? 10 : 5 - Math.abs(positionFactor),
|
||||
}}
|
||||
initial={getInitialState()}
|
||||
animate={getAnimateState()}
|
||||
transition={{
|
||||
duration: CARD_TRANSITION_DURATION,
|
||||
ease: CARD_TRANSITION_EASE,
|
||||
delay: getDelay(),
|
||||
}}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
imageAlt={item.imageAlt}
|
||||
videoAriaLabel={item.videoAriaLabel}
|
||||
imageClassName="w-full h-full rounded-theme-capped object-cover"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-background/50 backdrop-blur-[1px] pointer-events-none select-none"
|
||||
initial={{ opacity: isCenter ? 0 : 1 }}
|
||||
animate={{ opacity: isCenter ? 0 : 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AngledCarousel.displayName = "AngledCarousel";
|
||||
|
||||
export default memo(AngledCarousel);
|
||||
140
src/components/cardStack/layouts/carousels/ArrowCarousel.tsx
Normal file
140
src/components/cardStack/layouts/carousels/ArrowCarousel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children, useCallback, useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ArrowCarouselProps } from "../../types";
|
||||
|
||||
const ArrowCarousel = ({
|
||||
children,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel = "Carousel section",
|
||||
}: ArrowCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||
}, []);
|
||||
|
||||
const 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 (
|
||||
<section
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
|
||||
<div className="w-content-width mx-auto">
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cls(
|
||||
"overflow-hidden w-full relative z-10 mask-fade-x",
|
||||
carouselClassName
|
||||
)}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
{childrenArray.map((child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-none w-60 md:w-40 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={cls("absolute inset-y-0 w-content-width mx-auto left-0 right-0 flex items-center justify-between pointer-events-none z-10", controlsClassName)}>
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft className="w-4/10 h-4/10 text-background" />
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight className="w-4/10 h-4/10 text-background" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
ArrowCarousel.displayName = "ArrowCarousel";
|
||||
|
||||
export default memo(ArrowCarousel);
|
||||
144
src/components/cardStack/layouts/carousels/AutoCarousel.tsx
Normal file
144
src/components/cardStack/layouts/carousels/AutoCarousel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import Marquee from "react-fast-marquee";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { AutoCarouselProps } from "../../types";
|
||||
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||
|
||||
const AutoCarousel = ({
|
||||
children,
|
||||
uniformGridCustomHeightClasses,
|
||||
animationType,
|
||||
speed = 50,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
itemClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel,
|
||||
showTextBox = true,
|
||||
dualMarquee = false,
|
||||
topMarqueeDirection = "left",
|
||||
bottomCarouselClassName = "",
|
||||
marqueeGapClassName = "",
|
||||
}: AutoCarouselProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
|
||||
const { itemRefs, bottomContentRef } = useCardAnimation({
|
||||
animationType,
|
||||
itemCount: childrenArray.length,
|
||||
isGrid: false
|
||||
});
|
||||
|
||||
// Bottom marquee direction is opposite of top
|
||||
const bottomMarqueeDirection = topMarqueeDirection === "left" ? "right" : "left";
|
||||
|
||||
// Reverse order for bottom marquee to avoid alignment with top
|
||||
const bottomChildren = dualMarquee ? [...childrenArray].reverse() : [];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
aria-live="off"
|
||||
>
|
||||
<div className={cls("w-full md:w-content-width mx-auto", containerClassName)}>
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{showTextBox && (title || titleSegments || description) && (
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"w-full flex flex-col",
|
||||
marqueeGapClassName || "gap-6"
|
||||
)}
|
||||
>
|
||||
{/* Top/Single Marquee */}
|
||||
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", carouselClassName)}>
|
||||
<Marquee gradient={false} speed={speed} direction={topMarqueeDirection}>
|
||||
{Children.map(childrenArray, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
|
||||
{/* Bottom Marquee (only if dualMarquee is true) - Reversed order, opposite direction */}
|
||||
{dualMarquee && (
|
||||
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", bottomCarouselClassName || carouselClassName)}>
|
||||
<Marquee gradient={false} speed={speed} direction={bottomMarqueeDirection}>
|
||||
{Children.map(bottomChildren, (child, index) => (
|
||||
<div
|
||||
key={`bottom-${index}`}
|
||||
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bottomContent && (
|
||||
<div ref={bottomContentRef}>
|
||||
{bottomContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
AutoCarousel.displayName = "AutoCarousel";
|
||||
|
||||
export default memo(AutoCarousel);
|
||||
178
src/components/cardStack/layouts/carousels/ButtonCarousel.tsx
Normal file
178
src/components/cardStack/layouts/carousels/ButtonCarousel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { ButtonCarouselProps } from "../../types";
|
||||
import { usePrevNextButtons } from "../../hooks/usePrevNextButtons";
|
||||
import { useScrollProgress } from "../../hooks/useScrollProgress";
|
||||
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||
|
||||
const ButtonCarousel = ({
|
||||
children,
|
||||
uniformGridCustomHeightClasses,
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
carouselItemClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel,
|
||||
}: ButtonCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
|
||||
|
||||
const {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
} = usePrevNextButtons(emblaApi);
|
||||
|
||||
const scrollProgress = useScrollProgress(emblaApi);
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
|
||||
const { itemRefs, bottomContentRef } = useCardAnimation({
|
||||
animationType,
|
||||
itemCount: childrenArray.length,
|
||||
isGrid: false
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative px-[var(--width-0)] py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className={cls("w-full mx-auto", containerClassName)}>
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{(title || titleSegments || description) && (
|
||||
<div className="w-content-width mx-auto">
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cls(
|
||||
"w-full flex flex-col gap-6"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"overflow-hidden w-full relative z-10 flex cursor-grab",
|
||||
carouselClassName
|
||||
)}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex gap-6 w-full">
|
||||
<div className="flex-shrink-0 w-carousel-padding" />
|
||||
{Children.map(childrenArray, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("flex-none select-none w-carousel-item-3 xl:w-carousel-item-4 mb-6", heightClasses, carouselItemClassName)}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-shrink-0 w-carousel-padding" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cls("w-full flex", controlsClassName)}>
|
||||
<div className="flex-shrink-0 w-carousel-padding-controls" />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div
|
||||
className="rounded-theme card relative h-2 w-50 overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-label="Carousel progress"
|
||||
aria-valuenow={Math.round(scrollProgress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="bg-foreground primary-button absolute w-full top-0 bottom-0 -left-full rounded-theme"
|
||||
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onPrevButtonClick}
|
||||
disabled={prevBtnDisabled}
|
||||
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft className="h-[40%] w-auto aspect-square text-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNextButtonClick}
|
||||
disabled={nextBtnDisabled}
|
||||
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight className="h-[40%] w-auto aspect-square text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-carousel-padding-controls" />
|
||||
</div>
|
||||
</div>
|
||||
{bottomContent && (
|
||||
<div ref={bottomContentRef} className="w-content-width mx-auto">
|
||||
{bottomContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonCarousel.displayName = "ButtonCarousel";
|
||||
|
||||
export default memo(ButtonCarousel);
|
||||
151
src/components/cardStack/layouts/carousels/FullWidthCarousel.tsx
Normal file
151
src/components/cardStack/layouts/carousels/FullWidthCarousel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children, cloneElement, isValidElement, useCallback, useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { FullWidthCarouselProps } from "../../types";
|
||||
|
||||
const FullWidthCarousel = ({
|
||||
children,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
dotsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel = "Carousel section",
|
||||
}: FullWidthCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap());
|
||||
}, []);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(index: number) => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollTo(index);
|
||||
},
|
||||
[emblaApi]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("select", onSelect).on("reInit", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect).off("reInit", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
const autoplay = setInterval(() => {
|
||||
emblaApi.scrollNext();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(autoplay);
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
|
||||
<div className="w-content-width mx-auto">
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cls(
|
||||
"overflow-hidden w-full relative z-10",
|
||||
carouselClassName
|
||||
)}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
{Children.map(childrenArray, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-none w-70 mr-6"
|
||||
>
|
||||
{isValidElement(child)
|
||||
? cloneElement(child, { isActive: selectedIndex === index } as Record<string, unknown>)
|
||||
: child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cls("flex items-center justify-center gap-2", dotsClassName)}>
|
||||
{childrenArray.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => scrollTo(index)}
|
||||
className={cls(
|
||||
"relative cursor-pointer h-2 rounded-theme bg-accent transition-all duration-300",
|
||||
selectedIndex === index
|
||||
? "w-8 opacity-100"
|
||||
: "w-2 opacity-20"
|
||||
)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
aria-current={selectedIndex === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
FullWidthCarousel.displayName = "FullWidthCarousel";
|
||||
|
||||
export default memo(FullWidthCarousel);
|
||||
146
src/components/cardStack/layouts/grid/GridLayout.tsx
Normal file
146
src/components/cardStack/layouts/grid/GridLayout.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { GridLayoutProps } from "../../types";
|
||||
import { gridConfigs } from "./gridConfigs";
|
||||
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||
|
||||
const GridLayout = ({
|
||||
children,
|
||||
itemCount,
|
||||
gridVariant = "uniform-all-items-equal",
|
||||
uniformGridCustomHeightClasses,
|
||||
gridRowsClassName,
|
||||
itemHeightClassesOverride,
|
||||
animationType,
|
||||
supports3DAnimation = false,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
gridClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel,
|
||||
}: GridLayoutProps) => {
|
||||
// Get config for this variant and item count
|
||||
const config = gridConfigs[gridVariant]?.[itemCount];
|
||||
|
||||
// Fallback to default uniform grid if no config
|
||||
const gridColsMap = {
|
||||
1: "md:grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-4",
|
||||
};
|
||||
const defaultGridCols = gridColsMap[itemCount as keyof typeof gridColsMap] || "md:grid-cols-4";
|
||||
|
||||
// Use config values or fallback
|
||||
const gridCols = config?.gridCols || defaultGridCols;
|
||||
const gridRows = gridRowsClassName || config?.gridRows || "";
|
||||
const itemClasses = config?.itemClasses || [];
|
||||
const itemHeightClasses = itemHeightClassesOverride || config?.itemHeightClasses || [];
|
||||
const heightClasses = uniformGridCustomHeightClasses || config?.heightClasses || "";
|
||||
const itemWrapperClass = config?.itemWrapperClass || "";
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
const { itemRefs, containerRef, perspectiveRef, bottomContentRef } = useCardAnimation({
|
||||
animationType,
|
||||
itemCount: childrenArray.length,
|
||||
isGrid: true,
|
||||
supports3DAnimation,
|
||||
gridVariant
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground === "invertDefault" && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
|
||||
{(title || titleSegments || description) && (
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={perspectiveRef}
|
||||
className={cls(
|
||||
"grid grid-cols-1 gap-6",
|
||||
gridCols,
|
||||
gridRows,
|
||||
gridClassName
|
||||
)}
|
||||
>
|
||||
{childrenArray.map((child, index) => {
|
||||
const itemClass = itemClasses[index] || "";
|
||||
const itemHeightClass = itemHeightClasses[index] || "";
|
||||
const combinedClass = cls(itemWrapperClass, itemClass, itemHeightClass, heightClasses);
|
||||
return combinedClass ? (
|
||||
<div
|
||||
key={index}
|
||||
className={combinedClass}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{bottomContent && (
|
||||
<div ref={bottomContentRef}>
|
||||
{bottomContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
GridLayout.displayName = "GridLayout";
|
||||
|
||||
export default memo(GridLayout);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user