Merge version_2_1780258550475 into main
Merge version_2_1780258550475 into main
This commit was merged in pull request #2.
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
import FooterSimpleCard from '@/components/sections/footer/FooterSimpleCard';
|
||||
import NavbarFloatingLogo from '@/components/ui/NavbarFloatingLogo';
|
||||
import SectionErrorBoundary from "@/components/ui/SectionErrorBoundary";
|
||||
import SiteBackgroundSlot from "@/components/ui/SiteBackgroundSlot";
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { StyleProvider } from "@/components/ui/StyleProvider";
|
||||
|
||||
export default function Layout() {
|
||||
const navItems = [
|
||||
{
|
||||
"name": "Home", "href": "#home"
|
||||
},
|
||||
{
|
||||
"name": "About", "href": "#about"
|
||||
},
|
||||
{
|
||||
"name": "Rooms", "href": "#rooms"
|
||||
},
|
||||
{
|
||||
"name": "Amenities", "href": "#amenities"
|
||||
},
|
||||
{
|
||||
"name": "Reviews", "href": "#reviews"
|
||||
},
|
||||
{
|
||||
"name": "Contact", "href": "#contact"
|
||||
},
|
||||
{
|
||||
"name": "Metrics", "href": "#metrics"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyleProvider buttonVariant="shift" siteBackground="floatingGradient" heroBackground="cornerGlow">
|
||||
<SiteBackgroundSlot />
|
||||
<SectionErrorBoundary name="navbar">
|
||||
<NavbarFloatingLogo
|
||||
logo="The Grand Hotel"
|
||||
logoImageSrc="https://storage.googleapis.com/webild/default/no-image.jpg?id=rwf84p"
|
||||
ctaButton={{
|
||||
text: "Book Now", href: "#contact"}}
|
||||
navItems={navItems} />
|
||||
</SectionErrorBoundary>
|
||||
<main className="flex-grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
<SectionErrorBoundary name="footer">
|
||||
<FooterSimpleCard
|
||||
brand="The Grand Hotel"
|
||||
columns={[
|
||||
{
|
||||
title: "Explore", items: [
|
||||
{
|
||||
label: "Rooms & Suites", href: "#rooms"},
|
||||
{
|
||||
label: "Dining", href: "#"},
|
||||
{
|
||||
label: "Spa & Wellness", href: "#"},
|
||||
{
|
||||
label: "Events", href: "#"},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About Us", items: [
|
||||
{
|
||||
label: "Our Story", href: "#about"},
|
||||
{
|
||||
label: "Guest Reviews", href: "#reviews"},
|
||||
{
|
||||
label: "Careers", href: "#"},
|
||||
{
|
||||
label: "Contact", href: "#contact"},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal", items: [
|
||||
{
|
||||
label: "Privacy Policy", href: "#"},
|
||||
{
|
||||
label: "Terms of Service", href: "#"},
|
||||
{
|
||||
label: "Cookie Policy", href: "#"},
|
||||
],
|
||||
},
|
||||
]}
|
||||
copyright="© 2024 The Grand Hotel. All rights reserved."
|
||||
links={[
|
||||
{
|
||||
label: "Instagram", href: "#"},
|
||||
{
|
||||
label: "Facebook", href: "#"},
|
||||
{
|
||||
label: "Twitter", href: "#"},
|
||||
]}
|
||||
/>
|
||||
</SectionErrorBoundary>
|
||||
</StyleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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-semibold 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/5" />
|
||||
|
||||
<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-semibold text-foreground truncate">{item.name}</h3>
|
||||
<p className="shrink-0 text-base font-semibold 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-semibold 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/5" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-foreground">Total</span>
|
||||
<span className="text-base font-semibold 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 };
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||
|
||||
type BlogItem = {
|
||||
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-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative aspect-4/3 rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 mb-1">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-6 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-foreground/75 truncate">
|
||||
{item.authorName} • {item.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2 md:mt-3">
|
||||
{item.tags.map((tag, index) => (
|
||||
<div key={index} className="px-3 py-1 text-sm primary-button text-primary-cta-text rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</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 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogTagCards;
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
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 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" 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-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded opacity-50">
|
||||
{negativeItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 secondary-button rounded">
|
||||
<X className="size-3 text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded">
|
||||
{positiveItems.map((item) => (
|
||||
<div key={item} 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">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesComparison;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
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 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||
{items.map((item) => (
|
||||
<ScrollReveal
|
||||
variant="fade"
|
||||
key={item.title}
|
||||
className="flex flex-col md:grid md:grid-cols-2 mx-auto gap-6 md:gap-20 p-6 md:p-10 card rounded group"
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-2">
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
|
||||
<div className="flex flex-col-reverse md:flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.tags.map((itemTag) => (
|
||||
<div key={itemTag} className="px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{itemTag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg md:text-xl leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square md:aspect-5/4 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedCards;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useState } from "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";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
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-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||
</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 gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 rotate-45 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5 rounded">
|
||||
{item.descriptions.map((desc, index) => (
|
||||
<p key={index} className="text-base md:text-lg leading-snug text-balance">{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 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureFlipCard key={item.title} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesFlipCards;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
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 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<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-4 left-4 xl:top-6 xl:left-6 2xl:top-8 2xl:left-8 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 text-sm rounded bg-background backface-hidden text-foreground">
|
||||
<p>{index + 1}</p>
|
||||
</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-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-2 bottom-2 xl:inset-x-3 xl:bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-0 group-hover:gap-1 xl:group-hover:gap-2 2xl:group-hover:gap-3 p-2 xl:p-3 2xl:p-4 transition-all duration-400">
|
||||
<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-snug text-white 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-snug text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCards;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoSharpProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="fade" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded-none">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded-none group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBentoSharp;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & (
|
||||
| { leftImageSrc: string; leftVideoSrc?: never }
|
||||
| { leftVideoSrc: string; leftImageSrc?: never }
|
||||
) & (
|
||||
| { rightImageSrc: string; rightVideoSrc?: never }
|
||||
| { rightVideoSrc: string; rightImageSrc?: never }
|
||||
);
|
||||
|
||||
interface FeaturesStickyCardsProps {
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const CardFrame = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
cardRef,
|
||||
className = "",
|
||||
}: {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
cardRef: (el: HTMLDivElement | null) => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div ref={cardRef} className={cls("card rounded p-1 overflow-hidden", className)}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FeaturesStickyCards = ({
|
||||
items,
|
||||
}: FeaturesStickyCardsProps) => {
|
||||
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const triggerRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const getAnimationConfig = (itemIndex: number, isLeftCard: boolean) => {
|
||||
const isOddItem = itemIndex % 2 === 1;
|
||||
if (isLeftCard) {
|
||||
return {
|
||||
from: { xPercent: -225, rotation: -45 },
|
||||
to: { rotation: isOddItem ? 10 : -10 },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: { xPercent: 225, rotation: 45 },
|
||||
to: { rotation: isOddItem ? -10 : 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const animateCards = (isMobile: boolean) => {
|
||||
items.forEach((_, itemIndex) => {
|
||||
[0, 1].forEach((cardIndex) => {
|
||||
const refIndex = itemIndex * 2 + cardIndex;
|
||||
const element = isMobile
|
||||
? mobileImageRefs.current[refIndex]
|
||||
: imageRefs.current[refIndex];
|
||||
|
||||
if (element) {
|
||||
const isLeftCard = cardIndex === 0;
|
||||
|
||||
const fromConfig = isMobile
|
||||
? {
|
||||
xPercent: isLeftCard ? -150 : 150,
|
||||
rotation: isLeftCard ? -25 : 25,
|
||||
}
|
||||
: getAnimationConfig(itemIndex, isLeftCard).from;
|
||||
|
||||
const toConfig = isMobile
|
||||
? {
|
||||
xPercent: 0,
|
||||
rotation: 0,
|
||||
duration: 1,
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: "top 90%",
|
||||
end: "top 50%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
xPercent: 0,
|
||||
rotation: getAnimationConfig(itemIndex, isLeftCard).to.rotation,
|
||||
scrollTrigger: {
|
||||
trigger: triggerRefs.current[itemIndex],
|
||||
start: "top bottom",
|
||||
end: "top top",
|
||||
scrub: 1,
|
||||
},
|
||||
};
|
||||
|
||||
gsap.fromTo(element, fromConfig, toConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mm.add("(max-width: 767px)", () => animateCards(true));
|
||||
mm.add("(min-width: 768px)", () => animateCards(false));
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
imageRefs.current = [];
|
||||
mobileImageRefs.current = [];
|
||||
triggerRefs.current = [];
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const sectionHeightStyle = { height: `${items.length * 100}vh` };
|
||||
|
||||
return (
|
||||
<section aria-label="Features sticky cards section" className="py-20 overflow-hidden md:overflow-visible">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="hidden md:flex relative" style={sectionHeightStyle}>
|
||||
<div
|
||||
className="absolute top-0 left-0 flex flex-col w-6/10 mx-auto right-0 z-10"
|
||||
style={sectionHeightStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { triggerRefs.current[index] = el; }}
|
||||
className="w-full mx-auto h-screen flex justify-center items-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-5xl md:text-6xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="md:max-w-6/10 text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{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" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="h-screen w-full absolute top-0 left-0">
|
||||
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-29/100 h-[70vh]"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-28/100 h-[70vh]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col gap-20 w-content-width mx-auto">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{itemIndex + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{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" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 justify-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesStickyCards;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const 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-semibold">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrand;
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBrandReveal = ({
|
||||
brand,
|
||||
columns,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
}) => {
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (footerRef.current) {
|
||||
setFooterHeight(footerRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (footerRef.current) {
|
||||
resizeObserver.observe(footerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-0 w-full mt-20"
|
||||
style={{
|
||||
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fixed bottom-0 w-full"
|
||||
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||
>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrandReveal;
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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-semibold">{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 truncate">{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;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
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="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 md:gap-12 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"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug 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"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardBrand;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardCarouselProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCarousel;
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroCenteredLogosProps = {
|
||||
avatarsSrc: string[];
|
||||
avatarText: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
logos: string[];
|
||||
hideMedia?: boolean;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroCenteredLogos = ({
|
||||
avatarsSrc,
|
||||
avatarText,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
logos,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
hideMedia = false,
|
||||
}: HeroCenteredLogosProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative h-svh flex flex-col mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
{!hideMedia && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover" />
|
||||
<div className="absolute inset-0 bg-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 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">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarText} size="lg" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 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 card rounded">
|
||||
<span className="text-xl font-semibold whitespace-nowrap text-foreground/75">{logo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroCenteredLogos;
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface HeroWorkScrollStackProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
description: string;
|
||||
descriptionMuted: string;
|
||||
primaryButton: { text: string; href: string; avatarSrc: string; avatarLabel: string };
|
||||
sectionTag: string;
|
||||
sectionTitle: string;
|
||||
sectionDescription: string;
|
||||
items: [
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string }
|
||||
];
|
||||
secondaryButton?: { text: string; href: string };
|
||||
heroAnimationDelay?: number;
|
||||
}
|
||||
|
||||
const HeroWorkScrollStack = ({
|
||||
tag,
|
||||
title,
|
||||
titleHighlight,
|
||||
description,
|
||||
descriptionMuted,
|
||||
primaryButton,
|
||||
sectionTag,
|
||||
sectionTitle,
|
||||
sectionDescription,
|
||||
items,
|
||||
secondaryButton,
|
||||
heroAnimationDelay,
|
||||
}: HeroWorkScrollStackProps) => {
|
||||
const animationRef = useRef<HTMLDivElement>(null);
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const card1Ref = useRef<HTMLDivElement>(null);
|
||||
const card2Ref = useRef<HTMLDivElement>(null);
|
||||
const card3Ref = useRef<HTMLDivElement>(null);
|
||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
||||
const handleSecondaryClick = useButtonClick(secondaryButton?.href || "#");
|
||||
|
||||
useEffect(() => {
|
||||
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const cardRefs = [card1Ref.current, card2Ref.current, card3Ref.current];
|
||||
const placeholder = placeholderRef.current;
|
||||
if (!placeholder) return;
|
||||
|
||||
const placeholderRect = placeholder.getBoundingClientRect();
|
||||
const placeholderCenterY = placeholderRect.top + placeholderRect.height / 2;
|
||||
|
||||
if (isDesktop) {
|
||||
// DESKTOP: Scrub animation tied to scroll position
|
||||
const xOffsets = ["32rem", "14.5rem", "-1.8rem"];
|
||||
const yAdjustments = [0, -48, 0];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [1.35, 1.3, 1.25];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: animationRef.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
tl.to(card, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.4, ease: "none" }, 0);
|
||||
tl.to(card, { zIndex: 1, duration: 0.1, ease: "none" }, 0.3);
|
||||
});
|
||||
} else {
|
||||
// MOBILE: Toggle animation - play/reverse on scroll
|
||||
const xOffsets = ["2.5rem", "0.5rem", "-1rem"];
|
||||
const yAdjustments = [-10, -30, 10];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [0.65, 0.7, 0.75];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
gsap.to(card, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
duration: 1.2,
|
||||
ease: "power2.inOut",
|
||||
scrollTrigger: {
|
||||
trigger: placeholder,
|
||||
start: "top 35%",
|
||||
toggleActions: "play none none reverse",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}, animationRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={animationRef}>
|
||||
<div id="hero" data-section="hero">
|
||||
<section aria-label="Hero section" className="relative h-fit md:h-svh pt-30 pb-20 md:py-0 flex items-center overflow-hidden md:overflow-visible">
|
||||
<HeroBackgroundSlot />
|
||||
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-full">
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 1.8, ease: [0.16, 1, 0.3, 1], delay: heroAnimationDelay ?? 0 }}
|
||||
className="w-full md:w-[46%] flex flex-col items-center md:items-start gap-3"
|
||||
>
|
||||
<div className="card backdrop-blur flex items-center gap-2 px-3 py-1 rounded">
|
||||
<span className="size-2 rounded-full bg-green-500 animate-pulsate [--accent:#22c55e]" />
|
||||
<p className="text-sm leading-snug font-medium text-foreground">{tag}</p>
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-7xl 2xl:text-8xl font-medium leading-[1.05] tracking-tight text-center md:text-left">
|
||||
<span className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent">
|
||||
{title}{" "}
|
||||
<span className="font-bold">{titleHighlight}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-base md:text-lg font-medium leading-snug text-center md:text-left max-w-[95%]">
|
||||
{description}{" "}
|
||||
<span className="text-foreground/50">{descriptionMuted}</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={primaryButton.href}
|
||||
onClick={handlePrimaryClick}
|
||||
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||
<img
|
||||
src={primaryButton.avatarSrc}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<span className="text-primary-cta-text text-sm font-medium mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
||||
+
|
||||
</span>
|
||||
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
||||
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
||||
<span className="text-foreground text-xs font-bold">{primaryButton.avatarLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-medium whitespace-nowrap">{primaryButton.text}</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<div ref={placeholderRef} className="w-full md:w-[54%] relative h-80 md:h-96">
|
||||
<div className="absolute inset-0 card rounded-2xl md:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="work" data-section="work">
|
||||
<section aria-label="Work section" className="py-20 md:pt-0">
|
||||
<div className="flex flex-col gap-8 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{sectionTag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={sectionTitle}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={sectionDescription}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-5">
|
||||
{items.map((item, index) => {
|
||||
const cardRef = index === 0 ? card1Ref : index === 1 ? card2Ref : card3Ref;
|
||||
return (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5">
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="aspect-4/3 rounded-2xl shadow-2xl relative card p-2 xl:p-3 2xl:p-4"
|
||||
>
|
||||
<div className="w-full h-full rounded-xl overflow-hidden relative">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="w-full h-full object-cover" />
|
||||
<span className="absolute bottom-2 left-2 xl:bottom-3 xl:left-3 2xl:bottom-4 2xl:left-4 px-3 py-1.5 text-xs font-medium text-primary-cta-text rounded-full backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20">
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg md:text-xl lg:text-2xl leading-snug">
|
||||
<span className="font-semibold text-foreground">{item.title}. </span>
|
||||
<span className="text-foreground/50">{item.description}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{secondaryButton && (
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={secondaryButton.href}
|
||||
onClick={handleSecondaryClick}
|
||||
className="group flex items-center gap-2 px-6 py-3 text-base font-medium rounded-full secondary-button text-secondary-cta-text transition-all duration-300"
|
||||
>
|
||||
<span>{secondaryButton.text}</span>
|
||||
<ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroWorkScrollStack;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type 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="pt-20 pb-10">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" className="flex flex-col w-content-width mx-auto">
|
||||
<div className="overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal mb-5" style={{ animationDuration: "30s" }}>
|
||||
{[...topRow, ...topRow, ...topRow, ...topRow].map((testimonial, index) => (
|
||||
<div key={`top-${index}`} className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 shrink-0 w-72 md:w-80 mr-5 p-6 xl:p-7 2xl:p-8 rounded card">
|
||||
<p className="text-lg leading-snug line-clamp-3">{testimonial.quote}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{testimonial.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal-reverse mb-10" style={{ animationDuration: "30s" }}>
|
||||
{[...bottomRow, ...bottomRow, ...bottomRow, ...bottomRow].map((testimonial, index) => (
|
||||
<div key={`bottom-${index}`} className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 shrink-0 w-72 md:w-80 mr-5 p-6 xl:p-7 2xl:p-8 rounded card">
|
||||
<p className="text-lg leading-snug line-clamp-3">{testimonial.quote}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{testimonial.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialMarqueeCards;
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Star } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import 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 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{testimonials.map((testimonial) => (
|
||||
<div key={testimonial.name} className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 h-full p-6 xl:p-7 2xl:p-8 rounded card">
|
||||
<div className="flex flex-col items-start gap-4 xl:gap-5 2xl:gap-6">
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug">{testimonial.quote}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{testimonial.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialRatingCards;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Star } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
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">
|
||||
<ScrollReveal variant="fade-blur" className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-6 text-accent", index < rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
|
||||
<TextAnimation
|
||||
text={quote}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-5xl 2xl:text-6xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.1} className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance">
|
||||
<p>{author}</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="flex items-center justify-center mt-1">
|
||||
{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-semibold">+{remainingCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialTrustCard;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Card = ({ children, className = "" }: CardProps) => (
|
||||
<div className={cls("p-6 xl:p-7 2xl:p-8 card rounded", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useStyle } from "@/components/ui/useStyle";
|
||||
import CornerGlowBackground from "@/components/ui/CornerGlowBackground";
|
||||
import GradientBarsBackground from "@/components/ui/GradientBarsBackground";
|
||||
import HorizonGlowBackground from "@/components/ui/HorizonGlowBackground";
|
||||
import LightRaysCenterBackground from "@/components/ui/LightRaysCenterBackground";
|
||||
import LightRaysCornerBackground from "@/components/ui/LightRaysCornerBackground";
|
||||
import RadialGradientBackground from "@/components/ui/RadialGradientBackground";
|
||||
|
||||
const HeroBackgroundSlot = () => {
|
||||
const { heroBackground } = useStyle();
|
||||
|
||||
switch (heroBackground) {
|
||||
case "cornerGlow":
|
||||
return <CornerGlowBackground position="absolute" />;
|
||||
case "gradientBars":
|
||||
return <GradientBarsBackground position="absolute" />;
|
||||
case "horizonGlow":
|
||||
return <HorizonGlowBackground position="absolute" />;
|
||||
case "lightRaysCenter":
|
||||
return <LightRaysCenterBackground position="absolute" />;
|
||||
case "lightRaysCorner":
|
||||
return <LightRaysCornerBackground position="absolute" />;
|
||||
case "radialGradient":
|
||||
return <RadialGradientBackground position="absolute" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default HeroBackgroundSlot;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
const OrbitingIcons = ({ centerIcon, items }: { centerIcon: string | LucideIcon; items: (string | LucideIcon)[] }) => {
|
||||
const CenterIcon = resolveIcon(centerIcon);
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-center h-full overflow-hidden"
|
||||
style={{ perspective: "2000px", maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, transparent, black 10%, black 90%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full" style={{ transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)" }}>
|
||||
<div className="absolute size-60 opacity-85 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute size-80 opacity-75 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute size-100 opacity-65 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute flex items-center justify-center size-40 border border-background-accent shadow rounded-full">
|
||||
<div className="flex items-center justify-center size-20 primary-button rounded-full">
|
||||
<CenterIcon className="size-10 text-primary-cta-text" strokeWidth={1.25} />
|
||||
</div>
|
||||
{items.map((iconInput, i) => {
|
||||
const Icon = resolveIcon(iconInput);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute flex items-center justify-center size-10 rounded shadow card -ml-5 -mt-5"
|
||||
style={{ top: "50%", left: "50%", animation: "orbit 12s linear infinite", "--initial-position": `${(360 / items.length) * i}deg`, "--translate-position": "160px" } as React.CSSProperties}
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrbitingIcons;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useStyle } from "@/components/ui/useStyle";
|
||||
import AuroraBackground from "@/components/ui/AuroraBackground";
|
||||
import CornerGlowBackground from "@/components/ui/CornerGlowBackground";
|
||||
import FloatingGradientBackground from "@/components/ui/FloatingGradientBackground";
|
||||
import GridLinesBackground from "@/components/ui/GridLinesBackground";
|
||||
import NoiseBackground from "@/components/ui/NoiseBackground";
|
||||
import NoiseGradientBackground from "@/components/ui/NoiseGradientBackground";
|
||||
|
||||
const SiteBackgroundSlot = () => {
|
||||
const { siteBackground } = useStyle();
|
||||
|
||||
switch (siteBackground) {
|
||||
case "aurora":
|
||||
return <AuroraBackground position="fixed" />;
|
||||
case "cornerGlow":
|
||||
return <CornerGlowBackground position="fixed" />;
|
||||
case "floatingGradient":
|
||||
return <FloatingGradientBackground position="fixed" />;
|
||||
case "gridLines":
|
||||
return <GridLinesBackground position="fixed" />;
|
||||
case "noise":
|
||||
return <NoiseBackground position="fixed" />;
|
||||
case "noiseGradient":
|
||||
return <NoiseGradientBackground position="fixed" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SiteBackgroundSlot;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchProducts, type Product } from "@/lib/api/product";
|
||||
|
||||
const useProducts = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const data = await fetchProducts();
|
||||
if (isMounted) {
|
||||
setProducts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { products, isLoading, error };
|
||||
};
|
||||
|
||||
export default useProducts;
|
||||
export type { Product };
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function HomePage() {
|
||||
avatarsSrc={[
|
||||
"http://img.b2bpic.net/free-photo/hotel-concierge-serving-coffee-client_482257-91383.jpg", "http://img.b2bpic.net/free-photo/smiling-woman-sitting-elegant-restaurant-with-tablet-talking-phone_1157-2100.jpg", "http://img.b2bpic.net/free-photo/smiling-tender-parisian-girl-stylish-outfit-sends-air-kiss-portrait-young-woman-with-expressive-look_197531-12004.jpg", "http://img.b2bpic.net/free-photo/front-view-valet-suit-working_23-2150274536.jpg"]}
|
||||
avatarText="Our dedicated team of hosts"
|
||||
title="Experience Unrivaled Luxury at The Grand Hotel"
|
||||
title="Unrivaled Luxury at The Grand Hotel"
|
||||
description="Indulge in sophisticated elegance, impeccable service, and breathtaking views. Your unforgettable escape begins here."
|
||||
primaryButton={{
|
||||
text: "Book Your Stay", href: "#contact"}}
|
||||
|
||||
Reference in New Issue
Block a user