Initial commit

This commit is contained in:
kudinDmitriyUp
2026-04-21 12:05:31 +00:00
commit 08a08ed28f
179 changed files with 21407 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import { useEffect } from "react";
import { X, Plus, Minus, Trash2 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import Button from "@/components/ui/Button";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type CartItem = {
id: string;
name: string;
price: string;
quantity: number;
imageSrc: string;
};
type ProductCartProps = {
isOpen: boolean;
onClose: () => void;
items: CartItem[];
total: string;
onQuantityChange?: (id: string, quantity: number) => void;
onRemove?: (id: string) => void;
onCheckout?: () => void;
};
const ProductCart = ({ isOpen, onClose, items, total, onQuantityChange, onRemove, onCheckout }: ProductCartProps) => {
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
document.body.style.overflow = isOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-1001">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 bg-foreground/50"
onClick={onClose}
/>
<motion.aside
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="card absolute right-0 top-0 flex flex-col p-5 h-screen w-screen md:w-96"
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium text-foreground">Cart ({items.length})</h2>
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
<X className="size-4 text-foreground" strokeWidth={1.5} />
</button>
</div>
<div className="mt-5 h-px w-full bg-foreground/10" />
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
) : (
<div className="flex flex-col gap-5">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
<div className="shrink-0 size-24 overflow-hidden rounded">
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
</div>
<div className="flex flex-1 flex-col justify-between min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-medium text-foreground truncate">{item.name}</h3>
<p className="shrink-0 text-base font-medium text-foreground">{item.price}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
className="card flex items-center justify-center size-8 rounded cursor-pointer"
>
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
</button>
<span className="min-w-5 text-center text-sm font-medium text-foreground">{item.quantity}</span>
<button
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
className="card flex items-center justify-center size-8 rounded cursor-pointer"
>
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
</button>
<button
onClick={() => onRemove?.(item.id)}
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
>
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-5">
<div className="h-px w-full bg-foreground/10" />
<div className="flex items-center justify-between">
<span className="text-base font-medium text-foreground">Total</span>
<span className="text-base font-medium text-foreground">{total}</span>
</div>
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
</div>
</motion.aside>
</div>
)}
</AnimatePresence>
);
};
export default ProductCart;
export type { CartItem };

View File

@@ -0,0 +1,142 @@
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
import { cls } from "@/lib/utils";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import useProducts from "@/hooks/useProducts";
import type { ProductVariant } from "./ProductDetailCard";
type CatalogProduct = {
id: string;
name: string;
price: string;
imageSrc: string;
category?: string;
rating?: number;
reviewCount?: string;
onClick?: () => void;
};
type ProductCatalogProps = {
products?: CatalogProduct[];
searchValue?: string;
onSearchChange?: (value: string) => void;
filters?: ProductVariant[];
};
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
const { products: fetchedProducts, isLoading } = useProducts();
const products: CatalogProduct[] = productsProp && productsProp.length > 0
? productsProp
: fetchedProducts.map((p) => ({
id: p.id,
name: p.name,
price: p.price,
imageSrc: p.imageSrc,
category: p.brand,
rating: p.rating,
reviewCount: p.reviewCount,
onClick: p.onProductClick,
}));
if (isLoading && (!productsProp || productsProp.length === 0)) {
return (
<section className="mx-auto py-20 w-content-width">
<div className="flex justify-center">
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
</div>
</section>
);
}
return (
<section className="mx-auto py-20 w-content-width">
{(onSearchChange || (filters && filters.length > 0)) && (
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
{onSearchChange && (
<div className="flex flex-1 flex-col gap-2 min-w-32">
<label className="text-sm font-medium text-foreground">Search</label>
<input
type="text"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search products..."
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
/>
</div>
)}
{filters && filters.length > 0 && (
<div className="flex gap-5 items-end">
{filters.map((filter) => (
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
<label className="text-sm font-medium text-foreground">{filter.label}</label>
<div className="secondary-button flex items-center px-3 h-9 rounded">
<select
value={filter.selected}
onChange={(e) => filter.onChange(e.target.value)}
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
>
{filter.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
))}
</div>
)}
</div>
)}
{products.length === 0 ? (
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<button
key={product.id}
onClick={product.onClick}
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
>
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
{product.category && (
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
)}
<div className="flex flex-col gap-1">
<h3 className="text-xl font-medium text-foreground truncate">{product.name}</h3>
{product.rating && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
strokeWidth={1.5}
/>
))}
</div>
{product.reviewCount && (
<span className="text-sm text-foreground">({product.reviewCount})</span>
)}
</div>
)}
</div>
<p className="text-2xl font-medium text-foreground">{product.price}</p>
</div>
</button>
))}
</div>
)}
</section>
);
};
export default ProductCatalog;
export type { CatalogProduct };

View File

@@ -0,0 +1,137 @@
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-medium text-foreground md:text-3xl">{name}</h2>
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-medium rounded text-secondary-cta-text">{ribbon}</span>}
</div>
<div className="h-px w-full bg-foreground/10" />
<div className="flex items-center justify-between">
<p className="text-xl font-medium 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-medium 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-medium 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

@@ -0,0 +1,84 @@
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 mx-auto w-content-width">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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-5 p-5 w-full md:w-4/10 2xl:w-3/10 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-xl font-medium">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
</div>
{index < items.length - 1 && (
<div className="mt-5 border-b border-accent/40" />
)}
</div>
);
})}
</div>
<div className="p-5 w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</div>
</div>
</section>
);
};
export default AboutFeaturesSplit;

View File

@@ -0,0 +1,70 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutMediaOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutMediaOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: AboutMediaOverlayProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
<div className="absolute inset-0">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<div className="absolute inset-0 bg-background/40 backdrop-blur-xs pointer-events-none select-none" />
</div>
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
<div className="flex flex-col items-center gap-3 md:gap-1 text-center">
<motion.span
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-3 px-3 py-1 text-sm card rounded"
>
{tag}
</motion.span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
</div>
</div>
</section>
);
};
export default AboutMediaOverlay;

View File

@@ -0,0 +1,61 @@
import { motion } from "motion/react";
import { Quote } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutTestimonialProps = {
tag: string;
quote: string;
author: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutTestimonial = ({
tag,
quote,
author,
role,
imageSrc,
videoSrc,
}: AboutTestimonialProps) => {
return (
<section aria-label="Testimonial section" className="py-20">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
<div className="relative md:col-span-3 p-8 md:px-12 md:py-20 card rounded">
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<div className="relative flex flex-col justify-center gap-5 py-8 md:py-5 h-full">
<span className="w-fit px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={quote}
variant="slide-up"
tag="p"
className="text-3xl md:text-4xl font-medium leading-tight"
/>
<div className="flex items-center gap-2">
<span className="text-base">{author}</span>
<span className="text-accent"></span>
<span className="text-base text-foreground/75">{role}</span>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
</div>
</section>
);
};
export default AboutTestimonial;

View File

@@ -0,0 +1,56 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface AboutTextSplitProps {
title: string;
descriptions: string[];
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
}
const AboutTextSplit = ({
title,
descriptions,
primaryButton,
secondaryButton,
}: AboutTextSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-30 mx-auto w-content-width">
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-7xl font-medium"
/>
</div>
<div className="flex flex-col gap-5 w-full md:w-1/2">
{descriptions.map((desc, index) => (
<TextAnimation
key={index}
text={desc}
variant="slide-up"
tag="p"
className="text-base md:text-2xl leading-tight text-foreground/75"
/>
))}
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap max-md:justify-center gap-5">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
</div>
<div className="w-full border-b border-foreground/10" />
</div>
</section>
);
};
export default AboutTextSplit;

View File

@@ -0,0 +1,157 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
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 justify-between gap-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="flex flex-col gap-2">
<span className="card w-fit rounded px-2 py-0.5 text-xs mb-0.5">{item.category}</span>
<h3 className="text-2xl md:text-3xl font-medium leading-tight line-clamp-2">{item.title}</h3>
<p className="text-sm leading-tight opacity-75 line-clamp-2">{item.excerpt}</p>
<div className="flex items-center gap-3 mt-1.5">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-9 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.authorName}</span>
<span className="text-xs opacity-75">{item.date}</span>
</div>
</div>
</div>
<div className="relative aspect-square rounded overflow-hidden">
<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>
</article>
);
};
type BlogMediaCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogMediaCardsProps) => {
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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogMediaCards;

View File

@@ -0,0 +1,160 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
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-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden">
<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-5">
<div className="flex flex-col gap-2">
<span className="primary-button w-fit rounded px-2 py-0.5 text-xs text-primary-cta-text">
{item.category}
</span>
<h3 className="text-xl font-medium leading-tight mt-1">{item.title}</h3>
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
</div>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-9 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.authorName}</span>
<span className="text-xs opacity-75">{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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogSimpleCards;

View File

@@ -0,0 +1,167 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
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 = {
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
tags: 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-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden">
<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-5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-5 rounded-full object-cover"
/>
<span className="text-xs opacity-75">
{item.authorName} {item.date}
</span>
</div>
<h3 className="text-xl font-medium leading-tight">{item.title}</h3>
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
</div>
<div className="flex flex-wrap gap-2">
{item.tags.map((tag, index) => (
<span
key={index}
className="primary-button rounded px-2 py-0.5 text-xs text-primary-cta-text"
>
{tag}
</span>
))}
</div>
</div>
</article>
);
};
type BlogTagCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogTagCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogTagCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
tags: [p.category],
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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogTagCards;

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import { sendContactEmail } from "@/lib/api/email";
const ContactCenter = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
termsText,
onSubmit,
}: {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
termsText?: string;
onSubmit?: (email: string) => void;
}) => {
const [email, setEmail] = useState("");
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex items-center justify-center py-20 card rounded"
>
<div className="flex flex-col items-center w-full md:w-1/2 gap-3 px-5">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-5xl md:text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-8/10 text-lg leading-tight text-center"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
>
{buttonText}
</button>
</form>
{termsText && (
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
{termsText}
</p>
)}
</div>
</motion.div>
</div>
</section>
);
};
export default ContactCenter;

View File

