56 Commits

Author SHA1 Message Date
7d167fbcf0 Merge version_4_1781088081301 into main
Merge version_4_1781088081301 into main
2026-06-10 10:41:45 +00:00
743c426a9b Update src/pages/HomePage.tsx 2026-06-10 10:41:41 +00:00
3febfd000b Update src/hooks/useProductCatalog.ts 2026-06-10 10:41:41 +00:00
6ccf82f9ea Update src/hooks/useBlogPosts.ts 2026-06-10 10:41:40 +00:00
90ee298d55 Update src/components/ui/TiltedStackCards.tsx 2026-06-10 10:41:40 +00:00
0a155cb143 Update src/components/ui/TextLink.tsx 2026-06-10 10:41:39 +00:00
9af59e8d01 Update src/components/ui/TextAnimation.tsx 2026-06-10 10:41:39 +00:00
318a689183 Update src/components/ui/LoopCarousel.tsx 2026-06-10 10:41:37 +00:00
0702a7d69c Update src/components/ui/IconTextMarquee.tsx 2026-06-10 10:41:36 +00:00
be7575242b Update src/components/ui/GridLinesBackground.tsx 2026-06-10 10:41:36 +00:00
10cc9e2b97 Update src/components/ui/ActiveBadge.tsx 2026-06-10 10:41:34 +00:00
befb9da51a Update src/components/tag/useRiveHoverInput.ts 2026-06-10 10:41:34 +00:00
9edb22e650 Update src/components/sections/team/TeamDetailedCards.tsx 2026-06-10 10:41:32 +00:00
8a779380e5 Merge version_3_1781088067236 into main
Merge version_3_1781088067236 into main
2026-06-10 10:41:31 +00:00
7483183ae4 Update src/components/sections/hero/HeroBillboardFeatures.tsx 2026-06-10 10:41:30 +00:00
35a697747e Update src/components/sections/hero/HeroBillboardCarousel.tsx 2026-06-10 10:41:30 +00:00
a9c120d494 Update src/components/sections/features/FeaturesRevealCardsBentoSharp.tsx 2026-06-10 10:41:29 +00:00
b8b47b9c8d Update src/pages/shop/ShopPage.tsx 2026-06-10 10:41:28 +00:00
8f930d9012 Update src/components/sections/blog/BlogArticle.tsx 2026-06-10 10:41:27 +00:00
b721108cf0 Update src/pages/shop/ProductPage.tsx 2026-06-10 10:41:27 +00:00
734bc31ffd Update src/components/sections/about/AboutText.tsx 2026-06-10 10:41:26 +00:00
854b37d63c Update src/pages/blog/BlogPage.tsx 2026-06-10 10:41:26 +00:00
e010d7f7da Update src/pages/HomePage.tsx 2026-06-10 10:41:26 +00:00
9197db6e03 Update src/hooks/useProductDetail.ts 2026-06-10 10:41:26 +00:00
b402be8284 Update src/hooks/useProductCatalog.ts 2026-06-10 10:41:25 +00:00
da71c01821 Update src/components/ui/TextLink.tsx 2026-06-10 10:41:24 +00:00
d4bc4d2272 Update src/components/ui/TextAnimation.tsx 2026-06-10 10:41:24 +00:00
6738d73c72 Update src/components/ui/LoopCarousel.tsx 2026-06-10 10:41:22 +00:00
233e9a9801 Update src/components/ui/IconTextMarquee.tsx 2026-06-10 10:41:22 +00:00
24f365deb7 Update src/components/ui/GridLinesBackground.tsx 2026-06-10 10:41:21 +00:00
7014bc95c3 Update src/components/sections/footer/FooterMinimal.tsx 2026-06-10 10:41:16 +00:00
37896b0993 Update src/components/sections/features/FeaturesRevealCardsBento.tsx 2026-06-10 10:41:16 +00:00
acbae37975 Update src/components/sections/features/FeaturesMediaGrid.tsx 2026-06-10 10:41:15 +00:00
d1e19600e3 Update src/components/sections/features/FeaturesMarqueeCards.tsx 2026-06-10 10:41:15 +00:00
0515d8127b Update src/components/sections/faq/FaqTwoColumn.tsx 2026-06-10 10:41:14 +00:00
d4728b8cc0 Update src/components/sections/faq/FaqSplitMedia.tsx 2026-06-10 10:41:13 +00:00
31aa53c417 Update src/components/sections/faq/FaqSimple.tsx 2026-06-10 10:41:13 +00:00
353cbcd574 Merge version_2_1781088040889 into main
Merge version_2_1781088040889 into main
2026-06-10 10:40:59 +00:00
a919218cb1 Update src/pages/HomePage.tsx 2026-06-10 10:40:56 +00:00
630c416c86 Update src/components/ui/SectionErrorBoundary.tsx 2026-06-10 10:40:55 +00:00
1451643ce7 Update src/components/ui/NavbarDropdown.tsx 2026-06-10 10:40:54 +00:00
c110af779e Update src/components/ui/HorizonGlowBackground.tsx 2026-06-10 10:40:53 +00:00
562fe1b5d5 Update src/components/ui/ButtonMagnetic.tsx 2026-06-10 10:40:52 +00:00
485d90e1bc Update src/components/sections/team/TeamGlassCards.tsx 2026-06-10 10:40:51 +00:00
70245d1903 Update src/components/sections/product/ProductMediaCards.tsx 2026-06-10 10:40:50 +00:00
cebb6ed9e3 Update src/components/sections/metrics/MetricsSimpleCards.tsx 2026-06-10 10:40:50 +00:00
1bed2c0b0f Update src/components/sections/metrics/MetricsGradientCards.tsx 2026-06-10 10:40:49 +00:00
d21ac95f9d Update src/components/sections/hero/HeroVideoExpand.tsx 2026-06-10 10:40:48 +00:00
a67b11b713 Update src/components/sections/hero/HeroSplitTestimonial.tsx 2026-06-10 10:40:48 +00:00
5f2c10d447 Update src/components/sections/hero/HeroBillboardFloatingCards.tsx 2026-06-10 10:40:47 +00:00
f78a3b89e2 Update src/components/sections/footer/FooterBrandReveal.tsx 2026-06-10 10:40:46 +00:00
6487a021e8 Update src/components/sections/features/FeaturesBentoGridCta.tsx 2026-06-10 10:40:45 +00:00
4bbbcfb66a Update src/components/sections/features/FeaturesBento.tsx 2026-06-10 10:40:45 +00:00
9b646ec808 Update src/components/sections/blog/BlogSimpleCards.tsx 2026-06-10 10:40:44 +00:00
d7e14827cd Update src/components/sections/about/AboutFeaturesSplit.tsx 2026-06-10 10:40:43 +00:00
f2d59b97fd Update src/components/ecommerce/ProductDetailCard.tsx 2026-06-10 10:40:43 +00:00
45 changed files with 1 additions and 3853 deletions

View File

