Merge version_2_1781088040889 into main #1
@@ -1,137 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import Transition from "@/components/ui/Transition";
|
|
||||||
|
|
||||||
type ProductVariant = {
|
|
||||||
label: string;
|
|
||||||
options: string[];
|
|
||||||
selected: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProductDetailCardProps = {
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
salePrice?: string;
|
|
||||||
images: string[];
|
|
||||||
description?: string;
|
|
||||||
rating?: number;
|
|
||||||
ribbon?: string;
|
|
||||||
inventoryStatus?: "in-stock" | "out-of-stock";
|
|
||||||
inventoryQuantity?: number;
|
|
||||||
sku?: string;
|
|
||||||
variants?: ProductVariant[];
|
|
||||||
quantity?: ProductVariant;
|
|
||||||
onAddToCart?: () => void;
|
|
||||||
onBuyNow?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
|
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mx-auto py-20 w-content-width">
|
|
||||||
<div className="flex flex-col gap-5 md:flex-row">
|
|
||||||
<div className="relative md:w-1/2">
|
|
||||||
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
|
|
||||||
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
|
|
||||||
</Transition>
|
|
||||||
{images.length > 1 && (
|
|
||||||
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
|
|
||||||
{images.map((src, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setSelectedImage(i)}
|
|
||||||
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
|
|
||||||
>
|
|
||||||
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
|
|
||||||
<div className={cls(
|
|
||||||
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
|
|
||||||
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
|
|
||||||
<div className="flex items-start justify-between gap-5">
|
|
||||||
<h2 className="flex-1 text-2xl font-semibold text-foreground md:text-3xl">{name}</h2>
|
|
||||||
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-semibold rounded text-secondary-cta-text">{ribbon}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="h-px w-full bg-foreground/5" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xl font-semibold text-foreground md:text-2xl">
|
|
||||||
{salePrice ? (
|
|
||||||
<>
|
|
||||||
<span className="text-foreground/75 line-through mr-1">{price}</span>
|
|
||||||
<span>{salePrice}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
price
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(inventoryStatus || inventoryQuantity || sku) && (
|
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
|
||||||
{inventoryStatus && (
|
|
||||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
|
|
||||||
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inventoryQuantity && (
|
|
||||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
|
|
||||||
)}
|
|
||||||
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
|
|
||||||
{variants && variants.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-5">
|
|
||||||
{variants.map((variant) => (
|
|
||||||
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
|
|
||||||
<label className="text-sm font-semibold text-foreground">{variant.label}</label>
|
|
||||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
|
||||||
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
|
||||||
{variant.options.map((option) => (
|
|
||||||
<option key={option} value={option}>{option}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{quantity && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="text-sm font-semibold text-foreground">{quantity.label}</label>
|
|
||||||
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
|
|
||||||
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
|
||||||
{quantity.options.map((option) => (
|
|
||||||
<option key={option} value={option}>{option}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col mt-auto gap-3 pt-5">
|
|
||||||
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
|
|
||||||
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductDetailCard;
|
|
||||||
export type { ProductVariant };
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import { resolveIcon } from "@/utils/resolve-icon";
|
|
||||||
|
|
||||||
type AboutFeaturesSplitProps = {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
items: { icon: string | LucideIcon; title: string; description: string }[];
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
const AboutFeaturesSplit = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
items,
|
|
||||||
imageSrc,
|
|
||||||
videoSrc,
|
|
||||||
}: AboutFeaturesSplitProps) => {
|
|
||||||
return (
|
|
||||||
<section aria-label="About section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
|
|
||||||
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const ItemIcon = resolveIcon(item.icon);
|
|
||||||
return (
|
|
||||||
<div key={item.title}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
|
|
||||||
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-semibold">{item.title}</h3>
|
|
||||||
<p className="text-base leading-snug">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
{index < items.length - 1 && (
|
|
||||||
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
|
||||||
<div className="relative size-full">
|
|
||||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AboutFeaturesSplit;
|
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
|
||||||
import useBlogPosts from "@/hooks/useBlogPosts";
|
|
||||||
|
|
||||||
type BlogItem = {
|
|
||||||
category: string;
|
|
||||||
title: string;
|
|
||||||
excerpt: string;
|
|
||||||
authorName: string;
|
|
||||||
authorImageSrc: string;
|
|
||||||
date: string;
|
|
||||||
imageSrc: string;
|
|
||||||
href?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
|
||||||
const handleClick = useButtonClick(item.href, item.onClick);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-4/3 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
|
||||||
<ImageOrVideo
|
|
||||||
imageSrc={item.imageSrc}
|
|
||||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
|
||||||
<button
|
|
||||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm primary-button text-primary-cta-text rounded w-fit">
|
|
||||||
<p>{item.category}</p>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
|
||||||
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
|
||||||
<ImageOrVideo
|
|
||||||
imageSrc={item.authorImageSrc}
|
|
||||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col min-w-0">
|
|
||||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
|
|
||||||
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type BlogSimpleCardsProps = {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
items?: BlogItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const BlogSimpleCards = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
items: itemsProp,
|
|
||||||
}: BlogSimpleCardsProps) => {
|
|
||||||
const { posts, isLoading } = useBlogPosts();
|
|
||||||
const isFromApi = posts.length > 0;
|
|
||||||
const items = isFromApi
|
|
||||||
? posts.map((p) => ({
|
|
||||||
category: p.category,
|
|
||||||
title: p.title,
|
|
||||||
excerpt: p.excerpt,
|
|
||||||
authorName: p.authorName,
|
|
||||||
authorImageSrc: p.authorAvatar,
|
|
||||||
date: p.date,
|
|
||||||
imageSrc: p.imageSrc,
|
|
||||||
onClick: p.onBlogClick,
|
|
||||||
}))
|
|
||||||
: itemsProp;
|
|
||||||
|
|
||||||
if (isLoading && !itemsProp) {
|
|
||||||
return (
|
|
||||||
<section aria-label="Blog section" className="py-20">
|
|
||||||
<div className="w-content-width mx-auto flex justify-center">
|
|
||||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Blog section" className="py-20">
|
|
||||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="fade-blur">
|
|
||||||
<GridOrCarousel>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<BlogCardItem key={index} item={item} />
|
|
||||||
))}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlogSimpleCards;
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
|
||||||
import TiltedStackCards from "@/components/ui/TiltedStackCards";
|
|
||||||
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
|
||||||
import OrbitingIcons from "@/components/ui/OrbitingIcons";
|
|
||||||
import IconTextMarquee from "@/components/ui/IconTextMarquee";
|
|
||||||
import ChatMarquee from "@/components/ui/ChatMarquee";
|
|
||||||
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
|
||||||
import MediaStack from "@/components/ui/MediaStack";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
type IconInput = string | LucideIcon;
|
|
||||||
|
|
||||||
type FeatureCard = { title: string; description: string } & (
|
|
||||||
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
|
||||||
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
|
|
||||||
| { bentoComponent: "animated-bar-chart" }
|
|
||||||
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
|
|
||||||
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
|
|
||||||
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
|
|
||||||
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
|
||||||
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const getBentoComponent = (feature: FeatureCard) => {
|
|
||||||
switch (feature.bentoComponent) {
|
|
||||||
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
|
|
||||||
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
|
|
||||||
case "animated-bar-chart": return <AnimatedBarChart />;
|
|
||||||
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
|
|
||||||
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
|
|
||||||
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
|
|
||||||
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
|
|
||||||
case "media-stack": return <MediaStack items={feature.mediaItems} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const FeaturesBento = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
features,
|
|
||||||
}: {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
features: FeatureCard[];
|
|
||||||
}) => (
|
|
||||||
<section aria-label="Features bento section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ScrollReveal variant="fade-blur">
|
|
||||||
<GridOrCarousel>
|
|
||||||
{features.map((feature) => (
|
|
||||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
|
|
||||||
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
|
|
||||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
|
||||||
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
|
||||||
<p className="text-base leading-snug">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default FeaturesBento;
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
|
|
||||||
type FeatureItem = {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
interface FeaturesBentoGridCtaProps {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
|
||||||
ctaButton?: {
|
|
||||||
text: string;
|
|
||||||
href: string;
|
|
||||||
avatarSrc?: string;
|
|
||||||
avatarLabel?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const FeaturesBentoGridCta = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
features,
|
|
||||||
ctaButton,
|
|
||||||
}: FeaturesBentoGridCtaProps) => {
|
|
||||||
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Features section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ctaButton && (
|
|
||||||
<ScrollReveal variant="fade" delay={0.2}>
|
|
||||||
<a
|
|
||||||
href={ctaButton.href}
|
|
||||||
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
|
||||||
>
|
|
||||||
{ctaButton.avatarSrc && (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
|
||||||
<img
|
|
||||||
src={ctaButton.avatarSrc}
|
|
||||||
className="w-9 h-9 rounded-full object-cover"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
|
||||||
<div className="overflow-hidden flex items-center">
|
|
||||||
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
|
||||||
+
|
|
||||||
</span>
|
|
||||||
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
|
||||||
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
|
|
||||||
</a>
|
|
||||||
</ScrollReveal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="fade" className="w-content-width mx-auto">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
|
||||||
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
|
||||||
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
|
||||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
|
||||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeaturesBentoGridCta;
|
|
||||||
|
|||||||
@@ -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,180 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useScroll, useTransform, motion } from "motion/react";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
|
|
||||||
type FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
|
|
||||||
|
|
||||||
type HeroBillboardFloatingCardsProps = {
|
|
||||||
avatarsSrc: string[];
|
|
||||||
avatarsLabel: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton: { text: string; href: string };
|
|
||||||
secondaryButton: { text: string; href: string };
|
|
||||||
note?: string;
|
|
||||||
floatingCardsSrc: [string, string, string, string];
|
|
||||||
logosSrc?: string[];
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
|
|
||||||
|
|
||||||
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
|
|
||||||
position: string;
|
|
||||||
rotation: string;
|
|
||||||
size: string;
|
|
||||||
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
|
|
||||||
}> = {
|
|
||||||
"top-left": {
|
|
||||||
position: "top-8 left-0",
|
|
||||||
rotation: "-rotate-8",
|
|
||||||
size: "size-20 xl:size-22 2xl:size-24",
|
|
||||||
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
|
|
||||||
},
|
|
||||||
"top-right": {
|
|
||||||
position: "top-4 right-4",
|
|
||||||
rotation: "rotate-10",
|
|
||||||
size: "size-18 xl:size-20 2xl:size-22",
|
|
||||||
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
|
|
||||||
},
|
|
||||||
"middle-left": {
|
|
||||||
position: "top-1/2 left-2",
|
|
||||||
rotation: "rotate-6",
|
|
||||||
size: "size-18 xl:size-20 2xl:size-22",
|
|
||||||
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
|
|
||||||
},
|
|
||||||
"middle-right": {
|
|
||||||
position: "top-1/2 right-0",
|
|
||||||
rotation: "-rotate-6",
|
|
||||||
size: "size-20 xl:size-22 2xl:size-24",
|
|
||||||
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeroBillboardFloatingCards = ({
|
|
||||||
avatarsSrc,
|
|
||||||
avatarsLabel,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
note,
|
|
||||||
floatingCardsSrc,
|
|
||||||
logosSrc,
|
|
||||||
imageSrc,
|
|
||||||
videoSrc,
|
|
||||||
}: HeroBillboardFloatingCardsProps) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { scrollYProgress } = useScroll({ target: containerRef });
|
|
||||||
|
|
||||||
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
|
||||||
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Hero section" className="relative">
|
|
||||||
<HeroBackgroundSlot />
|
|
||||||
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
|
|
||||||
<div className="relative w-content-width mx-auto">
|
|
||||||
{POSITIONS.map((position, index) => {
|
|
||||||
const config = FLOATING_CARD_CONFIG[position];
|
|
||||||
const src = floatingCardsSrc[index];
|
|
||||||
if (!src) return null;
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className={cls("absolute z-10 hidden md:block", config.position)}
|
|
||||||
animate={{ y: [0, config.animation.yOffset, 0] }}
|
|
||||||
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
|
|
||||||
>
|
|
||||||
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
|
|
||||||
<div className="p-0.5 pr-3 mb-1 card rounded-full">
|
|
||||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h1"
|
|
||||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="text-lg md:text-xl leading-snug text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
|
||||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{note && (
|
|
||||||
<motion.div
|
|
||||||
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
|
||||||
<Check className="size-1/2 text-primary-cta-text" />
|
|
||||||
</div>
|
|
||||||
{note}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
|
||||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
style={{ rotateX: rotate, scale }}
|
|
||||||
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
|
||||||
>
|
|
||||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{logosSrc && logosSrc.length > 0 && (
|
|
||||||
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
|
|
||||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
|
|
||||||
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
|
|
||||||
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
|
|
||||||
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroBillboardFloatingCards;
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
|
|
||||||
type Testimonial = {
|
|
||||||
name: string;
|
|
||||||
handle: string;
|
|
||||||
text: string;
|
|
||||||
rating: number;
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
type HeroSplitTestimonialProps = {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton: { text: string; href: string };
|
|
||||||
secondaryButton: { text: string; href: string };
|
|
||||||
testimonials: Testimonial[];
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
const INTERVAL = 5000;
|
|
||||||
|
|
||||||
const HeroSplitTestimonial = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
imageSrc,
|
|
||||||
videoSrc,
|
|
||||||
testimonials,
|
|
||||||
}: HeroSplitTestimonialProps) => {
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (testimonials.length <= 1) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
|
||||||
}, INTERVAL);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [currentIndex, testimonials.length]);
|
|
||||||
|
|
||||||
const testimonial = testimonials[currentIndex];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
|
||||||
<HeroBackgroundSlot />
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
|
||||||
<div className="w-full md:w-1/2">
|
|
||||||
<div className="flex flex-col items-center md:items-start gap-3">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h1"
|
|
||||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
|
||||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="fade-blur" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
|
||||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={currentIndex}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="absolute bottom-4 left-4 right-4 xl:bottom-6 xl:right-6 2xl:bottom-8 2xl:right-8 md:left-auto md:max-w-5/10 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
|
||||||
>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
|
||||||
<Star
|
|
||||||
key={index}
|
|
||||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ImageOrVideo
|
|
||||||
imageSrc={testimonial.imageSrc}
|
|
||||||
videoSrc={testimonial.videoSrc}
|
|
||||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
|
||||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroSplitTestimonial;
|
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import AutoFillText from "@/components/ui/AutoFillText";
|
|
||||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
|
||||||
|
|
||||||
const StaggerText = ({ text }: { text: string }) => (
|
|
||||||
<span className="truncate overflow-hidden">
|
|
||||||
{[...text].map((char, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
|
|
||||||
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
type HeroVideoExpandProps = {
|
|
||||||
title: string;
|
|
||||||
primaryButton: { text: string; href: string };
|
|
||||||
secondaryButton: { text: string; href: string };
|
|
||||||
onComplete?: () => void;
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
const HeroVideoExpand = ({
|
|
||||||
title,
|
|
||||||
videoSrc,
|
|
||||||
imageSrc,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
onComplete,
|
|
||||||
}: HeroVideoExpandProps) => {
|
|
||||||
const [showLoader, setShowLoader] = useState(true);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
|
||||||
const handleSecondaryClick = useButtonClick(secondaryButton.href);
|
|
||||||
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
const { scrollYProgress } = useScroll({
|
|
||||||
target: sectionRef,
|
|
||||||
offset: ["start start", "end start"],
|
|
||||||
});
|
|
||||||
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
|
|
||||||
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const expandTimer = setTimeout(() => setExpanded(true), 600);
|
|
||||||
const hideTimer = setTimeout(() => {
|
|
||||||
setShowLoader(false);
|
|
||||||
onComplete?.();
|
|
||||||
}, 1500);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(expandTimer);
|
|
||||||
clearTimeout(hideTimer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AnimatePresence>
|
|
||||||
{showLoader && (
|
|
||||||
<motion.div
|
|
||||||
key="loader"
|
|
||||||
className="fixed inset-0 z-100 bg-background"
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
|
|
||||||
animate={
|
|
||||||
expanded
|
|
||||||
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
|
|
||||||
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
|
|
||||||
}
|
|
||||||
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
|
|
||||||
>
|
|
||||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
|
|
||||||
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
|
|
||||||
<ImageOrVideo
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
videoSrc={videoSrc}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
|
|
||||||
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
|
|
||||||
className="w-1/2 md:w-auto"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={primaryButton.href}
|
|
||||||
onClick={handlePrimaryClick}
|
|
||||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
|
|
||||||
>
|
|
||||||
<StaggerText text={primaryButton.text} />
|
|
||||||
</a>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
|
|
||||||
className="w-1/2 md:w-auto"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={secondaryButton.href}
|
|
||||||
onClick={handleSecondaryClick}
|
|
||||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
|
|
||||||
>
|
|
||||||
<StaggerText text={secondaryButton.text} />
|
|
||||||
</a>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroVideoExpand;
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
import { resolveIcon } from "@/utils/resolve-icon";
|
|
||||||
|
|
||||||
type Metric = {
|
|
||||||
value: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: string | LucideIcon;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MetricsGradientCards = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
metrics,
|
|
||||||
}: {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
metrics: Metric[];
|
|
||||||
}) => (
|
|
||||||
<section aria-label="Metrics section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="slide-up"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="slide-up">
|
|
||||||
<GridOrCarousel>
|
|
||||||
{metrics.map((metric) => {
|
|
||||||
const IconComponent = resolveIcon(metric.icon);
|
|
||||||
return (
|
|
||||||
<div key={metric.value} className="relative flex flex-col items-center justify-center gap-0 p-6 xl:p-7 2xl:p-8 min-h-70 xl:min-h-80 2xl:min-h-90 h-full card rounded min-w-0">
|
|
||||||
<span
|
|
||||||
className="max-w-full text-9xl font-semibold leading-none text-center truncate scale-150 md:scale-100"
|
|
||||||
style={{
|
|
||||||
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 80%)",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
backgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{metric.value}
|
|
||||||
</span>
|
|
||||||
<span className="max-w-full mt-[-0.25em] md:mt-[-0.75em] text-4xl font-semibold text-center text-balance">{metric.title}</span>
|
|
||||||
<p className="max-w-9/10 mt-2 text-base leading-snug text-center text-balance">{metric.description}</p>
|
|
||||||
<div className="absolute bottom-6 left-6 xl:bottom-7 xl:left-7 2xl:bottom-8 2xl:left-8 flex items-center justify-center size-10 primary-button rounded">
|
|
||||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default MetricsGradientCards;
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
|
|
||||||
type Metric = {
|
|
||||||
value: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MetricsSimpleCards = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
metrics,
|
|
||||||
}: {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
metrics: Metric[];
|
|
||||||
}) => (
|
|
||||||
<section aria-label="Metrics section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade-blur"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade-blur"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="slide-up">
|
|
||||||
<GridOrCarousel>
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<div key={metric.value} className="flex flex-col justify-between gap-6 p-6 md:p-10 min-h-60 md:min-h-70 2xl:min-h-80 h-full card rounded">
|
|
||||||
<span className="text-9xl md:text-8xl font-semibold leading-none truncate">{metric.value}</span>
|
|
||||||
<p className="text-base leading-snug text-balance">{metric.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default MetricsSimpleCards;
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
import useProducts from "@/hooks/useProducts";
|
|
||||||
|
|
||||||
type ProductMediaCardsProps = {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
products?: {
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
imageSrc: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProductMediaCards = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
products: productsProp,
|
|
||||||
}: ProductMediaCardsProps) => {
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
|
||||||
const products = isFromApi
|
|
||||||
? fetchedProducts.map((p) => ({
|
|
||||||
name: p.name,
|
|
||||||
price: p.price,
|
|
||||||
imageSrc: p.imageSrc,
|
|
||||||
onClick: p.onProductClick,
|
|
||||||
}))
|
|
||||||
: productsProp;
|
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
|
||||||
return (
|
|
||||||
<section aria-label="Products section" className="py-20">
|
|
||||||
<div className="w-content-width mx-auto flex justify-center">
|
|
||||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="Products section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="slide-up">
|
|
||||||
<GridOrCarousel>
|
|
||||||
{products.map((product) => (
|
|
||||||
<button
|
|
||||||
key={product.name}
|
|
||||||
onClick={product.onClick}
|
|
||||||
className="group h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 text-left card rounded cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="aspect-square rounded overflow-hidden">
|
|
||||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 p-3 xl:p-3.5 2xl:p-4">
|
|
||||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
|
||||||
<h3 className="text-2xl font-semibold truncate">{product.name}</h3>
|
|
||||||
<p className="text-base font-medium">{product.price}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center size-9 shrink-0 rounded primary-button">
|
|
||||||
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductMediaCards;
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import Button from "@/components/ui/Button";
|
|
||||||
import TextAnimation from "@/components/ui/TextAnimation";
|
|
||||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
|
||||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
|
||||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
|
||||||
|
|
||||||
type TeamMember = {
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
|
||||||
|
|
||||||
const TeamGlassCards = ({
|
|
||||||
tag,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
members,
|
|
||||||
}: {
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButton?: { text: string; href: string };
|
|
||||||
secondaryButton?: { text: string; href: string };
|
|
||||||
members: TeamMember[];
|
|
||||||
}) => (
|
|
||||||
<section aria-label="Team section" className="py-20">
|
|
||||||
<div className="flex flex-col gap-8 md:gap-10">
|
|
||||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
|
||||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
|
||||||
<p>{tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={title}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={true}
|
|
||||||
tag="h2"
|
|
||||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
text={description}
|
|
||||||
variant="fade"
|
|
||||||
gradientText={false}
|
|
||||||
tag="p"
|
|
||||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(primaryButton || secondaryButton) && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
|
||||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
|
||||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollReveal variant="slide-up">
|
|
||||||
<GridOrCarousel >
|
|
||||||
{members.map((member) => (
|
|
||||||
<div key={member.name} className="relative aspect-4/5 rounded overflow-hidden">
|
|
||||||
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 h-1/3 backdrop-blur-xl"
|
|
||||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-x-6 bottom-6 xl:inset-x-7 xl:bottom-7 2xl:inset-x-8 2xl:bottom-8 flex flex-col text-background">
|
|
||||||
<span className="text-2xl font-semibold leading-snug truncate">{member.name}</span>
|
|
||||||
<span className="text-base leading-snug truncate">{member.role}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</GridOrCarousel>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default TeamGlassCards;
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { motion, useMotionValue, useSpring } from "motion/react";
|
|
||||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ButtonMagneticProps {
|
|
||||||
text: string;
|
|
||||||
variant?: "primary" | "secondary";
|
|
||||||
href?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
animate?: boolean;
|
|
||||||
animationDelay?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonMagnetic = ({ text, variant = "primary", href = "#", onClick, animate = true, animationDelay = 0, className = "" }: ButtonMagneticProps) => {
|
|
||||||
const handleClick = useButtonClick(href, onClick);
|
|
||||||
const ref = useRef<HTMLAnchorElement>(null);
|
|
||||||
|
|
||||||
const x = useMotionValue(0);
|
|
||||||
const y = useMotionValue(0);
|
|
||||||
const springX = useSpring(x, { stiffness: 150, damping: 15 });
|
|
||||||
const springY = useSpring(y, { stiffness: 150, damping: 15 });
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (!ref.current || window.innerWidth < 768) return;
|
|
||||||
const rect = ref.current.getBoundingClientRect();
|
|
||||||
const offsetX = (e.clientX - rect.left - rect.width / 2) * 0.15;
|
|
||||||
const offsetY = (e.clientY - rect.top - rect.height / 2) * 0.15;
|
|
||||||
x.set(offsetX);
|
|
||||||
y.set(offsetY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
x.set(0);
|
|
||||||
y.set(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const button = (
|
|
||||||
<motion.a
|
|
||||||
ref={ref}
|
|
||||||
href={href}
|
|
||||||
onClick={handleClick}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
style={{ x: springX, y: springY }}
|
|
||||||
className={cls("flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer", variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text", className)}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</motion.a>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!animate) return button;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.6, delay: animationDelay, ease: "easeOut" }}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ButtonMagnetic;
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { cls } from "@/lib/utils";
|
|
||||||
|
|
||||||
type HorizonGlowBackgroundProps = {
|
|
||||||
position: "fixed" | "absolute";
|
|
||||||
};
|
|
||||||
|
|
||||||
const HorizonGlowBackground = ({ position }: HorizonGlowBackgroundProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cls(position, "inset-0 -z-10 overflow-hidden pointer-events-none select-none mask-[linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]")} aria-hidden="true">
|
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 w-full h-full -bottom-[9vh] overflow-hidden z-0">
|
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden blur-[57px] [background:radial-gradient(50%_50%_at_50%_50%,color-mix(in_srgb,var(--color-primary-cta)_25%,transparent),transparent)]" />
|
|
||||||
<div className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-screen rounded-[100%] [background:linear-gradient(180deg,color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),transparent)]" />
|
|
||||||
<div className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%] bg-background [box-shadow:inset_0_2px_20px_color-mix(in_srgb,var(--color-primary-cta)_30%,transparent),0_-10px_50px_1px_color-mix(in_srgb,var(--color-primary-cta)_25%,transparent)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HorizonGlowBackground;
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
import { ArrowUpRight } from "lucide-react";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import Button from "@/components/ui/Button";
|
|
||||||
|
|
||||||
interface NavbarDropdownProps {
|
|
||||||
logo: string;
|
|
||||||
navItems: { name: string; href: string }[];
|
|
||||||
ctaButton: { text: string; href: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
|
||||||
if (href.startsWith("#")) {
|
|
||||||
e.preventDefault();
|
|
||||||
const element = document.getElementById(href.slice(1));
|
|
||||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
onClose?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavbarDropdown = ({ logo, navItems, ctaButton }: NavbarDropdownProps) => {
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const navRef = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && menuOpen) setMenuOpen(false);
|
|
||||||
};
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (menuOpen && navRef.current && !navRef.current.contains(e.target as Node)) {
|
|
||||||
setMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [menuOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav ref={navRef} className="fixed z-1000 top-5 left-1/2 -translate-x-1/2 w-content-width">
|
|
||||||
<div className="flex items-center justify-between p-2 xl:p-3 2xl:p-4 rounded backdrop-blur-sm card">
|
|
||||||
<a href="/" className="pl-2 text-xl font-medium text-foreground">{logo}</a>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 xl:gap-3 2xl:gap-4">
|
|
||||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" animate={false} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="relative flex items-center justify-center size-9 rounded cursor-pointer primary-button"
|
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cls(
|
|
||||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
|
||||||
menuOpen ? "rotate-45" : "-translate-y-1"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cls(
|
|
||||||
"absolute w-3 h-px bg-primary-cta-text transition-all duration-300",
|
|
||||||
menuOpen ? "-rotate-45" : "translate-y-1"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{menuOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
className="absolute top-full right-2 xl:right-3 2xl:right-4 -mt-1 px-4 py-1 w-3/4 md:w-3/10 2xl:w-25/100 rounded primary-button"
|
|
||||||
>
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<div key={item.name}>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
|
||||||
className="group flex items-center justify-between py-3 w-full"
|
|
||||||
>
|
|
||||||
<span className="text-base text-primary-cta-text group-hover:ml-2 transition-[margin] duration-300">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
<ArrowUpRight
|
|
||||||
className="h-(--text-base) w-auto text-primary-cta-text group-hover:rotate-45 group-hover:mr-2 transition-all duration-300"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{index < navItems.length - 1 && (
|
|
||||||
<div className="h-px bg-primary-cta-text/20" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavbarDropdown;
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-section error boundary inserted around every assembled section by the
|
|
||||||
* backend's page-assembler. Goal: a single section that throws at runtime
|
|
||||||
* (missing required prop, broken `.map`, etc.) shows a small placeholder
|
|
||||||
* instead of taking down the entire page with a white screen.
|
|
||||||
*
|
|
||||||
* Also reports the failure via the `/__webild/render-status` probe channel
|
|
||||||
* so Bob-AI's post-commit poll picks up the section name + error message and
|
|
||||||
* the model gets the signal to fix the right section on the next loop turn.
|
|
||||||
*
|
|
||||||
* The probe POST is best-effort and silent — sandbox-only (gated by
|
|
||||||
* `window.parent !== window`), so production deploys never call it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Section slug — same value the wrapping `<div data-section="…">` uses. */
|
|
||||||
name: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RENDER_STATUS_URL = "/__webild/render-status";
|
|
||||||
|
|
||||||
export default class SectionErrorBoundary extends Component<Props, State> {
|
|
||||||
state: State = { hasError: false };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown): State {
|
|
||||||
return {
|
|
||||||
hasError: true,
|
|
||||||
errorMessage:
|
|
||||||
error instanceof Error ? error.message : String(error ?? "unknown"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (window.parent === window) return;
|
|
||||||
try {
|
|
||||||
fetch(RENDER_STATUS_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
reason: "section_error_boundary",
|
|
||||||
section: this.props.name,
|
|
||||||
error: String(error?.message || error || "unknown"),
|
|
||||||
stack: String(error?.stack || "").slice(0, 4000),
|
|
||||||
componentStack: String(info?.componentStack || "").slice(0, 4000),
|
|
||||||
t: Date.now(),
|
|
||||||
}),
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-label="Section render error placeholder"
|
|
||||||
className="w-content-width mx-auto my-8 p-6 card rounded text-center"
|
|
||||||
>
|
|
||||||
<p className="text-base font-medium text-foreground">
|
|
||||||
This section failed to render.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-foreground/60">
|
|
||||||
Section: <code className="font-mono">{this.props.name}</code>
|
|
||||||
</p>
|
|
||||||
{this.state.errorMessage ? (
|
|
||||||
<p className="mt-2 text-xs text-foreground/50 max-w-xl mx-auto break-words">
|
|
||||||
{this.state.errorMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="mt-3 text-xs text-foreground/40">
|
|
||||||
Tell Bob exactly what's wrong (e.g. "fix the {this.props.name} section") to retry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function HomePage() {
|
|||||||
<SectionErrorBoundary name="hero">
|
<SectionErrorBoundary name="hero">
|
||||||
<HeroSplitVerticalMarquee
|
<HeroSplitVerticalMarquee
|
||||||
tag="Welcome to Paradise"
|
tag="Welcome to Paradise"
|
||||||
title="Experience Unrivaled Luxury at The Grand Oasis"
|
title="Unrivaled Luxury at The Grand Oasis"
|
||||||
description="Indulge in exquisite comfort, bespoke service, and breathtaking views. Your unforgettable escape begins here, where every moment is crafted to perfection."
|
description="Indulge in exquisite comfort, bespoke service, and breathtaking views. Your unforgettable escape begins here, where every moment is crafted to perfection."
|
||||||
primaryButton={{
|
primaryButton={{
|
||||||
text: "Explore Rooms",
|
text: "Explore Rooms",
|
||||||
|
|||||||
Reference in New Issue
Block a user