@@ -0,0 +1,47 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
const ContactCta = ({
tag,
text,
primaryButton,
secondaryButton,
}: {
tag: string;
text: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
}) => {
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex items-center justify-center py-20 px-5 md:px-10 card rounded"
>
<div className="w-full md:w-3/4 flex flex-col items-center gap-3">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={text}
variant="slide-up"
tag="h2"
className="text-4xl md:text-5xl font-medium text-center leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-1">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default ContactCta;

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type ContactSplitEmailProps = {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
termsText?: string;
onSubmit?: (email: string) => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitEmail = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
termsText,
onSubmit,
imageSrc,
videoSrc,
}: ContactSplitEmailProps) => {
const [email, setEmail] = useState("");
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<div className="flex items-center justify-center py-15 md:py-20 card rounded">
<div className="flex flex-col items-center w-full gap-3 px-5">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-5xl md:text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-8/10 text-lg leading-tight text-center"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
>
{buttonText}
</button>
</form>
{termsText && (
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
{termsText}
</p>
)}
</div>
</div>
<div className="h-100 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</motion.div>
</div>
</section>
);
};
export default ContactSplitEmail;

View File

@@ -0,0 +1,144 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type InputField = {
name: string;
type: string;
placeholder: string;
required?: boolean;
};
type TextareaField = {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
};
type ContactSplitFormProps = {
tag: string;
title: string;
description: string;
inputs: InputField[];
textarea?: TextareaField;
buttonText: string;
onSubmit?: (data: Record<string, string>) => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitForm = ({
tag,
title,
description,
inputs,
textarea,
buttonText,
onSubmit,
imageSrc,
videoSrc,
}: ContactSplitFormProps) => {
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
inputs.forEach((input) => {
initial[input.name] = "";
});
if (textarea) {
initial[textarea.name] = "";
}
return initial;
});
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ formData });
onSubmit?.(formData);
const reset: Record<string, string> = {};
inputs.forEach((input) => {
reset[input.name] = "";
});
if (textarea) {
reset[textarea.name] = "";
}
setFormData(reset);
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<div className="p-5 md:p-10 card rounded">
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col items-center gap-1 text-center">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-4xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-sm md:text-base leading-tight text-balance"
/>
</div>
<div className="flex flex-col gap-3">
{inputs.map((input) => (
<input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
required={input.required}
aria-label={input.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
/>
))}
{textarea && (
<textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
required={textarea.required}
rows={textarea.rows || 5}
aria-label={textarea.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
/>
)}
<button
type="submit"
className="w-full h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer primary-button"
>
{buttonText}
</button>
</div>
</form>
</div>
<div className="h-100 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</motion.div>
</div>
</section>
);
};
export default ContactSplitForm;

View File