@@ -1,137 +0,0 @@
import { useState } from "react";
import { Star } from "lucide-react";
import { cls } from "@/lib/utils";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Button from "@/components/ui/Button";
import Transition from "@/components/ui/Transition";
type ProductVariant = {
label: string;
options: string[];
selected: string;
onChange: (value: string) => void;
};
type ProductDetailCardProps = {
name: string;
price: string;
salePrice?: string;
images: string[];
description?: string;
rating?: number;
ribbon?: string;
inventoryStatus?: "in-stock" | "out-of-stock";
inventoryQuantity?: number;
sku?: string;
variants?: ProductVariant[];
quantity?: ProductVariant;
onAddToCart?: () => void;
onBuyNow?: () => void;
};
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
const [selectedImage, setSelectedImage] = useState(0);
return (
<section className="mx-auto py-20 w-content-width">
<div className="flex flex-col gap-5 md:flex-row">
<div className="relative md:w-1/2">
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
</Transition>
{images.length > 1 && (
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
{images.map((src, i) => (
<button
key={i}
onClick={() => setSelectedImage(i)}
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
>
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
<div className={cls(
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
)} />
</button>
))}
</div>
)}
</div>
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
<div className="flex items-start justify-between gap-5">
<h2 className="flex-1 text-2xl font-semibold text-foreground md:text-3xl">{name}</h2>
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-semibold rounded text-secondary-cta-text">{ribbon}</span>}
</div>
<div className="h-px w-full bg-foreground/5" />
<div className="flex items-center justify-between">
<p className="text-xl font-semibold text-foreground md:text-2xl">
{salePrice ? (
<>
<span className="text-foreground/75 line-through mr-1">{price}</span>
<span>{salePrice}</span>
</>
) : (
price
)}
</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
))}
</div>
</div>
{(inventoryStatus || inventoryQuantity || sku) && (
<div className="flex flex-wrap gap-3 text-sm">
{inventoryStatus && (
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
</span>
)}
{inventoryQuantity && (
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
)}
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
</div>
)}
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
{variants && variants.length > 0 && (
<div className="flex flex-wrap gap-5">
{variants.map((variant) => (
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
<label className="text-sm font-semibold text-foreground">{variant.label}</label>
<div className="secondary-button flex items-center px-3 h-9 rounded">
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
{variant.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
))}
</div>
)}
{quantity && (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-foreground">{quantity.label}</label>
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
{quantity.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
)}
<div className="flex flex-col mt-auto gap-3 pt-5">
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
</div>
</div>
</div>
</section>
);
};
export default ProductDetailCard;
export type { ProductVariant };

View File

@@ -1,90 +0,0 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { resolveIcon } from "@/utils/resolve-icon";
type AboutFeaturesSplitProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: { icon: string | LucideIcon; title: string; description: string }[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutFeaturesSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
}: AboutFeaturesSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
{items.map((item, index) => {
const ItemIcon = resolveIcon(item.icon);
return (
<div key={item.title}>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-semibold">{item.title}</h3>
<p className="text-base leading-snug">{item.description}</p>
</div>
{index < items.length - 1 && (
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
)}
</div>
);
})}
</div>
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
<div className="relative size-full">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
</div>
</div>
</div>
</div>
</section>
);
};
export default AboutFeaturesSplit;

View File

@@ -1,37 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface AboutTextProps {
title: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
}
const AboutText = ({
title,
primaryButton,
secondaryButton,
}: AboutTextProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-2 items-center">
<TextAnimation
text={title}
variant="slide-up"
gradientText={false}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap gap-3 justify-center mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
</section>
);
};
export default AboutText;

View File

@@ -1,91 +0,0 @@
import ScrollReveal from "@/components/ui/ScrollReveal";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type BlogArticleProps = {
category: string;
title: string;
excerpt?: string;
content: string;
imageSrc: string;
authorName: string;
authorImageSrc: string;
date: string;
readingTime?: string;
backButton?: { text: string; href: string };
};
const BlogArticle = ({
category,
title,
excerpt,
content,
imageSrc,
authorName,
authorImageSrc,
date,
readingTime,
backButton = { text: "Back to Blog", href: "/blog" },
}: BlogArticleProps) => {
return (
<article aria-label="Blog article" className="py-20">
<div className="flex flex-col gap-10">
<ScrollReveal variant="fade">
<div className="flex flex-col gap-3 w-content-width md:max-w-4xl mx-auto">
<div className="flex items-center gap-2 px-3 py-1 mb-1 text-sm text-foreground/75 card rounded w-fit">
<a
href={backButton.href}
className="hover:text-foreground transition-colors"
>
{backButton.text}
</a>
<span>/</span>
<span className="text-foreground">{category}</span>
</div>
<h1 className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance">
{title}
</h1>
{excerpt && (
<p className="text-lg md:text-xl leading-snug text-balance">
{excerpt}
</p>
)}
<div className="flex items-center gap-3 mt-2 md:mt-3">
<ImageOrVideo
imageSrc={authorImageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{authorName}</span>
<span className="text-base text-foreground/75 leading-snug truncate">
{date}
{readingTime && ` · ${readingTime}`}
</span>
</div>
</div>
</div>
</ScrollReveal>
<ScrollReveal variant="fade">
<div className="w-content-width md:max-w-4xl mx-auto aspect-video card rounded overflow-hidden p-2 xl:p-3 2xl:p-4">
<ImageOrVideo
imageSrc={imageSrc}
className="size-full object-cover"
/>
</div>
</ScrollReveal>
<ScrollReveal variant="fade">
<div
className="w-content-width md:max-w-4xl mx-auto flex flex-col gap-6 [&>h1]:text-4xl [&>h1]:font-semibold [&>h1]:mt-4 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:mt-4 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:mt-2 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:mt-2 [&>p]:text-base [&>p]:leading-relaxed [&>p]:text-foreground/85 [&_strong]:font-semibold [&_em]:italic [&_u]:underline [&>ul]:flex [&>ul]:flex-col [&>ul]:gap-2 [&>ul]:list-disc [&>ul]:pl-6 [&>ul]:text-base [&>ul]:leading-relaxed [&>ul]:text-foreground/85 [&>ol]:flex [&>ol]:flex-col [&>ol]:gap-2 [&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:text-base [&>ol]:leading-relaxed [&>ol]:text-foreground/85"
dangerouslySetInnerHTML={{ __html: content }}
/>
</ScrollReveal>
</div>
</article>
);
};
export default BlogArticle;

View File

@@ -1,159 +0,0 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
category: string;
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-2">
<div className="px-3 py-1 mb-1 text-sm primary-button text-primary-cta-text rounded w-fit">
<p>{item.category}</p>
</div>
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
</div>
<div className="flex items-center gap-3 mt-2 md:mt-3">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
</div>
</div>
</div>
</article>
);
};
type BlogSimpleCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogSimpleCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
category: p.category,
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
imageSrc: p.imageSrc,
onClick: p.onBlogClick,
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="slide-up"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default BlogSimpleCards;

View File

@@ -1,107 +0,0 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqSimple = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade" className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{items.map((item, index) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</ScrollReveal>
</div>
</section>
);
};
export default FaqSimple;

View File

@@ -1,122 +0,0 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
type FaqSplitMediaProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const FaqSplitMedia = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
}: FaqSplitMediaProps) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="slide-up"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<ScrollReveal variant="slide-up" className="card relative md:col-span-2 h-80 md:h-auto rounded overflow-hidden">
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 size-full object-cover"
/>
</ScrollReveal>
<ScrollReveal variant="slide-up" delay={0.1} className="md:col-span-3 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{items.map((item, index) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</ScrollReveal>
</div>
</div>
</section>
);
};
export default FaqSplitMedia;

View File

@@ -1,122 +0,0 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqTwoColumn = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
const halfLength = Math.ceil(items.length / 2);
const firstColumn = items.slice(0, halfLength);
const secondColumn = items.slice(halfLength);
const renderAccordionItem = (item: FaqItem, index: number) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up" className="card rounded p-6 xl:p-10">
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4">
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
</div>
{secondColumn.length > 0 && (
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
</div>
)}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FaqTwoColumn;

