Initial commit

This commit is contained in:
vitalijmulika
2025-12-15 11:53:22 +02:00
commit ecb043f83b
320 changed files with 69802 additions and 0 deletions

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

510
src/app/globals.css Normal file
View File

@@ -0,0 +1,510 @@
@import "tailwindcss";
:root {
/* Base units */
/* --vw is set by ThemeProvider */
--background: #f3f7f1;;
--card: #edf2eb;;
--foreground: #43483f;;
--primary-cta: #a8c4a0;;
--secondary-cta: #ffffff;;
--accent: #cbd8c8;;
--background-accent: #93b08b;;
/* 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-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: 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-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;
}
}
@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-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: 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);
}
@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-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-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%;
}
}
}
* {
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-open-sans), sans-serif;
}

1277
src/app/layout.tsx Normal file

File diff suppressed because it is too large Load Diff

336
src/app/page.tsx Normal file
View File

@@ -0,0 +1,336 @@
"use client"
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
import HeroWaitlist from '@/components/sections/hero/HeroWaitlist';
import AboutShowcase from '@/components/sections/about/AboutShowcase';
import ProductCardFive from '@/components/sections/product/ProductCardFive';
import TeamCardNine from '@/components/sections/team/TeamCardNine';
import TestimonialCardOne from '@/components/sections/testimonial/TestimonialCardOne';
import ContactCenterForm from '@/components/sections/contact/ContactCenterForm';
import FooterSocial from '@/components/sections/footer/FooterSocial';
import { Coffee, Leaf, Sparkles, Users, Star, Instagram, Facebook, Twitter } from "lucide-react";
export default function LandingPage() {
return (
<ThemeProvider
defaultButtonVariant="hover-bubble"
defaultTextAnimation="entrance-slide"
borderRadius="rounded"
contentWidth="medium"
sizing="largeSizeExtraLargeSpacing"
background="animatedGrid"
cardStyle="glass-flat"
primaryButtonStyle="shadow"
secondaryButtonStyle="glass"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingOverlay
brandName="Brew Haven"
navItems={[
{ name: "Home", id: "hero" },
{ name: "About", id: "about" },
{ name: "Menu", id: "menu" },
{ name: "Team", id: "team" },
{ name: "Reviews", id: "testimonials" }
]}
button={{
text: "Visit Us",
href: "#contact"
}}
/>
</div>
<div id="hero" data-section="hero">
<HeroWaitlist
title="Welcome to Brew Haven"
description="Discover exceptional coffee crafted with passion. Join our community of coffee lovers and get updates on new blends and special offers."
tag="Premium Coffee Experience"
tagIcon={Coffee}
fields={[
{
name: "email",
type: "email",
placeholder: "Your email",
ariaLabel: "Email address",
required: true
},
{
name: "name",
type: "text",
placeholder: "Your name",
ariaLabel: "Your name",
required: true
}
]}
buttonText="Subscribe"
onSubmit={(data) => console.log('Newsletter signup:', data)}
/>
</div>
<div id="about" data-section="about">
<AboutShowcase
title="Our Coffee Story"
description="Since 2015, Brew Haven has been sourcing the finest single-origin coffees from sustainable farms around the world. We believe in direct trade relationships, environmental responsibility, and creating the perfect cup every single time."
tag="About Us"
tagIcon={Leaf}
leftItem={{
title: "Ethical Sourcing",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792319761-0ja9a7kg.jpg",
imageAlt: "Cozy modern coffee shop interior"
}}
rightItem={{
title: "Expert Craftsmanship",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792320998-57mf7dij.jpg",
imageAlt: "Professional barista making coffee"
}}
centerImageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792319761-0ja9a7kg.jpg"
centerImageAlt="Brew Haven coffee shop interior"
useCappedBorderRadius={true}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground="noInvert"
buttons={[
{
text: "Learn Our Process",
href: "#"
}
]}
/>
</div>
<div id="menu" data-section="menu">
<ProductCardFive
title="Featured Menu"
description="Explore our signature drinks and seasonal specials, each crafted to perfection by our expert baristas."
tag="What We Serve"
tagIcon={Sparkles}
products={[
{
id: "espresso",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792322185-w3lgbhvc.jpg",
imageAlt: "Double shot espresso",
button: {
text: "Order Now",
id: "order-espresso"
}
},
{
id: "latte",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792324070-6n6dljg7.jpg",
imageAlt: "Latte with latte art",
button: {
text: "Order Now",
id: "order-latte"
}
},
{
id: "cappuccino",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792325147-9osxkgp2.jpg",
imageAlt: "Cappuccino",
button: {
text: "Order Now",
id: "order-cappuccino"
}
}
]}
gridVariant="three-columns-all-equal-width"
animationType="slide-up"
containerStyle="default"
textboxLayout="default"
useInvertedBackground="noInvert"
/>
</div>
<div id="team" data-section="team">
<TeamCardNine
title="Meet Our Baristas"
description="Passionate coffee experts dedicated to creating your perfect cup every visit."
tag="Our Team"
tagIcon={Users}
members={[
{
id: "1",
name: "Marco",
role: "Head Barista & Founder",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792327013-olqkgnbv.jpg",
imageAlt: "Marco, Head Barista",
socialLinks: [
{
icon: Instagram,
href: "https://instagram.com",
ariaLabel: "Instagram"
}
]
},
{
id: "2",
name: "Sofia",
role: "Master Barista",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792328068-fchzicys.jpg",
imageAlt: "Sofia, Master Barista",
socialLinks: [
{
icon: Instagram,
href: "https://instagram.com",
ariaLabel: "Instagram"
}
]
},
{
id: "3",
name: "James",
role: "Coffee Specialist",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792329590-2nzegqo5.jpg",
imageAlt: "James, Coffee Specialist",
socialLinks: [
{
icon: Instagram,
href: "https://instagram.com",
ariaLabel: "Instagram"
}
]
}
]}
animationType="slide-up"
containerStyle="default"
textboxLayout="default"
useInvertedBackground="noInvert"
/>
</div>
<div id="testimonials" data-section="testimonials">
<TestimonialCardOne
title="What Customers Say"
description="Join thousands of coffee lovers who have made Brew Haven their daily stop."
tag="Customer Reviews"
tagIcon={Star}
testimonials={[
{
id: "1",
name: "Sarah M.",
role: "Coffee Enthusiast",
company: "Downtown Regular",
rating: 5,
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792331016-bjdd71t2.jpg",
imageAlt: "Sarah enjoying coffee"
},
{
id: "2",
name: "David L.",
role: "Business Owner",
company: "Local Startup",
rating: 5,
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792332654-3ss6esqc.jpg",
imageAlt: "David with coffee"
},
{
id: "3",
name: "Emma R.",
role: "Marketing Manager",
company: "Tech Company",
rating: 5,
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792334192-pa7xw2zr.jpg",
imageAlt: "Emma enjoying coffee"
},
{
id: "4",
name: "Michael T.",
role: "Student",
company: "University",
rating: 5,
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_30ordogEOn7VO6LZdIWb5atLXky/uploaded-1765792335833-ajwgagqq.jpg",
imageAlt: "Michael with coffee"
}
]}
gridVariant="uniform-all-items-equal"
animationType="slide-up"
containerStyle="default"
textboxLayout="default"
useInvertedBackground="noInvert"
/>
</div>
<div id="contact" data-section="contact">
<ContactCenterForm
title="Visit Us Today"
description="Located in the heart of the city. Open daily from 7 AM to 7 PM. We look forward to brewing your perfect cup."
inputs={[
{
name: "name",
type: "text",
placeholder: "Your name",
required: true
},
{
name: "email",
type: "email",
placeholder: "Your email",
required: true
}
]}
textarea={{
name: "message",
placeholder: "Tell us about your visit or special request...",
rows: 4,
required: false
}}
buttonText="Send Message"
useInvertedBackground="noInvert"
onSubmit={(data) => console.log('Contact form:', data)}
/>
</div>
<div id="footer" data-section="footer">
<FooterSocial
logoText="Brew Haven"
columns={[
{
title: "Company",
items: [
{ label: "About Us", href: "#about" },
{ label: "Our Story", href: "#" },
{ label: "Careers", href: "#" }
]
},
{
title: "Menu",
items: [
{ label: "Coffee Selection", href: "#menu" },
{ label: "Pastries", href: "#" },
{ label: "Seasonal Drinks", href: "#" }
]
},
{
title: "Connect",
items: [
{ label: "Contact", href: "#contact" },
{ label: "Find Us", href: "#" },
{ label: "Newsletter", href: "#hero" }
]
}
]}
socialLinks={[
{
icon: Instagram,
href: "https://instagram.com/brewhaven",
ariaLabel: "Instagram"
},
{
icon: Facebook,
href: "https://facebook.com/brewhaven",
ariaLabel: "Facebook"
},
{
icon: Twitter,
href: "https://twitter.com/brewhaven",
ariaLabel: "Twitter"
}
]}
copyrightText="© Brew Haven, Inc. 2025"
/>
</div>
</ThemeProvider>
);
}

View 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" | "invertCard";
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);

View File

@@ -0,0 +1,25 @@
"use client";
import { ReactNode } from "react";
interface PostHogWrapperProps {
children: ReactNode;
}
/**
* PostHog analytics wrapper component.
* Currently a passthrough - configure with PostHog SDK when ready.
*/
export function PostHogWrapper({ children }: PostHogWrapperProps) {
// TODO: Add PostHog initialization here when needed
// Example:
// useEffect(() => {
// if (typeof window !== 'undefined') {
// posthog.init('YOUR_API_KEY', {
// api_host: 'https://app.posthog.com'
// });
// }
// }, []);
return <>{children}</>;
}

290
src/components/Textbox.tsx Normal file
View File

@@ -0,0 +1,290 @@
"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;
}
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 = "",
}: 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" || useInvertedBackground === "invertCard") && "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" || useInvertedBackground === "invertCard") && "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" || useInvertedBackground === "invertCard") && "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" && "mt-3",
center && textboxLayout === "default" && "justify-center",
avatarGroupClassName
)}
/>
) : null, [avatars, avatarText, textboxLayout, center, avatarGroupClassName]);
// Shared buttons/children component
const actionsElement = useMemo(() => buttons && buttons.length > 0 ? (
<div className={cls(
"flex 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)}>
{tagElement}
{titleElement}
{descriptionElement}
{actionsElement}
{avatarsElement}
</div>
);
};
TextBox.displayName = "TextBox";
export default memo(TextBox);

View 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);

View File

@@ -0,0 +1,111 @@
"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;
perspectiveThreeD?: boolean;
}
const AnimatedGridBackground = ({
className = "",
squareSize = 100,
numSquares = 50,
maxOpacity = 0.15,
perspectiveThreeD = true,
}: 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(
"fixed inset-0 -z-10 overflow-hidden [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
className
)}
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-accent/20"
/>
</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);

View 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/10 -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/10 -rotate-[60deg] rounded-[100%]" />
{/* center left */}
<div className="absolute top-[-20vh] left-[2vw] -translate-x-[0%] w-[15vw] h-[150vh] bg-background-accent/10 -rotate-[45deg] rounded-[100%]" />
{/* top left */}
<div className="absolute top-[-30vh] left-0 -translate-x-[0%] w-[10vw] h-[70vh] bg-background-accent/10 -rotate-[45deg] rounded-[100%]" />
{/* bottom center */}
<div className="absolute bottom-[-40vh] left-0 -translate-x-[0%] w-[120vw] h-[50vh] bg-background-accent/5 -rotate-[20deg] rounded-[100%]" />
</div>
);
};
AuroraBackground.displayName = "AuroraBackground";
export default memo(AuroraBackground);

View 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);