@@ -0,0 +1,109 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col gap-3"
>
{items.map((item, index) => (
<div
key={index}
className="card rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg md:text-xl font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 rounded primary-button">
<Plus
className={cls(
"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 md:pt-0 text-sm md:text-base leading-relaxed opacity-75">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</motion.div>
</div>
</section>
);
};
export default FaqSimple;

View File

@@ -0,0 +1,130 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
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"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
className="md:col-span-3 flex flex-col gap-3"
>
{items.map((item, index) => (
<div
key={index}
className="card rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 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-2 text-sm leading-relaxed opacity-75">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
};
export default FaqSplitMedia;

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
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}
className="secondary-button rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 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-2 text-sm leading-relaxed opacity-75">{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">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="card rounded p-3 md:p-5"
>
<div className="flex flex-col md:flex-row gap-3 md:gap-5">
<div className="flex flex-1 flex-col gap-3 md:gap-5">
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
</div>
{secondColumn.length > 0 && (
<div className="flex flex-1 flex-col gap-3 md:gap-5">
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
</div>
)}
</div>
</motion.div>
</div>
</section>
);
};
export default FaqTwoColumn;

View File

@@ -0,0 +1,93 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesAlternatingSplitProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesAlternatingSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesAlternatingSplitProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{items.map((item, index) => (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls("flex flex-col gap-5 md:gap-12 p-5 md:p-12 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
>
<div className="flex flex-col justify-center w-full md:w-1/2 gap-3">
<span className="flex items-center justify-center size-8 mb-1 text-sm rounded primary-button text-primary-cta-text">
{index + 1}
</span>
<h3 className="text-4xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
<p className="text-base leading-tight text-balance">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
</div>
)}
</div>
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default FeaturesAlternatingSplit;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import { ArrowRight } 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";
type FeatureItem = {
title: string;
tags: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesArrowCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesArrowCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesArrowCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 h-full cursor-pointer group">
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
</div>
<div className="flex flex-col justify-between gap-5 p-5 flex-1 card rounded">
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-between gap-5">
<div className="flex flex-wrap items-center gap-2">
{item.tags.map((itemTag) => (
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
))}
</div>
<ArrowRight className="shrink-0 h-[1em] w-auto transition-transform duration-300 group-hover:-rotate-45" strokeWidth={1.5} />
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesArrowCards;

View File

@@ -0,0 +1,104 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
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"; items: { icon: IconInput; label: string; value: string }[] }
| { bentoComponent: "tilted-stack-cards"; items: [{ 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; items: IconInput[] }
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; texts: string[] }
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; items: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
| { bentoComponent: "media-stack"; items: [{ 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.items} />;
case "tilted-stack-cards": return <TiltedStackCards items={feature.items} />;
case "animated-bar-chart": return <AnimatedBarChart />;
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.items} />;
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.texts} />;
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.items} completedLabel={feature.completedLabel} />;
case "media-stack": return <MediaStack items={feature.items} />;
}
};
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">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{features.map((feature) => (
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded h-full">
<div className="relative h-72 overflow-hidden">{getBentoComponent(feature)}</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
<p className="text-sm leading-tight">{feature.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default FeaturesBento;

View File

@@ -0,0 +1,83 @@
import { motion } from "motion/react";
import { Check, X } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface FeaturesComparisonProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
negativeItems: string[];
positiveItems: string[];
}
const FeaturesComparison = ({
tag,
title,
description,
primaryButton,
secondaryButton,
negativeItems,
positiveItems,
}: FeaturesComparisonProps) => {
return (
<section aria-label="Features comparison section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5"
>
<div className="flex flex-col gap-5 p-5 card rounded opacity-50">
{negativeItems.map((item) => (
<div key={item} className="flex items-center gap-2 text-base">
<X className="shrink-0 h-[1em] w-auto" />
<span className="text-base truncate">{item}</span>
</div>
))}
</div>
<div className="flex flex-col gap-5 p-5 card rounded">
{positiveItems.map((item) => (
<div key={item} className="flex items-center gap-2 text-base">
<Check className="shrink-0 h-[1em] w-auto" />
<span className="text-base truncate">{item}</span>
</div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default FeaturesComparison;

View File

@@ -0,0 +1,94 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FeatureItem = {
title: string;
description: string;
tags: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesDetailedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{items.map((item) => (
<motion.article
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:grid md:grid-cols-10 2xl:w-8/10 mx-auto gap-5 md:gap-10 p-5 md:p-10 cursor-pointer card rounded group"
>
<div className="flex flex-col md:col-span-6 gap-3 md:gap-12">
<h3 className="text-3xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
<div className="flex flex-col mt-auto gap-5">
<div className="flex flex-wrap gap-2">
{item.tags.map((itemTag) => (
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
))}
</div>
<p className="text-base md:text-2xl leading-tight text-balance">
{item.description}
</p>
</div>
</div>
<div className="aspect-square md:col-span-4 rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
</div>
</motion.article>
))}
</div>
</div>
</section>
);
};
export default FeaturesDetailedCards;

View File

@@ -0,0 +1,97 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type StepItem = {
tag: string;
title: string;
subtitle: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedStepsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
steps: StepItem[];
}
const FeaturesDetailedSteps = ({
tag,
title,
description,
primaryButton,
secondaryButton,
steps,
}: FeaturesDetailedStepsProps) => {
return (
<section aria-label="Features detailed steps section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{steps.map((step, index) => {
const stepNumber = String(index + 1).padStart(2, "0");
return (
<motion.div
key={step.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-5 md:gap-12 p-5 md:p-12 card rounded overflow-hidden"
>
<div className="flex flex-col justify-between w-full md:w-1/2">
<div className="flex flex-col gap-5">
<span className="w-fit px-3 py-1 text-sm card rounded">{step.tag}</span>
<h3 className="text-5xl md:text-8xl font-medium leading-none">{step.title}</h3>
</div>
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
<div className="flex flex-col gap-2 md:gap-5">
<h4 className="text-2xl md:text-3xl font-medium text-balance">{step.subtitle}</h4>
<p className="text-base md:text-lg leading-tight text-balance">{step.description}</p>
</div>
</div>
<div className="flex flex-col w-full md:w-35/100 gap-10">
<span className="hidden md:block self-end text-8xl font-medium text-accent">{stepNumber}</span>
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
</div>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
};
export default FeaturesDetailedSteps;

View File

@@ -0,0 +1,100 @@
import { motion } from "motion/react";
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 { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
mediaItems: [
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }),
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })
];
};
interface FeaturesDualMediaProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesDualMedia = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesDualMediaProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={2}>
{items.map((item) => {
const IconComponent = resolveIcon(item.icon);
return (
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-center mb-1 size-15 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
</div>
<div className="grid grid-cols-2 flex-1 mt-auto gap-5">
{item.mediaItems.map((mediaItem, mediaIndex) => (
<div key={mediaIndex} className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={mediaItem.imageSrc} videoSrc={mediaItem.videoSrc} />
</div>
))}
</div>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesDualMedia;

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import { motion } from "motion/react";
import { Plus } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
descriptions: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesFlipCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
const [isFlipped, setIsFlipped] = useState(false);
return (
<div
className="relative w-full cursor-pointer perspective-[3000px]"
onClick={() => setIsFlipped(!isFlipped)}
>
<div
data-flipped={isFlipped}
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
>
<div className="flex flex-col gap-5 p-5 card rounded backface-hidden">
<div className="flex items-start justify-between gap-5">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
<Plus className="h-2/5 w-2/5 text-primary-cta-text" />
</div>
</div>
<div className="relative overflow-hidden aspect-4/5 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-between gap-5 p-5 card rounded backface-hidden transform-[rotateY(180deg)]">
<div className="flex items-start justify-between gap-5">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
<Plus className="h-2/5 w-2/5 rotate-45 text-primary-cta-text" />
</div>
</div>
<div className="flex flex-col gap-3">
{item.descriptions.map((desc, index) => (
<p key={index} className="text-lg leading-tight text-foreground/75">{desc}</p>
))}
</div>
</div>
</div>
</div>
);
};
const FeaturesFlipCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesFlipCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<FeatureFlipCard key={item.title} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesFlipCards;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
import HoverPattern from "@/components/ui/HoverPattern";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import type { LucideIcon } from "lucide-react";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
interface FeaturesIconCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureItem[];
}
const FeaturesIconCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: FeaturesIconCardsProps) => {
return (
<section aria-label="Features icon cards section" className="py-20">
<div className="flex flex-col w-content-width mx-auto gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{features.map((feature) => {
const FeatureIcon = resolveIcon(feature.icon);
return (
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded">
<HoverPattern className="flex items-center justify-center aspect-square rounded">
<div className="relative z-10 flex items-center justify-center size-12 primary-button rounded shadow">
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</div>
</HoverPattern>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
<p className="text-sm leading-tight">{feature.description}</p>
</div>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesIconCards;

View File

@@ -0,0 +1,100 @@
import { Fragment } from "react";
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
type FeatureItem = {
label: string;
title: string;
bullets: string[];
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
};
interface FeaturesLabeledListProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesLabeledList = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesLabeledListProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{items.map((item) => (
<motion.div
key={item.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
>
<div className="w-full md:w-1/2 flex md:justify-start">
<h3 className="text-7xl font-medium leading-tight truncate">{item.label}</h3>
</div>
<div className="w-full h-px bg-foreground/20 md:hidden" />
<div className="flex flex-col w-full md:w-1/2 gap-5">
<h4 className="text-2xl md:text-3xl font-medium leading-tight">{item.title}</h4>
<div className="flex flex-wrap items-center gap-1">
{item.bullets.map((text, index) => (
<Fragment key={index}>
<span className="text-base">{text}</span>
{index < item.bullets.length - 1 && <span className="text-base text-accent"></span>}
</Fragment>
))}
</div>
<div className="flex flex-wrap gap-3 mt-2">
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />
<Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default FeaturesLabeledList;

View File

@@ -0,0 +1,82 @@
import { motion } from "motion/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";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMediaCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-sm leading-tight">{item.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesMediaCards;

View File

@@ -0,0 +1,99 @@
import type { LucideIcon } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import LoopCarousel from "@/components/ui/LoopCarousel";
import Button from "@/components/ui/Button";
import { useButtonClick } from "@/hooks/useButtonClick";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
title: string;
description: string;
buttonIcon: string | LucideIcon;
buttonHref?: string;
buttonOnClick?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCarouselProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
const Icon = resolveIcon(item.buttonIcon);
return (
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute bottom-0 left-0 w-full h-1/3 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-linear-to-t from-foreground/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-5 p-5">
<div className="flex flex-col min-w-0">
<h3 className="text-2xl md:text-3xl font-medium leading-tight text-background">{item.title}</h3>
<p className="text-sm md:text-base leading-tight text-background/75">{item.description}</p>
</div>
<button
onClick={handleClick}
type="button"
aria-label={item.buttonHref ? `Navigate to ${item.buttonHref}` : "Action button"}
className="flex items-center justify-center shrink-0 size-8 cursor-pointer primary-button rounded"
>
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</button>
</div>
</div>
);
};
const FeaturesMediaCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMediaCarouselProps) => {
return (
<section aria-label="Features section" className="w-full py-20">
<div className="flex flex-col w-full gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<LoopCarousel>
{items.map((item) => (
<FeatureMediaCarouselCard key={item.title} item={item} />
))}
</LoopCarousel>
</div>
</section>
);
};
export default FeaturesMediaCarousel;

View File

@@ -0,0 +1,104 @@
import { motion } from "motion/react";
import { BadgeCheck } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
avatarSrc: string;
buttonText: string;
buttonHref?: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const FeaturesProfileCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}) => (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="group relative overflow-hidden aspect-5/6 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute top-5 right-5 z-20">
<Button text={item.buttonText} href={item.buttonHref} variant="primary" />
</div>
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
<div className="relative flex flex-col gap-1 p-3">
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
<div className="flex items-center gap-2">
<div className="size-8 shrink-0 overflow-hidden rounded secondary-button">
<img src={item.avatarSrc} alt="" className="h-full w-full object-cover" />
</div>
<h3 className="text-xl font-semibold leading-tight truncate text-background transition-colors duration-400 group-hover:text-foreground">
{item.title}
</h3>
<BadgeCheck className="size-5 shrink-0 text-background transition-colors duration-400 group-hover:text-foreground" strokeWidth={2} />
</div>
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default FeaturesProfileCards;

View File

@@ -0,0 +1,105 @@
import { motion } from "motion/react";
import { Info } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesRevealCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesRevealCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute top-5 left-5 z-20 perspective-[1000px]">
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden">
<span className="text-sm font-medium text-foreground">{index + 1}</span>
</div>
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
<div className="relative flex flex-col gap-1 p-3">
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-tight text-background transition-colors duration-400 group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesRevealCards;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
label: string;
value: string;
};
interface FeaturesStatisticsCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesStatisticsCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesStatisticsCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col h-full card rounded">
<div className="flex flex-col flex-1 gap-10 p-5">
<div className="flex flex-col gap-1">
<h3 className="text-2xl md:text-3xl font-medium leading-tight truncate">{item.title}</h3>
<p className="text-base md:text-lg leading-tight text-foreground/75">{item.description}</p>
</div>
<div className="flex items-center justify-between gap-2 mt-auto">
<div className="flex items-center min-w-0 flex-1 gap-2">
<span className="shrink-0 size-[1em] rounded-sm bg-accent" />
<span className="text-base truncate">{item.label}</span>
</div>
<span className="text-xl md:text-2xl font-medium">{item.value}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesStatisticsCards;

View File

@@ -0,0 +1,92 @@
import { motion } from "motion/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";
type FeatureItem = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTaggedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesTaggedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesTaggedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 h-full group">
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
<span className="absolute top-5 right-5 px-3 py-1 text-sm card rounded">{item.tag}</span>
</div>
<div className="flex flex-col gap-5 p-5 flex-1 card rounded">
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
</div>
)}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesTaggedCards;

View File

@@ -0,0 +1,123 @@
import { useState, useEffect, useRef } from "react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Transition from "@/components/ui/Transition";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTimelineCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesTimelineCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesTimelineCardsProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setProgress(0);
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
}, 50);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [activeIndex]);
useEffect(() => {
if (progress === 100) {
setActiveIndex((i) => (i + 1) % items.length);
}
}, [progress, items.length]);
const handleCardClick = (index: number) => {
if (index !== activeIndex) setActiveIndex(index);
};
return (
<section aria-label="Features timeline section" className="py-20">
<div className="flex flex-col w-content-width mx-auto gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<Transition className="flex flex-col gap-5">
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
<Transition key={activeIndex} transitionType="full" className="absolute inset-6 overflow-hidden rounded">
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
</Transition>
</div>
<div className={cls(
"grid grid-cols-1 gap-5",
items.length === 2 && "md:grid-cols-2",
items.length === 3 && "md:grid-cols-3",
items.length >= 4 && "md:grid-cols-4"
)}>
{items.map((item, index) => (
<div
key={item.title}
data-active={index === activeIndex}
onClick={() => handleCardClick(index)}
className="flex flex-col justify-between gap-5 p-5 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center size-8 primary-button rounded">
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
</div>
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
<p className="text-base leading-tight text-balance">{item.description}</p>
</div>
<div className="relative w-full h-px overflow-hidden">
<div className="absolute inset-0 bg-foreground/20" />
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
</div>
</div>
))}
</div>
</Transition>
</div>
</section>
);
};
export default FeaturesTimelineCards;

View File

@@ -0,0 +1,61 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBasic = ({
columns,
leftText,
rightText,
}: {
columns: FooterColumn[];
leftText: string;
rightText: string;
}) => {
return (
<footer aria-label="Site footer" className="w-full pt-20 pb-10">
<div className="w-content-width mx-auto">
<div className="w-full flex flex-wrap justify-between gap-y-10 mb-10">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="w-full flex items-center justify-between pt-5">
<span className="text-sm opacity-50">{leftText}</span>
<span className="text-sm opacity-50">{rightText}</span>
</div>
</div>
</footer>
);
};
export default FooterBasic;

View File

@@ -0,0 +1,66 @@
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-medium hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
</div>
);
};
const FooterBrand = ({
brand,
columns,
}: {
brand: string;
columns: FooterColumn[];
}) => {
return (
<footer
aria-label="Site footer"
className="w-full py-15 mt-20 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-medium">{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>
);
};
export default FooterBrand;

View File

@@ -0,0 +1,101 @@
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-medium 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-medium">{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

@@ -0,0 +1,57 @@
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-medium" 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

@@ -0,0 +1,82 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterColumn = {
title: string;
items: { label: string; href?: string; onClick?: () => void }[];
};
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimple = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer aria-label="Site footer" className="w-full py-15 mt-20 primary-button text-primary-cta-text">
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-medium">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
);
};
export default FooterSimple;

View File

@@ -0,0 +1,82 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleCard = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer aria-label="Site footer" className="w-full py-20">
<div className="w-content-width mx-auto p-10 rounded-lg card">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-medium">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-foreground/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
);
};
export default FooterSimpleCard;

View File

@@ -0,0 +1,95 @@
import { useButtonClick } from "@/hooks/useButtonClick";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleMedia = ({
imageSrc,
videoSrc,
brand,
columns,
copyright,
links,
}: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }) & {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer aria-label="Site footer" className="relative w-full mt-20 overflow-hidden">
<div className="w-full aspect-square md:aspect-16/6 mask-fade-top-long">
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="w-full h-full object-cover rounded-none!"
/>
</div>
<div className="w-full py-15 primary-button text-primary-cta-text">
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-medium">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</div>
</footer>
);
};
export default FooterSimpleMedia;

View File

@@ -0,0 +1,120 @@
import { useRef, useEffect, useState } from "react";
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterColumn = {
title: string;
items: { label: string; href?: string; onClick?: () => void }[];
};
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleReveal = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
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 primary-button text-primary-cta-text"
>
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-medium">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
</div>
</section>
);
};
export default FooterSimpleReveal;

View File

