Compare commits
11 Commits
version_4_
...
version_3_
| Author | SHA1 | Date | |
|---|---|---|---|
| b8b47b9c8d | |||
| b721108cf0 | |||
| 854b37d63c | |||
| e010d7f7da | |||
| 9197db6e03 | |||
| b402be8284 | |||
| da71c01821 | |||
| d4bc4d2272 | |||
| 6738d73c72 | |||
| 233e9a9801 | |||
| 24f365deb7 |
@@ -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,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 };
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
|
|||||||
@@ -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="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."
|
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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user