View 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) 0%, 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) 0%, transparent 70%)`,
}}
/>
</div>
);
};
CircleGradientBackground.displayName = "CircleGradientBackground";
export default memo(CircleGradientBackground);

View 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);

View 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;
}
}

View File

@@ -0,0 +1,67 @@
'use client';
import React, { memo } from 'react';
import { cls } from '@/lib/utils';
interface GradientBarsBackgroundProps {
className?: string;
numBars?: number;
gradientFrom?: string;
gradientTo?: string;
opacity?: number;
}
const GradientBarsBackground = ({
className = "",
numBars = 15,
gradientFrom = "var(--color-primary-cta)",
gradientTo = "transparent",
opacity = 0.5,
}: GradientBarsBackgroundProps) => {
const calculateHeight = (index: number, total: number): number => {
const position = index / (total - 1);
const maxHeight = 100;
const minHeight = 30;
const center = 0.5;
const distanceFromCenter = Math.abs(position - center);
const heightPercentage = Math.pow(distanceFromCenter * 2, 1.2);
return minHeight + (maxHeight - minHeight) * heightPercentage;
};
return (
<div
className={cls("fixed inset-0 -z-10 overflow-hidden", className)}
aria-hidden="true"
>
<div
className="flex h-full w-full backface-hidden antialiased"
style={{
transform: 'translateZ(0)',
}}
>
{Array.from({ length: numBars }).map((_, index) => {
const height = calculateHeight(index, numBars);
return (
<div
key={index}
className="h-full origin-bottom box-border"
style={{
flex: `1 0 calc(100% / ${numBars})`,
maxWidth: `calc(100% / ${numBars})`,
background: `linear-gradient(to top, ${gradientFrom}, ${gradientTo})`,
transform: `scaleY(${height / 100})`,
opacity: opacity,
}}
/>
);
})}
</div>
</div>
);
};
GradientBarsBackground.displayName = 'GradientBarsBackground';
export default memo(GradientBarsBackground);

View 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);

View 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);

View File

@@ -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);

View 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);

View 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);

View File

@@ -0,0 +1,34 @@
'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("fixed top-0 left-0 right-0 bottom-0 h-screen w-full -z-10", className)}
style={{
background: `radial-gradient(${size} at ${position}, ${centerColor} 40%, ${edgeColor} 100%)`,
}}
aria-hidden="true"
/>
);
};
RadialGradientBackground.displayName = 'RadialGradientBackground';
export default memo(RadialGradientBackground);

View File

@@ -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%);
}
}

View File

@@ -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);

View File

@@ -0,0 +1,33 @@
"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 type { ButtonVariantProps } from "./types";
export type { ButtonVariant, ButtonVariantProps, ButtonPropsForVariant } from "./types";
const buttonComponents = {
"hover-magnetic": ButtonHoverMagnetic,
"hover-bubble": ButtonHoverBubble,
"expand-hover": ButtonExpandHover,
"icon-arrow": ButtonIconArrow,
"shift-hover": ButtonShiftHover,
"text-stagger": ButtonTextStagger,
"text-underline": ButtonTextUnderline,
} 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);

View File

@@ -0,0 +1,89 @@
"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";
}
const ButtonExpandHover = ({
text,
onClick,
href,
className = "",
textClassName = "",
iconClassName = "",
iconBgClassName = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonExpandHoverProps) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
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);

View File

@@ -0,0 +1,80 @@
"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";
}
const ButtonHoverBubble = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
iconClassName = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonHoverBubbleProps) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
aria-label={ariaLabel || text}
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);

View File

@@ -0,0 +1,54 @@
"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";
}
const ButtonHoverMagnetic = ({
text,
onClick,
href,
className = "",
textClassName = "",
strengthFactor = 20,
disabled = false,
ariaLabel,
type = "button",
}: ButtonHoverMagneticProps) => {
const magneticRef = useMagneticEffect(strengthFactor);
const handleClick = useButtonClick(href, onClick);
return (
<button
ref={magneticRef as React.RefObject<HTMLButtonElement>}
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);

View File

@@ -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;

View File

@@ -0,0 +1,63 @@
"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";
}
const ButtonIconArrow = ({
text,
onClick,
href,
className = "",
textClassName = "",
iconClassName = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonIconArrowProps) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
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);

View File

@@ -0,0 +1,70 @@
"use client";
import { useRef, memo } from "react";
import { useShiftAnimation } from "./useShiftAnimation";
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";
}
const ButtonShiftHover = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonShiftHoverProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick);
useShiftAnimation(buttonRef, text);
return (
<button
ref={buttonRef}
type={type}
onClick={handleClick}
disabled={disabled}
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);

View 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);
}
}

View File

@@ -0,0 +1,26 @@
import { useEffect, RefObject } from "react";
export const useShiftAnimation = (
buttonRef: RefObject<HTMLButtonElement | null>,
text: string | undefined,
selector: string = "[data-button-animate-chars]"
) => {
useEffect(() => {
const buttonElement = buttonRef.current?.querySelector(selector);
if (!buttonElement) return;
const textContent = text || buttonElement.textContent || "";
buttonElement.innerHTML = "";
[...textContent].forEach((char) => {
const span = document.createElement("span");
span.textContent = char;
if (char === " ") {
span.style.whiteSpace = "pre";
}
buttonElement.appendChild(span);
});
}, [buttonRef, text, selector]);
};

View File

@@ -0,0 +1,71 @@
"use client";
import { useRef, memo } from "react";
import { useStaggerAnimation } from "./useStaggerAnimation";
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";
}
const ButtonTextStagger = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonTextStaggerProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick);
useStaggerAnimation(buttonRef, text);
return (
<button
ref={buttonRef}
type={type}
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 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);

View 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);
}
}

View File

@@ -0,0 +1,28 @@
import { useEffect, RefObject } from "react";
export const useStaggerAnimation = (
buttonRef: RefObject<HTMLButtonElement | null>,
text: string | undefined,
selector: string = "[data-button-animate-chars]",
offsetIncrement: number = 0.01
) => {
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;
span.style.transitionDelay = `${index * offsetIncrement}s`;
if (char === " ") {
span.style.whiteSpace = "pre";
}
buttonElement.appendChild(span);
});
}, [buttonRef, text, selector, offsetIncrement]);
};

View File

@@ -0,0 +1,50 @@
"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";
}
const ButtonTextUnderline = ({
text,
onClick,
href,
className = "",
disabled = false,
ariaLabel,
type = "button",
}: ButtonTextUnderlineProps) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
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);

View 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;

View File

@@ -0,0 +1,65 @@
export type ButtonVariant =
| "hover-magnetic"
| "hover-bubble"
| "expand-hover"
| "icon-arrow"
| "shift-hover"
| "text-stagger"
| "text-underline";
export type CTAButtonVariant = Exclude<ButtonVariant, "text-underline">;
export type ButtonWithBgClassName = "text-stagger" | "shift-hover";
export const hasBgClassName = (variant?: string): variant is ButtonWithBgClassName => {
return variant === "text-stagger" || variant === "shift-hover";
};
export type BaseButtonProps = {
text: string;
onClick?: () => void;
href?: string;
className?: string;
};
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: "icon-arrow";
textClassName?: string;
iconClassName?: string;
} & BaseButtonProps)
| ({
variant: "shift-hover";
bgClassName?: string;
textClassName?: string;
} & BaseButtonProps)
| ({
variant: "text-stagger";
bgClassName?: string;
} & BaseButtonProps)
| ({
variant: "text-underline";
disabled?: boolean;
} & BaseButtonProps);
export type ButtonPropsForVariant<V extends ButtonVariant> = Extract<
ButtonVariantProps,
{ variant?: V }
>;

View File

@@ -0,0 +1,31 @@
import { useLenis } from "lenis/react";
export const useButtonClick = (href?: string, onClick?: () => void) => {
const lenis = useLenis();
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 (lenis) {
lenis.scrollTo(`#${href}`);
} else {
const element = document.getElementById(href);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}
}
}
onClick?.();
};
return handleClick;
};

View File

@@ -0,0 +1,129 @@
"use client";
import { memo, Children, Fragment } 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;
variant?: "card" | "border";
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,
variant = "card",
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",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "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) => (
<Fragment key={index}>
{variant === "border" && index === 0 && (
<div className="h-px bg-accent/75" />
)}
<div
ref={(el) => { itemRefs.current[index] = el; }}
className={cls(!disableCardWrapper && variant === "card" && "card", !disableCardWrapper && variant === "card" && (useUncappedRounding ? "rounded-theme" : "rounded-theme-capped"), cardClassName)}
>
{child}
</div>
{variant === "border" && index < childrenArray.length - 1 && (
<div className="h-px bg-accent/75" />
)}
</Fragment>
))}
</div>
</div>
</section>
);
};
CardList.displayName = "CardList";
export default memo(CardList);

View File

@@ -0,0 +1,195 @@
"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";
const CardStack = ({
children,
mode = "buttons",
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
containerStyle = "default",
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
carouselThreshold = 5,
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;
// Timeline layout for zigzag pattern (works best with 3-6 items)
if ((gridVariant === "timeline" || gridVariant === "timeline-three-columns") && itemCount >= 3 && itemCount <= 6) {
return (
<TimelineBase
variant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
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" || gridVariant === "timeline-three-columns") && itemCount > 6);
// Grid layout for 1-4 items
if (!useCarousel) {
return (
<GridLayout
itemCount={itemCount}
gridVariant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
gridRowsClassName={gridRowsClassName}
itemHeightClassesOverride={itemHeightClassesOverride}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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") {
return (
<AutoCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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
return (
<ButtonCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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);

View 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-1", textBoxClassName),
titleClassName: cls("text-6xl font-medium text-center", titleClassName),
descriptionClassName: cls("text-lg leading-[1.2] 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-1 md:mb-3 mx-auto", tagClassName),
buttonContainerClassName: cls("flex 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-1", textBoxClassName),
titleClassName: cls("text-4xl md:text-5xl font-medium text-center", titleClassName),
descriptionClassName: cls("text-lg leading-[1.2] 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-1 md:mb-3 mx-auto", tagClassName),
buttonContainerClassName: cls("flex 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-[1.2]", descriptionClassName),
tagClassName: cls("px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2", tagClassName),
buttonContainerClassName: cls("flex 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);

View File

@@ -0,0 +1,95 @@
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { CardAnimationType } from "../types";
gsap.registerPlugin(ScrollTrigger);
interface UseCardAnimationProps {
animationType: CardAnimationType;
itemCount: number;
}
export const useCardAnimation = ({ animationType, itemCount }: UseCardAnimationProps) => {
const itemRefs = useRef<(HTMLElement | null)[]>([]);
useGSAP(() => {
if (animationType === "none" || itemRefs.current.length === 0) return;
const items = itemRefs.current.filter((el) => el !== null);
if (animationType === "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 (animationType === "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 (animationType === "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 (animationType === "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",
},
}
);
}
}, [animationType, itemCount]);
return { itemRefs };
};

View 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,
};
};

View 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,
};
};

View 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;
};

View 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,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,147 @@
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/types/button";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
export type { ButtonConfig, TextboxLayout, InvertedBackground };
export type TitleSegment =
| { type: "text"; content: string }
| { type: "image"; src: string; alt?: string };
export interface TimelineCardStackItem {
id: number;
title: string;
description: string;
image: string;
imageAlt?: string;
}
export type GridVariant =
| "uniform-all-items-equal"
| "uniform-staggered-items"
| "uniform-alternating-heights"
| "uniform-alternating-heights-inverted"
| "uniform-alternating-sizes"
| "uniform-alternating-sizes-inverted"
| "two-items-tall-short"
| "two-items-short-tall"
| "bento-grid"
| "bento-grid-inverted"
| "two-columns-alternating-heights"
| "asymmetric-60-wide-40-narrow"
| "three-columns-all-equal-width"
| "four-items-2x2-equal-grid"
| "four-items-2x2-alternating-heights"
| "four-items-2x2-alternating-heights-inverted"
| "four-items-2x2-staggered-grid"
| "four-items-2x2-staggered-grid-inverted"
| "one-large-right-three-stacked-left"
| "items-top-row-full-width-bottom"
| "full-width-top-items-bottom-row"
| "one-large-left-three-stacked-right"
| "timeline"
| "timeline-three-columns";
export type CardAnimationType =
| "none"
| "opacity"
| "slide-up"
| "scale-rotate"
| "blur-reveal";
export type ContainerStyle = "default" | "card";
export interface TextBoxProps {
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
export interface CardStackProps extends TextBoxProps {
children: React.ReactNode;
mode?: "auto" | "buttons";
gridVariant?: GridVariant;
uniformGridCustomHeightClasses?: string;
gridRowsClassName?: string;
itemHeightClassesOverride?: string[];
animationType: CardAnimationType;
containerStyle?: ContainerStyle;
carouselThreshold?: number;
className?: string;
containerClassName?: string;
gridClassName?: string;
carouselClassName?: string;
carouselItemClassName?: string;
controlsClassName?: string;
ariaLabel?: string;
}
export interface GridLayoutProps extends TextBoxProps {
children: React.ReactNode;
itemCount: number;
gridVariant?: GridVariant;
uniformGridCustomHeightClasses?: string;
gridRowsClassName?: string;
itemHeightClassesOverride?: string[];
animationType: CardAnimationType;
containerStyle?: ContainerStyle;
className?: string;
containerClassName?: string;
gridClassName?: string;
ariaLabel: string;
}
export interface AutoCarouselProps extends TextBoxProps {
children: React.ReactNode;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
containerStyle?: ContainerStyle;
speed?: number;
className?: string;
containerClassName?: string;
carouselClassName?: string;
itemClassName?: string;
ariaLabel: string;
showTextBox?: boolean;
dualMarquee?: boolean;
topMarqueeDirection?: "left" | "right";
bottomMarqueeDirection?: "left" | "right";
bottomCarouselClassName?: string;
marqueeGapClassName?: string;
}
export interface ButtonCarouselProps extends TextBoxProps {
children: React.ReactNode;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
containerStyle?: ContainerStyle;
className?: string;
containerClassName?: string;
carouselClassName?: string;
carouselItemClassName?: string;
controlsClassName?: string;
ariaLabel: string;
}
export interface FullWidthCarouselProps extends TextBoxProps {
children: React.ReactNode;
className?: string;
containerClassName?: string;
carouselClassName?: string;
dotsClassName?: string;
ariaLabel: string;
}

View File

@@ -0,0 +1,101 @@
"use client";
import { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import EmailSignupForm from "@/components/form/EmailSignupForm";
import Tag from "@/components/shared/Tag";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { AnimationType } from "@/components/text/types";
import { LucideIcon } from "lucide-react";
interface ContactFormProps {
title: string;
description: string;
tag: string;
tagIcon?: LucideIcon;
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
inputPlaceholder?: string;
buttonText?: string;
termsText?: string;
onSubmit?: (email: string) => void;
centered?: boolean;
className?: string;
tagClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
formWrapperClassName?: string;
formClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
termsClassName?: string;
}
const ContactForm = ({
title,
description,
tag,
tagIcon,
useInvertedBackground,
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
onSubmit,
centered = false,
className = "",
tagClassName = "",
titleClassName = "",
descriptionClassName = "",
formWrapperClassName = "",
formClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
termsClassName = "",
}: ContactFormProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div className={cls("relative z-1 flex flex-col gap-4", centered && "items-center text-center", className)}>
<div className={cls("w-full flex flex-col gap-1", centered && "items-center")}>
<Tag text={tag} icon={tagIcon} useInvertedBackground={useInvertedBackground} className={tagClassName} />
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={title}
variant="trigger"
className={cls("text-4xl md:text-5xl font-medium leading-[1.175] text-balance", shouldUseLightText ? "text-background" : "text-foreground", centered && "w-full md:w-8/10", titleClassName)}
/>
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={description}
variant="words-trigger"
className={cls("text-base leading-[1.15] mb-1 text-balance", shouldUseLightText ? "text-background" : "text-foreground", centered && "w-full md:w-8/10", descriptionClassName)}
/>
</div>
<div className={cls("w-full flex flex-col gap-1", formWrapperClassName)}>
<EmailSignupForm
inputPlaceholder={inputPlaceholder}
buttonText={buttonText}
onSubmit={onSubmit}
className={formClassName}
inputClassName={inputClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
<p className={cls("text-xs", shouldUseLightText ? "text-background/75" : "text-foreground/75", termsClassName)}>
{termsText}
</p>
</div>
</div>
);
};
ContactForm.displayName = "ContactForm";
export default memo(ContactForm);

View File

@@ -0,0 +1,77 @@
"use client";
import { memo, useState } from "react";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface EmailSignupFormProps {
inputPlaceholder?: string;
buttonText?: string;
onSubmit?: (email: string) => void;
className?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const EmailSignupForm = memo(({
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
onSubmit,
className = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: EmailSignupFormProps) => {
const theme = useTheme();
const [email, setEmail] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onSubmit) {
onSubmit(email);
}
};
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full md:w-auto" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between md:justify-center" };
}
return {};
};
return (
<form onSubmit={handleSubmit} className={cls("relative z-1 flex flex-col md:flex-row gap-3 md:gap-1 w-full card rounded-theme-capped md:rounded-theme p-1", className)}>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className={cls(
"flex-1 px-4 text-base text-center md:text-left text-foreground placeholder:text-foreground/75 focus:outline-none focus:border-none truncate",
inputClassName
)}
aria-label="Email address"
/>
<Button
{...getButtonProps(
{ text: buttonText, props: getButtonConfigProps() },
0,
theme.defaultButtonVariant,
cls("w-full md:w-auto", buttonClassName),
buttonTextClassName
)}
/>
</form>
);
});
EmailSignupForm.displayName = "EmailSignupForm";
export default EmailSignupForm;