@@ -0,0 +1,62 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboard = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardProps) => {
return (
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full p-3 md:p-5 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboard;

View File

@@ -0,0 +1,54 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
type HeroBillboardBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardBrandProps) => {
return (
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-end gap-5">
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl leading-tight text-balance text-right"
/>
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full p-3 md:p-5 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboardBrand;

View File

@@ -0,0 +1,69 @@
import Button from "@/components/ui/Button";
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="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
>
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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-1.5 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

@@ -0,0 +1,76 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardScrollProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardScroll = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardScrollProps) => {
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">
<div
ref={containerRef}
className="pt-25 pb-20 md:py-30 perspective-distant"
>
<div className="w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="w-content-width mx-auto mt-8 p-3 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-5 card rounded overflow-hidden hidden md:block"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboardScroll;

View File

@@ -0,0 +1,124 @@
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 TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroBillboardTestimonialProps = {
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 HeroBillboardTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroBillboardTestimonialProps) => {
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="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="relative w-full p-3 md:p-5 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 bottom-6 left-6 right-6 md:left-10 md:bottom-10 md:right-auto md:max-w-sm p-5 card rounded flex flex-col gap-5"
>
<div className="flex gap-1">
{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-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</section>
);
};
export default HeroBillboardTestimonial;

View File

@@ -0,0 +1,55 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import TiltedCarousel from "@/components/ui/TiltedCarousel";
type HeroBillboardTiltedCarouselProps = {
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 HeroBillboardTiltedCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroBillboardTiltedCarouselProps) => {
return (
<section
aria-label="Hero section"
className="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
>
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<TiltedCarousel items={items} />
</section>
);
};
export default HeroBillboardTiltedCarousel;

View File

@@ -0,0 +1,62 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
type HeroBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBrandProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold">{brand}</AutoFillText>
</div>
</div>
</section>
);
};
export default HeroBrand;

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type HeroBrandCarouselProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const INTERVAL = 4000;
const HeroBrandCarousel = ({
brand,
description,
primaryButton,
secondaryButton,
items,
}: HeroBrandCarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % items.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, items.length]);
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
{items.map((item, index) => (
<div
key={index}
className={cls(
"absolute inset-0 transition-opacity duration-500",
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
)}
aria-hidden={currentIndex !== index}
>
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</div>
))}
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold">{brand}</AutoFillText>
<div className="flex gap-3 pb-5">
{items.map((_, index) => (
<button
key={index}
className="relative h-1 w-full rounded overflow-hidden bg-foreground/20 cursor-pointer"
onClick={() => setCurrentIndex(index)}
aria-label="Slide"
aria-current={currentIndex === index}
>
<div
className={cls(
"absolute inset-0 bg-foreground rounded origin-left",
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
)}
style={{ animationDuration: `${INTERVAL}ms` }}
/>
</button>
))}
</div>
</div>
</div>
</section>
);
};
export default HeroBrandCarousel;

View File

@@ -0,0 +1,67 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
type HeroCenteredLogosProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
logos: string[];
};
const HeroCenteredLogos = ({
tag,
title,
description,
primaryButton,
secondaryButton,
logos,
}: HeroCenteredLogosProps) => {
return (
<section aria-label="Hero section" className="h-svh flex flex-col">
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="md:max-w-7/10 text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-content-width mx-auto pb-8 overflow-hidden mask-fade-x"
>
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
{[...logos, ...logos, ...logos, ...logos].map((logo, index) => (
<div key={index} className="shrink-0 mx-3 px-4 py-2 rounded card">
<span className="text-xl font-semibold whitespace-nowrap opacity-75">{logo}</span>
</div>
))}
</div>
</motion.div>
</section>
);
};
export default HeroCenteredLogos;

View File

@@ -0,0 +1,66 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroOverlayProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
<div className="flex flex-col gap-3 w-full md:w-4/10 2xl:w-35/100">
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap gap-3 mt-1.5">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
</section>
);
};
export default HeroOverlay;

View File

@@ -0,0 +1,129 @@
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 TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroOverlayTestimonialProps = {
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 HeroOverlayTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroOverlayTestimonialProps) => {
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 w-full h-svh overflow-hidden flex flex-col justify-start"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pt-35">
<div className="flex flex-col gap-3 w-full md:w-4/10 2xl:w-35/100">
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap gap-3 mt-1.5">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<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 z-10 bottom-3 left-3 right-3 p-5 card rounded flex flex-col gap-5 md:left-auto md:bottom-8 md:right-8 md:max-w-25/100 2xl:max-w-2/10"
>
<div className="flex gap-1">
{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-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</section>
);
};
export default HeroOverlayTestimonial;

View File

@@ -0,0 +1,64 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroSplitProps) => {
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 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">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-3 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
</div>
</section>
);
};
export default HeroSplit;

View File