View File

@@ -1,103 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
import TiltedStackCards from "@/components/ui/TiltedStackCards";
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
import OrbitingIcons from "@/components/ui/OrbitingIcons";
import IconTextMarquee from "@/components/ui/IconTextMarquee";
import ChatMarquee from "@/components/ui/ChatMarquee";
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
import MediaStack from "@/components/ui/MediaStack";
import type { LucideIcon } from "lucide-react";
type IconInput = string | LucideIcon;
type FeatureCard = { title: string; description: string } & (
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
| { bentoComponent: "animated-bar-chart" }
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
);
const getBentoComponent = (feature: FeatureCard) => {
switch (feature.bentoComponent) {
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
case "animated-bar-chart": return <AnimatedBarChart />;
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
case "media-stack": return <MediaStack items={feature.mediaItems} />;
}
};
const FeaturesBento = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureCard[];
}) => (
<section aria-label="Features bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{features.map((feature) => (
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
<p className="text-base leading-snug">{feature.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default FeaturesBento;

View File

@@ -1,112 +0,0 @@
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesBentoGridCtaProps {
tag: string;
title: string;
description: string;
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
ctaButton?: {
text: string;
href: string;
avatarSrc?: string;
avatarLabel?: string;
};
}
const FeaturesBentoGridCta = ({
tag,
title,
description,
features,
ctaButton,
}: FeaturesBentoGridCtaProps) => {
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{ctaButton && (
<ScrollReveal variant="fade" delay={0.2}>
<a
href={ctaButton.href}
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
>
{ctaButton.avatarSrc && (
<div className="flex items-center">
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
<img
src={ctaButton.avatarSrc}
className="w-9 h-9 rounded-full object-cover"
alt=""
/>
</div>
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
<div className="overflow-hidden flex items-center">
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
+
</span>
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
<span className="w-9 h-9 rounded-full flex items-center justify-center">
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
</span>
</div>
</div>
</div>
</div>
)}
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
</a>
</ScrollReveal>
)}
</div>
<ScrollReveal variant="fade" className="w-content-width mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{features.map((feature, index) => (
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
<p className="text-base leading-snug text-balance">{feature.description}</p>
</div>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesBentoGridCta;

View File

@@ -1,82 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title?: string;
description?: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMarqueeCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesMarqueeCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMarqueeCardsProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section aria-label="Features section" className="pt-20 pb-10">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade">
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => (
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mb-10 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full h-full rounded-lg object-cover"
/>
</div>
))}
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMarqueeCards;

View File

@@ -1,82 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaGridProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMediaGridProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 h-full">
<div className="aspect-square overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMediaGrid;

View File

@@ -1,109 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
href: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsBentoProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
}
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoProps) => {
const gridClasses = [
"md:col-span-2",
"md:col-span-4",
"md:col-span-3",
"md:col-span-3",
"md:col-span-2",
"md:col-span-2",
"md:col-span-2",
];
const staggerDelays = [
0,
0.1,
0,
0.1,
0,
0.1,
0.2,
];
return (
<section aria-label="Features reveal cards bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="slide-up"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
{items.map((item, index) => (
<ScrollReveal key={item.title} variant="fade" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
<a href={item.href} className="block relative overflow-hidden rounded">
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="rounded group-hover:scale-105 transition-transform duration-500"
/>
</div>
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
<div className="absolute inset-0 -z-10 card rounded translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</a>
</ScrollReveal>
))}
</div>
</div>
</section>
);
};
export default FeaturesRevealCardsBento;

View File

@@ -1,109 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
href: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsBentoSharpProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
}
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
const gridClasses = [
"md:col-span-2",
"md:col-span-4",
"md:col-span-3",
"md:col-span-3",
"md:col-span-2",
"md:col-span-2",
"md:col-span-2",
];
const staggerDelays = [
0,
0.1,
0,
0.1,
0,
0.1,
0.2,
];
return (
<section aria-label="Features reveal cards bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
{items.map((item, index) => (
<ScrollReveal key={item.title} variant="fade" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
<a href={item.href} className="block relative overflow-hidden rounded-none">
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="rounded-none group-hover:scale-105 transition-transform duration-500"
/>
</div>
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</a>
</ScrollReveal>
))}
</div>
</div>
</section>
);
};
export default FeaturesRevealCardsBentoSharp;

View File

