Initial commit

This commit is contained in:
dk
2026-06-14 10:23:53 +00:00
commit fa062e2a76
315 changed files with 37881 additions and 0 deletions

39
src/hooks/useBlogPost.ts Normal file
View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { fetchBlogPost, type BlogPost } from "@/lib/api/blog";
const useBlogPost = (slugOrId: string) => {
const [post, setPost] = useState<BlogPost | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
const loadPost = async () => {
try {
const data = await fetchBlogPost(slugOrId);
if (isMounted) {
setPost(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Failed to fetch post"));
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadPost();
return () => {
isMounted = false;
};
}, [slugOrId]);
return { post, isLoading, error };
};
export default useBlogPost;

40
src/hooks/useBlogPosts.ts Normal file
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

@@ -0,0 +1,64 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useLenis } from "lenis/react";
export const useButtonClick = (href?: string, onClick?: () => void) => {
const navigate = useNavigate();
const location = useLocation();
const lenis = useLenis();
const scrollToElement = (sectionId: string, delay: number = 100) => {
setTimeout(() => {
const element = document.getElementById(sectionId);
if (element) {
if (lenis) {
lenis.scrollTo(element, { offset: 0 });
} else {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}, delay);
};
const handleClick = (e?: React.MouseEvent) => {
if (href) {
const isExternalLink = /^(https?:\/\/|www\.)/.test(href);
const isEmailOrPhone = /^(mailto:|tel:)/.test(href);
if (isExternalLink) {
window.open(
href.startsWith("www.") ? `https://${href}` : href,
"_blank",
"noopener,noreferrer"
);
} else if (isEmailOrPhone) {
// Let browser handle mailto:/tel: naturally
onClick?.();
return;
} else if (href.startsWith("/")) {
e?.preventDefault();
const [path, hash] = href.split("#");
if (path !== location.pathname) {
navigate(path);
if (hash) {
setTimeout(() => {
scrollToElement(hash, 100);
}, 100);
}
} else if (hash) {
scrollToElement(hash, 50);
}
} else if (href.startsWith("#")) {
e?.preventDefault();
scrollToElement(href.slice(1), 50);
} else {
e?.preventDefault();
scrollToElement(href, 50);
}
}
onClick?.();
};
return handleClick;
};

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";
import type { EmblaCarouselType } from "embla-carousel";
export const useCarouselControls = (emblaApi: EmblaCarouselType | undefined) => {
const [prevDisabled, setPrevDisabled] = useState(true);
const [nextDisabled, setNextDisabled] = useState(true);
const [scrollProgress, setScrollProgress] = useState(0);
const scrollPrev = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollNext();
}, [emblaApi]);
const onSelect = useCallback((api: EmblaCarouselType) => {
setPrevDisabled(!api.canScrollPrev());
setNextDisabled(!api.canScrollNext());
}, []);
const onScroll = useCallback((api: EmblaCarouselType) => {
const progress = Math.max(0, Math.min(1, api.scrollProgress()));
setScrollProgress(progress * 100);
}, []);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
onScroll(emblaApi);
emblaApi.on("reInit", onSelect).on("select", onSelect);
emblaApi.on("reInit", onScroll).on("scroll", onScroll);
return () => {
emblaApi.off("reInit", onSelect).off("select", onSelect);
emblaApi.off("reInit", onScroll).off("scroll", onScroll);
};
}, [emblaApi, onSelect, onScroll]);
return { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress };
};

162
src/hooks/useCart.ts Normal file
View File