@@ -0,0 +1,129 @@
import { useEffect, useRef } from "react";
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type KpiItem = {
value: string;
label: string;
};
type HeroSplitKpiProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
kpis: [KpiItem, KpiItem, KpiItem];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
const HeroSplitKpi = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
kpis,
}: HeroSplitKpiProps) => {
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
useEffect(() => {
if (window.innerWidth <= 768) return;
let mouseX = 0;
let mouseY = 0;
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
const multipliers = [-0.25, -0.5, 0.25];
let animationId: number;
const onMouseMove = (e: MouseEvent) => {
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
};
const animate = () => {
offsets.forEach((offset, i) => {
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
const el = kpiRefs.current[i];
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
});
animationId = requestAnimationFrame(animate);
};
animate();
window.addEventListener("mousemove", onMouseMove);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(animationId);
};
}, []);
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 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">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full h-full p-3 card rounded overflow-hidden scale-80"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
{kpis.map((kpi, index) => (
<motion.div
key={index}
ref={(el) => { kpiRefs.current[index] = el; }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
className={cls(
"absolute flex flex-col items-center p-3 md:p-5 card backdrop-blur-sm rounded",
KPI_POSITIONS[index]
)}
>
<p className="text-2xl md:text-4xl font-medium">{kpi.value}</p>
<p className="text-sm md:text-base text-foreground/70">{kpi.label}</p>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default HeroSplitKpi;

View File

@@ -0,0 +1,71 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitMediaGridProps = {
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 },
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
];
};
const HeroSplitMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroSplitMediaGridProps) => {
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 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">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full md:w-1/2 grid grid-cols-2 gap-3"
>
{items.map((item, index) => (
<div key={index} className="h-80 md:h-[55vh] p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</motion.div>
</div>
</section>
);
};
export default HeroSplitMediaGrid;

View File

@@ -0,0 +1,126 @@
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 TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 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">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", 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-3 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-6 left-6 right-6 md:left-auto md:max-w-5/10 p-5 card rounded flex flex-col gap-5"
>
<div className="flex gap-1">
{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-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</section>
);
};
export default HeroSplitTestimonial;

View File

@@ -0,0 +1,81 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitVerticalMarqueeProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const HeroSplitVerticalMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
leftItems,
rightItems,
}: HeroSplitVerticalMarqueeProps) => {
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 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">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-3 overflow-hidden">
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col gap-3 animate-marquee-vertical">
{duplicatedLeft.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col gap-3 animate-marquee-vertical-reverse">
{duplicatedRight.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};
export default HeroSplitVerticalMarquee;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
interface HeroTiltedCardsProps {
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 HeroTiltedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroTiltedCardsProps) => {
const marqueeItems = [...items, ...items];
const galleryStyles = [
"-rotate-6 z-10 -translate-y-5",
"rotate-6 z-20 translate-y-5 -ml-15",
"-rotate-6 z-30 -translate-y-5 -ml-15",
"rotate-6 z-40 translate-y-5 -ml-15",
"-rotate-6 z-50 -translate-y-5 -ml-15",
];
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col items-center gap-10 md:gap-15 w-full md:w-content-width mx-auto">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="block md:hidden w-full overflow-hidden mask-padding-x"
>
<div className="flex w-max animate-marquee-horizontal">
{marqueeItems.map((item, index) => (
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="hidden md:flex justify-center items-center w-full"
>
<div className="flex items-center justify-center">
{items.map((item, index) => (
<div
key={index}
className={cls(
"relative w-[23%] aspect-4/5 p-2 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
galleryStyles[index]
)}
>
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default HeroTiltedCards;

View File

@@ -0,0 +1,71 @@
import { cls } from "@/lib/utils";
type ContentItem =
| { type: "paragraph"; text: string }
| { type: "list"; items: string[] }
| { type: "numbered-list"; items: string[] };
type ContentSection = {
heading: string;
content: ContentItem[];
};
const PolicyContent = ({
title,
subtitle,
sections,
}: {
title: string;
subtitle?: string;
sections: ContentSection[];
}) => {
return (
<article aria-label="Policy content" className="w-content-width mx-auto pt-40 pb-20">
<div className="md:max-w-1/2 mx-auto flex flex-col gap-5">
<div className="flex flex-col gap-3">
<h1 className="text-3xl md:text-4xl font-medium leading-tight">{title}</h1>
{subtitle && (
<p className="text-sm opacity-50">{subtitle}</p>
)}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="flex flex-col gap-5">
{sections.map((section) => (
<section key={section.heading} className="flex flex-col gap-3">
<h2 className="text-xl md:text-2xl font-medium leading-tight">{section.heading}</h2>
{section.content.map((item, i) => {
if (item.type === "paragraph") {
return (
<p key={i} className="text-sm md:text-base opacity-75 leading-relaxed">
{item.text}
</p>
);
}
const ListTag = item.type === "numbered-list" ? "ol" : "ul";
return (
<ListTag
key={i}
className={cls(
"flex flex-col gap-3 pl-5 text-sm md:text-base opacity-75 leading-relaxed",
item.type === "numbered-list" ? "list-decimal" : "list-disc"
)}
>
{item.items.map((li, j) => (
<li key={j}>{li}</li>
))}
</ListTag>
);
})}
</section>
))}
</div>
</div>
</article>
);
};
export default PolicyContent;

View File

@@ -0,0 +1,87 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type Metric = {
value: string;
title: string;
features: string[];
};
const MetricsFeatureCards = ({
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 h-full card rounded">
<div className="flex flex-col gap-0">
<span className="text-8xl md:text-7xl font-medium leading-none truncate">{metric.value}</span>
<span className="text-xl truncate">{metric.title}</span>
</div>
<div className="flex flex-col gap-3 pt-5 border-t border-accent">
{metric.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-sm leading-tight">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsFeatureCards;

View File

@@ -0,0 +1,93 @@
import { motion } from "motion/react";
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 { 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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{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-5 min-h-70 h-full card rounded">
<span
className="text-9xl font-medium leading-none text-center truncate"
style={{
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 72%)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{metric.value}
</span>
<span className="mt-[-0.75em] text-4xl font-medium text-center truncate">{metric.title}</span>
<p className="max-w-9/10 md:max-w-7/10 mt-2 text-base leading-tight text-center line-clamp-2">{metric.description}</p>
<div className="absolute bottom-5 left-5 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>
</motion.div>
</div>
</section>
);
export default MetricsGradientCards;

View File

@@ -0,0 +1,83 @@
import { motion } from "motion/react";
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 { resolveIcon } from "@/utils/resolve-icon";
type Metric = {
icon: string | LucideIcon;
title: string;
value: string;
};
const MetricsIconCards = ({
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => {
const IconComponent = resolveIcon(metric.icon);
return (
<div key={metric.value} className="flex flex-col items-center justify-center gap-3 p-5 min-h-70 h-full card rounded">
<div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center size-8 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<span className="text-xl truncate">{metric.title}</span>
</div>
<span className="text-7xl font-medium leading-none truncate">{metric.value}</span>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsIconCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Metric = {
value: string;
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const MetricsMediaCards = ({
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 w-content-width mx-auto">
{metrics.map((metric, index) => {
const isEven = index % 2 === 1;
const isLast = index === metrics.length - 1;
const isOddTotal = metrics.length % 2 !== 0;
const shouldSpanFull = isLast && isOddTotal;
return (
<motion.div
key={metric.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls("grid grid-cols-2 gap-5", shouldSpanFull && "md:col-span-2")}
>
<div className={cls(
"flex flex-col justify-between gap-5 p-5 card rounded",
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
isEven && "order-2 md:order-1"
)}>
<span className="text-5xl md:text-6xl font-medium leading-tight truncate">{metric.value}</span>
<div className="flex flex-col gap-2">
<span className="text-xl md:text-2xl font-medium truncate">{metric.title}</span>
<div className="w-full h-px bg-accent" />
<p className="text-base leading-tight truncate">{metric.description}</p>
</div>
</div>
<div className={cls(
"rounded overflow-hidden",
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
isEven && "order-1 md:order-2"
)}>
<ImageOrVideo imageSrc={metric.imageSrc} videoSrc={metric.videoSrc} />
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
export default MetricsMediaCards;

View File

@@ -0,0 +1,62 @@
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
type Metric = {
value: string;
description: string;
};
const MetricsMinimalCards = ({
tag,
title,
metrics,
}: {
tag: string;
title: string;
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8 w-content-width mx-auto">
<div className="flex flex-col gap-5">
<span className="px-3 py-1 w-fit text-sm card rounded md:hidden">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-3xl md:text-5xl font-medium leading-tight text-balance"
/>
</div>
<div className="w-full h-px bg-accent" />
<div className="flex flex-col md:flex-row md:items-start gap-8">
<span className="hidden md:block px-3 py-1 text-sm card rounded">{tag}</span>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls(
"grid grid-cols-1 gap-5 flex-1",
metrics.length >= 2 && "md:grid-cols-2"
)}
>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 md:p-8 aspect-video card rounded">
<span className="text-6xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
<div className="flex flex-col gap-5">
<div className="w-full h-px bg-accent" />
<p className="text-base md:text-lg leading-tight text-balance">{metric.description}</p>
</div>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
export default MetricsMinimalCards;

View File

@@ -0,0 +1,72 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 min-h-70 h-full card rounded">
<span className="text-7xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
<p className="text-base leading-tight text-balance">{metric.description}</p>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsSimpleCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type PricingPlan = {
tag: string;
price: string;
description: string;
features: string[];
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
};
const PricingCenteredCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col items-center gap-5 p-5 h-full card rounded text-center">
<span className="px-5 py-2 text-sm card rounded">{plan.tag}</span>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-base">{plan.description}</span>
</div>
<div className="flex flex-col gap-3 w-full">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="flex flex-col gap-3 w-full">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base text-left">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingCenteredCards;

View File

@@ -0,0 +1,105 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import { cls } from "@/lib/utils";
type PricingPlan = {
tag: string;
price: string;
description: string;
features: string[];
highlight?: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
};
const PricingHighlightedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col h-full">
<div className={cls("px-5 py-2 text-sm", plan.highlight ? "text-center primary-button rounded-t text-primary-cta-text" : "invisible")}>
{plan.highlight || "placeholder"}
</div>
<div className={cls("flex flex-col items-center gap-5 p-5 flex-1 card text-center", plan.highlight ? "rounded-t-none rounded-b" : "rounded")}>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-xl font-medium">{plan.tag}</span>
</div>
<div className="h-px w-full bg-foreground/20" />
<div className="flex flex-col gap-3 w-full">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base text-left">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col gap-3 w-full mt-auto">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingHighlightedCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type PricingPlan = {
tag: string;
price: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: string[];
};
const PricingLayeredCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col gap-3 p-3 h-full card rounded">
<div className="flex flex-col gap-3 p-5 secondary-button rounded">
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.tag}</span>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-base">{plan.description}</span>
</div>
<div className="flex flex-col gap-3">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
</div>
<div className="flex flex-col gap-3 p-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base leading-tight">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingLayeredCards;

View File

@@ -0,0 +1,95 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type PricingPlan = {
tag: string;
price: string;
period: string;
features: string[];
primaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const PricingMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{plans.map((plan) => (
<motion.div
key={plan.tag}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row gap-5 md:gap-10 p-5 card rounded"
>
<div className="w-full md:w-1/2 aspect-square md:aspect-4/3 rounded overflow-hidden">
<ImageOrVideo imageSrc={plan.imageSrc} videoSrc={plan.videoSrc} />
</div>
<div className="flex flex-col justify-center gap-5 w-full md:w-1/2">
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.price}{plan.period}</span>
<h3 className="text-4xl md:text-5xl font-medium truncate">{plan.tag}</h3>
<div className="flex flex-col gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-sm leading-tight">{feature}</span>
</div>
))}
</div>
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-fit mt-1" />
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default PricingMediaCards;

View File

@@ -0,0 +1,92 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type PricingPlan = {
tag: string;
price: string;
description: string;
features: string[];
};
const PricingSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col gap-5 p-5 h-full card rounded">
<span className="px-5 py-2 w-fit text-sm card rounded">{plan.tag}</span>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-base">{plan.description}</span>
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="flex flex-col gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingSimpleCards;

View File

@@ -0,0 +1,107 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
type PricingPlan = {
tag: string;
price: string;
period: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
featuresTitle: string;
features: string[];
};
const PricingSplitCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{plans.map((plan) => (
<motion.div
key={plan.tag}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
>
<div className="flex flex-col justify-between gap-5 w-full md:w-1/2">
<div className="flex flex-col gap-3">
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.tag}</span>
<div className="flex items-baseline gap-1">
<span className="text-5xl md:text-6xl font-medium">{plan.price}</span>
<span className="text-2xl">{plan.period}</span>
</div>
<p className="text-xl md:text-2xl leading-tight text-balance">{plan.description}</p>
</div>
<div className="flex flex-col gap-3">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
</div>
<div className="w-full h-px bg-foreground/20 md:hidden" />
<div className="flex flex-col gap-5 w-full md:w-1/2">
<h3 className="text-xl font-medium">{plan.featuresTitle}</h3>
<div className="flex flex-col gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base leading-tight">{feature}</span>
</div>
))}
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default PricingSplitCards;

View File

@@ -0,0 +1,120 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import { motion } from "motion/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 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">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{products.map((product) => (
<button
key={product.name}
onClick={product.onClick}
className="group h-full flex flex-col gap-5 p-5 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">
<div className="flex-1 min-w-0">
<h3 className="text-base font-medium truncate">{product.name}</h3>
<p className="text-2xl font-medium">{product.price}</p>
</div>
<div className="flex items-center justify-center size-10 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>
</motion.div>
</div>
</section>
);
};
export default ProductMediaCards;

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Plus, Minus, Loader2 } from "lucide-react";
import { motion } from "motion/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 useProducts from "@/hooks/useProducts";
type ProductQuantityCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
products?: {
name: string;
price: string;
imageSrc: string;
onAddToCart?: (quantity: number) => void;
}[];
};
const ProductQuantityCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
products: productsProp,
}: ProductQuantityCardsProps) => {
const [quantities, setQuantities] = useState<Record<string, number>>({});
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,
onAddToCart: undefined as ((quantity: number) => void) | undefined,
}))
: productsProp;
const getQuantity = (name: string) => quantities[name] || 1;
const handleIncrement = (name: string) => {
setQuantities((prev) => ({ ...prev, [name]: (prev[name] || 1) + 1 }));
};
const handleDecrement = (name: string) => {
setQuantities((prev) => ({ ...prev, [name]: Math.max(1, (prev[name] || 1) - 1) }));
};
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">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{products.map((product) => (
<div
key={product.name}
className="h-full flex flex-col gap-5 p-5 card rounded"
>
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={product.imageSrc} />
</div>
<div className="flex flex-col gap-3">
<h3 className="text-xl font-medium truncate">{product.name}</h3>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleDecrement(product.name)}
className="flex items-center justify-center size-8 rounded card cursor-pointer"
aria-label="Decrease quantity"
>
<Minus className="size-4" strokeWidth={1.5} />
</button>
<span className="w-fit text-base text-center font-medium">{getQuantity(product.name)}</span>
<button
onClick={() => handleIncrement(product.name)}
className="flex items-center justify-center size-8 rounded card cursor-pointer"
aria-label="Increase quantity"
>
<Plus className="size-4" strokeWidth={1.5} />
</button>
</div>
<button
onClick={() => product.onAddToCart?.(getQuantity(product.name))}
className="h-8 px-5 rounded primary-button text-base text-primary-cta-text font-medium cursor-pointer"
>
{product.price}
</button>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default ProductQuantityCards;

View File

@@ -0,0 +1,142 @@
import { Star, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
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 useProducts from "@/hooks/useProducts";
type ProductRatingCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
products?: {
brand: string;
name: string;
price: string;
rating: number;
reviewCount: string;
imageSrc: string;
onClick?: () => void;
}[];
};
const ProductRatingCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
products: productsProp,
}: ProductRatingCardsProps) => {
const { products: fetchedProducts, isLoading } = useProducts();
const isFromApi = fetchedProducts.length > 0;
const products = isFromApi
? fetchedProducts.map((p) => ({
brand: p.brand || "",
name: p.name,
price: p.price,
rating: p.rating || 0,
reviewCount: p.reviewCount || "0",
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">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{products.map((product) => (
<button
key={product.name}
onClick={product.onClick}
className="group h-full flex flex-col gap-5 p-5 text-left card rounded cursor-pointer"
>
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={product.imageSrc} />
</div>
<div className="flex flex-col gap-2">
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.brand}</span>
<div className="flex flex-col gap-1">
<h3 className="text-xl font-medium truncate">{product.name}</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls(
"size-4 text-accent",
index < Math.floor(product.rating) ? "fill-accent" : "opacity-20"
)}
strokeWidth={1.5}
/>
))}
</div>
<span className="text-sm">({product.reviewCount})</span>
</div>
</div>
<p className="text-2xl font-medium">{product.price}</p>
</div>
</button>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default ProductRatingCards;

View File

@@ -0,0 +1,128 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import { motion } from "motion/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 useProducts from "@/hooks/useProducts";
type ProductVariantCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
products?: {
name: string;
variant: string;
price: string;
imageSrc: string;
onClick?: () => void;
}[];
};
const ProductVariantCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
products: productsProp,
}: ProductVariantCardsProps) => {
const { products: fetchedProducts, isLoading } = useProducts();
const isFromApi = fetchedProducts.length > 0;
const products = isFromApi
? fetchedProducts.map((p) => ({
name: p.name,
variant: p.variant || "",
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">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{products.map((product) => (
<button
key={product.name}
onClick={product.onClick}
className="group h-full flex flex-col gap-5 p-5 text-left card rounded cursor-pointer"
>
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo
imageSrc={product.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">
<div className="flex items-center justify-center size-12 rounded-full primary-button opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300">
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col flex-1 min-w-0">
<h3 className="text-xl font-medium truncate leading-tight text-balance">{product.name}</h3>
<p className="text-sm text-foreground/60">{product.variant}</p>
</div>
<span className="text-xl font-medium shrink-0">{product.price}</span>
</div>
</button>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default ProductVariantCards;

View File

@@ -0,0 +1,68 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
const SocialProofMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
names,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
names: string[];
}) => {
return (
<section aria-label="Social proof section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm rounded card">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="w-content-width mx-auto overflow-hidden mask-fade-x"
>
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
{[...names, ...names, ...names, ...names].map((name, index) => (
<div key={index} className="shrink-0 mx-3 px-5 py-3 rounded card">
<span className="text-2xl font-semibold whitespace-nowrap opacity-75">{name}</span>
</div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default SocialProofMarquee;

View File

@@ -0,0 +1,107 @@
import { motion } from "motion/react";
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 { 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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{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 bottom-5 left-5 right-5 flex flex-col gap-2 p-5 card backdrop-blur-sm rounded">
<div className="flex items-start justify-between gap-3">
<span className="text-2xl font-medium leading-tight truncate">{member.name}</span>
<span className="px-3 py-1 text-xs leading-tight secondary-button text-secondary-cta-text rounded truncate">{member.role}</span>
</div>
<p className="text-base leading-tight">{member.description}</p>
<div className="flex gap-3 mt-1">
{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>
</motion.div>
</div>
</section>
);
export default TeamDetailedCards;

View File

@@ -0,0 +1,83 @@
import { motion } from "motion/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";
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{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-5 bottom-5 flex flex-col text-background">
<span className="text-2xl font-medium leading-tight truncate">{member.name}</span>
<span className="text-base leading-tight truncate">{member.role}</span>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default TeamGlassCards;

View File

@@ -0,0 +1,93 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type TeamMember = {
name: string;
role: string;
detail: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type TeamGroup = {
title: string;
members: TeamMember[];
};
const TeamListCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
groups,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
groups: TeamGroup[];
}) => (
<section aria-label="Team section" className="py-20">
<div className="flex flex-col gap-8 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col gap-8"
>
{groups.map((group) => (
<div key={group.title} className="p-5 card rounded">
<h3 className="mb-3 text-2xl md:text-3xl font-medium">{group.title}</h3>
<div className="flex flex-col divide-y divide-accent border-t border-accent">
{group.members.map((member) => (
<div key={member.name} className="flex items-center gap-3 py-5 last:pb-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="relative size-12 md:size-16 shrink-0 overflow-hidden rounded">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
</div>
<div className="flex flex-col min-w-0">
<span className="text-lg md:text-xl font-medium leading-tight truncate">{member.name}</span>
<span className="text-base leading-tight opacity-75 truncate">{member.role}</span>
</div>
</div>
<span className="text-sm md:text-lg font-medium shrink-0">{member.detail}</span>
</div>
))}
</div>
</div>
))}
</motion.div>
</div>
</section>
);
export default TeamListCards;

View File

@@ -0,0 +1,68 @@
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type TeamMember = {
name: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TeamMinimalCards = ({
tag,
title,
members,
}: {
tag: string;
title: string;
members: TeamMember[];
}) => (
<section aria-label="Team section" className="py-20">
<div className="flex flex-col gap-5 md:gap-8 w-content-width mx-auto">
<div className="flex flex-col gap-5">
<span className="md:hidden px-3 py-1 w-fit text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-3xl md:text-5xl font-medium leading-tight text-balance"
/>
</div>
<div className="w-full h-px bg-accent" />
<div className="flex flex-col md:flex-row md:items-start gap-8">
<span className="hidden md:block px-3 py-1 text-sm card rounded">{tag}</span>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls(
"grid grid-cols-1 gap-5 flex-1",
members.length >= 2 && "md:grid-cols-2"
)}
>
{members.map((member) => (
<div key={member.name} className="flex flex-col gap-5 p-5 card rounded overflow-hidden">
<div className="relative aspect-square md:aspect-5/4 rounded overflow-hidden">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
</div>
<div className="flex flex-col gap-5">
<div className="w-full h-px bg-accent" />
<div className="flex flex-col">
<span className="text-xl font-medium leading-tight truncate">{member.name}</span>
<span className="text-base leading-tight truncate">{member.role}</span>
</div>
</div>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
export default TeamMinimalCards;

View File

@@ -0,0 +1,79 @@
import { motion } from "motion/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";
type TeamMember = {
name: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TeamOverlayCards = ({
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">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{members.map((member) => (
<div key={member.name} className="relative aspect-4/5 card rounded">
<div className="relative w-full h-full rounded overflow-hidden">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
<div className="absolute bottom-5 left-5 right-5 flex items-center justify-between gap-3 p-3 card backdrop-blur-sm rounded">
<span className="text-xl font-medium leading-tight truncate">{member.name}</span>
<span className="px-3 py-2 text-sm leading-tight primary-button text-primary-cta-text rounded truncate">{member.role}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default TeamOverlayCards;

View File

@@ -0,0 +1,74 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type TeamMember = {
name: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TeamStackedCards = ({
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 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-wrap justify-center gap-y-5"
>
{members.map((member) => (
<div key={member.name} className="flex flex-col items-center w-[55%] md:w-[28%] -mx-[4%] md:-mx-[2%] text-center">
<div className="p-3 mb-3 w-full aspect-square card rounded overflow-hidden">
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} className="rounded" />
</div>
<span className="w-4/5 text-2xl font-medium leading-tight truncate">{member.name}</span>
<span className="w-4/5 text-base leading-tight opacity-75 truncate">{member.role}</span>
</div>
))}
</motion.div>
</div>
</section>
);
export default TeamStackedCards;

View File

@@ -0,0 +1,74 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import Transition from "@/components/ui/Transition";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type Avatar = {
name: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialAvatarCard = ({
tag,
title,
primaryButton,
secondaryButton,
avatars,
}: {
tag: string;
title: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
avatars: Avatar[];
}) => {
const visibleAvatars = avatars.slice(0, 5);
const remainingCount = avatars.length - visibleAvatars.length;
return (
<section aria-label="Testimonials section" className="py-20">
<div className="w-content-width mx-auto">
<Transition className="flex flex-col items-center gap-5 py-20 px-8 rounded card">
<div className="flex flex-col items-center gap-3">
<span className="px-3 py-1 text-sm rounded card">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h3"
className="md:max-w-7/10 text-3xl md:text-5xl font-medium leading-tight text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex items-center mt-1">
{visibleAvatars.map((avatar, index) => (
<div
key={avatar.name}
className={cls("relative size-14 md:size-20 overflow-hidden rounded-full border-2 border-background", index > 0 && "-ml-5")}
style={{ zIndex: visibleAvatars.length - index }}
>
<ImageOrVideo imageSrc={avatar.imageSrc} videoSrc={avatar.videoSrc} />
</div>
))}
{remainingCount > 0 && (
<div
className="flex items-center justify-center size-14 md:size-20 -ml-5 rounded-full border-2 border-background card"
style={{ zIndex: 0 }}
>
<span className="text-sm md:text-base font-medium">+{remainingCount}</span>
</div>
)}
</div>
</Transition>
</div>
</section>
);
};
export default TestimonialAvatarCard;

View File

@@ -0,0 +1,125 @@
import { useState } from "react";
import { motion } from "motion/react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Transition from "@/components/ui/Transition";
type Testimonial = {
title: string;
quote: string;
name: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => {
const [activeIndex, setActiveIndex] = useState(0);
const handlePrev = () => {
setActiveIndex((prev) => (prev === 0 ? testimonials.length - 1 : prev - 1));
};
const handleNext = () => {
setActiveIndex((prev) => (prev === testimonials.length - 1 ? 0 : prev + 1));
};
const activeTestimonial = testimonials[activeIndex];
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm rounded card">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
<div className="flex flex-col justify-between gap-5 p-5 md:p-8 rounded card">
<Transition key={activeIndex} transitionType="fade" whileInView={false} className="flex flex-col gap-3">
<h3 className="text-2xl md:text-3xl font-medium leading-tight">
{activeTestimonial.title}
</h3>
<blockquote className="text-base md:text-lg leading-tight opacity-75">
&ldquo;{activeTestimonial.quote}&rdquo;
</blockquote>
</Transition>
<div className="h-px w-full md:hidden bg-foreground/50" />
<div className="flex items-center justify-between gap-5">
<Transition key={activeIndex} transitionType="fade" whileInView={false} className="flex flex-col">
<span className="text-base font-medium leading-tight">{activeTestimonial.name}</span>
<span className="text-sm leading-tight opacity-75">{activeTestimonial.role}</span>
</Transition>
<div className="flex gap-3">
<button
onClick={handlePrev}
aria-label="Previous testimonial"
className="flex items-center justify-center size-9 cursor-pointer rounded primary-button"
>
<ArrowLeft className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</button>
<button
onClick={handleNext}
aria-label="Next testimonial"
className="flex items-center justify-center size-9 cursor-pointer rounded primary-button"
>
<ArrowRight className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</button>
</div>
</div>
</div>
<Transition key={activeIndex} transitionType="fade" whileInView={false} className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={activeTestimonial.imageSrc} videoSrc={activeTestimonial.videoSrc} />
</Transition>
</motion.div>
</div>
</section>
);
};
export default TestimonialDetailedCards;

View File

@@ -0,0 +1,115 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
role: string;
quote: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialMarqueeCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => {
const half = Math.ceil(testimonials.length / 2);
const topRow = testimonials.slice(0, half);
const bottomRow = testimonials.slice(half);
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm rounded card">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col gap-5 w-content-width mx-auto"
>
<div className="overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
{[...topRow, ...topRow, ...topRow, ...topRow].map((testimonial, index) => (
<div key={`top-${index}`} className="shrink-0 w-72 md:w-80 mr-5 p-5 rounded card">
<div className="flex flex-col justify-between gap-5 h-full">
<p className="text-lg leading-tight line-clamp-3">{testimonial.quote}</p>
<div className="flex items-center gap-3">
<div className="size-10 overflow-hidden rounded-full">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
</div>
<div className="flex flex-col">
<span className="text-base font-medium leading-tight">{testimonial.name}</span>
<span className="text-sm leading-tight opacity-75">{testimonial.role}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal-reverse" style={{ animationDuration: "30s" }}>
{[...bottomRow, ...bottomRow, ...bottomRow, ...bottomRow].map((testimonial, index) => (
<div key={`bottom-${index}`} className="shrink-0 w-72 md:w-80 mr-5 p-5 rounded card">
<div className="flex flex-col justify-between gap-5 h-full">
<p className="text-lg leading-tight line-clamp-3">{testimonial.quote}</p>
<div className="flex items-center gap-3">
<div className="size-10 overflow-hidden rounded-full">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
</div>
<div className="flex flex-col">
<span className="text-base font-medium leading-tight">{testimonial.name}</span>
<span className="text-sm leading-tight opacity-75">{testimonial.role}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default TestimonialMarqueeCards;

View File

@@ -0,0 +1,126 @@
import { motion } from "motion/react";
import { Star } 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 { cls } from "@/lib/utils";
type Testimonial = {
name: string;
role: string;
company: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type Metric = {
value: string;
label: string;
};
const TestimonialMetricsCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
metrics: [Metric, Metric, Metric];
}) => {
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{testimonials.map((testimonial) => (
<div key={testimonial.name} className="relative aspect-3/4 rounded overflow-hidden">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
<div className="absolute inset-x-5 bottom-5 flex flex-col gap-2 p-5 card rounded backdrop-blur-sm">
<div className="flex gap-1 mb-1">
{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>
<span className="text-2xl font-medium leading-tight">{testimonial.name}</span>
<div className="flex flex-col">
<span className="text-base leading-tight">{testimonial.role}</span>
<span className="text-base leading-tight opacity-75">{testimonial.company}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row items-center justify-between w-content-width mx-auto p-8 md:py-16 card rounded"
>
{metrics.map((metric, index) => (
<div key={metric.label} className="flex flex-col md:flex-row items-center w-full md:flex-1">
<div className={cls("flex flex-col items-center flex-1 gap-1 text-center md:py-0", index === 0 ? "pb-5" : index === 2 ? "pt-5" : "py-5")}>
<span className="text-5xl font-medium">{metric.value}</span>
<span className="text-base">{metric.label}</span>
</div>
{index < 2 && (
<div className="w-full h-px md:h-20 md:w-px bg-foreground/20" />
)}
</div>
))}
</motion.div>
</div>
</div>
</section>
);
};
export default TestimonialMetricsCards;

View File

@@ -0,0 +1,98 @@
import { motion } from "motion/react";
import { Star } from "lucide-react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type Testimonial = {
name: string;
role: string;
company: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialOverlayCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{testimonials.map((testimonial) => (
<div key={testimonial.name} className="relative aspect-3/4 rounded overflow-hidden">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
<div className="absolute inset-x-5 bottom-5 flex flex-col gap-2 p-5 card rounded backdrop-blur-sm">
<div className="flex gap-1 mb-1">
{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>
<span className="text-2xl font-medium leading-tight">{testimonial.name}</span>
<div className="flex flex-col">
<span className="text-base leading-tight">{testimonial.role}</span>
<span className="text-base leading-tight opacity-75">{testimonial.company}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default TestimonialOverlayCards;

View File

@@ -0,0 +1,82 @@
import { motion } from "motion/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";
type Testimonial = {
name: string;
role: string;
quote: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialQuoteCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={4}>
{testimonials.map((testimonial) => (
<div key={testimonial.name} className="flex flex-col gap-5 p-5 card rounded">
<div className="relative size-24 overflow-hidden rounded">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
</div>
<div className="flex flex-col gap-1">
<span className="text-2xl font-medium leading-tight">{testimonial.name}</span>
<span className="text-base leading-tight opacity-75">{testimonial.role}</span>
</div>
<p className="text-lg leading-tight">{testimonial.quote}</p>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default TestimonialQuoteCards;

View File

@@ -0,0 +1,92 @@
import { Star } 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 { cls } from "@/lib/utils";
type Testimonial = {
name: string;
role: string;
quote: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialRatingCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => {
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm rounded card">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<GridOrCarousel carouselThreshold={3}>
{testimonials.map((testimonial) => (
<div key={testimonial.name} className="flex flex-col justify-between gap-5 h-full p-5 rounded card">
<div className="flex flex-col items-start gap-5">
<div className="flex gap-1">
{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-tight">{testimonial.quote}</p>
</div>
<div className="flex items-center gap-3">
<div className="size-10 overflow-hidden rounded-full">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
</div>
<div className="flex flex-col">
<span className="text-base font-medium leading-tight">{testimonial.name}</span>
<span className="text-sm leading-tight opacity-75">{testimonial.role}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</div>
</section>
);
};
export default TestimonialRatingCards;

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from "react";
import { motion } from "motion/react";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaCarouselType } from "embla-carousel";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type Testimonial = {
tag: string;
title: string;
quote: string;
name: string;
date: string;
} & ({ avatarImageSrc: string; avatarVideoSrc?: never } | { avatarVideoSrc: string; avatarImageSrc?: never })
& ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialSplitCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
testimonials,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
testimonials: Testimonial[];
}) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
const [selectedIndex, setSelectedIndex] = useState(0);
const scrollTo = useCallback((index: number) => emblaApi?.scrollTo(index), [emblaApi]);
const onSelect = useCallback((api: EmblaCarouselType) => {
setSelectedIndex(api.selectedScrollSnap());
}, []);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col gap-5"
>
<div ref={emblaRef} className="overflow-hidden">
<div className="flex">
{testimonials.map((testimonial) => (
<div key={testimonial.name} className="flex-none w-content-width md:w-[calc(var(--width-content-width)*0.8)] mr-5">
<div className="flex flex-col justify-between md:grid md:grid-cols-2 h-full card rounded overflow-hidden">
<div className="flex flex-col justify-between gap-5 md:gap-8 p-5 md:p-10">
<div className="flex flex-col gap-3 md:gap-5">
<span className="px-3 py-1 w-fit text-sm card rounded">{testimonial.tag}</span>
<h3 className="text-3xl md:text-4xl font-medium leading-tight">{testimonial.title}</h3>
<p className="text-base md:text-lg leading-tight opacity-75">{testimonial.quote}</p>
</div>
<div className="flex items-center gap-3">
<div className="size-12 overflow-hidden rounded-full">
<ImageOrVideo imageSrc={testimonial.avatarImageSrc} videoSrc={testimonial.avatarVideoSrc} />
</div>
<div className="flex flex-col">
<span className="text-base font-medium leading-tight">{testimonial.name}</span>
<span className="text-sm leading-tight opacity-75">{testimonial.date}</span>
</div>
</div>
</div>
<div className="relative min-h-80 h-full md:aspect-square">
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-center gap-2">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => scrollTo(index)}
className={cls("size-2 rounded-full transition-colors", index === selectedIndex ? "bg-foreground" : "bg-foreground/10")}
aria-label="Go to slide"
/>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default TestimonialSplitCards;

View File

@@ -0,0 +1,91 @@
import { Star } from "lucide-react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type Avatar = {
name: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const TestimonialTrustCard = ({
quote,
rating,
author,
avatars,
}: {
quote: string;
rating: number;
author: string;
avatars: Avatar[];
}) => {
const visibleAvatars = avatars.slice(0, 6);
const remainingCount = avatars.length - visibleAvatars.length;
return (
<section aria-label="Testimonials section" className="py-20">
<div className="flex flex-col items-center gap-5 w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex gap-1"
>
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-6 text-accent", index < rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</motion.div>
<TextAnimation
text={quote}
variant="slide-up"
tag="p"
className="text-3xl md:text-5xl font-medium leading-tight text-center text-balance"
/>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
className="text-xl text-center"
>
{author}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="flex items-center justify-center"
>
{visibleAvatars.map((avatar, index) => (
<div
key={avatar.name}
className={cls("relative size-12 md:size-16 overflow-hidden rounded-full border-2 border-background", index > 0 && "-ml-5")}
style={{ zIndex: visibleAvatars.length - index }}
>
<ImageOrVideo imageSrc={avatar.imageSrc} videoSrc={avatar.videoSrc} />
</div>
))}
{remainingCount > 0 && (
<div
className="flex items-center justify-center size-12 md:size-16 -ml-5 rounded-full border-2 border-background card"
style={{ zIndex: 0 }}
>
<span className="text-sm md:text-base font-medium">+{remainingCount}</span>
</div>
)}
</motion.div>
</div>
</section>
);
};
export default TestimonialTrustCard;

View File

@@ -0,0 +1,46 @@
import { useState, useEffect } from "react";
import { cls } from "@/lib/utils";
const BARS = [
{ height: 100, hoverHeight: 40 },
{ height: 84, hoverHeight: 100 },
{ height: 62, hoverHeight: 75 },
{ height: 90, hoverHeight: 50 },
{ height: 70, hoverHeight: 90 },
{ height: 50, hoverHeight: 60 },
{ height: 75, hoverHeight: 85 },
{ height: 80, hoverHeight: 70 },
];
const AnimatedBarChart = () => {
const [active, setActive] = useState(2);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const interval = setInterval(() => setActive((p) => (p + 1) % BARS.length), 3000);
return () => clearInterval(interval);
}, []);
return (
<div
className="hidden md:block h-full w-full"
style={{ maskImage: "linear-gradient(to bottom, black 40%, transparent)" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-end gap-4 h-full w-full">
{BARS.map((bar, i) => (
<div
key={i}
className="relative w-full rounded bg-background-accent transition-all duration-500"
style={{ height: `${isHovered ? bar.hoverHeight : bar.height}%` }}
>
<div className={cls("absolute inset-0 rounded primary-button transition-opacity duration-500", active === i ? "opacity-100" : "opacity-0")} />
</div>
))}
</div>
</div>
);
};
export default AnimatedBarChart;

View File

@@ -0,0 +1,67 @@
import { useRef, useEffect, useState } from "react";
import { cls } from "@/lib/utils";
const AutoFillText = ({
children,
className = "",
paddingY = "py-10",
}: {
children: string;
className?: string;
paddingY?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLHeadingElement>(null);
const [fontSize, setFontSize] = useState<number | null>(null);
const hasDescenders = /[gjpqy]/.test(children);
const lineHeight = hasDescenders ? 1.2 : 0.8;
useEffect(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
const calculateSize = () => {
const containerWidth = container.offsetWidth;
if (containerWidth === 0) return;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const styles = getComputedStyle(text);
ctx.font = `${styles.fontWeight} 100px ${styles.fontFamily}`;
const textWidth = ctx.measureText(children).width;
if (textWidth > 0) {
setFontSize((containerWidth / textWidth) * 100);
}
};
calculateSize();
const observer = new ResizeObserver(calculateSize);
observer.observe(container);
return () => observer.disconnect();
}, [children]);
return (
<div ref={containerRef} className={cls("w-full min-w-0 flex-1", !hasDescenders && paddingY)}>
<h2
ref={textRef}
className={cls(
"whitespace-nowrap transition-opacity duration-150",
fontSize ? "opacity-100" : "opacity-0",
className
)}
style={{ fontSize: fontSize ? `${fontSize}px` : undefined, lineHeight }}
>
{children}
</h2>
</div>
);
};
export default AutoFillText;

View File

@@ -0,0 +1,57 @@
"use client";
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import { useButtonClick } from "@/hooks/useButtonClick";
interface ButtonProps {
text: string;
variant?: "primary" | "secondary";
href?: string;
onClick?: () => void;
animate?: boolean;
animateImmediately?: boolean;
delay?: number;
className?: string;
}
const Button = ({ text, variant = "primary", href, onClick, animate = false, animateImmediately = false, delay = 0, className = "" }: ButtonProps) => {
const handleClick = useButtonClick(href, onClick);
const classes = cls(
"flex items-center justify-center h-9 px-6 text-sm rounded cursor-pointer",
variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text",
className
);
const button = href
? <a href={href} onClick={handleClick} className={classes}>{text}</a>
: <button onClick={handleClick} className={classes}>{text}</button>;
if (animateImmediately) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay, ease: "easeOut" }}
>
{button}
</motion.div>
);
}
if (!animate) return button;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, delay, ease: "easeOut" }}
>
{button}
</motion.div>
);
};
export default Button;

View File

@@ -0,0 +1,46 @@
import type { LucideIcon } from "lucide-react";
import { Send } from "lucide-react";
import { cls } from "@/lib/utils";
import { resolveIcon } from "@/utils/resolve-icon";
type Exchange = { userMessage: string; aiResponse: string };
const ChatMarquee = ({ aiIcon, userIcon, exchanges, placeholder }: { aiIcon: string | LucideIcon; userIcon: string | LucideIcon; exchanges: Exchange[]; placeholder: string }) => {
const AiIcon = resolveIcon(aiIcon);
const UserIcon = resolveIcon(userIcon);
const messages = exchanges.flatMap((e) => [{ content: e.userMessage, isUser: true }, { content: e.aiResponse, isUser: false }]);
const duplicated = [...messages, ...messages];
return (
<div className="relative flex flex-col h-full w-full overflow-hidden">
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col px-4 animate-marquee-vertical">
{duplicated.map((msg, i) => (
<div key={i} className={cls("flex items-end gap-2 mb-4 shrink-0", msg.isUser ? "flex-row-reverse" : "flex-row")}>
{msg.isUser ? (
<div className="flex items-center justify-center h-8 w-8 primary-button rounded shrink-0">
<UserIcon className="h-3 w-3 text-primary-cta-text" />
</div>
) : (
<div className="flex items-center justify-center h-8 w-8 card rounded shrink-0">
<AiIcon className="h-3 w-3" />
</div>
)}
<div className={cls("max-w-3/4 px-4 py-3 text-sm leading-tight", msg.isUser ? "primary-button rounded-2xl rounded-br-none text-primary-cta-text" : "card rounded-2xl rounded-bl-none")}>
{msg.content}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 p-2 pl-4 card rounded">
<p className="flex-1 text-sm text-foreground/75 truncate">{placeholder}</p>
<div className="flex items-center justify-center h-7 w-7 primary-button rounded">
<Send className="h-3 w-3 text-primary-cta-text" strokeWidth={1.75} />
</div>
</div>
</div>
);
};
export default ChatMarquee;

View File

@@ -0,0 +1,47 @@
import { Check, Loader } from "lucide-react";
import { cls } from "@/lib/utils";
type Item = { label: string; detail: string };
const DELAYS = [
["delay-150", "delay-200", "delay-[250ms]"],
["delay-[350ms]", "delay-[400ms]", "delay-[450ms]"],
["delay-[550ms]", "delay-[600ms]", "delay-[650ms]"],
];
const ChecklistTimeline = ({ heading, subheading, items, completedLabel }: { heading: string; subheading: string; items: [Item, Item, Item]; completedLabel: string }) => (
<div className="group relative flex items-center justify-center h-full w-full overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
{[1, 0.8, 0.6].map((s) => <div key={s} className="absolute h-full aspect-square rounded-full border border-background-accent/30" style={{ transform: `scale(${s})` }} />)}
</div>
<div className="relative flex flex-col gap-3 p-4 max-w-full w-8/10 mask-fade-y">
<div className="flex items-center gap-2 p-3 card shadow rounded">
<Loader className="h-4 w-4 text-primary transition-transform duration-1000 group-hover:rotate-360" strokeWidth={1.5} />
<p className="text-xs truncate">{heading}</p>
<p className="text-xs text-foreground/75 ml-auto whitespace-nowrap">{subheading}</p>
</div>
{items.map((item, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 card shadow rounded">
<div className="relative flex items-center justify-center h-6 w-6 card shadow rounded">
<div className="absolute h-2 w-2 primary-button rounded transition-opacity duration-300 group-hover:opacity-0" />
<div className={cls("absolute inset-0 flex items-center justify-center primary-button rounded opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100", DELAYS[i][0])}>
<Check className="h-3 w-3 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
<div className="flex-1 flex items-center justify-between gap-4 min-w-0">
<p className={cls("text-xs truncate opacity-0 transition-opacity duration-300 group-hover:opacity-100", DELAYS[i][1])}>{item.label}</p>
<p className={cls("text-xs text-foreground/75 whitespace-nowrap opacity-0 translate-y-1 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0", DELAYS[i][2])}>{item.detail}</p>
</div>
</div>
))}
<div className="relative flex items-center justify-center p-3 primary-button rounded">
<div className="absolute flex gap-2 transition-opacity duration-500 delay-900 group-hover:opacity-0">
{[0, 1, 2].map((j) => <div key={j} className="h-2 w-2 rounded bg-primary-cta-text" />)}
</div>
<p className="text-xs text-primary-cta-text truncate opacity-0 transition-opacity duration-500 delay-900 group-hover:opacity-100">{completedLabel}</p>
</div>
</div>
</div>
);
export default ChecklistTimeline;

View File

@@ -0,0 +1,64 @@
import { Children, type ReactNode } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cls } from "@/lib/utils";
import { useCarouselControls } from "@/hooks/useCarouselControls";
interface GridOrCarouselProps {
children: ReactNode;
carouselThreshold?: 2 | 3 | 4;
}
const GridOrCarousel = ({ children, carouselThreshold = 4 }: GridOrCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
const { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress } = useCarouselControls(emblaApi);
const items = Children.toArray(children);
const count = items.length;
if (count <= carouselThreshold) {
return (
<div className={cls(
"grid grid-cols-1 gap-5 mx-auto w-content-width",
count === 2 && "md:grid-cols-2",
count === 3 && "md:grid-cols-3",
count === 4 && "md:grid-cols-4"
)}>
{children}
</div>
);
}
return (
<div className="flex flex-col gap-5 w-full">
<div ref={emblaRef} className="overflow-hidden w-full cursor-grab">
<div className="flex gap-4">
<div className="shrink-0 w-carousel-padding" />
{items.map((child, i) => (
<div key={i} className={cls("shrink-0", carouselThreshold === 2 ? "w-carousel-item-2" : carouselThreshold === 3 ? "w-carousel-item-3" : "w-carousel-item-4")}>{child}</div>
))}
<div className="shrink-0 w-carousel-padding" />
</div>
</div>
<div className="flex w-full">
<div className="shrink-0 w-carousel-padding-controls" />
<div className="flex justify-between items-center w-full">
<div className="relative h-2 w-1/2 card rounded overflow-hidden">
<div className="absolute top-0 bottom-0 -left-full w-full primary-button rounded" style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }} />
</div>
<div className="flex items-center gap-3">
<button onClick={scrollPrev} disabled={prevDisabled} type="button" aria-label="Previous" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
<ChevronLeft className="h-2/5 aspect-square text-secondary-cta-text" />
</button>
<button onClick={scrollNext} disabled={nextDisabled} type="button" aria-label="Next" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
<ChevronRight className="h-2/5 aspect-square text-secondary-cta-text" />
</button>
</div>
</div>
<div className="shrink-0 w-carousel-padding-controls" />
</div>
</div>
);
};
export default GridOrCarousel;

View File

@@ -0,0 +1,61 @@
import { useRef, useState, useEffect } from "react";
import { motion, useMotionValue, useMotionTemplate } from "motion/react";
import { cls } from "@/lib/utils";
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const randomChars = () => Array.from({ length: 1500 }, () => CHARS[Math.floor(Math.random() * 62)]).join("");
interface HoverPatternProps {
children: React.ReactNode;
className?: string;
}
const HoverPattern = ({ children, className = "" }: HoverPatternProps) => {
const ref = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const [chars, setChars] = useState(randomChars);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
if (isMobile && ref.current) {
x.set(ref.current.offsetWidth / 2);
y.set(ref.current.offsetHeight / 2);
}
}, [isMobile, x, y]);
const mask = useMotionTemplate`radial-gradient(${isMobile ? 110 : 250}px at ${x}px ${y}px, white, transparent)`;
const base = "absolute inset-0 rounded-[inherit] transition-opacity duration-300";
return (
<div
ref={ref}
className={cls("group/pattern relative", className)}
onMouseMove={isMobile ? undefined : (e) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
setChars(randomChars());
}}
>
{children}
<div className="pointer-events-none absolute inset-0 rounded-[inherit]">
<div className={cls(base, isMobile ? "opacity-25" : "opacity-0 group-hover/pattern:opacity-25")} style={{ background: "linear-gradient(var(--foreground), transparent)" }} />
<motion.div className={cls(base, "bg-linear-to-r from-accent to-accent/50 backdrop-blur-xl", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }} />
<motion.div className={cls(base, "mix-blend-overlay", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }}>
<p className="absolute inset-0 h-full whitespace-pre-wrap wrap-break-word font-mono text-xs font-bold text-foreground">{chars}</p>
</motion.div>
</div>
</div>
);
};
export default HoverPattern;

View File

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

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