@@ -1,101 +0,0 @@
import { useRef, useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useButtonClick } from "@/hooks/useButtonClick";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<div className="flex items-center gap-2 text-base">
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
<button
onClick={handleClick}
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
</div>
);
};
const FooterBrandReveal = ({
brand,
columns,
}: {
brand: string;
columns: FooterColumn[];
}) => {
const footerRef = useRef<HTMLDivElement>(null);
const [footerHeight, setFooterHeight] = useState(0);
useEffect(() => {
const updateHeight = () => {
if (footerRef.current) {
setFooterHeight(footerRef.current.offsetHeight);
}
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
if (footerRef.current) {
resizeObserver.observe(footerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
return (
<section
className="relative z-0 w-full mt-20"
style={{
height: footerHeight ? `${footerHeight}px` : "auto",
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
}}
>
<div
className="fixed bottom-0 w-full"
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
>
<footer
ref={footerRef}
aria-label="Site footer"
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
>
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
<AutoFillText className="font-semibold">{brand}</AutoFillText>
<div
className={cls(
"flex flex-col gap-8 mb-10 md:flex-row",
columns.length === 1 ? "md:justify-center" : "md:justify-between"
)}
>
{columns.map((column, index) => (
<div key={index} className="flex flex-col items-start gap-3">
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
</footer>
</div>
</section>
);
};
export default FooterBrandReveal;

View File

@@ -1,57 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { useButtonClick } from "@/hooks/useButtonClick";
import AutoFillText from "@/components/ui/AutoFillText";
import { resolveIcon } from "@/utils/resolve-icon";
type SocialLink = {
icon: string | LucideIcon;
href?: string;
onClick?: () => void;
};
const SocialLinkItem = ({ icon, href, onClick }: SocialLink) => {
const Icon = resolveIcon(icon);
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
>
<Icon className="size-4" strokeWidth={1.5} />
</button>
);
};
const FooterMinimal = ({
brand,
copyright,
socialLinks,
}: {
brand: string;
copyright: string;
socialLinks?: SocialLink[];
}) => {
return (
<footer aria-label="Site footer" className="relative w-full py-20">
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
<AutoFillText className="font-semibold" paddingY="py-5">{brand}</AutoFillText>
<div className="h-px w-full mb-5 bg-foreground/50" />
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
<span className="text-base opacity-75">{copyright}</span>
{socialLinks && socialLinks.length > 0 && (
<div className="flex items-center gap-3">
{socialLinks.map((link, index) => (
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
))}
</div>
)}
</div>
</div>
</footer>
);
};
export default FooterMinimal;

View File

@@ -1,75 +0,0 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardCarouselProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const HeroBillboardCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroBillboardCarouselProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section
aria-label="Hero section"
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
>
<HeroBackgroundSlot />
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => (
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full h-full rounded-lg object-cover"
/>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroBillboardCarousel;

View File

@@ -1,105 +0,0 @@
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import ActiveBadge from "@/components/ui/ActiveBadge";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
type HeroBillboardFeaturesProps = {
badge: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
features: FeatureItem[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroBillboardFeatures = ({
badge,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
features,
}: HeroBillboardFeaturesProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (features.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % features.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [features.length]);
const feature = features[currentIndex];
const FeatureIcon = resolveIcon(feature.icon);
return (
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col gap-12 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<ActiveBadge text={badge} className="mb-1" />
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
>
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
<p className="text-base font-medium leading-snug">{feature.title}</p>
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
</motion.div>
</AnimatePresence>
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboardFeatures;

View File

@@ -1,180 +0,0 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import { Check } from "lucide-react";
import { cls } from "@/lib/utils";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import AvatarGroup from "@/components/ui/AvatarGroup";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
type HeroBillboardFloatingCardsProps = {
avatarsSrc: string[];
avatarsLabel: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
note?: string;
floatingCardsSrc: [string, string, string, string];
logosSrc?: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
position: string;
rotation: string;
size: string;
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
}> = {
"top-left": {
position: "top-8 left-0",
rotation: "-rotate-8",
size: "size-20 xl:size-22 2xl:size-24",
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
},
"top-right": {
position: "top-4 right-4",
rotation: "rotate-10",
size: "size-18 xl:size-20 2xl:size-22",
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
},
"middle-left": {
position: "top-1/2 left-2",
rotation: "rotate-6",
size: "size-18 xl:size-20 2xl:size-22",
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
},
"middle-right": {
position: "top-1/2 right-0",
rotation: "-rotate-6",
size: "size-20 xl:size-22 2xl:size-24",
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
},
};
const HeroBillboardFloatingCards = ({
avatarsSrc,
avatarsLabel,
title,
description,
primaryButton,
secondaryButton,
note,
floatingCardsSrc,
logosSrc,
imageSrc,
videoSrc,
}: HeroBillboardFloatingCardsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: containerRef });
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
return (
<section aria-label="Hero section" className="relative">
<HeroBackgroundSlot />
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
<div className="relative w-content-width mx-auto">
{POSITIONS.map((position, index) => {
const config = FLOATING_CARD_CONFIG[position];
const src = floatingCardsSrc[index];
if (!src) return null;
return (
<motion.div
key={index}
className={cls("absolute z-10 hidden md:block", config.position)}
animate={{ y: [0, config.animation.yOffset, 0] }}
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
>
<motion.div
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
>
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
</motion.div>
</motion.div>
);
})}
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
<div className="p-0.5 pr-3 mb-1 card rounded-full">
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
</div>
<TextAnimation
text={title}
variant="slide-up"
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
{note && (
<motion.div
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<span className="flex items-center gap-2">
<div className="flex items-center justify-center size-4 primary-button rounded-full">
<Check className="size-1/2 text-primary-cta-text" />
</div>
{note}
</span>
</motion.div>
)}
</div>
</div>
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
</div>
<motion.div
style={{ rotateX: rotate, scale }}
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
</motion.div>
{logosSrc && logosSrc.length > 0 && (
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
</div>
))}
</div>
</ScrollReveal>
)}
</div>
</section>
);
};
export default HeroBillboardFloatingCards;

View File

@@ -1,128 +0,0 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroSplitTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroSplitTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroSplitTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute bottom-4 left-4 right-4 xl:bottom-6 xl:right-6 2xl:bottom-8 2xl:right-8 md:left-auto md:max-w-5/10 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
>
<div className="flex gap-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</ScrollReveal>
</div>
</section>
);
};
export default HeroSplitTestimonial;

View File

@@ -1,146 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { useButtonClick } from "@/hooks/useButtonClick";
const StaggerText = ({ text }: { text: string }) => (
<span className="truncate overflow-hidden">
{[...text].map((char, index) => (
<span
key={index}
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
>
{char}
</span>
))}
</span>
);
type HeroVideoExpandProps = {
title: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
onComplete?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroVideoExpand = ({
title,
videoSrc,
imageSrc,
primaryButton,
secondaryButton,
onComplete,
}: HeroVideoExpandProps) => {
const [showLoader, setShowLoader] = useState(true);
const [expanded, setExpanded] = useState(false);
const handlePrimaryClick = useButtonClick(primaryButton.href);
const handleSecondaryClick = useButtonClick(secondaryButton.href);
const sectionRef = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start start", "end start"],
});
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
useEffect(() => {
const expandTimer = setTimeout(() => setExpanded(true), 600);
const hideTimer = setTimeout(() => {
setShowLoader(false);
onComplete?.();
}, 1500);
return () => {
clearTimeout(expandTimer);
clearTimeout(hideTimer);
};
}, []);
return (
<>
<AnimatePresence>
{showLoader && (
<motion.div
key="loader"
className="fixed inset-0 z-100 bg-background"
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
className="absolute inset-0"
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
animate={
expanded
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
}
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
</motion.div>
</motion.div>
)}
</AnimatePresence>
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</motion.div>
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className="w-full"
>
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
</motion.div>
<div className="flex items-center gap-3">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={primaryButton.href}
onClick={handlePrimaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
>
<StaggerText text={primaryButton.text} />
</a>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={secondaryButton.href}
onClick={handleSecondaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
>
<StaggerText text={secondaryButton.text} />
</a>
</motion.div>
</div>
</div>
</div>
</section>
</>
);
};
export default HeroVideoExpand;

View File

@@ -1,92 +0,0 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { resolveIcon } from "@/utils/resolve-icon";
type Metric = {
value: string;
title: string;
description: string;
icon: string | LucideIcon;
};
const MetricsGradientCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="slide-up"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{metrics.map((metric) => {
const IconComponent = resolveIcon(metric.icon);
return (
<div key={metric.value} className="relative flex flex-col items-center justify-center gap-0 p-6 xl:p-7 2xl:p-8 min-h-70 xl:min-h-80 2xl:min-h-90 h-full card rounded min-w-0">
<span
className="max-w-full text-9xl font-semibold leading-none text-center truncate scale-150 md:scale-100"
style={{
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 80%)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{metric.value}
</span>
<span className="max-w-full mt-[-0.25em] md:mt-[-0.75em] text-4xl font-semibold text-center text-balance">{metric.title}</span>
<p className="max-w-9/10 mt-2 text-base leading-snug text-center text-balance">{metric.description}</p>
<div className="absolute bottom-6 left-6 xl:bottom-7 xl:left-7 2xl:bottom-8 2xl:left-8 flex items-center justify-center size-10 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" />
</div>
</div>
);
})}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default MetricsGradientCards;

View File

@@ -1,71 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type Metric = {
value: string;
description: string;
};
const MetricsSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade-blur"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade-blur"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-6 p-6 md:p-10 min-h-60 md:min-h-70 2xl:min-h-80 h-full card rounded">
<span className="text-9xl md:text-8xl font-semibold leading-none truncate">{metric.value}</span>
<p className="text-base leading-snug text-balance">{metric.description}</p>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default MetricsSimpleCards;

View File

@@ -1,119 +0,0 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import useProducts from "@/hooks/useProducts";
type ProductMediaCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
products?: {
name: string;
price: string;
imageSrc: string;
onClick?: () => void;
}[];
};
const ProductMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
products: productsProp,
}: ProductMediaCardsProps) => {
const { products: fetchedProducts, isLoading } = useProducts();
const isFromApi = fetchedProducts.length > 0;
const products = isFromApi
? fetchedProducts.map((p) => ({
name: p.name,
price: p.price,
imageSrc: p.imageSrc,
onClick: p.onProductClick,
}))
: productsProp;
if (isLoading && !productsProp) {
return (
<section aria-label="Products section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!products || products.length === 0) {
return null;
}
return (
<section aria-label="Products section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{products.map((product) => (
<button
key={product.name}
onClick={product.onClick}
className="group h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 text-left card rounded cursor-pointer"
>
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={product.imageSrc} />
</div>
<div className="flex items-center justify-between gap-3 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<h3 className="text-2xl font-semibold truncate">{product.name}</h3>
<p className="text-base font-medium">{product.price}</p>
</div>
<div className="flex items-center justify-center size-9 shrink-0 rounded primary-button">
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
</div>
</div>
</button>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default ProductMediaCards;

View File

@@ -1,108 +0,0 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { resolveIcon } from "@/utils/resolve-icon";
type SocialLink = {
icon: string | LucideIcon;
url: string;
};
type TeamMember = {
name: string;
role: string;
description: string;
socialLinks: SocialLink[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TeamDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
members,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
members: TeamMember[];
}) => (
<section aria-label="Team section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade">
<GridOrCarousel >
{members.map((member) => (
<div key={member.name} className="relative aspect-4/5 rounded overflow-hidden">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex flex-col gap-1 xl:gap-2 2xl:gap-3 p-4 xl:p-5 2xl:p-6 card backdrop-blur-sm rounded">
<div className="flex items-start justify-between gap-3">
<span className="text-2xl font-semibold leading-snug truncate">{member.name}</span>
<div className="px-3 py-1 text-sm secondary-button text-secondary-cta-text rounded">
<p className="truncate">{member.role}</p>
</div>
</div>
<p className="text-base leading-snug">{member.description}</p>
<div className="flex gap-3 mt-1 md:mt-2">
{member.socialLinks.map((link, index) => {
const IconComponent = resolveIcon(link.icon);
return (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center size-9 primary-button rounded"
>
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</a>
);
})}
</div>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default TeamDetailedCards;

View File

@@ -1,82 +0,0 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type TeamMember = {
name: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TeamGlassCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
members,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
members: TeamMember[];
}) => (
<section aria-label="Team section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant="fade"
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant="fade"
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel >
{members.map((member) => (
<div key={member.name} className="relative aspect-4/5 rounded overflow-hidden">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
<div
className="absolute inset-x-0 bottom-0 h-1/3 backdrop-blur-xl"
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
aria-hidden="true"
/>
<div className="absolute inset-x-6 bottom-6 xl:inset-x-7 xl:bottom-7 2xl:inset-x-8 2xl:bottom-8 flex flex-col text-background">
<span className="text-2xl font-semibold leading-snug truncate">{member.name}</span>
<span className="text-base leading-snug truncate">{member.role}</span>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default TeamGlassCards;

View File

@@ -1,30 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useStateMachineInput } from "@rive-app/react-canvas";
export function useRiveHoverInput(
rive: unknown,
stateMachineName: string,
hoverInputName: string
) {
const hoverInput = useStateMachineInput(
rive as never,
stateMachineName,
hoverInputName
);
const hoverInputRef = useRef<typeof hoverInput | null>(null);
useEffect(() => {
hoverInputRef.current = hoverInput ?? null;
}, [hoverInput]);
return useCallback(
(isHovering: boolean) => {
const input = hoverInputRef.current;
if (!input) return;
input.value = isHovering;
},
[]
);
}

View File

@@ -1,22 +0,0 @@
import { cls } from "@/lib/utils";
interface ActiveBadgeProps {
text: string;
className?: string;
}
const ActiveBadge = ({ text, className }: ActiveBadgeProps) => {
return (
<div
className={cls(
"card backdrop-blur flex items-center gap-2 px-3 py-1 rounded",
className
)}
>
<span className="size-2 rounded-full bg-accent animate-pulsate" />
<p className="text-sm leading-snug font-medium text-foreground">{text}</p>
</div>
);
};
export default ActiveBadge;

View File

@@ -1,69 +0,0 @@
"use client";
import { useRef } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
import { useButtonClick } from "@/hooks/useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonMagneticProps {
text: string;
variant?: "primary" | "secondary";
href?: string;
onClick?: () => void;
animate?: boolean;
animationDelay?: number;
className?: string;
}
const ButtonMagnetic = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonMagneticProps) => {
const handleClick = useButtonClick(href, onClick);
const ref = useRef<HTMLAnchorElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, { stiffness: 150, damping: 15 });
const springY = useSpring(y, { stiffness: 150, damping: 15 });
const handleMouseMove = (e: React.MouseEvent) => {
if (!ref.current || window.innerWidth < 768) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = (e.clientX - rect.left - rect.width / 2) * 0.15;
const offsetY = (e.clientY - rect.top - rect.height / 2) * 0.15;
x.set(offsetX);
y.set(offsetY);
};
const handleMouseLeave = () => {
x.set(0);
y.set(0);
};
const button = (
<motion.a
ref={ref}
href={href}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ x: springX, y: springY }}
className={cls("flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
>
{text}
</motion.a>
);
if (!animate) return button;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
>
{button}
</motion.div>
);
};
export default ButtonMagnetic;

View File

@@ -1,16 +0,0 @@
import { cls } from "@/lib/utils";
type GridLinesBackgroundProps = {
position: "fixed" | "absolute";
};
const GridLinesBackground = ({ position }: GridLinesBackgroundProps) => {
return (
<div
className={cls(position, "inset-0 -z-10 overflow-hidden bg-background pointer-events-none select-none mask-[radial-gradient(circle_at_center,white_0%,transparent_90%)] bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px)] bg-size-[10vw_10vw]")}
aria-hidden="true"
/>
);
};
export default GridLinesBackground;

View File

@@ -1,19 +0,0 @@
import { cls } from "@/lib/utils";
type HorizonGlowBackgroundProps = {
position: "fixed" | "absolute";
};
const HorizonGlowBackground = ({ position }: HorizonGlowBackgroundProps) => {
return (
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none mask-[linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]")} aria-hidden="true">
<div className="absolute left-1/2 -translate-x-1/2 w-full h-full -bottom-[9vh] overflow-hidden z-0">
<div className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden blur-[57px] [background:radial-gradient(50%_50%_at_50%_50%,color-mix(in_srgb,var(--color-primary-cta)_25%,transparent),transparent)]" />
<div className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-screen rounded-[100%] [background:linear-gradient(180deg,color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),transparent)]" />
<div className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%] bg-background [box-shadow:inset_0_2px_20px_color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),0_-10px_50px_1px_color-mix(in_srgb,var(--color-primary-cta)_25%,transparent)]" />
</div>
</div>
);
};
export default HorizonGlowBackground;

View File

@@ -1,29 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { cls } from "@/lib/utils";
import { resolveIcon } from "@/utils/resolve-icon";
const IconTextMarquee = ({ centerIcon, texts }: { centerIcon: string | LucideIcon; texts: string[] }) => {
const CenterIcon = resolveIcon(centerIcon);
const items = [...texts, ...texts];
return (
<div className="relative flex flex-col h-full w-full overflow-hidden" style={{ maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)" }}>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 w-full opacity-60">
{Array.from({ length: 10 }).map((_, row) => (
<div key={row} className={cls("flex gap-2", row % 2 === 0 ? "animate-marquee-horizontal" : "animate-marquee-horizontal-reverse")}>
{items.map((text, i) => (
<div key={i} className="flex items-center justify-center px-4 py-2 card rounded">
<p className="text-sm leading-snug">{text}</p>
</div>
))}
</div>
))}
</div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-16 primary-button backdrop-blur-sm rounded">
<CenterIcon className="size-6 text-primary-cta-text" strokeWidth={1.5} />
</div>
</div>
);
};
export default IconTextMarquee;

View File

@@ -1,76 +0,0 @@
import { Children, useCallback, useEffect, useState, type ReactNode } from "react";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaCarouselType } from "embla-carousel";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cls } from "@/lib/utils";
interface LoopCarouselProps {
children: ReactNode;
}
const LoopCarousel = ({ children }: LoopCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center", containScroll: "trimSnaps" });
const [selectedIndex, setSelectedIndex] = useState(0);
const items = Children.toArray(children);
const onSelect = useCallback((api: EmblaCarouselType) => {
setSelectedIndex(api.selectedScrollSnap());
}, []);
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
return (
<div className="relative w-full md:w-content-width mx-auto">
<div ref={emblaRef} className="overflow-hidden w-full mask-fade-x-medium">
<div className="flex w-full">
{items.map((child, index) => (
<div key={index} className="shrink-0 w-content-width md:w-[clamp(18rem,50vw,48rem)] mr-3 md:mr-6">
<div
className={cls(
"transition-all duration-500 ease-out",
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
)}
>
{child}
</div>
</div>
))}
</div>
</div>
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between w-content-width mx-auto pointer-events-none">
<button
onClick={scrollPrev}
type="button"
aria-label="Previous slide"
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
>
<ChevronLeft className="h-2/5 aspect-square text-primary-cta-text" />
</button>
<button
onClick={scrollNext}
type="button"
aria-label="Next slide"
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
>
<ChevronRight className="h-2/5 aspect-square text-primary-cta-text" />
</button>
</div>
</div>
);
};
export default LoopCarousel;

View File

@@ -1,109 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { ArrowUpRight } from "lucide-react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
interface NavbarDropdownProps {
logo: string;
navItems: { name: string; href: string }[];
ctaButton: { text: string; href: string };
}
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
if (href.startsWith("#")) {
e.preventDefault();
const element = document.getElementById(href.slice(1));
element?.scrollIntoView({ behavior: "smooth", block: "start" });
}
onClose?.();
};
const NavbarDropdown = ({ logo, navItems, ctaButton }: NavbarDropdownProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
};
const handleClickOutside = (e: MouseEvent) => {
if (menuOpen && navRef.current && !navRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [menuOpen]);
return (
<nav ref={navRef} className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
<div className="flex items-center justify-between p-2 xl:p-3 2xl:p-4 rounded backdrop-blur-sm card">
<a href="/" className="pl-2 text-xl font-medium text-foreground">{logo}</a>
<div className="flex items-center gap-2 xl:gap-3 2xl:gap-4">
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
<button
className="relative flex items-center justify-center size-9 rounded cursor-pointer primary-button"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Toggle menu"
aria-expanded={menuOpen}
>
<span
className={cls(
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
menuOpen ? "rotate-45" : "-translate-y-1"
)}
/>
<span
className={cls(
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
menuOpen ? "-rotate-45" : "translate-y-1"
)}
/>
</button>
</div>
</div>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="absolute top-full right-2 xl:right-3 2xl:right-4 -mt-1 px-4 py-1 w-3/4 md:w-3/10 2xl:w-25/100 rounded primary-button"
>
{navItems.map((item, index) => (
<div key={item.name}>
<a
href={item.href}
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
className="group flex items-center justify-between py-3 w-full"
>
<span className="text-base text-primary-cta-text group-hover:ml-2 transition-[margin] duration-300">
{item.name}
</span>
<ArrowUpRight
className="h-(--text-base) w-auto text-primary-cta-text group-hover:rotate-45 group-hover:mr-2 transition-all duration-300"
strokeWidth={1.75}
/>
</a>
{index < navItems.length - 1 && (
<div className="h-px bg-primary-cta-text/20" />
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</nav>
);
};
export default NavbarDropdown;

View File

@@ -1,90 +0,0 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
/**
* Per-section error boundary inserted around every assembled section by the
* backend's page-assembler. Goal: a single section that throws at runtime
* (missing required prop, broken `.map`, etc.) shows a small placeholder
* instead of taking down the entire page with a white screen.
*
* Also reports the failure via the `/__webild/render-status` probe channel
* so Bob-AI's post-commit poll picks up the section name + error message and
* the model gets the signal to fix the right section on the next loop turn.
*
* The probe POST is best-effort and silent — sandbox-only (gated by
* `window.parent !== window`), so production deploys never call it.
*/
interface Props {
/** Section slug — same value the wrapping `<div data-section="…">` uses. */
name: string;
children: ReactNode;
}
interface State {
hasError: boolean;
errorMessage?: string;
}
const RENDER_STATUS_URL = "/__webild/render-status";
export default class SectionErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: unknown): State {
return {
hasError: true,
errorMessage:
error instanceof Error ? error.message : String(error ?? "unknown"),
};
}
componentDidCatch(error: Error, info: ErrorInfo) {
if (typeof window === "undefined") return;
if (window.parent === window) return;
try {
fetch(RENDER_STATUS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ok: false,
reason: "section_error_boundary",
section: this.props.name,
error: String(error?.message || error || "unknown"),
stack: String(error?.stack || "").slice(0, 4000),
componentStack: String(info?.componentStack || "").slice(0, 4000),
t: Date.now(),
}),
keepalive: true,
}).catch(() => {});
} catch {
/* ignore */
}
}
render() {
if (this.state.hasError) {
return (
<div
aria-label="Section render error placeholder"
className="w-content-width mx-auto my-8 p-6 card rounded text-center"
>
<p className="text-base font-medium text-foreground">
This section failed to render.
</p>
<p className="mt-1 text-sm text-foreground/60">
Section: <code className="font-mono">{this.props.name}</code>
</p>
{this.state.errorMessage ? (
<p className="mt-2 text-xs text-foreground/50 max-w-xl mx-auto break-words">
{this.state.errorMessage}
</p>
) : null}
<p className="mt-3 text-xs text-foreground/40">
Tell Bob exactly what's wrong (e.g. &quot;fix the {this.props.name} section&quot;) to retry.
</p>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,96 +0,0 @@
import { useState, useEffect } from "react";
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
type Variant = "slide-up" | "fade-blur" | "fade";
interface TextAnimationProps {
text: string;
variant: Variant;
gradientText: boolean;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
className?: string;
}
const VARIANTS = {
"slide-up": {
hidden: { opacity: 0, y: "50%" },
visible: { opacity: 1, y: 0 },
},
"fade-blur": {
hidden: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "none" },
},
"fade": {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
};
const EASING: Record<Variant, [number, number, number, number]> = {
"slide-up": [0.25, 0.46, 0.45, 0.94],
"fade-blur": [0.45, 0, 0.55, 1],
"fade": [0.45, 0, 0.55, 1],
};
const TextAnimation = ({ text, variant, gradientText, tag = "p", className = "" }: TextAnimationProps) => {
const Tag = motion[tag] as typeof motion.p;
const words = text.split(" ");
const [animationComplete, setAnimationComplete] = useState(false);
const [reverted, setReverted] = useState(false);
const gradientClass = gradientText
? "bg-gradient-to-r from-foreground to-primary-cta bg-clip-text text-transparent pb-[0.1em] -mb-[0.1em]"
: "";
useEffect(() => {
if (animationComplete && !reverted) {
const delay = variant === "fade-blur" && gradientText ? 0 : 700;
const timer = setTimeout(() => {
setReverted(true);
}, delay);
return () => clearTimeout(timer);
}
}, [animationComplete, reverted, variant, gradientText]);
if (reverted) {
return (
<Tag
className={cls("leading-[1.2]", gradientClass, className)}
initial={false}
>
{text}
</Tag>
);
}
return (
<Tag
className={cls(
"leading-[1.2] transition-all duration-700",
animationComplete && gradientClass,
className
)}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-20%" }}
transition={{ staggerChildren: 0.04 }}
onAnimationComplete={() => setAnimationComplete(true)}
>
{words.map((word, i) => (
<span key={i}>
{i > 0 && " "}
<motion.span
className="inline-block"
variants={VARIANTS[variant]}
transition={{ duration: 0.6, ease: EASING[variant] }}
>
{word}
</motion.span>
</span>
))}
</Tag>
);
};
export default TextAnimation;

View File

@@ -1,33 +0,0 @@
"use client";
import { useButtonClick } from "@/hooks/useButtonClick";
import { cls } from "@/lib/utils";
interface TextLinkProps {
text: string;
href?: string;
onClick?: () => void;
className?: string;
}
const TextLink = ({ text, href = "#", onClick, className = "" }: TextLinkProps) => {
const handleClick = useButtonClick(href, onClick);
return (
<a
href={href}
onClick={handleClick}
className={cls(
"relative text-sm text-foreground cursor-pointer",
"after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-current",
"after:scale-x-0 after:origin-right after:transition-transform after:duration-300",
"hover:after:scale-x-100 hover:after:origin-left",
className
)}
>
{text}
</a>
);
};
export default TextLink;

View File

@@ -1,29 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { cls } from "@/lib/utils";
import { resolveIcon } from "@/utils/resolve-icon";
type Item = { icon: string | LucideIcon; title: string; subtitle: string; detail: string };
const POS = ["-translate-y-14 hover:-translate-y-20", "translate-x-16 hover:-translate-y-4", "translate-x-32 translate-y-16 hover:translate-y-10"];
const TiltedStackCards = ({ items }: { items: [Item, Item, Item] }) => (
<div
className="h-full grid place-items-center [grid-template-areas:'stack']"
style={{ maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, black, black 80%, transparent)", maskComposite: "intersect" }}
>
{items.map((item, i) => (
<div key={i} className={cls("flex flex-col justify-between gap-2 p-6 w-80 h-36 card rounded transition-all duration-500 -skew-y-[8deg] [grid-area:stack] 2xl:w-90", POS[i])}>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center size-5 rounded primary-button">
{(() => { const Icon = resolveIcon(item.icon); return <Icon className="size-3 text-primary-cta-text" strokeWidth={1.5} />; })()}
</div>
<p className="text-base">{item.title}</p>
</div>
<p className="text-lg whitespace-nowrap">{item.subtitle}</p>
<p className="text-base">{item.detail}</p>
</div>
))}
</div>
);
export default TiltedStackCards;

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from "react";
import { fetchBlogPosts, defaultPosts, type BlogPost } from "@/lib/api/blog";
const useBlogPosts = () => {
const [posts, setPosts] = useState<BlogPost[]>(defaultPosts);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
const loadPosts = async () => {
try {
const data = await fetchBlogPosts();
if (isMounted) {
setPosts(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Failed to fetch posts"));
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadPosts();
return () => {
isMounted = false;
};
}, []);
return { posts, isLoading, error };
};
export default useBlogPosts;
export type { BlogPost };

View File

@@ -1,136 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import useProducts from "./useProducts";
type SortOption = "Newest" | "Price: Low-High" | "Price: High-Low";
type CatalogProduct = {
id: string;
name: string;
price: string;
imageSrc: string;
imageAlt?: string;
rating?: number;
reviewCount?: string;
category?: string;
onProductClick?: () => void;
};
type ProductVariant = {
label: string;
options: string[];
selected: string;
onChange: (value: string) => void;
};
type UseProductCatalogOptions = {
onProductClick?: (productId: string) => void;
};
const useProductCatalog = (options: UseProductCatalogOptions = {}) => {
const { onProductClick } = options;
const { products: fetchedProducts, isLoading } = useProducts();
const [search, setSearch] = useState("");
const [category, setCategory] = useState("All");
const [sort, setSort] = useState<SortOption>("Newest");
const handleProductClick = useCallback(
(productId: string) => {
onProductClick?.(productId);
},
[onProductClick]
);
const catalogProducts: CatalogProduct[] = useMemo(() => {
if (fetchedProducts.length === 0) return [];
return fetchedProducts.map((product) => ({
id: product.id,
name: product.name,
price: product.price,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt || product.name,
rating: product.rating || 0,
reviewCount: product.reviewCount,
category: product.brand,
onProductClick: () => handleProductClick(product.id),
}));
}, [fetchedProducts, handleProductClick]);
const categories = useMemo(() => {
const categorySet = new Set<string>();
catalogProducts.forEach((product) => {
if (product.category) {
categorySet.add(product.category);
}
});
return Array.from(categorySet).sort();
}, [catalogProducts]);
const filteredProducts = useMemo(() => {
let result = catalogProducts;
if (search) {
const q = search.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.category?.toLowerCase().includes(q) ?? false)
);
}
if (category !== "All") {
result = result.filter((p) => p.category === category);
}
if (sort === "Price: Low-High") {
result = [...result].sort(
(a, b) =>
parseFloat(a.price.replace("$", "").replace(",", "")) -
parseFloat(b.price.replace("$", "").replace(",", ""))
);
} else if (sort === "Price: High-Low") {
result = [...result].sort(
(a, b) =>
parseFloat(b.price.replace("$", "").replace(",", "")) -
parseFloat(a.price.replace("$", "").replace(",", ""))
);
}
return result;
}, [catalogProducts, search, category, sort]);
const filters: ProductVariant[] = useMemo(
() => [
{
label: "Category",
options: ["All", ...categories],
selected: category,
onChange: setCategory,
},
{
label: "Sort",
options: ["Newest", "Price: Low-High", "Price: High-Low"] as SortOption[],
selected: sort,
onChange: (value) => setSort(value as SortOption),
},
],
[categories, category, sort]
);
return {
products: filteredProducts,
isLoading,
search,
setSearch,
category,
setCategory,
sort,
setSort,
filters,
categories,
};
};
export default useProductCatalog;
export type { SortOption, CatalogProduct, ProductVariant };

View File

@@ -1,209 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import useProduct from "./useProduct";
import type { ExtendedCartItem } from "./useCart";
type ProductImage = {
src: string;
alt: string;
};
type ProductMeta = {
salePrice?: string;
ribbon?: string;
inventoryStatus?: string;
inventoryQuantity?: number;
sku?: string;
};
type ProductVariant = {
label: string;
options: string[];
selected: string;
onChange: (value: string) => void;
};
const useProductDetail = (productId: string) => {
const { product, isLoading, error } = useProduct(productId);
const [selectedQuantity, setSelectedQuantity] = useState(1);
const [selectedVariants, setSelectedVariants] = useState<Record<string, string>>({});
const images = useMemo<ProductImage[]>(() => {
if (!product) return [];
if (product.images && product.images.length > 0) {
return product.images.map((src, index) => ({
src,
alt: product.imageAlt || `${product.name} - Image ${index + 1}`,
}));
}
return [
{
src: product.imageSrc,
alt: product.imageAlt || product.name,
},
];
}, [product]);
const meta = useMemo<ProductMeta>(() => {
if (!product?.metadata) return {};
const metadata = product.metadata;
let salePrice: string | undefined;
const onSaleValue = metadata.onSale;
const onSale =
String(onSaleValue) === "true" || onSaleValue === 1 || String(onSaleValue) === "1";
const salePriceValue = metadata.salePrice;
if (onSale && salePriceValue !== undefined && salePriceValue !== null) {
if (typeof salePriceValue === "number") {
salePrice = `$${salePriceValue.toFixed(2)}`;
} else {
const salePriceStr = String(salePriceValue);
salePrice = salePriceStr.startsWith("$") ? salePriceStr : `$${salePriceStr}`;
}
}
let inventoryQuantity: number | undefined;
if (metadata.inventoryQuantity !== undefined) {
const qty = metadata.inventoryQuantity;
inventoryQuantity = typeof qty === "number" ? qty : parseInt(String(qty), 10);
}
return {
salePrice,
ribbon: metadata.ribbon ? String(metadata.ribbon) : undefined,
inventoryStatus: metadata.inventoryStatus ? String(metadata.inventoryStatus) : undefined,
inventoryQuantity,
sku: metadata.sku ? String(metadata.sku) : undefined,
};
}, [product]);
const variants = useMemo<ProductVariant[]>(() => {
if (!product) return [];
const variantList: ProductVariant[] = [];
if (product.metadata?.variantOptions) {
try {
const variantOptionsStr = String(product.metadata.variantOptions);
const parsedOptions = JSON.parse(variantOptionsStr);
if (Array.isArray(parsedOptions)) {
parsedOptions.forEach((option: { name?: string; values?: string | string[] }) => {
if (option.name && option.values) {
const values =
typeof option.values === "string"
? option.values.split(",").map((v: string) => v.trim())
: Array.isArray(option.values)
? option.values.map((v) => String(v).trim())
: [String(option.values)];
if (values.length > 0) {
const optionLabel = option.name;
const currentSelected = selectedVariants[optionLabel] || values[0];
variantList.push({
label: optionLabel,
options: values,
selected: currentSelected,
onChange: (value) => {
setSelectedVariants((prev) => ({
...prev,
[optionLabel]: value,
}));
},
});
}
}
});
}
} catch {
// Failed to parse variantOptions
}
}
if (variantList.length === 0 && product.brand) {
variantList.push({
label: "Brand",
options: [product.brand],
selected: product.brand,
onChange: () => {},
});
}
if (variantList.length === 0 && product.variant) {
const variantOptions = product.variant.includes("/")
? product.variant.split("/").map((v) => v.trim())
: [product.variant];
const variantLabel = "Variant";
const currentSelected = selectedVariants[variantLabel] || variantOptions[0];
variantList.push({
label: variantLabel,
options: variantOptions,
selected: currentSelected,
onChange: (value) => {
setSelectedVariants((prev) => ({
...prev,
[variantLabel]: value,
}));
},
});
}
return variantList;
}, [product, selectedVariants]);
const quantityVariant = useMemo<ProductVariant>(
() => ({
label: "Quantity",
options: Array.from({ length: 10 }, (_, i) => String(i + 1)),
selected: String(selectedQuantity),
onChange: (value) => setSelectedQuantity(parseInt(value, 10)),
}),
[selectedQuantity]
);
const createCartItem = useCallback((): ExtendedCartItem | null => {
if (!product) return null;
const variantStrings = Object.entries(selectedVariants).map(
([label, value]) => `${label}: ${value}`
);
if (variantStrings.length === 0 && product.variant) {
variantStrings.push(`Variant: ${product.variant}`);
}
const variantId = Object.values(selectedVariants).join("-") || "default";
return {
id: `${product.id}-${variantId}-${selectedQuantity}`,
productId: product.id,
name: product.name,
variants: variantStrings,
price: product.price,
quantity: selectedQuantity,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt || product.name,
};
}, [product, selectedVariants, selectedQuantity]);
return {
product,
isLoading,
error,
images,
meta,
variants,
quantityVariant,
selectedQuantity,
selectedVariants,
createCartItem,
};
};
export default useProductDetail;
export type { ProductImage, ProductMeta, ProductVariant };

View File

@@ -16,7 +16,7 @@ export default function HomePage() {
<SectionErrorBoundary name="hero">
<HeroSplitVerticalMarquee
tag="Welcome to Paradise"
title="Experience Unrivaled Luxury at The Grand Oasis"
title="Luxury at The Grand Oasis"
description="Indulge in exquisite comfort, bespoke service, and breathtaking views. Your unforgettable escape begins here, where every moment is crafted to perfection."
primaryButton={{
text: "Explore Rooms",

View File

@@ -1,13 +0,0 @@
import BlogSimpleCards from "@/components/sections/blog/BlogSimpleCards";
const BlogPage = () => {
return (
<BlogSimpleCards
tag="Blog"
title="Latest Articles"
description="Stay updated with our latest insights and news"
/>
);
};
export default BlogPage;

View File

@@ -1,79 +0,0 @@
import { ReactLenis } from "lenis/react";
import { useParams, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import ProductDetailCard from "@/components/ecommerce/ProductDetailCard";
import ProductCart from "@/components/ecommerce/ProductCart";
import useProductDetail from "@/hooks/useProductDetail";
import useCart from "@/hooks/useCart";
import useCheckout from "@/hooks/useCheckout";
const ProductPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { product, isLoading, images, createCartItem, selectedQuantity } = useProductDetail(id || "");
const { items: cartItems, isOpen: cartOpen, setIsOpen: setCartOpen, addItem, updateQuantity, removeItem, total: cartTotal, getCheckoutItems } = useCart();
const { buyNow, checkout } = useCheckout();
if (isLoading) {
return (
<section className="w-content-width mx-auto py-20">
<div className="flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!product) {
return (
<section className="w-content-width mx-auto py-20 text-center">
<p className="text-foreground mb-4">Product not found</p>
<button onClick={() => navigate("/shop")} className="primary-button px-6 py-2 rounded-theme text-primary-cta-text">
Back to Shop
</button>
</section>
);
}
const handleAddToCart = () => {
const item = createCartItem();
if (item) addItem(item);
};
const handleBuyNow = () => {
buyNow(product, selectedQuantity);
};
const handleCheckout = async () => {
if (cartItems.length === 0) return;
const url = new URL(window.location.href);
url.searchParams.set("success", "true");
await checkout(getCheckoutItems(), { successUrl: url.toString() });
};
return (
<ReactLenis root>
<ProductDetailCard
name={product.name}
price={product.price}
description={product.description}
images={images.map((img) => img.src)}
rating={product.rating}
onAddToCart={handleAddToCart}
onBuyNow={handleBuyNow}
/>
<ProductCart
isOpen={cartOpen}
onClose={() => setCartOpen(false)}
items={cartItems}
total={`$${cartTotal}`}
onQuantityChange={updateQuantity}
onRemove={removeItem}
onCheckout={handleCheckout}
/>
</ReactLenis>
);
};
export default ProductPage;

View File

@@ -1,31 +0,0 @@
import { useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import ProductCatalog from "@/components/ecommerce/ProductCatalog";
import useProductCatalog from "@/hooks/useProductCatalog";
const ShopPage = () => {
const navigate = useNavigate();
const { products, isLoading, search, setSearch } = useProductCatalog({
onProductClick: (productId) => navigate(`/shop/${productId}`),
});
if (isLoading) {
return (
<section className="w-content-width mx-auto py-20">
<div className="flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
return (
<ProductCatalog
products={products}
searchValue={search}
onSearchChange={setSearch}
/>
);
};
export default ShopPage;