@@ -0,0 +1,162 @@
import { useState, useEffect, useCallback, useMemo } from "react";
type CartItem = {
id: string;
name: string;
variants?: string[];
price: string;
quantity: number;
imageSrc: string;
imageAlt?: string;
};
type ExtendedCartItem = CartItem & {
productId: string;
};
const CART_STORAGE_KEY = "shop_cart_items";
const saveCartToStorage = (items: ExtendedCartItem[]) => {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items));
} catch {}
};
const loadCartFromStorage = (): ExtendedCartItem[] => {
try {
const stored = localStorage.getItem(CART_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {}
return [];
};
const clearCartFromStorage = () => {
try {
localStorage.removeItem(CART_STORAGE_KEY);
} catch {}
};
const useCart = () => {
const [cartItems, setCartItems] = useState<ExtendedCartItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get("success");
const sessionId = urlParams.get("session_id");
if (success === "true" || sessionId) {
clearCartFromStorage();
setCartItems([]);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.delete("session_id");
window.history.replaceState({}, "", url.pathname + url.search);
} else {
const loadedItems = loadCartFromStorage();
if (loadedItems.length > 0) {
setCartItems(loadedItems);
}
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get("success");
const sessionId = urlParams.get("session_id");
if (success === "true" || sessionId) return;
if (cartItems.length > 0) {
saveCartToStorage(cartItems);
} else {
clearCartFromStorage();
}
}, [cartItems]);
const addItem = useCallback((item: ExtendedCartItem) => {
setCartItems((prev) => {
const existing = prev.find((i) => i.id === item.id);
if (existing) {
return prev.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
);
}
return [...prev, item];
});
setIsOpen(true);
}, []);
const updateQuantity = useCallback((itemId: string, quantity: number) => {
setCartItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, quantity } : item))
);
}, []);
const removeItem = useCallback((itemId: string) => {
setCartItems((prev) => prev.filter((item) => item.id !== itemId));
}, []);
const clearCart = useCallback(() => {
setCartItems([]);
clearCartFromStorage();
}, []);
const total = useMemo(() => {
return cartItems
.reduce(
(sum, item) =>
sum + parseFloat(item.price.replace("$", "").replace(",", "")) * item.quantity,
0
)
.toFixed(2);
}, [cartItems]);
const itemCount = useMemo(() => {
return cartItems.reduce((sum, item) => sum + item.quantity, 0);
}, [cartItems]);
const getCheckoutItems = useCallback(() => {
return cartItems.map((item) => {
const metadata: Record<string, string> = {};
if (item.variants && item.variants.length > 0) {
item.variants.forEach((variant) => {
const [key, value] = variant.split(":").map((s) => s.trim());
if (key && value) {
metadata[key.toLowerCase()] = value;
}
});
}
return {
productId: item.productId,
quantity: item.quantity,
imageSrc: item.imageSrc,
imageAlt: item.imageAlt,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
};
});
}, [cartItems]);
return {
items: cartItems,
isOpen,
setIsOpen,
addItem,
updateQuantity,
removeItem,
clearCart,
total,
itemCount,
getCheckoutItems,
};
};
export default useCart;
export type { CartItem, ExtendedCartItem };

118
src/hooks/useCheckout.ts Normal file
View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import { type Product } from "@/lib/api/product";
type CheckoutItem = {
productId: string;
quantity: number;
imageSrc?: string;
imageAlt?: string;
metadata?: {
brand?: string;
variant?: string;
rating?: number;
reviewCount?: string;
[key: string]: string | number | undefined;
};
};
type CheckoutResult = {
success: boolean;
url?: string;
error?: string;
};
const useCheckout = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const checkout = async (items: CheckoutItem[], options?: { successUrl?: string; cancelUrl?: string }): Promise<CheckoutResult> => {
const apiUrl = import.meta.env.VITE_API_URL;
const projectId = import.meta.env.VITE_PROJECT_ID;
if (!apiUrl || !projectId) {
const errorMsg = "VITE_API_URL or VITE_PROJECT_ID not configured";
setError(errorMsg);
return { success: false, error: errorMsg };
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${apiUrl}/stripe/project/checkout-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId,
items,
successUrl: options?.successUrl || window.location.href,
cancelUrl: options?.cancelUrl || window.location.href,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.message || `Request failed with status ${response.status}`;
setError(errorMsg);
return { success: false, error: errorMsg };
}
const data = await response.json();
if (data.data.url) {
window.location.href = data.data.url;
}
return { success: true, url: data.data.url };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to create checkout session";
setError(errorMsg);
return { success: false, error: errorMsg };
} finally {
setIsLoading(false);
}
};
const buyNow = async (product: Product | string, quantity: number = 1): Promise<CheckoutResult> => {
const successUrl = new URL(window.location.href);
successUrl.searchParams.set("success", "true");
if (typeof product === "string") {
return checkout([{ productId: product, quantity }], { successUrl: successUrl.toString() });
}
let metadata: CheckoutItem["metadata"] = {};
if (product.metadata && Object.keys(product.metadata).length > 0) {
const { ...restMetadata } = product.metadata;
metadata = restMetadata;
} else {
if (product.brand) metadata.brand = product.brand;
if (product.variant) metadata.variant = product.variant;
if (product.rating !== undefined) metadata.rating = product.rating;
if (product.reviewCount) metadata.reviewCount = product.reviewCount;
}
return checkout(
[
{
productId: product.id,
quantity,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
},
],
{ successUrl: successUrl.toString() }
);
};
const clearError = () => setError(null);
return { checkout, buyNow, isLoading, error, clearError };
};
export default useCheckout;
export type { CheckoutItem, CheckoutResult };

45
src/hooks/useProduct.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { fetchProduct, type Product } from "@/lib/api/product";
const useProduct = (productId: string) => {
const [product, setProduct] = useState<Product | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
const loadProduct = async () => {
if (!productId) {
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const data = await fetchProduct(productId);
if (isMounted) {
setProduct(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Failed to fetch product"));
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadProduct();
return () => {
isMounted = false;
};
}, [productId]);
return { product, isLoading, error };
};
export default useProduct;

View File

@@ -0,0 +1,136 @@
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 };

View File

@@ -0,0 +1,209 @@
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 };

40
src/hooks/useProducts.ts Normal file
View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { fetchProducts, type Product } from "@/lib/api/product";
const useProducts = () => {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
const loadProducts = async () => {
try {
const data = await fetchProducts();
if (isMounted) {
setProducts(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadProducts();
return () => {
isMounted = false;
};
}, []);
return { products, isLoading, error };
};
export default useProducts;
export type { Product };