11 Commits

14 changed files with 646 additions and 332 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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