View File

@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface InputProps {
type?: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const Input = ({
type = "text",
placeholder = "",
value,
onChange,
required = false,
disabled = false,
ariaLabel,
className = "",
}: InputProps) => {
return (
<input
type={type}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
disabled={disabled}
aria-label={ariaLabel || placeholder}
className={cls(
"relative z-1 px-4 py-3 secondary-button rounded-theme text-base text-foreground placeholder:text-foreground/75 focus:outline-none",
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
);
};
Input.displayName = "Input";
export default memo(Input);

View File

@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface TextareaProps {
placeholder?: string;
value: string;
onChange: (value: string) => void;
rows?: number;
required?: boolean;
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const Textarea = ({
placeholder = "",
value,
onChange,
rows = 5,
required = false,
disabled = false,
ariaLabel,
className = "",
}: TextareaProps) => {
return (
<textarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
required={required}
disabled={disabled}
aria-label={ariaLabel || placeholder}
className={cls(
"relative z-1 px-4 py-3 secondary-button rounded-theme-capped text-base text-foreground placeholder:text-foreground/75 focus:outline-none resize-none",
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
);
};
Textarea.displayName = "Textarea";
export default memo(Textarea);

View File

@@ -0,0 +1,99 @@
"use client";
import React, { memo, useState } from "react";
import Input from "@/components/form/Input";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface FormField {
name: string;
type?: string;
placeholder?: string;
ariaLabel?: string;
required?: boolean;
}
interface WaitlistFormProps {
fields?: FormField[];
buttonText?: string;
onSubmit?: (data: Record<string, string>) => void;
className?: string;
inputsContainerClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const WaitlistForm = ({
fields = [
{ name: "email", type: "email", placeholder: "Your email", ariaLabel: "Email address", required: true },
{ name: "username", type: "text", placeholder: "Telegram username", ariaLabel: "Username", required: true }
],
buttonText = "Join waitlist",
onSubmit,
className = "",
inputsContainerClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: WaitlistFormProps) => {
const theme = useTheme();
const [formData, setFormData] = useState<Record<string, string>>(
fields.reduce((acc, field) => ({ ...acc, [field.name]: "" }), {})
);
const handleInputChange = (name: string, value: string) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onSubmit) {
onSubmit(formData);
}
};
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between" };
}
return {};
};
return (
<form onSubmit={handleSubmit} className={cls("relative z-1 flex flex-col gap-3 w-full", className)}>
<div className={cls("flex flex-col md:flex-row gap-3", inputsContainerClassName)}>
{fields.map((field) => (
<Input
key={field.name}
type={field.type || "text"}
placeholder={field.placeholder || ""}
value={formData[field.name] || ""}
onChange={(value) => handleInputChange(field.name, value)}
required={field.required !== false}
ariaLabel={field.ariaLabel || field.placeholder}
className={cls("w-full", inputClassName)}
/>
))}
</div>
<Button
{...getButtonProps(
{ text: buttonText, props: getButtonConfigProps() },
0,
theme.defaultButtonVariant,
cls("w-full", buttonClassName),
buttonTextClassName
)}
/>
</form>
);
};
WaitlistForm.displayName = "WaitlistForm";
export default memo(WaitlistForm);

View File

@@ -0,0 +1,48 @@
"use client";
import { memo } from "react";
import Image from "next/image";
import { cls } from "@/lib/utils";
interface LogoProps {
logoSrc?: string;
logoAlt?: string;
brandName?: string;
className?: string;
imageClassName?: string;
textClassName?: string;
}
const Logo = memo<LogoProps>(function Logo({
logoSrc,
logoAlt = "",
brandName = "Webild",
className = "",
imageClassName = "",
textClassName = ""
}) {
if (logoSrc) {
return (
<div className={cls("relative h-[var(--text-xl)] w-auto", className)}>
<Image
src={logoSrc}
alt={logoAlt}
width={100}
height={24}
className={cls("h-full w-auto object-contain", imageClassName)}
unoptimized={logoSrc.startsWith('http') || logoSrc.startsWith('//')}
/>
</div>
);
}
return (
<h2 className={cls("text-xl font-medium text-foreground", textClassName)}>
{brandName}
</h2>
);
});
Logo.displayName = "Logo";
export default Logo;

View File

@@ -0,0 +1,82 @@
"use client";
import { memo } from "react";
import Button from "../button/Button";
import ButtonTextUnderline from "../button/ButtonTextUnderline";
import Logo from "./Logo";
import { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarLayoutFloatingInlineProps {
navItems: NavItem[];
logoSrc?: string;
logoAlt?: string;
brandName?: string;
button: ButtonConfig;
className?: string;
navItemClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarLayoutFloatingInline = memo<NavbarLayoutFloatingInlineProps>(
function NavbarLayoutFloatingInline({
navItems,
logoSrc,
logoAlt = "",
brandName = "Webild",
button,
className = "",
navItemClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}) {
const theme = useTheme();
return (
<nav
role="navigation"
aria-label="Main navigation"
className={cls(
"fixed z-[100] top-6 w-full",
"transition-all duration-500 ease-in-out",
className
)}
>
<div className={cls(
"w-content-width mx-auto",
"flex items-center justify-between",
"card rounded-theme",
"p-3 pl-6 h-fit relative"
)}>
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 hidden md:flex gap-6 items-center">
{navItems.map((item, index) => (
<ButtonTextUnderline
key={index}
text={item.name}
href={item.id}
className={cls("!text-base", navItemClassName)}
/>
))}
</div>
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
buttonClassName,
buttonTextClassName
)}
/>
</div>
</nav>
);
}
);
export default NavbarLayoutFloatingInline;

View File

@@ -0,0 +1,92 @@
"use client";
import { memo } from "react";
import ExpandingMenu from "../expandingMenu/ExpandingMenu";
import Button from "../../button/Button";
import Logo from "../Logo";
import { useScrollDetection } from "./useScrollDetection";
import { useMenuAnimation } from "./useMenuAnimation";
import { useResponsive } from "./useResponsive";
import type { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarLayoutFloatingOverlayProps {
navItems: NavItem[];
logoSrc?: string;
logoAlt?: string;
brandName?: string;
button: ButtonConfig;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarLayoutFloatingOverlay = memo<NavbarLayoutFloatingOverlayProps>(
function NavbarLayoutFloatingOverlay({
navItems,
logoSrc,
logoAlt = "",
brandName = "Webild",
button,
buttonClassName = "",
buttonTextClassName = "",
}) {
const theme = useTheme();
const isScrolled = useScrollDetection(50);
const { menuOpen, buttonZIndex, handleMenuToggle } =
useMenuAnimation();
const isMobile = useResponsive(768);
return (
<nav
role="navigation"
aria-label="Main navigation"
className={cls(
"fixed z-[100] top-6 w-full",
"transition-all duration-500 ease-in-out"
)}
>
<div
className={cls(
"w-content-width mx-auto",
"flex items-center justify-between",
"card rounded-theme backdrop-blur-xs",
"px-6 md:pr-3"
)}
style={{
height: "calc(var(--vw-0_75) + var(--vw-0_75) + var(--height-9))",
}}
>
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
<div
className="flex items-center transition-all duration-500 ease-in-out"
style={{ paddingRight: "calc(var(--height-9) + var(--vw-0_75))" }}
>
{!isMobile && (
<div className="hidden md:flex">
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
cls(buttonZIndex, buttonClassName),
buttonTextClassName
)}
/>
</div>
)}
<ExpandingMenu
isOpen={menuOpen}
onToggle={handleMenuToggle}
navItems={navItems}
isScrolled={isScrolled}
/>
</div>
</div>
</nav>
);
}
);
export default NavbarLayoutFloatingOverlay;

View File

@@ -0,0 +1,40 @@
import { useState, useCallback, useEffect } from 'react';
export const useMenuAnimation = () => {
const [menuOpen, setMenuOpen] = useState(false);
const [buttonZIndex, setButtonZIndex] = useState('z-[100]');
const [animationTimeoutId, setAnimationTimeoutId] = useState<NodeJS.Timeout | null>(null);
const handleMenuToggle = useCallback(() => {
const isOpening = !menuOpen;
setMenuOpen(prev => !prev);
if (animationTimeoutId) {
clearTimeout(animationTimeoutId);
}
if (isOpening) {
setButtonZIndex('z-0');
} else {
const timeoutId = setTimeout(() => {
setButtonZIndex('z-[100]');
}, 800);
setAnimationTimeoutId(timeoutId);
}
}, [menuOpen, animationTimeoutId]);
useEffect(() => {
return () => {
if (animationTimeoutId) {
clearTimeout(animationTimeoutId);
}
};
}, [animationTimeoutId]);
return {
menuOpen,
buttonZIndex,
handleMenuToggle,
setMenuOpen
};
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
import { throttle } from '@/utils/throttle';
export const useResponsive = (breakpoint: number = 768) => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const handleResize = throttle(() => {
setIsMobile(window.innerWidth < breakpoint);
}, 150);
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
import { throttle } from '@/utils/throttle';
export const useScrollDetection = (threshold: number = 50) => {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = throttle(() => {
setIsScrolled(window.scrollY > threshold);
}, 100);
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [threshold]);
return isScrolled;
};

View File

@@ -0,0 +1,86 @@
"use client";
import { useState, memo, useCallback } from "react";
import MobileMenu from "../mobileMenu/MobileMenu";
import ButtonTextUnderline from "@/components/button/ButtonTextUnderline";
import Logo from "../Logo";
import { Plus } from "lucide-react";
import { NavbarProps } from "@/types/navigation";
import { useScrollState } from "./useScrollState";
import { cls } from "@/lib/utils";
const SCROLL_THRESHOLD = 50;
const NavbarStyleApple = ({
navItems,
logoSrc,
logoAlt = "",
brandName = "Webild",
}: NavbarProps) => {
const isScrolled = useScrollState(SCROLL_THRESHOLD);
const [menuOpen, setMenuOpen] = useState(false);
const handleMenuToggle = useCallback(() => {
setMenuOpen((prev) => !prev);
}, []);
const handleMobileNavClick = useCallback(() => {
setMenuOpen(false);
}, []);
return (
<nav
className={cls(
"fixed z-[1000] top-0 left-0 w-full transition-all duration-500 ease-in-out",
isScrolled
? "bg-background/80 backdrop-blur-sm h-15"
: "bg-background/0 backdrop-blur-0 h-20"
)}
>
<div className="flex items-center justify-between h-full w-content-width mx-auto">
<div className="flex items-center transition-all duration-500 ease-in-out">
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
</div>
<div
className="hidden md:flex items-center gap-6 transition-all duration-500 ease-in-out"
role="navigation"
>
{navItems.map((item, index) => (
<ButtonTextUnderline
key={index}
text={item.name}
href={item.id}
className="!text-base"
/>
))}
</div>
<button
className="flex md:hidden shrink-0 h-8 aspect-square rounded-theme bg-foreground items-center justify-center cursor-pointer"
onClick={handleMenuToggle}
aria-label="Toggle menu"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
>
<Plus
className={cls(
"w-1/2 h-1/2 text-background transition-transform duration-300",
menuOpen ? "rotate-45" : "rotate-0"
)}
strokeWidth={1.5}
aria-hidden="true"
/>
</button>
</div>
<MobileMenu
menuOpen={menuOpen}
onMenuToggle={handleMenuToggle}
navItems={navItems}
onNavClick={handleMobileNavClick}
/>
</nav>
);
};
export default memo(NavbarStyleApple);

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, useRef } from 'react';
export const useScrollState = (threshold: number = 50) => {
const [isScrolled, setIsScrolled] = useState(false);
const rafRef = useRef<number | undefined>(undefined);
useEffect(() => {
const handleScroll = () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setIsScrolled(window.scrollY > threshold);
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [threshold]);
return isScrolled;
};

View File

@@ -0,0 +1,63 @@
"use client";
import { memo } from "react";
import Button from "../button/Button";
import Logo from "./Logo";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarStyleMinimalProps {
logoSrc?: string;
logoAlt?: string;
brandName?: string;
button: ButtonConfig;
className?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarStyleMinimal = memo<NavbarStyleMinimalProps>(
function NavbarStyleMinimal({
logoSrc,
logoAlt = "",
brandName = "Webild",
button,
className = "",
buttonClassName = "",
buttonTextClassName = "",
}) {
const theme = useTheme();
return (
<nav
role="navigation"
aria-label="Main navigation"
className={cls(
"fixed z-[100] top-6 w-full",
"transition-all duration-500 ease-in-out",
className
)}
>
<div className={cls(
"w-content-width mx-auto",
"flex items-center justify-between"
)}>
<Logo logoSrc={logoSrc} logoAlt={logoAlt} brandName={brandName} />
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
buttonClassName,
buttonTextClassName
)}
/>
</div>
</nav>
);
}
);
export default NavbarStyleMinimal;

View File

@@ -0,0 +1,147 @@
"use client";
import { useCallback, memo } from 'react';
import { useResponsiveMenuWidth } from './useResponsiveMenuWidth';
import { useButtonClick } from '@/components/button/useButtonClick';
interface NavItem {
id: string;
name: string;
}
interface ExpandingMenuProps {
isOpen: boolean;
onToggle: () => void;
navItems: NavItem[];
isScrolled?: boolean;
}
const ExpandingMenu = memo<ExpandingMenuProps>(function ExpandingMenu({
isOpen,
onToggle,
navItems,
isScrolled = false
}) {
const { isMounted, menuWidth } = useResponsiveMenuWidth();
const handleNavClick = useCallback(() => {
onToggle();
}, [onToggle]);
return (
<div
className={`
rounded-theme-capped absolute top-3 right-3
transition-[top] duration-500 ease-in-out
${isScrolled ? '' : ''}
`}>
<div
aria-hidden="true"
className={`
primary-button
backdrop-blur-xs
transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-foreground rounded-theme-capped absolute top-0 right-0
${isOpen
? 'w-full h-full'
: 'h-9 w-[var(--height-9)]'
}
`}
/>
<div className={`
relative p-6 flex flex-col gap-6
transition-all duration-500 ease-[cubic-bezier(0.5,0.5,0,1)]
pointer-events-auto origin-[100%_0]
${isOpen
? 'scale-100 opacity-100 visible'
: 'scale-[0.15] opacity-0 invisible'
}
`}
style={{
transition: 'all 0.5s cubic-bezier(0.5, 0.5, 0, 1), transform 0.7s cubic-bezier(0.5, 0.5, 0, 1)',
width: isMounted ? menuWidth : 'var(--width-20)'
}}
>
<p className="text-xl text-background" aria-hidden="true">Menu</p>
<ul
role="menu"
className="relative list-none flex flex-col gap-3 m-0 p-0"
>
{navItems.map((item) => {
const MenuButton = () => {
const handleClick = useButtonClick(item.id, handleNavClick);
return (
<button
aria-label={`Navigate to ${item.name}`}
className={`
text-background flex justify-between items-center
no-underline bg-none border-none cursor-pointer w-full
font-inherit group
`}
onClick={handleClick}
>
<span className="text-base">
{item.name}
</span>
<div className="bg-current rounded-theme-capped shrink-0 h-2 aspect-square" />
</button>
);
};
return (
<li
key={item.id}
role="menuitem"
className="m-0 p-0 list-none"
>
<MenuButton />
</li>
);
})}
</ul>
</div>
<button
aria-label={isOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isOpen}
aria-controls="navigation-menu"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
pointer-events-auto cursor-pointer rounded-theme-capped
flex justify-center items-center
h-9 w-[var(--height-9)] aspect-square absolute top-0 right-0
bg-transparent border-none
${isOpen
? '-translate-x-3 translate-y-3'
: 'translate-x-0 translate-y-0'
}
`}
onClick={onToggle}
>
<span
aria-hidden="true"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-background w-[40%] h-0.25 absolute
${isOpen
? 'translate-y-0 rotate-45'
: '-translate-y-1 hover:translate-y-1'
}
`} />
<span
aria-hidden="true"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-background w-[40%] h-0.25 absolute
${isOpen
? 'translate-y-0 -rotate-45'
: 'translate-y-1 hover:-translate-y-1'
}
`} />
</button>
</div>
);
});
export default ExpandingMenu;

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export const useResponsiveMenuWidth = () => {
const [isMounted, setIsMounted] = useState(false);
const [menuWidth, setMenuWidth] = useState('var(--width-20)');
useEffect(() => {
setIsMounted(true);
const handleResize = () => {
setMenuWidth(
window.innerWidth >= 768
? 'var(--width-20)'
: 'calc(var(--width-80) - var(--vw-0_75) * 2)'
);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return { isMounted, menuWidth };
};

View File

@@ -0,0 +1,79 @@
"use client";
import { memo, Fragment } from 'react';
import { ArrowRight, Plus } from 'lucide-react';
import { NavItem } from '@/types/navigation';
import { useMenuAnimation } from './useMenuAnimation';
import { useButtonClick } from '@/components/button/useButtonClick';
interface MobileMenuProps {
menuOpen: boolean;
onMenuToggle: () => void;
navItems: NavItem[];
onNavClick: (id: string) => void;
children?: React.ReactNode;
}
const MobileMenu = ({
menuOpen,
onMenuToggle,
navItems,
onNavClick,
children
}: MobileMenuProps) => {
const menuRef = useMenuAnimation(menuOpen);
return (
<div
id="mobile-menu"
className="md:hidden z-10 fixed top-3 left-1/2 -translate-x-1/2 h-fit rounded-theme-capped card p-6 flex flex-col gap-6 opacity-0"
style={{ width: 'calc(100vw - var(--vw-0_75) * 2)' }}
ref={menuRef}
role="navigation"
aria-label="Mobile navigation menu"
>
<div className="w-full flex justify-between items-center">
<p className="text-xl text-foreground">Menu</p>
<button
className="shrink-0 h-8 aspect-square rounded-theme bg-foreground flex items-center justify-center cursor-pointer"
onClick={onMenuToggle}
aria-label="Close menu"
>
<Plus className="w-1/2 h-1/2 text-background rotate-45" strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-4">
{navItems.map((item, index) => {
const NavButton = () => {
const handleClick = useButtonClick(item.id, () => onNavClick(item.id));
return (
<button
className="w-full h-fit flex justify-between items-center cursor-pointer"
onClick={handleClick}
aria-label={`Navigate to ${item.name}`}
>
<p className="text-base font-medium">{item.name}</p>
<ArrowRight strokeWidth={1.5} className="h-[var(--text-base)] w-auto text-foreground" aria-hidden="true" />
</button>
);
};
return (
<Fragment key={index}>
<NavButton />
{index < navItems.length - 1 && (
<div className="w-full h-px bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
)}
</Fragment>
);
})}
</div>
{children && (
<div className="flex gap-3 items-center">{children}</div>
)}
</div>
);
};
export default memo(MobileMenu);

View File

@@ -0,0 +1,40 @@
import { useRef, useEffect } from 'react';
import { gsap } from 'gsap';
export const useMenuAnimation = (menuOpen: boolean) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (menuRef.current) {
if (menuOpen) {
gsap.to(menuRef.current, {
y: "0%",
opacity: 1,
pointerEvents: "auto",
duration: 0.8,
ease: "power3.out"
});
} else {
gsap.to(menuRef.current, {
y: "-135%",
opacity: 1,
pointerEvents: "none",
duration: 0.8,
ease: "power3.inOut"
});
}
}
}, [menuOpen]);
useEffect(() => {
if (menuRef.current) {
gsap.set(menuRef.current, {
y: "-135%",
opacity: 1,
pointerEvents: "none"
});
}
}, []);
return menuRef;
};

View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect, useRef, ReactNode } from "react";
interface AnimationContainerProps {
children: ReactNode;
className?: string;
animationDuration?: number;
animationType?: "full" | "fade";
style?: React.CSSProperties;
}
const AnimationContainer = ({
children,
className = "w-full h-fit flex flex-col gap-6",
animationDuration = 800,
animationType = "full",
style,
}: AnimationContainerProps) => {
const animationClass =
animationType === "full"
? "animation-container"
: "animation-container-fade";
const [activeClass, setActiveClass] = useState(animationClass);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = setTimeout(() => {
setActiveClass("");
}, animationDuration);
return () => clearTimeout(timer);
}, [animationDuration]);
return (
<div
ref={containerRef}
className={`${className} ${activeClass}`.trim()}
style={style}
>
{children}
</div>
);
};
export default AnimationContainer;

View File

@@ -0,0 +1,102 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
interface Feature {
icon: LucideIcon;
title: string;
description: string;
}
interface AboutFeatureProps {
title: string;
features: Feature[];
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
featuresContainerClassName?: string;
featureCardClassName?: string;
featureIconContainerClassName?: string;
featureIconClassName?: string;
featureTitleClassName?: string;
featureDescriptionClassName?: string;
}
const AboutFeature = ({
title,
features,
useInvertedBackground,
ariaLabel = "About features section",
className = "",
containerClassName = "",
titleClassName = "",
featuresContainerClassName = "",
featureCardClassName = "",
featureIconContainerClassName = "",
featureIconClassName = "",
featureTitleClassName = "",
featureDescriptionClassName = "",
}: AboutFeatureProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const gridColsMap = {
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const gridCols = gridColsMap[features.length as keyof typeof gridColsMap] || "md:grid-cols-4";
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="words-trigger"
className={cls("text-2xl md:text-5xl font-medium leading-[1.175]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
<div className={cls("grid grid-cols-1 gap-6", gridCols, featuresContainerClassName)}>
{features.map((feature, index) => {
const Icon = feature.icon;
return (
<div
key={index}
className={cls(
"card flex flex-col justify-between gap-4 p-6 rounded-theme-capped h-50 md:h-60 2xl:h-70",
featureCardClassName
)}
>
<div className={cls("relative z-1 primary-button h-12 w-fit aspect-square flex items-center justify-center rounded-theme", featureIconContainerClassName)} aria-hidden="true">
<Icon className={cls("h-4/10 w-auto text-background", featureIconClassName)} strokeWidth={1.5} />
</div>
<div className="relative z-1 flex flex-col gap-1">
<h3 className={cls("text-2xl font-medium", shouldUseLightText && "text-background", featureTitleClassName)}>
{feature.title}
</h3>
<p className={cls("text-sm leading-[1.1]", shouldUseLightText ? "text-background" : "text-foreground", featureDescriptionClassName)}>
{feature.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
);
};
AboutFeature.displayName = "AboutFeature";
export default memo(AboutFeature);

View File

@@ -0,0 +1,102 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
interface Metric {
icon: LucideIcon;
label: string;
value: string;
}
interface AboutMetricProps {
title: string;
metrics: Metric[];
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
metricsContainerClassName?: string;
metricCardClassName?: string;
metricIconClassName?: string;
metricLabelClassName?: string;
metricValueClassName?: string;
}
const AboutMetric = ({
title,
metrics,
useInvertedBackground,
ariaLabel = "About metrics section",
className = "",
containerClassName = "",
titleClassName = "",
metricsContainerClassName = "",
metricCardClassName = "",
metricIconClassName = "",
metricLabelClassName = "",
metricValueClassName = "",
}: AboutMetricProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const gridColsMap = {
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const gridCols = gridColsMap[metrics.length as keyof typeof gridColsMap] || "md:grid-cols-4";
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="words-trigger"
className={cls("text-2xl md:text-5xl font-medium leading-[1.175]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
<div className={cls("grid grid-cols-1 gap-6", gridCols, metricsContainerClassName)}>
{metrics.map((metric, index) => {
const Icon = metric.icon;
return (
<div
key={index}
className={cls(
"h-fit card rounded-theme-capped px-6 py-8 md:py-10 flex flex-col items-center justify-center gap-3",
metricCardClassName
)}
>
<div className="relative z-1 w-full flex items-center justify-center gap-2">
<div className={cls("h-8 primary-button aspect-square rounded-theme flex items-center justify-center", metricIconClassName)}>
<Icon className="h-4/10 text-background" strokeWidth={1.5} />
</div>
<h3 className={cls("text-xl truncate", shouldUseLightText && "text-background", metricLabelClassName)}>
{metric.label}
</h3>
</div>
<div className="relative z-1 w-full flex items-center justify-center">
<h4 className={cls("text-6xl font-medium truncate", shouldUseLightText && "text-background", metricValueClassName)}>
{metric.value}
</h4>
</div>
</div>
);
})}
</div>
</div>
</section>
);
};
AboutMetric.displayName = "AboutMetric";
export default memo(AboutMetric);

View File

@@ -0,0 +1,214 @@
"use client";
import React, { memo, useMemo } from "react";
import TimelinePhoneView from "@/components/cardStack/layouts/timelines/TimelinePhoneView";
import TextAnimation from "@/components/text/TextAnimation";
import Button from "@/components/button/Button";
import Tag from "@/components/shared/Tag";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment } from "@/components/cardStack/types";
import type { TimelinePhoneViewItem } from "@/components/cardStack/hooks/usePhoneAnimations";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
type AboutPhone = {
imageAlt?: string;
videoAriaLabel?: string;
} & (
| { imageSrc: string; videoSrc?: never }
| { videoSrc: string; imageSrc?: never }
);
interface AboutPhoneTimelineProps {
title: string;
titleSegments?: TitleSegment[];
description: string;
tag: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
phoneOne: AboutPhone;
phoneTwo: AboutPhone;
textboxLayout: TextboxLayout;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
desktopContainerClassName?: string;
mobileContainerClassName?: string;
desktopContentClassName?: string;
desktopWrapperClassName?: string;
mobileWrapperClassName?: string;
phoneFrameClassName?: string;
mobilePhoneFrameClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
contentClassName?: string;
tagClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
interface AboutContentProps {
title: string;
description: string;
tag: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
contentClassName: string;
tagClassName: string;
titleClassName: string;
descriptionClassName: string;
buttonContainerClassName: string;
buttonClassName: string;
buttonTextClassName: string;
}
const AboutContent = ({
title,
description,
tag,
tagIcon: TagIcon,
buttons,
useInvertedBackground,
contentClassName,
tagClassName,
titleClassName,
descriptionClassName,
buttonContainerClassName,
buttonClassName,
buttonTextClassName,
}: AboutContentProps) => {
const theme = useTheme();
return (
<div className={cls("h-full w-full flex items-center justify-center px-10", contentClassName)}>
<div className="flex flex-col gap-3 md:gap-1 items-center mb-0 md:mb-[12.5vh] 2xl:mb-[14vh]">
<Tag
text={tag}
icon={TagIcon}
useInvertedBackground={useInvertedBackground}
className={cls("mb-1 md:mb-3", tagClassName)}
/>
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="trigger"
as="h2"
className={cls("text-6xl font-medium text-center", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
<TextAnimation
type={theme.defaultTextAnimation}
text={description}
variant="trigger"
as="p"
className={cls("text-lg leading-[1.2] text-center", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") ? "text-background/75" : "text-foreground/75", descriptionClassName)}
/>
{buttons && buttons.length > 0 && (
<div className={cls("flex gap-4 mt-1 md:mt-3 justify-center", buttonContainerClassName)}>
{buttons.map((button, index) => (
<Button key={`${button.text}-${index}`} {...getButtonProps(button, index, theme.defaultButtonVariant, buttonClassName, buttonTextClassName)} />
))}
</div>
)}
</div>
</div>
);
};
const AboutPhoneTimeline = ({
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
phoneOne,
phoneTwo,
textboxLayout,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
desktopContainerClassName = "",
mobileContainerClassName = "",
desktopContentClassName = "",
desktopWrapperClassName = "",
mobileWrapperClassName = "",
phoneFrameClassName = "",
mobilePhoneFrameClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
contentClassName = "",
tagClassName = "",
titleClassName = "",
descriptionClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: AboutPhoneTimelineProps) => {
const timelineItems: TimelinePhoneViewItem[] = useMemo(() => [{
trigger: 'about-trigger',
content: (
<AboutContent
title={title}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
useInvertedBackground={useInvertedBackground}
contentClassName={contentClassName}
tagClassName={tagClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
),
imageOne: phoneOne.imageSrc,
videoOne: phoneOne.videoSrc,
imageAltOne: phoneOne.imageAlt || `${title} - Image 1`,
videoAriaLabelOne: phoneOne.videoAriaLabel || `${title} - Video 1`,
imageTwo: phoneTwo.imageSrc,
videoTwo: phoneTwo.videoSrc,
imageAltTwo: phoneTwo.imageAlt || `${title} - Image 2`,
videoAriaLabelTwo: phoneTwo.videoAriaLabel || `${title} - Video 2`,
}], [title, description, tag, tagIcon, buttons, phoneOne, phoneTwo, useInvertedBackground, contentClassName, tagClassName, titleClassName, descriptionClassName, buttonContainerClassName, buttonClassName, buttonTextClassName]);
return (
<TimelinePhoneView
items={timelineItems}
showTextBox={false}
title=""
titleSegments={titleSegments}
description=""
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
desktopContainerClassName={desktopContainerClassName}
mobileContainerClassName={mobileContainerClassName}
desktopContentClassName={desktopContentClassName}
desktopWrapperClassName={desktopWrapperClassName}
mobileWrapperClassName={mobileWrapperClassName}
phoneFrameClassName={phoneFrameClassName}
mobilePhoneFrameClassName={mobilePhoneFrameClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
ariaLabel={ariaLabel}
/>
);
};
AboutPhoneTimeline.displayName = "AboutPhoneTimeline";
export default memo(AboutPhoneTimeline);

View File

@@ -0,0 +1,245 @@
"use client";
import { memo } from "react";
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
import MediaContent from "@/components/shared/MediaContent";
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/types/button";
import type { CardAnimationType, TitleSegment } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface ShowcaseItem {
title: string;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
}
interface ColumnItemProps {
item: ShowcaseItem;
position: "left" | "right";
shouldUseLightText: boolean;
useCappedBorderRadius: boolean;
itemMediaWrapperClassName: string;
itemImageClassName: string;
itemTitleClassName: string;
mediaRef?: (el: HTMLDivElement | null) => void;
textRef?: (el: HTMLDivElement | null) => void;
}
const ColumnItem = ({
item,
position,
shouldUseLightText,
useCappedBorderRadius,
itemMediaWrapperClassName,
itemImageClassName,
itemTitleClassName,
mediaRef,
textRef,
}: ColumnItemProps) => {
const mediaBlock = (
<div ref={mediaRef} className={cls("w-full h-auto md:h-1/2 aspect-square md:aspect-auto overflow-hidden", useCappedBorderRadius ? "rounded-theme-capped" : "rounded-theme", itemMediaWrapperClassName)}>
<MediaContent
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
imageAlt={item.imageAlt || ""}
videoAriaLabel={item.videoAriaLabel || "Item video"}
imageClassName={cls("w-full h-full object-cover", itemImageClassName)}
/>
</div>
);
const textBlock = (
<div ref={textRef} className={cls("relative card rounded-theme-capped md:h-1/2 flex flex-col p-6", position === "left" ? "justify-end text-left" : "md:justify-start md:text-right")}>
<h3 className={cls("text-3xl font-medium leading-tight line-clamp-6", shouldUseLightText ? "text-background" : "text-foreground", itemTitleClassName)}>
{item.title}
</h3>
</div>
);
return position === "left" ? (
<div className="flex flex-col gap-6 h-full">
{mediaBlock}
{textBlock}
</div>
) : (
<div className="flex flex-col gap-6 h-full">
{textBlock}
{mediaBlock}
</div>
);
};
interface AboutShowcaseProps {
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
leftItem: ShowcaseItem;
rightItem: ShowcaseItem;
centerImageSrc?: string;
centerVideoSrc?: string;
centerImageAlt?: string;
centerVideoAriaLabel?: string;
ariaLabel?: string;
useCappedBorderRadius: boolean;
animationType: CardAnimationType;
textboxLayout: TextboxLayout;
useInvertedBackground: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
contentClassName?: string;
leftColumnClassName?: string;
rightColumnClassName?: string;
centerColumnClassName?: string;
itemTitleClassName?: string;
itemMediaWrapperClassName?: string;
itemImageClassName?: string;
centerMediaWrapperClassName?: string;
centerImageClassName?: string;
}
const AboutShowcase = ({
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
leftItem,
rightItem,
centerImageSrc,
centerVideoSrc,
centerImageAlt = "",
centerVideoAriaLabel = "Showcase video",
ariaLabel = "About section",
useCappedBorderRadius,
animationType,
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
contentClassName = "",
leftColumnClassName = "",
rightColumnClassName = "",
centerColumnClassName = "",
itemTitleClassName = "",
itemMediaWrapperClassName = "",
itemImageClassName = "",
centerMediaWrapperClassName = "",
centerImageClassName = "",
}: AboutShowcaseProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const { itemRefs } = useCardAnimation({ animationType, itemCount: 5 });
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-10 md:gap-15", 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={cls("relative grid grid-cols-1 md:grid-cols-3 gap-6", contentClassName)}>
<div className={cls("flex flex-col", leftColumnClassName)}>
<ColumnItem
item={leftItem}
position="left"
shouldUseLightText={shouldUseLightText}
useCappedBorderRadius={useCappedBorderRadius}
itemMediaWrapperClassName={itemMediaWrapperClassName}
itemImageClassName={itemImageClassName}
itemTitleClassName={itemTitleClassName}
mediaRef={(el) => { itemRefs.current[0] = el; }}
textRef={(el) => { itemRefs.current[1] = el; }}
/>
</div>
<div className={cls("flex flex-col aspect-[9/16] md:aspect-auto", centerColumnClassName)}>
<div
ref={(el) => { itemRefs.current[2] = el; }}
className={cls("w-full h-full overflow-hidden", useCappedBorderRadius ? "rounded-theme-capped" : "rounded-theme", centerMediaWrapperClassName)}
>
<MediaContent
imageSrc={centerImageSrc}
videoSrc={centerVideoSrc}
imageAlt={centerImageAlt}
videoAriaLabel={centerVideoAriaLabel}
imageClassName={cls("w-full h-full object-cover", centerImageClassName)}
/>
</div>
</div>
<div className={cls("flex flex-col", rightColumnClassName)}>
<ColumnItem
item={rightItem}
position="right"
shouldUseLightText={shouldUseLightText}
useCappedBorderRadius={useCappedBorderRadius}
itemMediaWrapperClassName={itemMediaWrapperClassName}
itemImageClassName={itemImageClassName}
itemTitleClassName={itemTitleClassName}
mediaRef={(el) => { itemRefs.current[4] = el; }}
textRef={(el) => { itemRefs.current[3] = el; }}
/>
</div>
</div>
</div>
</section>
);
};
AboutShowcase.displayName = "AboutShowcase";
export default memo(AboutShowcase);

View File

@@ -0,0 +1,279 @@
"use client";
import React, { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { CardAnimationType } from "@/components/cardStack/types";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type MediaProps =
| {
imageSrc: string;
imageAlt?: string;
videoSrc?: never;
videoAriaLabel?: never;
}
| {
videoSrc: string;
videoAriaLabel?: string;
imageSrc?: never;
imageAlt?: never;
};
type BannerMedia = MediaProps & {
title: string;
};
type GridCard = {
title: string;
description: string;
};
type BottomMedia = MediaProps;
interface BannerGridAboutProps {
bannerMedia: BannerMedia;
cards: GridCard[];
bottomMedia: BottomMedia;
animationType: CardAnimationType;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
bannerCardClassName?: string;
bannerMediaClassName?: string;
bannerTitleClassName?: string;
gridClassName?: string;
cardClassName?: string;
cardTitleClassName?: string;
cardDescriptionClassName?: string;
bottomMediaCardClassName?: string;
bottomMediaClassName?: string;
}
interface GridCardItemProps {
card: GridCard;
shouldUseLightText: boolean;
cardClassName?: string;
cardTitleClassName?: string;
cardDescriptionClassName?: string;
}
const GridCardItem = memo(({
card,
shouldUseLightText,
cardClassName = "",
cardTitleClassName = "",
cardDescriptionClassName = "",
}: GridCardItemProps) => {
return (
<div
className={cls(
"card rounded-theme-capped p-6 md:p-8 flex flex-col justify-between gap-4 aspect-video",
cardClassName
)}
>
<h3 className={cls(
"relative z-1 text-xl text-balance md:text-3xl font-medium truncate",
shouldUseLightText ? "text-background" : "text-foreground",
cardTitleClassName
)}>
{card.title}
</h3>
<p className={cls(
"relative z-1 text-base text-balance md:text-lg leading-tight line-clamp-3",
shouldUseLightText ? "text-background/75" : "text-foreground/75",
cardDescriptionClassName
)}>
{card.description}
</p>
</div>
);
});
GridCardItem.displayName = "GridCardItem";
const BannerGridAbout = ({
bannerMedia,
cards,
bottomMedia,
animationType,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
bannerCardClassName = "",
bannerMediaClassName = "",
bannerTitleClassName = "",
gridClassName = "",
cardClassName = "",
cardTitleClassName = "",
cardDescriptionClassName = "",
bottomMediaCardClassName = "",
bottomMediaClassName = "",
}: BannerGridAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const cardCount = cards.length;
// 1 card: 3 items (hero, card, media)
// 2 cards: 4 items (hero, card1, media, card2)
// 3 cards: 5 items (hero, card1, card2, card3, media)
const itemCount = cardCount + 2;
const { itemRefs } = useCardAnimation({ animationType, itemCount });
return (
<section
aria-label={ariaLabel}
className={cls(
"relative w-full py-20",
useInvertedBackground === "invertCard" && "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div className={cls("w-content-width mx-auto", containerClassName)}>
<div className="flex flex-col gap-4">
{/* Banner Media Card */}
<div
ref={(el) => { itemRefs.current[0] = el; }}
className={cls(
"relative w-full aspect-video rounded-theme-capped overflow-hidden",
bannerCardClassName
)}
>
<MediaContent
imageSrc={bannerMedia.imageSrc}
videoSrc={bannerMedia.videoSrc}
imageAlt={bannerMedia.imageAlt}
videoAriaLabel={bannerMedia.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", bannerMediaClassName)}
/>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className={cls(
"max-w-8/10 md:max-w-1/2 text-4xl md:text-6xl font-medium text-center text-balance text-background mix-blend-difference",
bannerTitleClassName
)}>
{bannerMedia.title}
</h2>
</div>
</div>
{/* Bottom Grid */}
<div className={cls(
"grid gap-4",
cardCount === 1 && "grid-cols-1 md:grid-cols-2",
cardCount === 2 && "grid-cols-1 md:grid-cols-3",
cardCount === 3 && "grid-cols-1 md:grid-cols-3",
gridClassName
)}>
{/* First Card */}
<div ref={(el) => { itemRefs.current[1] = el; }}>
<GridCardItem
card={cards[0]}
shouldUseLightText={shouldUseLightText}
cardClassName={cardClassName}
cardTitleClassName={cardTitleClassName}
cardDescriptionClassName={cardDescriptionClassName}
/>
</div>
{/* 1 card: Media on right */}
{cardCount === 1 && (
<div
ref={(el) => { itemRefs.current[2] = el; }}
className={cls(
"relative rounded-theme-capped overflow-hidden aspect-square md:aspect-video",
bottomMediaCardClassName
)}
>
<MediaContent
imageSrc={bottomMedia.imageSrc}
videoSrc={bottomMedia.videoSrc}
imageAlt={bottomMedia.imageAlt}
videoAriaLabel={bottomMedia.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", bottomMediaClassName)}
/>
</div>
)}
{/* 2 cards: Media in middle */}
{cardCount === 2 && (
<>
<div
ref={(el) => { itemRefs.current[2] = el; }}
className={cls(
"relative rounded-theme-capped overflow-hidden aspect-square md:aspect-video order-last md:order-none",
bottomMediaCardClassName
)}
>
<MediaContent
imageSrc={bottomMedia.imageSrc}
videoSrc={bottomMedia.videoSrc}
imageAlt={bottomMedia.imageAlt}
videoAriaLabel={bottomMedia.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", bottomMediaClassName)}
/>
</div>
<div ref={(el) => { itemRefs.current[3] = el; }}>
<GridCardItem
card={cards[1]}
shouldUseLightText={shouldUseLightText}
cardClassName={cardClassName}
cardTitleClassName={cardTitleClassName}
cardDescriptionClassName={cardDescriptionClassName}
/>
</div>
</>
)}
{/* 3 cards: Card 2, Card 3, then Media full width */}
{cardCount === 3 && (
<>
<div ref={(el) => { itemRefs.current[2] = el; }}>
<GridCardItem
card={cards[1]}
shouldUseLightText={shouldUseLightText}
cardClassName={cardClassName}
cardTitleClassName={cardTitleClassName}
cardDescriptionClassName={cardDescriptionClassName}
/>
</div>
<div ref={(el) => { itemRefs.current[3] = el; }}>
<GridCardItem
card={cards[2]}
shouldUseLightText={shouldUseLightText}
cardClassName={cardClassName}
cardTitleClassName={cardTitleClassName}
cardDescriptionClassName={cardDescriptionClassName}
/>
</div>
<div
ref={(el) => { itemRefs.current[4] = el; }}
className={cls(
"relative rounded-theme-capped overflow-hidden aspect-video md:aspect-[16/3] col-span-2 md:col-span-3",
bottomMediaCardClassName
)}
>
<MediaContent
imageSrc={bottomMedia.imageSrc}
videoSrc={bottomMedia.videoSrc}
imageAlt={bottomMedia.imageAlt}
videoAriaLabel={bottomMedia.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", bottomMediaClassName)}
/>
</div>
</>
)}
</div>
</div>
</div>
</section>
);
};
BannerGridAbout.displayName = "BannerGridAbout";
export default memo(BannerGridAbout);

View File

@@ -0,0 +1,130 @@
"use client";
import React, { memo } from "react";
import Image from "next/image";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type HeadingSegment =
| { type: "text"; content: string }
| { type: "image"; src: string; alt?: string };
interface InlineImageSplitTextAboutProps {
heading: HeadingSegment[];
buttons?: ButtonConfig[];
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
headingClassName?: string;
imageWrapperClassName?: string;
imageClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const InlineImageSplitTextAbout = ({
heading,
buttons,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
headingClassName = "",
imageWrapperClassName = "",
imageClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: InlineImageSplitTextAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div
className={cls(
"w-content-width mx-auto flex flex-col gap-6 items-center",
containerClassName
)}
>
<h2
className={cls(
"text-4xl md:text-5xl font-medium text-center leading-[1.15] text-balance",
(useInvertedBackground === "invertDefault" ||
useInvertedBackground === "invertCard") &&
"text-background",
headingClassName
)}
>
{heading.map((segment, index) => {
const imageIndex = heading
.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",
imageWrapperClassName
)}
>
<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",
imageClassName
)}
unoptimized={segment.src.startsWith("http") || segment.src.startsWith("//")}
aria-hidden={!segment.alt || segment.alt === ""}
/>
</div>
</span>
);
return (
<React.Fragment key={index}>
{index > 0 && " "}
{element}
</React.Fragment>
);
})}
</h2>
{buttons && buttons.length > 0 && (
<div className={cls("flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button key={index} {...getButtonProps(button, index, theme.defaultButtonVariant, cls("px-8", buttonClassName), cls("text-base", buttonTextClassName))} />
))}
</div>
)}
</div>
</section>
);
};
InlineImageSplitTextAbout.displayName = "InlineImageSplitTextAbout";
export default memo(InlineImageSplitTextAbout);

View File

@@ -0,0 +1,81 @@
"use client";
import { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface MediaAboutProps {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
buttons?: ButtonConfig[];
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const MediaAbout = ({
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
buttons,
useInvertedBackground,
ariaLabel = "About section",
className = "",
mediaWrapperClassName = "",
mediaClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: MediaAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("relative w-content-width mx-auto aspect-square md:aspect-video rounded-theme-capped overflow-hidden", mediaWrapperClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
{buttons && buttons.length > 0 && (
<div className={cls("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button
key={index}
{...getButtonProps(
button,
index,
theme.defaultButtonVariant,
cls("px-8", buttonClassName),
cls("text-base", buttonTextClassName)
)}
/>
))}
</div>
)}
</div>
</section>
);
};
MediaAbout.displayName = "MediaAbout";
export default memo(MediaAbout);

View File

@@ -0,0 +1,132 @@
"use client";
import { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
type MediaItem = {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
};
interface MediaGridAboutProps {
title: string;
description: string;
mediaItems: MediaItem[];
imagePosition?: "left" | "right";
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentCardClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
mediaCardClassName?: string;
mediaGridClassName?: string;
mediaClassName?: string;
}
const MediaGridAbout = ({
title,
description,
mediaItems,
imagePosition = "right",
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
contentCardClassName = "",
titleClassName = "",
descriptionClassName = "",
mediaCardClassName = "",
mediaGridClassName = "",
mediaClassName = "",
}: MediaGridAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const contentCard = (
<div className={cls("card rounded-theme-capped p-6 md:p-10 flex flex-col justify-between gap-6", contentCardClassName)}>
<h2 className={cls("text-4xl md:text-5xl font-medium leading-tight text-balance", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
{title}
</h2>
<p className={cls("text-base md:text-lg leading-tight text-balance", shouldUseLightText ? "text-background/75" : "text-foreground/75", descriptionClassName)}>
{description}
</p>
</div>
);
const items = mediaItems.slice(0, 6);
const itemCount = items.length;
const getGridLayout = () => {
switch (itemCount) {
case 1:
return "grid-cols-1";
case 2:
return "grid-cols-1";
case 3:
return "grid-cols-2";
case 4:
return "grid-cols-2";
case 5:
return "grid-cols-2";
case 6:
return "grid-cols-2";
default:
return "grid-cols-1";
}
};
const getItemClassName = (index: number) => {
if (itemCount === 3 && index === 2) return "col-span-2";
if (itemCount === 5 && index === 4) return "col-span-2";
return "";
};
const mediaCard = (
<div className={cls("card aspect-square rounded-theme-capped overflow-hidden p-6", mediaCardClassName)}>
<div className={cls("relative z-1 grid h-full gap-6", getGridLayout(), mediaGridClassName)}>
{items.map((item, index) => (
<div key={index} className={cls("relative overflow-hidden", getItemClassName(index))}>
<MediaContent
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
imageAlt={item.imageAlt}
videoAriaLabel={item.videoAriaLabel}
imageClassName={cls("absolute inset-0 w-full h-full object-cover", mediaClassName)}
/>
</div>
))}
</div>
</div>
);
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto grid grid-cols-1 md:grid-cols-2 gap-6", containerClassName)}>
{imagePosition === "left" ? (
<>
{mediaCard}
{contentCard}
</>
) : (
<>
{contentCard}
{mediaCard}
</>
)}
</div>
</section>
);
};
MediaGridAbout.displayName = "MediaGridAbout";
export default memo(MediaGridAbout);

View File

@@ -0,0 +1,125 @@
"use client";
import { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import Button from "@/components/button/Button";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface MediaSplitAboutProps {
title: string;
description: string;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
buttons?: ButtonConfig[];
imagePosition?: "left" | "right";
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentCardClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
mediaCardClassName?: string;
mediaClassName?: string;
}
const MediaSplitAbout = ({
title,
description,
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
buttons,
imagePosition = "right",
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
contentCardClassName = "",
titleClassName = "",
descriptionClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
mediaCardClassName = "",
mediaClassName = "",
}: MediaSplitAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const contentCard = (
<div className={cls("card rounded-theme-capped p-6 md:p-10 flex flex-col justify-between gap-10", contentCardClassName)}>
<div className="relative z-1 flex flex-col gap-4">
<h2 className={cls("text-4xl font-medium leading-tight", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
{title}
</h2>
<p className={cls("text-base md:text-lg leading-tight", shouldUseLightText ? "text-background/75" : "text-foreground/75", descriptionClassName)}>
{description}
</p>
</div>
{buttons && buttons.length > 0 && (
<div className={cls("relative z-1 flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button
key={index}
{...getButtonProps(
button,
index,
theme.defaultButtonVariant,
cls("px-8", buttonClassName),
cls("text-base", buttonTextClassName)
)}
/>
))}
</div>
)}
</div>
);
const mediaCard = (
<div className={cls("card md:aspect-square rounded-theme-capped overflow-hidden", mediaCardClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("relative z-1 w-full h-full object-cover", mediaClassName)}
/>
</div>
);
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto grid grid-cols-1 md:grid-cols-2 gap-6", containerClassName)}>
{imagePosition === "left" ? (
<>
{mediaCard}
{contentCard}
</>
) : (
<>
{contentCard}
{mediaCard}
</>
)}
</div>
</section>
);
};
MediaSplitAbout.displayName = "MediaSplitAbout";
export default memo(MediaSplitAbout);

View File

@@ -0,0 +1,163 @@
"use client";
import { memo, useState } from "react";
import MediaContent from "@/components/shared/MediaContent";
import AnimationContainer from "@/components/sections/AnimationContainer";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface TabOption {
id: string;
label: string;
description: string;
}
interface MediaSplitTabsAboutProps {
title: string;
description?: string;
tabs: TabOption[];
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
imagePosition?: "left" | "right";
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentCardClassName?: string;
titleClassName?: string;
titleDescriptionClassName?: string;
tabsContainerClassName?: string;
tabClassName?: string;
activeTabClassName?: string;
tabIndicatorClassName?: string;
descriptionClassName?: string;
mediaCardClassName?: string;
mediaClassName?: string;
}
const MediaSplitTabsAbout = ({
title,
description,
tabs,
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
imagePosition = "right",
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
contentCardClassName = "",
titleClassName = "",
titleDescriptionClassName = "",
tabsContainerClassName = "",
tabClassName = "",
activeTabClassName = "",
tabIndicatorClassName = "",
descriptionClassName = "",
mediaCardClassName = "",
mediaClassName = "",
}: MediaSplitTabsAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const [activeTab, setActiveTab] = useState(tabs[0]?.id || "");
const activeTabData = tabs.find((tab) => tab.id === activeTab);
const contentCard = (
<div className={cls("card rounded-theme-capped p-6 md:p-10 md:h-160 2xl:h-180 flex flex-col justify-between gap-3 md:gap-6", contentCardClassName)}>
<div className="relative z-1 flex flex-col gap-2">
<h2 className={cls("text-4xl font-medium leading-tight", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
{title}
</h2>
{description && (
<p className={cls("text-base md:text-lg leading-tight", shouldUseLightText ? "text-background" : "text-foreground", titleDescriptionClassName)}>
{description}
</p>
)}
</div>
<div className="relative z-1 flex flex-col gap-6">
<div className={cls("flex flex-wrap gap-x-6 gap-y-1 md:gap-6", tabsContainerClassName)}>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cls(
"flex items-center gap-2 text-lg md:text-xl transition-colors cursor-pointer",
activeTab === tab.id
? (shouldUseLightText ? "text-background" : "text-foreground")
: (shouldUseLightText ? "text-background/50" : "text-foreground/50"),
activeTab === tab.id && activeTabClassName,
tabClassName
)}
aria-pressed={activeTab === tab.id}
>
<span
className={cls(
"rounded-full bg-accent transition-all duration-300",
activeTab === tab.id ? "w-2 h-2" : "w-0 h-0",
tabIndicatorClassName
)}
aria-hidden="true"
/>
{tab.label}
</button>
))}
</div>
<div className="w-full h-px bg-accent" />
<AnimationContainer
key={activeTab}
className="w-full"
>
<p className={cls("text-base md:text-lg leading-tight", shouldUseLightText ? "text-background" : "text-foreground", descriptionClassName)}>
{activeTabData?.description}
</p>
</AnimationContainer>
</div>
</div>
);
const mediaCard = (
<div className={cls("card aspect-square md:aspect-auto md:h-160 2xl:h-180 rounded-theme-capped overflow-hidden", mediaCardClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("relative z-1 w-full h-full object-cover", mediaClassName)}
/>
</div>
);
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto grid grid-cols-1 md:grid-cols-10 gap-6", containerClassName)}>
{imagePosition === "left" ? (
<>
<div className="md:col-span-4">{mediaCard}</div>
<div className="md:col-span-6">{contentCard}</div>
</>
) : (
<>
<div className="md:col-span-6">{contentCard}</div>
<div className="md:col-span-4">{mediaCard}</div>
</>
)}
</div>
</section>
);
};
MediaSplitTabsAbout.displayName = "MediaSplitTabsAbout";
export default memo(MediaSplitTabsAbout);

View File

@@ -0,0 +1,82 @@
"use client";
import { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface ParallaxAboutProps {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
buttons?: ButtonConfig[];
ariaLabel?: string;
className?: string;
mediaClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const ParallaxAbout = ({
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
buttons,
ariaLabel = "About section",
className = "",
mediaClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: ParallaxAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative z-0 w-full h-svh", className)}
style={{
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
}}
>
<div className="fixed inset-0 w-full h-full">
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("w-full h-full object-cover rounded-none!", mediaClassName)}
/>
</div>
{buttons && buttons.length > 0 && (
<div className={cls("relative z-10 flex items-center justify-center h-full", buttonContainerClassName)}>
<div className="flex gap-4">
{buttons.slice(0, 2).map((button, index) => (
<Button
key={index}
{...getButtonProps(
button,
index,
theme.defaultButtonVariant,
cls("px-8", buttonClassName),
cls("text-base", buttonTextClassName)
)}
/>
))}
</div>
</div>
)}
</section>
);
};
ParallaxAbout.displayName = "ParallaxAbout";
export default memo(ParallaxAbout);

View File

@@ -0,0 +1,178 @@
"use client";
import { memo, Fragment } from "react";
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
import MediaContent from "@/components/shared/MediaContent";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/types/button";
import type { TitleSegment } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface BulletPoint {
title: string;
description: string;
icon?: LucideIcon;
}
interface SplitAboutProps {
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
bulletPoints: BulletPoint[];
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
ariaLabel?: string;
imagePosition?: "left" | "right";
textboxLayout: TextboxLayout;
useInvertedBackground: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
contentClassName?: string;
bulletPointClassName?: string;
bulletTitleClassName?: string;
bulletDescriptionClassName?: string;
mediaWrapperClassName?: string;
imageClassName?: string;
}
const SplitAbout = ({
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
bulletPoints,
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "About section video",
ariaLabel = "About section",
imagePosition = "right",
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
contentClassName = "",
bulletPointClassName = "",
bulletTitleClassName = "",
bulletDescriptionClassName = "",
mediaWrapperClassName = "",
imageClassName = "",
}: SplitAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const mediaContent = (
<div className={cls("w-full md:w-6/10 2xl:w-7/10 overflow-hidden rounded-theme-capped card md:relative p-4", mediaWrapperClassName)}>
<div className="md:relative w-full md:h-full">
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("z-1 w-full h-auto object-cover rounded-theme-capped md:absolute md:inset-0 md:h-full", imageClassName)}
/>
</div>
</div>
);
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<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={cls("flex flex-col md:flex-row gap-6 md:items-stretch")}>
{imagePosition === "left" && mediaContent}
<div className={cls("w-full md:w-4/10 2xl:w-3/10 rounded-theme-capped card p-6 flex flex-col gap-6 justify-center", contentClassName)}>
{bulletPoints.map((point, index) => {
const Icon = point.icon;
return (
<Fragment key={index}>
<div className={cls("relative z-1 flex flex-col gap-2", bulletPointClassName)}>
{Icon && (
<div className="h-10 w-fit aspect-square rounded-theme primary-button flex items-center justify-center flex-shrink-0 mb-1">
<Icon className="h-[40%] w-[40%] text-background" strokeWidth={1.5} />
</div>
)}
<div className="flex flex-col gap-0">
<h3 className={cls("text-xl font-medium", shouldUseLightText && "text-background", bulletTitleClassName)}>
{point.title}
</h3>
<p className={cls("text-base leading-[1.4]", shouldUseLightText ? "text-background" : "text-foreground", bulletDescriptionClassName)}>
{point.description}
</p>
</div>
</div>
{index < bulletPoints.length - 1 && (
<div className="relative z-1 w-full border-b border-accent/10" />
)}
</Fragment>
);
})}
</div>
{imagePosition === "right" && mediaContent}
</div>
</div>
</section>
);
};
SplitAbout.displayName = "SplitAbout";
export default memo(SplitAbout);

View File

@@ -0,0 +1,129 @@
"use client";
import React, { memo } from "react";
import TextBox from "@/components/Textbox";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/components/cardStack/types";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
interface FeatureCard {
id: string;
title: string;
description: string;
label: string;
}
interface SplitAboutCardsProps {
title: string;
description: string;
tag?: string;
tagIcon?: LucideIcon;
features: FeatureCard[];
buttons?: ButtonConfig[];
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
featuresContainerClassName?: string;
featureCardClassName?: string;
featureTitleClassName?: string;
featureDescriptionClassName?: string;
featureLabelClassName?: string;
}
const SplitAboutCards = ({
title,
description,
tag,
tagIcon,
features,
buttons,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
featuresContainerClassName = "",
featureCardClassName = "",
featureTitleClassName = "",
featureDescriptionClassName = "",
featureLabelClassName = "",
}: SplitAboutCardsProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const isOddCount = features.length % 2 !== 0;
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-30", containerClassName)}>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-15">
<div className="w-full lg:w-1/3">
<TextBox
title={title}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout="default"
useInvertedBackground={useInvertedBackground}
className={cls("gap-2! md:gap-3!", textBoxClassName)}
titleClassName={cls("text-5xl", titleClassName)}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
</div>
<div className={cls("w-full lg:w-2/3 grid grid-cols-1 md:grid-cols-2 gap-6", featuresContainerClassName)}>
{features.map((feature, index) => {
const isLastItemOdd = isOddCount && index === features.length - 1;
return (
<div
key={feature.id}
className={cls("card rounded-theme-capped p-6 flex flex-col gap-10", isLastItemOdd && "md:col-span-2", featureCardClassName)}
>
<div className="relative z-1 flex flex-col gap-1">
<h3 className={cls("text-2xl md:text-3xl font-medium", shouldUseLightText ? "text-background" : "text-foreground", featureTitleClassName)}>
{feature.title}
</h3>
<p className={cls("text-base leading-tight", shouldUseLightText ? "text-background/75" : "text-foreground/75", featureDescriptionClassName)}>
{feature.description}
</p>
</div>
<p className={cls("relative z-1 text-xl md:text-2xl font-medium", shouldUseLightText ? "text-background" : "text-foreground", featureLabelClassName)}>
{feature.label}
</p>
</div>
);
})}
</div>
</div>
</div>
</section>
);
};
SplitAboutCards.displayName = "SplitAboutCards";
export default memo(SplitAboutCards);

View File

@@ -0,0 +1,94 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
interface FeatureItem {
text: string;
icon: LucideIcon;
}
interface SplitAboutFeaturesProps {
title: string;
features: FeatureItem[];
showBorder?: boolean;
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
featuresContainerClassName?: string;
featureClassName?: string;
featureIconClassName?: string;
featureTextClassName?: string;
}
const SplitAboutFeatures = ({
title,
features,
showBorder = false,
useInvertedBackground,
ariaLabel = "About features section",
className = "",
containerClassName = "",
titleClassName = "",
featuresContainerClassName = "",
featureClassName = "",
featureIconClassName = "",
featureTextClassName = "",
}: SplitAboutFeaturesProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const isOddCount = features.length % 2 !== 0;
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-30", containerClassName)}>
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="trigger"
className={cls("text-7xl font-medium", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
</div>
<div className={cls("relative w-full md:w-1/2 grid grid-cols-2 gap-6", featuresContainerClassName)}>
{features.map((feature, index) => {
const Icon = feature.icon;
const isLastItemOdd = isOddCount && index === features.length - 1;
return (
<div
key={index}
className={cls("relative card rounded-theme-capped w-full flex items-center gap-3 p-8", isLastItemOdd && "col-span-2", featureClassName)}
>
<div className="relative z-1 h-8 w-auto aspect-square rounded-theme primary-button flex items-center justify-center" >
<Icon
className={cls("h-45/100 w-45/100 text-background", featureIconClassName)}
aria-hidden="true"
/>
</div>
<p className={cls("relative z-1 text-base leading-tight", shouldUseLightText ? "text-background/75" : "text-foreground/75", featureTextClassName)}>
{feature.text}
</p>
</div>
);
})}
</div>
</div>
{showBorder && <div className="w-full border-b border-foreground/10" />}
</div>
</section>
);
};
SplitAboutFeatures.displayName = "SplitAboutFeatures";
export default memo(SplitAboutFeatures);

View File

@@ -0,0 +1,102 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import { cls } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface Metric {
label: string;
value: string;
}
interface SplitAboutMetricProps {
title: string;
description: string[];
metrics: [Metric, Metric];
showBorder?: boolean;
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
metricsContainerClassName?: string;
metricClassName?: string;
metricValueClassName?: string;
metricLabelClassName?: string;
}
const SplitAboutMetric = ({
title,
description,
metrics,
showBorder = false,
useInvertedBackground,
ariaLabel = "About metrics section",
className = "",
containerClassName = "",
titleClassName = "",
descriptionClassName = "",
metricsContainerClassName = "",
metricClassName = "",
metricValueClassName = "",
metricLabelClassName = "",
}: SplitAboutMetricProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-30", containerClassName)}>
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="trigger"
className={cls("text-7xl font-medium", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
</div>
<div className="w-full md:w-1/2 flex flex-col gap-12">
{description.map((desc, index) => (
<TextAnimation
key={index}
type={theme.defaultTextAnimation}
text={desc}
variant="words-trigger"
className={cls("text-base md:text-2xl leading-[1.3]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") ? "text-background/75" : "text-foreground/75", descriptionClassName)}
/>
))}
<div className="relative w-full border-b border-accent/50" />
<div className={cls("relative grid grid-cols-2 gap-8 md:gap-12", metricsContainerClassName)}>
{metrics.map((metric, index) => (
<div
key={index}
className={cls("w-full flex flex-col items-center text-center gap-0", metricClassName)}
>
<h3 className={cls("w-full text-9xl font-medium leading-[1.0] truncate", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", metricValueClassName)}>
{metric.value}
</h3>
<p className={cls("text-sm truncate", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") ? "text-background/80" : "text-foreground/80", metricLabelClassName)}>
{metric.label}
</p>
</div>
))}
</div>
</div>
</div>
{showBorder && <div className="w-full border-b border-foreground/10" />}
</div>
</section>
);
};
SplitAboutMetric.displayName = "SplitAboutMetric";
export default memo(SplitAboutMetric);

View File

@@ -0,0 +1,54 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import { cls } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface TagAboutProps {
tag: string;
description: string;
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
tagClassName?: string;
descriptionClassName?: string;
}
const TagAbout = ({
tag,
description,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
tagClassName = "",
descriptionClassName = "",
}: TagAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto relative overflow-hidden", containerClassName)}>
<p className={cls("inline-block mr-15 text-base md:text-xl text-accent", tagClassName)}>
{tag}
</p>
<TextAnimation
type={theme.defaultTextAnimation}
text={description}
variant="words-trigger"
as="span"
className={cls(" !inline text-2xl md:text-5xl font-medium leading-[1.15]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", descriptionClassName)}
/>
</div>
</section>
);
};
TagAbout.displayName = "TagAbout";
export default memo(TagAbout);

View File

@@ -0,0 +1,105 @@
"use client";
import React, { memo } from "react";
import Tag from "@/components/shared/Tag";
import TextAnimation from "@/components/text/TextAnimation";
import { cls } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
interface TagAboutCardProps {
tag: string;
tagIcon?: LucideIcon;
title: string;
paragraphs: string[];
icon: LucideIcon;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
cardClassName?: string;
tagClassName?: string;
titleClassName?: string;
paragraphsContainerClassName?: string;
paragraphClassName?: string;
iconBoxClassName?: string;
iconClassName?: string;
}
const TagAboutCard = ({
tag,
tagIcon,
title,
paragraphs,
icon: Icon,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
cardClassName = "",
tagClassName = "",
titleClassName = "",
paragraphsContainerClassName = "",
paragraphClassName = "",
iconBoxClassName = "",
iconClassName = "",
}: TagAboutCardProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div className={cls("w-content-width md:w-40 mx-auto", containerClassName)}>
<div className={cls("relative card rounded-theme-capped p-8 md:p-12", cardClassName)}>
<div className={cls(
"absolute! -top-7 -right-7 md:-top-8 md:-right-8 primary-button rounded-theme-capped h-14 md:h-16 w-auto aspect-square flex items-center justify-center",
iconBoxClassName
)}>
<Icon className={cls("h-5/10 text-background", iconClassName)} strokeWidth={1.5} />
</div>
<div className="flex flex-col gap-6">
<Tag
text={tag}
icon={tagIcon}
className={tagClassName}
/>
<TextAnimation
text={title}
type={theme.defaultTextAnimation}
variant="words-trigger"
as="h2"
className={cls("text-4xl md:text-5xl font-medium text-balance text-foreground", titleClassName)}
/>
<div className={cls("flex flex-col gap-3", paragraphsContainerClassName)}>
{paragraphs.map((paragraph, index) => (
<p
key={index}
className={cls("text-base text-foreground/75 leading-tight", paragraphClassName)}
>
{paragraph}
</p>
))}
</div>
</div>
</div>
</div>
</section>
);
};
TagAboutCard.displayName = "TagAboutCard";
export default memo(TagAboutCard);

View File

@@ -0,0 +1,238 @@
"use client";
import { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import Tag from "@/components/shared/Tag";
import Button from "@/components/button/Button";
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface TagMediaSplitAboutProps {
variant: "card" | "border";
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
contentTag: string;
contentTagIcon?: LucideIcon;
contentTitle: string;
contentDescription: string;
contentButtons?: ButtonConfig[];
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
imagePosition?: "left" | "right";
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
textBoxTitleClassName?: string;
textBoxDescriptionClassName?: string;
textBoxClassName?: string;
textBoxTagClassName?: string;
textBoxButtonContainerClassName?: string;
textBoxButtonClassName?: string;
textBoxButtonTextClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
contentClassName?: string;
contentCardClassName?: string;
contentTagClassName?: string;
contentTitleClassName?: string;
contentDescriptionClassName?: string;
contentButtonContainerClassName?: string;
contentButtonClassName?: string;
contentButtonTextClassName?: string;
mediaCardClassName?: string;
mediaClassName?: string;
}
const TagMediaSplitAbout = ({
variant,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
contentTag,
contentTagIcon,
contentTitle,
contentDescription,
contentButtons,
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
imagePosition = "right",
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
textBoxTitleClassName = "",
textBoxDescriptionClassName = "",
textBoxClassName = "",
textBoxTagClassName = "",
textBoxButtonContainerClassName = "",
textBoxButtonClassName = "",
textBoxButtonTextClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
contentClassName = "",
contentCardClassName = "",
contentTagClassName = "",
contentTitleClassName = "",
contentDescriptionClassName = "",
contentButtonContainerClassName = "",
contentButtonClassName = "",
contentButtonTextClassName = "",
mediaCardClassName = "",
mediaClassName = "",
}: TagMediaSplitAboutProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const contentCard = (
<div className={cls(
"flex flex-col justify-between gap-6",
variant === "card" && "card rounded-theme-capped p-6 md:p-10",
contentCardClassName
)}>
<Tag
text={contentTag}
icon={contentTagIcon}
useInvertedBackground={variant === "card" ? useInvertedBackground : undefined}
className={contentTagClassName}
/>
<div className="relative z-1 flex flex-col gap-4">
<h2 className={cls(
"text-4xl font-medium leading-tight",
variant === "card"
? (shouldUseLightText ? "text-background" : "text-foreground")
: (useInvertedBackground !== "noInvert" ? "text-background" : "text-foreground"),
contentTitleClassName
)}>
{contentTitle}
</h2>
<p className={cls(
"text-base md:text-lg leading-tight",
variant === "card"
? (shouldUseLightText ? "text-background/75" : "text-foreground/75")
: (useInvertedBackground !== "noInvert" ? "text-background/75" : "text-foreground/75"),
contentDescriptionClassName
)}>
{contentDescription}
</p>
{contentButtons && contentButtons.length > 0 && (
<div className={cls("relative z-1 flex gap-4", contentButtonContainerClassName)}>
{contentButtons.slice(0, 2).map((button, index) => (
<Button
key={`${button.text}-${index}`}
{...getButtonProps(button, index, theme.defaultButtonVariant, contentButtonClassName, contentButtonTextClassName)}
/>
))}
</div>
)}
</div>
</div>
);
const mediaCard = (
<div className={cls("card rounded-theme-capped overflow-hidden", mediaCardClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("relative z-1 w-full h-full md:aspect-square object-cover", mediaClassName)}
/>
</div>
);
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-10", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={textBoxTitleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={textBoxDescriptionClassName}
tagClassName={textBoxTagClassName}
buttonContainerClassName={textBoxButtonContainerClassName}
buttonClassName={textBoxButtonClassName}
buttonTextClassName={textBoxButtonTextClassName}
/>
<div className={cls(
"grid grid-cols-1 gap-6",
variant === "border" ? "md:grid-cols-[1fr_1px_1fr] md:gap-10" : "md:grid-cols-2",
contentClassName
)}>
{variant === "border" && (
<div className={cls(
"w-full h-px md:hidden",
useInvertedBackground !== "noInvert" ? "bg-background/20" : "bg-foreground/20"
)} />
)}
{imagePosition === "left" ? (
<>
{mediaCard}
{variant === "border" && (
<div className={cls(
"w-full h-px md:w-px md:h-auto md:self-stretch",
useInvertedBackground !== "noInvert" ? "bg-background/20" : "bg-foreground/20"
)} />
)}
{contentCard}
</>
) : (
<>
{contentCard}
{variant === "border" && (
<div className={cls(
"w-full h-px md:w-px md:h-auto md:self-stretch",
useInvertedBackground !== "noInvert" ? "bg-background/20" : "bg-foreground/20"
)} />
)}
{mediaCard}
</>
)}
</div>
</div>
</section>
);
};
TagMediaSplitAbout.displayName = "TagMediaSplitAbout";
export default memo(TagMediaSplitAbout);

View File

@@ -0,0 +1,135 @@
"use client";
import React, { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import Tag from "@/components/shared/Tag";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type MediaProps =
| {
imageSrc: string;
imageAlt?: string;
videoSrc?: never;
videoAriaLabel?: never;
}
| {
videoSrc: string;
videoAriaLabel?: string;
imageSrc?: never;
imageAlt?: never;
};
type TestimonialAboutCardProps = MediaProps & {
tag: string;
tagIcon?: LucideIcon;
title: string;
description: string;
subdescription: string;
icon: LucideIcon;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
cardClassName?: string;
contentClassName?: string;
tagClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
subdescriptionClassName?: string;
footerClassName?: string;
iconBoxClassName?: string;
iconClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
};
const TestimonialAboutCard = ({
tag,
tagIcon,
title,
description,
subdescription,
icon: Icon,
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "Testimonial video",
useInvertedBackground,
ariaLabel = "Testimonial section",
className = "",
containerClassName = "",
cardClassName = "",
contentClassName = "",
tagClassName = "",
titleClassName = "",
descriptionClassName = "",
subdescriptionClassName = "",
footerClassName = "",
iconBoxClassName = "",
iconClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
}: TestimonialAboutCardProps) => {
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20",
useInvertedBackground === "invertCard"
? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground"
: "w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
>
<div className={cls("w-content-width mx-auto grid grid-cols-1 md:grid-cols-5 gap-6", containerClassName)}>
<div className={cls("relative md:col-span-3 card rounded-theme-capped p-8 md:p-12", cardClassName)}>
<div className={cls(
"absolute -top-7 -left-7 md:-top-8 md:-left-8 primary-button rounded-theme-capped h-14 md:h-16 w-auto aspect-square flex items-center justify-center",
iconBoxClassName
)}>
<Icon className={cls("h-5/10 text-background", iconClassName)} strokeWidth={1.5} />
</div>
<div className={cls("relative h-full flex flex-col justify-center gap-4 md:gap-6 py-8 md:py-4", contentClassName)}>
<Tag
text={tag}
icon={tagIcon}
className={cls("mb-1", tagClassName)}
/>
<h2 className={cls("text-3xl md:text-4xl font-medium text-foreground leading-tight", titleClassName)}>
{title}
</h2>
<div className={cls("flex items-center gap-2", footerClassName)}>
<span className={cls("text-base text-foreground", descriptionClassName)}>
{description}
</span>
<span className="text-accent"></span>
<span className={cls("text-base text-foreground/75", subdescriptionClassName)}>
{subdescription}
</span>
</div>
</div>
</div>
<div className={cls("md:col-span-2 card aspect-square rounded-theme-capped overflow-hidden", mediaWrapperClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
</div>
</div>
</section>
);
};
TestimonialAboutCard.displayName = "TestimonialAboutCard";
export default memo(TestimonialAboutCard);

View File

@@ -0,0 +1,65 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface TextAboutProps {
title: string;
buttons?: ButtonConfig[];
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const TextAbout = ({
title,
buttons,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
titleClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: TextAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6 items-center", containerClassName)}>
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="words-trigger"
className={cls("text-2xl md:text-5xl font-medium text-center leading-[1.175]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
{buttons && buttons.length > 0 && (
<div className={cls("flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button key={index} {...getButtonProps(button, index, theme.defaultButtonVariant, cls("px-8", buttonClassName), cls("text-base", buttonTextClassName))} />
))}
</div>
)}
</div>
</section>
);
};
TextAbout.displayName = "TextAbout";
export default memo(TextAbout);

View File

@@ -0,0 +1,88 @@
"use client";
import React, { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface TextSplitAboutProps {
title: string;
description: string[];
buttons?: ButtonConfig[];
showBorder?: boolean;
useInvertedBackground: "noInvert" | "invertDefault" | "invertCard";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const TextSplitAbout = ({
title,
description,
buttons,
showBorder = false,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
titleClassName = "",
descriptionClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: TextSplitAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20", useInvertedBackground === "invertCard" ? "w-content-width-expanded mx-auto rounded-theme-capped bg-foreground" : "w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-30", containerClassName)}>
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="trigger"
className={cls("text-7xl font-medium", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") && "text-background", titleClassName)}
/>
</div>
<div className="w-full md:w-1/2 flex flex-col gap-6">
{description.map((desc, index) => (
<TextAnimation
key={index}
type={theme.defaultTextAnimation}
text={desc}
variant="words-trigger"
className={cls("text-base md:text-2xl leading-[1.3]", (useInvertedBackground === "invertDefault" || useInvertedBackground === "invertCard") ? "text-background/75" : "text-foreground/75", descriptionClassName)}
/>
))}
{buttons && buttons.length > 0 && (
<div className={cls("flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button key={index} {...getButtonProps(button, index, theme.defaultButtonVariant, cls("px-8", buttonClassName), cls("text-base", buttonTextClassName))} />
))}
</div>
)}
</div>
</div>
{showBorder && <div className="w-full border-b border-foreground/10" />}
</div>
</section>
);
};
TextSplitAbout.displayName = "TextSplitAbout";
export default memo(TextSplitAbout);

View File

@@ -0,0 +1,213 @@
"use client";
import React, { memo } from "react";
import CardStack from "@/components/cardStack/CardStack";
import Tag from "@/components/shared/Tag";
import MediaContent from "@/components/shared/MediaContent";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, CardAnimationType, ContainerStyle, TitleSegment } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
type MediaProps =
| {
imageSrc: string;
imageAlt?: string;
videoSrc?: never;
videoAriaLabel?: never;
}
| {
videoSrc: string;
videoAriaLabel?: string;
imageSrc?: never;
imageAlt?: never;
};
type BlogCard = MediaProps & {
id: string;
category: string;
title: string;
tags: string[];
onBlogClick?: () => void;
};
interface BlogCardEightProps {
blogs: BlogCard[];
carouselMode?: "auto" | "buttons";
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
containerStyle: ContainerStyle;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
cardClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
cardTitleClassName?: string;
footerClassName?: string;
tagClassName?: string;
textBoxTitleClassName?: string;
textBoxTitleImageWrapperClassName?: string;
textBoxTitleImageClassName?: string;
textBoxDescriptionClassName?: string;
gridClassName?: string;
carouselClassName?: string;
controlsClassName?: string;
textBoxClassName?: string;
textBoxTagClassName?: string;
textBoxButtonContainerClassName?: string;
textBoxButtonClassName?: string;
textBoxButtonTextClassName?: string;
}
interface BlogCardItemProps {
blog: BlogCard;
cardClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
cardTitleClassName?: string;
footerClassName?: string;
tagClassName?: string;
}
const BlogCardItem = memo(({
blog,
cardClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
cardTitleClassName = "",
footerClassName = "",
tagClassName = "",
}: BlogCardItemProps) => {
return (
<article
className={cls("relative h-full card group flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
onClick={blog.onBlogClick}
role="article"
aria-label={blog.title}
>
<div className="relative h-full primary-button flex flex-col gap-4 p-4 rounded-theme-capped">
<span className="text-base text-background/75 leading-tight truncate">
{blog.category}
</span>
<div className={cls("relative z-1 w-full aspect-square md:aspect-[3/4] overflow-hidden rounded-theme-capped", mediaWrapperClassName)}>
<MediaContent
imageSrc={blog.imageSrc}
videoSrc={blog.videoSrc}
imageAlt={blog.imageAlt}
videoAriaLabel={blog.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
</div>
<h3 className={cls("text-2xl font-medium leading-tight text-background line-clamp-2", cardTitleClassName)}>
{blog.title}
</h3>
</div>
<div className={cls("flex flex-wrap gap-2 mt-auto", footerClassName)}>
{blog.tags.map((tag, index) => (
<Tag key={index} text={tag} className={tagClassName} />
))}
</div>
</article>
);
});
BlogCardItem.displayName = "BlogCardItem";
const BlogCardEight = ({
blogs,
carouselMode = "buttons",
uniformGridCustomHeightClasses,
animationType,
containerStyle,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
ariaLabel = "Blog section",
className = "",
containerClassName = "",
cardClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
cardTitleClassName = "",
footerClassName = "",
tagClassName = "",
textBoxTitleClassName = "",
textBoxTitleImageWrapperClassName = "",
textBoxTitleImageClassName = "",
textBoxDescriptionClassName = "",
gridClassName = "",
carouselClassName = "",
controlsClassName = "",
textBoxClassName = "",
textBoxTagClassName = "",
textBoxButtonContainerClassName = "",
textBoxButtonClassName = "",
textBoxButtonTextClassName = "",
}: BlogCardEightProps) => {
return (
<CardStack
mode={carouselMode}
gridVariant="uniform-all-items-equal"
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
ariaLabel={ariaLabel}
className={className}
containerClassName={containerClassName}
gridClassName={gridClassName}
carouselClassName={carouselClassName}
controlsClassName={controlsClassName}
textBoxClassName={textBoxClassName}
titleClassName={textBoxTitleClassName}
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
titleImageClassName={textBoxTitleImageClassName}
descriptionClassName={textBoxDescriptionClassName}
tagClassName={textBoxTagClassName}
buttonContainerClassName={textBoxButtonContainerClassName}
buttonClassName={textBoxButtonClassName}
buttonTextClassName={textBoxButtonTextClassName}
>
{blogs.map((blog) => (
<BlogCardItem
key={blog.id}
blog={blog}
cardClassName={cardClassName}
mediaWrapperClassName={mediaWrapperClassName}
mediaClassName={mediaClassName}
cardTitleClassName={cardTitleClassName}
footerClassName={footerClassName}
tagClassName={tagClassName}
/>
))}
</CardStack>
);
};
BlogCardEight.displayName = "BlogCardEight";
export default memo(BlogCardEight);

Some files were not shown because too many files have changed in this diff Show More