Merge version_4_1781088081301 into main #3
@@ -1,37 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
|
||||
interface AboutTextProps {
|
||||
title: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
}
|
||||
|
||||
const AboutText = ({
|
||||
title,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: AboutTextProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-2 items-center">
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 justify-center mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutText;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type BlogArticleProps = {
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
imageSrc: string;
|
||||
authorName: string;
|
||||
authorImageSrc: string;
|
||||
date: string;
|
||||
readingTime?: string;
|
||||
backButton?: { text: string; href: string };
|
||||
};
|
||||
|
||||
const BlogArticle = ({
|
||||
category,
|
||||
title,
|
||||
excerpt,
|
||||
content,
|
||||
imageSrc,
|
||||
authorName,
|
||||
authorImageSrc,
|
||||
date,
|
||||
readingTime,
|
||||
backButton = { text: "Back to Blog", href: "/blog" },
|
||||
}: BlogArticleProps) => {
|
||||
return (
|
||||
<article aria-label="Blog article" className="py-20">
|
||||
<div className="flex flex-col gap-10">
|
||||
<ScrollReveal variant="fade">
|
||||
<div className="flex flex-col gap-3 w-content-width md:max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2 px-3 py-1 mb-1 text-sm text-foreground/75 card rounded w-fit">
|
||||
<a
|
||||
href={backButton.href}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{backButton.text}
|
||||
</a>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{category}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{excerpt && (
|
||||
<p className="text-lg md:text-xl leading-snug text-balance">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={authorImageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{authorName}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">
|
||||
{date}
|
||||
{readingTime && ` · ${readingTime}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<div className="w-content-width md:max-w-4xl mx-auto aspect-video card rounded overflow-hidden p-2 xl:p-3 2xl:p-4">
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<div
|
||||
className="w-content-width md:max-w-4xl mx-auto flex flex-col gap-6 [&>h1]:text-4xl [&>h1]:font-semibold [&>h1]:mt-4 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:mt-4 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:mt-2 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:mt-2 [&>p]:text-base [&>p]:leading-relaxed [&>p]:text-foreground/85 [&_strong]:font-semibold [&_em]:italic [&_u]:underline [&>ul]:flex [&>ul]:flex-col [&>ul]:gap-2 [&>ul]:list-disc [&>ul]:pl-6 [&>ul]:text-base [&>ul]:leading-relaxed [&>ul]:text-foreground/85 [&>ol]:flex [&>ol]:flex-col [&>ol]:gap-2 [&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:text-base [&>ol]:leading-relaxed [&>ol]:text-foreground/85"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogArticle;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoSharpProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="fade" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded-none">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded-none group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBentoSharp;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardCarouselProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCarousel;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import ActiveBadge from "@/components/ui/ActiveBadge";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type HeroBillboardFeaturesProps = {
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardFeatures = ({
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
features,
|
||||
}: HeroBillboardFeaturesProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (features.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % features.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [features.length]);
|
||||
|
||||
const feature = features[currentIndex];
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<ActiveBadge text={badge} className="mb-1" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
|
||||
>
|
||||
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
|
||||
<p className="text-base font-medium leading-snug">{feature.title}</p>
|
||||
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardFeatures;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TeamMember = {
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
socialLinks: SocialLink[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const TeamDetailedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
members,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
members: TeamMember[];
|
||||
}) => (
|
||||
<section aria-label="Team section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel >
|
||||
{members.map((member) => (
|
||||
<div key={member.name} className="relative aspect-4/5 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
|
||||
|
||||
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex flex-col gap-1 xl:gap-2 2xl:gap-3 p-4 xl:p-5 2xl:p-6 card backdrop-blur-sm rounded">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="text-2xl font-semibold leading-snug truncate">{member.name}</span>
|
||||
<div className="px-3 py-1 text-sm secondary-button text-secondary-cta-text rounded">
|
||||
<p className="truncate">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-base leading-snug">{member.description}</p>
|
||||
|
||||
<div className="flex gap-3 mt-1 md:mt-2">
|
||||
{member.socialLinks.map((link, index) => {
|
||||
const IconComponent = resolveIcon(link.icon);
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center size-9 primary-button rounded"
|
||||
>
|
||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default TeamDetailedCards;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStateMachineInput } from "@rive-app/react-canvas";
|
||||
|
||||
export function useRiveHoverInput(
|
||||
rive: unknown,
|
||||
stateMachineName: string,
|
||||
hoverInputName: string
|
||||
) {
|
||||
const hoverInput = useStateMachineInput(
|
||||
rive as never,
|
||||
stateMachineName,
|
||||
hoverInputName
|
||||
);
|
||||
|
||||
const hoverInputRef = useRef<typeof hoverInput | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hoverInputRef.current = hoverInput ?? null;
|
||||
}, [hoverInput]);
|
||||
|
||||
return useCallback(
|
||||
(isHovering: boolean) => {
|
||||
const input = hoverInputRef.current;
|
||||
if (!input) return;
|
||||
input.value = isHovering;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ActiveBadgeProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ActiveBadge = ({ text, className }: ActiveBadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"card backdrop-blur flex items-center gap-2 px-3 py-1 rounded",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="size-2 rounded-full bg-accent animate-pulsate" />
|
||||
<p className="text-sm leading-snug font-medium text-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveBadge;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridLinesBackgroundProps = {
|
||||
position: "fixed" | "absolute";
|
||||
};
|
||||
|
||||
const GridLinesBackground = ({ position }: GridLinesBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(position, "inset-0 -z-10 overflow-hidden bg-background pointer-events-none select-none mask-[radial-gradient(circle_at_center,white_0%,transparent_90%)] bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_srgb,var(--color-background-accent)_17.5%,transparent)_1px,transparent_1px)] bg-size-[10vw_10vw]")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridLinesBackground;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
const IconTextMarquee = ({ centerIcon, texts }: { centerIcon: string | LucideIcon; texts: string[] }) => {
|
||||
const CenterIcon = resolveIcon(centerIcon);
|
||||
const items = [...texts, ...texts];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden" style={{ maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)" }}>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 w-full opacity-60">
|
||||
{Array.from({ length: 10 }).map((_, row) => (
|
||||
<div key={row} className={cls("flex gap-2", row % 2 === 0 ? "animate-marquee-horizontal" : "animate-marquee-horizontal-reverse")}>
|
||||
{items.map((text, i) => (
|
||||
<div key={i} className="flex items-center justify-center px-4 py-2 card rounded">
|
||||
<p className="text-sm leading-snug">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-16 primary-button backdrop-blur-sm rounded">
|
||||
<CenterIcon className="size-6 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconTextMarquee;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Children, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import type { EmblaCarouselType } from "embla-carousel";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface LoopCarouselProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const LoopCarousel = ({ children }: LoopCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center", containScroll: "trimSnaps" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const items = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((api: EmblaCarouselType) => {
|
||||
setSelectedIndex(api.selectedScrollSnap());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
|
||||
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("select", onSelect).on("reInit", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect).off("reInit", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full md:w-content-width mx-auto">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full mask-fade-x-medium">
|
||||
<div className="flex w-full">
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className="shrink-0 w-content-width md:w-[clamp(18rem,50vw,48rem)] mr-3 md:mr-6">
|
||||
<div
|
||||
className={cls(
|
||||
"transition-all duration-500 ease-out",
|
||||
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between w-content-width mx-auto pointer-events-none">
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronLeft className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
className="flex items-center justify-center h-9 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronRight className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoopCarousel;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Variant = "slide-up" | "fade-blur" | "fade";
|
||||
|
||||
interface TextAnimationProps {
|
||||
text: string;
|
||||
variant: Variant;
|
||||
gradientText: boolean;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
"slide-up": {
|
||||
hidden: { opacity: 0, y: "50%" },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
},
|
||||
"fade-blur": {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
visible: { opacity: 1, filter: "none" },
|
||||
},
|
||||
"fade": {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const EASING: Record<Variant, [number, number, number, number]> = {
|
||||
"slide-up": [0.25, 0.46, 0.45, 0.94],
|
||||
"fade-blur": [0.45, 0, 0.55, 1],
|
||||
"fade": [0.45, 0, 0.55, 1],
|
||||
};
|
||||
|
||||
const TextAnimation = ({ text, variant, gradientText, tag = "p", className = "" }: TextAnimationProps) => {
|
||||
const Tag = motion[tag] as typeof motion.p;
|
||||
const words = text.split(" ");
|
||||
const [animationComplete, setAnimationComplete] = useState(false);
|
||||
const [reverted, setReverted] = useState(false);
|
||||
|
||||
const gradientClass = gradientText
|
||||
? "bg-gradient-to-r from-foreground to-primary-cta bg-clip-text text-transparent pb-[0.1em] -mb-[0.1em]"
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (animationComplete && !reverted) {
|
||||
const delay = variant === "fade-blur" && gradientText ? 0 : 700;
|
||||
const timer = setTimeout(() => {
|
||||
setReverted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [animationComplete, reverted, variant, gradientText]);
|
||||
|
||||
if (reverted) {
|
||||
return (
|
||||
<Tag
|
||||
className={cls("leading-[1.2]", gradientClass, className)}
|
||||
initial={false}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cls(
|
||||
"leading-[1.2] transition-all duration-700",
|
||||
animationComplete && gradientClass,
|
||||
className
|
||||
)}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
transition={{ staggerChildren: 0.04 }}
|
||||
onAnimationComplete={() => setAnimationComplete(true)}
|
||||
>
|
||||
{words.map((word, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && " "}
|
||||
<motion.span
|
||||
className="inline-block"
|
||||
variants={VARIANTS[variant]}
|
||||
transition={{ duration: 0.6, ease: EASING[variant] }}
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextAnimation;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface TextLinkProps {
|
||||
text: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextLink = ({ text, href = "#", onClick, className = "" }: TextLinkProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cls(
|
||||
"relative text-sm text-foreground cursor-pointer",
|
||||
"after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-current",
|
||||
"after:scale-x-0 after:origin-right after:transition-transform after:duration-300",
|
||||
"hover:after:scale-x-100 hover:after:origin-left",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextLink;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Item = { icon: string | LucideIcon; title: string; subtitle: string; detail: string };
|
||||
|
||||
const POS = ["-translate-y-14 hover:-translate-y-20", "translate-x-16 hover:-translate-y-4", "translate-x-32 translate-y-16 hover:translate-y-10"];
|
||||
|
||||
const TiltedStackCards = ({ items }: { items: [Item, Item, Item] }) => (
|
||||
<div
|
||||
className="h-full grid place-items-center [grid-template-areas:'stack']"
|
||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, black, black 80%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={cls("flex flex-col justify-between gap-2 p-6 w-80 h-36 card rounded transition-all duration-500 -skew-y-[8deg] [grid-area:stack] 2xl:w-90", POS[i])}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5 rounded primary-button">
|
||||
{(() => { const Icon = resolveIcon(item.icon); return <Icon className="size-3 text-primary-cta-text" strokeWidth={1.5} />; })()}
|
||||
</div>
|
||||
<p className="text-base">{item.title}</p>
|
||||
</div>
|
||||
<p className="text-lg whitespace-nowrap">{item.subtitle}</p>
|
||||
<p className="text-base">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TiltedStackCards;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchBlogPosts, defaultPosts, type BlogPost } from "@/lib/api/blog";
|
||||
|
||||
const useBlogPosts = () => {
|
||||
const [posts, setPosts] = useState<BlogPost[]>(defaultPosts);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const data = await fetchBlogPosts();
|
||||
if (isMounted) {
|
||||
setPosts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to fetch posts"));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPosts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { posts, isLoading, error };
|
||||
};
|
||||
|
||||
export default useBlogPosts;
|
||||
export type { BlogPost };
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import useProducts from "./useProducts";
|
||||
|
||||
type SortOption = "Newest" | "Price: Low-High" | "Price: High-Low";
|
||||
|
||||
type CatalogProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
imageAlt?: string;
|
||||
rating?: number;
|
||||
reviewCount?: string;
|
||||
category?: string;
|
||||
onProductClick?: () => void;
|
||||
};
|
||||
|
||||
type ProductVariant = {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type UseProductCatalogOptions = {
|
||||
onProductClick?: (productId: string) => void;
|
||||
};
|
||||
|
||||
const useProductCatalog = (options: UseProductCatalogOptions = {}) => {
|
||||
const { onProductClick } = options;
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState("All");
|
||||
const [sort, setSort] = useState<SortOption>("Newest");
|
||||
|
||||
const handleProductClick = useCallback(
|
||||
(productId: string) => {
|
||||
onProductClick?.(productId);
|
||||
},
|
||||
[onProductClick]
|
||||
);
|
||||
|
||||
const catalogProducts: CatalogProduct[] = useMemo(() => {
|
||||
if (fetchedProducts.length === 0) return [];
|
||||
|
||||
return fetchedProducts.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
imageSrc: product.imageSrc,
|
||||
imageAlt: product.imageAlt || product.name,
|
||||
rating: product.rating || 0,
|
||||
reviewCount: product.reviewCount,
|
||||
category: product.brand,
|
||||
onProductClick: () => handleProductClick(product.id),
|
||||
}));
|
||||
}, [fetchedProducts, handleProductClick]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categorySet = new Set<string>();
|
||||
catalogProducts.forEach((product) => {
|
||||
if (product.category) {
|
||||
categorySet.add(product.category);
|
||||
}
|
||||
});
|
||||
return Array.from(categorySet).sort();
|
||||
}, [catalogProducts]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = catalogProducts;
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.category?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
if (category !== "All") {
|
||||
result = result.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
if (sort === "Price: Low-High") {
|
||||
result = [...result].sort(
|
||||
(a, b) =>
|
||||
parseFloat(a.price.replace("$", "").replace(",", "")) -
|
||||
parseFloat(b.price.replace("$", "").replace(",", ""))
|
||||
);
|
||||
} else if (sort === "Price: High-Low") {
|
||||
result = [...result].sort(
|
||||
(a, b) =>
|
||||
parseFloat(b.price.replace("$", "").replace(",", "")) -
|
||||
parseFloat(a.price.replace("$", "").replace(",", ""))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [catalogProducts, search, category, sort]);
|
||||
|
||||
const filters: ProductVariant[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Category",
|
||||
options: ["All", ...categories],
|
||||
selected: category,
|
||||
onChange: setCategory,
|
||||
},
|
||||
{
|
||||
label: "Sort",
|
||||
options: ["Newest", "Price: Low-High", "Price: High-Low"] as SortOption[],
|
||||
selected: sort,
|
||||
onChange: (value) => setSort(value as SortOption),
|
||||
},
|
||||
],
|
||||
[categories, category, sort]
|
||||
);
|
||||
|
||||
return {
|
||||
products: filteredProducts,
|
||||
isLoading,
|
||||
search,
|
||||
setSearch,
|
||||
category,
|
||||
setCategory,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
categories,
|
||||
};
|
||||
};
|
||||
|
||||
export default useProductCatalog;
|
||||
export type { SortOption, CatalogProduct, ProductVariant };
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function HomePage() {
|
||||
<SectionErrorBoundary name="hero">
|
||||
<HeroSplitVerticalMarquee
|
||||
tag="Welcome to Paradise"
|
||||
title="Unrivaled Luxury at The Grand Oasis"
|
||||
title="Luxury at The Grand Oasis"
|
||||
description="Indulge in exquisite comfort, bespoke service, and breathtaking views. Your unforgettable escape begins here, where every moment is crafted to perfection."
|
||||
primaryButton={{
|
||||
text: "Explore Rooms",
|
||||
|
||||
Reference in New Issue
Block a user