Update shopify collection

This commit is contained in:
Rami Bitar
2026-06-10 13:48:13 -04:00
parent eeeafd36d3
commit fc42f2d114
95 changed files with 7228 additions and 6178 deletions

View File

@@ -0,0 +1,216 @@
'use client';
import React from 'react';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { Loader } from '@/components/ui/loader';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
RiCloseLine,
RiImageLine,
RiSubtractLine,
RiAddLine,
} from '@remixicon/react';
const CartDrawer: React.FC = () => {
const { isOpen, closeCart, items, itemCount, totalAmount, checkoutUrl, loading, removeItem, updateItemQuantity } = useShopifyCart();
const handleCheckout = () => {
if (checkoutUrl) {
redirectToCheckout(checkoutUrl);
}
};
const getItemImage = (item: typeof items[0]) => {
return item.merchandise.image?.url;
};
const getSelectedOptions = (item: typeof items[0]) => {
return item.merchandise.selectedOptions ?? [];
};
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
<SheetContent className="w-full max-w-md" side="right" showCloseButton={false}>
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base">
Shopping Cart ({itemCount})
</SheetTitle>
<Button
onClick={closeCart}
variant="ghost"
size="icon-sm"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-4">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={32} />
</div>
) : items.length === 0 ? (
<Empty className="py-12">
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>Add some products to get started!</EmptyDescription>
</EmptyHeader>
<Button onClick={closeCart}>
Continue Shopping
</Button>
</Empty>
) : (
<div className="space-y-6">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
return (
<div key={item.id} className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0">
{/* Product Image */}
<div className="w-20 h-20 bg-gray-100 overflow-hidden flex-shrink-0">
{image ? (
<img
src={image}
alt={item.merchandise.product.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<RiImageLine size={24} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 mb-1 line-clamp-2">
{item.merchandise.product.title}
</h4>
{/* Variant Info */}
{selectedOptions.length > 0 && (
<div className="text-xs text-gray-500 mb-2">
{selectedOptions.map((option, index) => (
<span key={option.name}>
{option.value}
{index < selectedOptions.length - 1 ? ' / ' : ''}
</span>
))}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-gray-200 rounded-md">
<Button
onClick={() => updateItemQuantity(item.id, item.quantity - 1)}
variant="ghost"
size="icon-sm"
disabled={item.quantity <= 1 || loading}
className="h-7 w-7"
>
<RiSubtractLine size={14} />
</Button>
<span className="px-2 py-1 font-medium min-w-[30px] text-center text-sm">
{item.quantity}
</span>
<Button
onClick={() => updateItemQuantity(item.id, item.quantity + 1)}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-7 w-7"
>
<RiAddLine size={14} />
</Button>
</div>
</div>
</div>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm text-gray-500">
${parseFloat(item.merchandise.price.amount).toFixed(2)}
</span>
</div>
{/* Remove Button */}
<div className="flex-shrink-0">
<Button
onClick={() => removeItem(item.id)}
variant="ghost"
size="icon-sm"
disabled={loading}
className="text-gray-400 hover:text-gray-900"
>
<RiCloseLine size={18} />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer - Checkout Section */}
{items.length > 0 && (
<div className="border-t border-border p-6">
{/* Subtotal */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-900">Subtotal</span>
<span className="text-sm font-medium text-gray-900">
${totalAmount.toFixed(2)}
</span>
</div>
<div className="text-xs text-gray-500 mb-4">
Shipping and taxes calculated at checkout
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Button
onClick={handleCheckout}
disabled={loading || !checkoutUrl}
className="w-full h-14 rounded-full text-base"
size="lg"
>
{loading ? (
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
</Button>
<Button
onClick={closeCart}
variant="link"
className="w-full"
>
Continue Shopping
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
);
};
export default CartDrawer;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface CollectionImage {
url: string;
altText?: string;
}
interface Collection {
id: string;
title: string;
handle: string;
description?: string;
image?: CollectionImage;
}
interface CollectionCardProps {
collection: Collection;
}
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
return (
<Link href={`/collections/${collection.handle}`} className="block group">
{/* Collection Image */}
<div className="aspect-video overflow-hidden bg-gray-100">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<i className="ri-folder-line text-6xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<div className="mt-3">
<h3 className="text-base font-medium text-gray-900 font-heading">
{collection.title}
</h3>
</div>
</Link>
);
};
export default CollectionCard;

View File

@@ -0,0 +1,108 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card';
interface CollectionDetailProps {
handle?: string;
}
const CollectionDetail: React.FC<CollectionDetailProps> = ({ handle: handleProp }) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { collection, loading, error, refetch } = useCollectionProducts(handle);
// Format title from handle
const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
: 'Collection';
if (loading) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
{formattedTitle}
</h2>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="mt-3 h-4 w-2/3 bg-gray-200 rounded"></div>
<div className="mt-2 h-4 w-1/4 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-medium tracking-tight mb-8 text-gray-900 font-heading">
{formattedTitle}
</h2>
<div className="bg-red-50 border border-red-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-error-warning-line text-4xl text-red-500 mb-4"></i>
<h3 className="text-lg font-medium text-red-800 mb-2">
Failed to Load Collection
</h3>
<p className="text-red-600 mb-4">
{error}
</p>
<button
onClick={() => refetch()}
className="bg-red-600 text-white px-6 py-2 rounded-md hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
{title}
</h2>
{products.length === 0 ? (
<div className="text-center py-12">
<div className="bg-gray-50 border border-gray-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-medium text-gray-600 mb-2">
No Products in Collection
</h3>
<p className="text-gray-500">
This collection doesn&apos;t have any products yet.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</div>
);
};
export default CollectionDetail;

View File

@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card';
const Collections: React.FC = () => {
const { collections, loading, error, refetch } = useCollections(20);
if (loading) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
Our Collections
</h2>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-video bg-gray-200"></div>
<div className="mt-3 h-4 w-1/3 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-medium tracking-tight mb-8 text-gray-900 font-heading">
Our Collections
</h2>
<div className="bg-red-50 border border-red-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-error-warning-line text-4xl text-red-500 mb-4"></i>
<h3 className="text-lg font-medium text-red-800 mb-2">
Failed to Load Collections
</h3>
<p className="text-red-600 mb-4">
{error}
</p>
<button
onClick={() => refetch()}
className="bg-red-600 text-white px-6 py-2 rounded-md hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
</div>
</div>
);
}
if (collections.length === 0) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-medium tracking-tight mb-8 text-gray-900 font-heading">
Our Collections
</h2>
<div className="bg-gray-50 border border-gray-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-folder-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-medium text-gray-600 mb-2">
No Collections Found
</h3>
<p className="text-gray-500">
Check back later or configure your Shopify store connection.
</p>
</div>
</div>
</div>
);
}
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
Our Collections
</h2>
{/* Collections Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} />
))}
</div>
</div>
</div>
);
};
export default Collections;

View File

@@ -0,0 +1,129 @@
import React from 'react';
import Link from 'next/link';
import { useShopifyCart, addCartLines } from '@/hooks/use-shopify-cart';
import { truncate } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
interface ProductImage {
url: string;
altText?: string;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
}
interface Product {
id: string;
title: string;
description?: string;
productType?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
}
interface ProductCardProps {
product: Product;
onAddToCart?: (product: Product) => void;
}
const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
const { cartId, openCart } = useShopifyCart();
const firstImage = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const firstVariant = product.variants.edges[0]?.node;
const isAvailable = firstVariant?.availableForSale || false;
const formatPrice = (price: ProductPrice) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: price.currencyCode,
}).format(parseFloat(price.amount));
};
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault(); // Prevent navigation when clicking add to cart
e.stopPropagation();
if (!firstVariant || !isAvailable || !cartId) return;
try {
await addCartLines(cartId, [{ merchandiseId: firstVariant.id, quantity: 1 }]);
openCart();
if (onAddToCart) {
onAddToCart(product);
}
} catch (err) {
console.error('Failed to add item to cart:', err);
}
};
return (
<div className="group">
{/* Product Image */}
<div className="aspect-square overflow-hidden bg-gray-100 relative">
{firstImage ? (
<Link href={`/products/${product.handle}`}>
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</Link>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<i className="ri-image-line text-6xl"></i>
</div>
)}
{/* Discount Badge */}
{hasDiscount && compareAtPrice && (
<Badge variant="destructive" className="absolute top-3 left-3">
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
</Badge>
)}
</div>
{/* Product Info */}
<div className="mt-3">
<Link href={`/products/${product.handle}`}>
<h3 className="text-base font-medium text-gray-900 line-clamp-2 font-heading">
{truncate(product.title, 50)}
</h3>
</Link>
<p className="text-base text-gray-500">
${parseFloat(price.amount).toFixed(2)}
</p>
</div>
</div>
);
};
export default ProductCard;

View File

@@ -0,0 +1,3 @@
import ProductDetail from './product-detail/index';
export default ProductDetail;

View File

@@ -0,0 +1,193 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import ProductRecommendations from '../product-recommendations';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { RiErrorWarningLine } from '@remixicon/react';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
interface ProductVariant {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: {
url: string;
altText?: string;
};
}
export type { Product, ProductVariant };
interface ProductDetailProps {
handle?: string;
}
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { addItem, openCart } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
// Initialize variant when product loads
useEffect(() => {
if (product) {
const firstVariant = product.variants.edges[0]?.node;
if (firstVariant) {
setSelectedVariant(firstVariant);
const initialOptions: Record<string, string> = {};
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
initialOptions[option.name] = option.value;
});
setSelectedOptions(initialOptions);
}
}
}, [product]);
const handleOptionChange = (optionName: string, value: string) => {
const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions);
// Find matching variant
const matchingVariant = product?.variants.edges.find(({ node }) => {
return node.selectedOptions.every(option =>
newOptions[option.name] === option.value
);
});
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
}
};
const handleAddToCart = async () => {
if (!selectedVariant || !product) return;
try {
setAddingToCart(true);
await addItem(selectedVariant.id, quantity);
openCart();
} catch (err) {
console.error('Failed to add item to cart:', err);
} finally {
setAddingToCart(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-2 grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 animate-pulse"></div>
))}
</div>
{/* Product Info Skeleton */}
<div>
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div>
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<Alert variant="destructive" className="max-w-md mx-auto p-8">
<RiErrorWarningLine size={36} />
<AlertTitle className="text-lg font-medium">
Product Not Found
</AlertTitle>
<AlertDescription className="mb-4">
{error || 'The requested product could not be found.'}
</AlertDescription>
<Button
onClick={() => window.history.back()}
variant="destructive"
>
Go Back
</Button>
</Alert>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{product.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
<div className="lg:col-span-2">
<ProductDetailGallery
images={product.images.edges.map(edge => edge.node)}
/>
</div>
<div className="lg:sticky lg:top-20 lg:self-start">
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
onOptionChange={handleOptionChange}
loading={addingToCart}
/>
</div>
</div>
</div>
<ProductRecommendations productId={product.id} />
</div>
);
};
export default ProductDetail;

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import { RiImageLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
interface ProductImage {
url: string;
altText?: string;
}
interface ProductDetailGalleryProps {
images: ProductImage[];
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({ images }) => {
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
if (images.length === 0) {
return (
<div className="aspect-square bg-gray-100 flex items-center justify-center text-gray-400">
<RiImageLine size={60} />
</div>
);
}
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
return (
<>
<div className="grid grid-cols-2 gap-4">
{images.map((image, index) => (
<button
key={index}
onClick={() => setZoomedIndex(index)}
className={cn(
'aspect-square bg-gray-100 overflow-hidden cursor-zoom-in group',
// A lone image (or the last of an odd count) spans both columns to avoid a gap
images.length % 2 === 1 && index === images.length - 1 && 'col-span-2'
)}
aria-label={`Zoom image ${index + 1}`}
>
<img
src={image.url}
alt={image.altText || `Product image ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</button>
))}
</div>
{/* Zoom Lightbox */}
<Dialog
open={zoomedIndex !== null}
onOpenChange={(open) => !open && setZoomedIndex(null)}
>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">Product image</DialogTitle>
{zoomedImage && (
<img
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
className="w-full h-auto max-h-[85vh] object-contain bg-gray-100"
/>
)}
</DialogContent>
</Dialog>
</>
);
};
export default ProductDetailGallery;

View File

@@ -0,0 +1,167 @@
import React from 'react';
import { Product, ProductVariant } from './index';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader } from '@/components/ui/loader';
import {
RiSubtractLine,
RiAddLine,
RiTruckLine,
RiArrowGoBackLine,
RiSecurePaymentLine,
} from '@remixicon/react';
interface ProductDetailInfoProps {
product: Product;
selectedVariant: ProductVariant | null;
selectedOptions: Record<string, string>;
quantity: number;
setQuantity: (quantity: number) => void;
handleAddToCart: () => void;
onOptionChange: (optionName: string, value: string) => void;
loading?: boolean;
}
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product,
selectedVariant,
selectedOptions,
quantity,
setQuantity,
handleAddToCart,
onOptionChange,
loading = false,
}) => {
const formatPrice = (price: { amount: string; currencyCode: string }) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(parseFloat(price.amount));
};
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
return (
<div>
<h1 className="text-4xl font-medium tracking-tight text-gray-900 mb-2 font-heading">
{product.title}
</h1>
{/* Price */}
<div className="flex items-center space-x-3 mb-8">
<span className="text-lg text-gray-500">
{formatPrice(price)}
</span>
{hasDiscount && compareAtPrice && (
<>
<span className="text-lg text-gray-400 line-through">
{formatPrice(compareAtPrice)}
</span>
<Badge variant="destructive">
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
</Badge>
</>
)}
</div>
{/* Description */}
{product.description && (
<div className="text-gray-600 mb-8 text-lg leading-relaxed">
{product.descriptionHtml ? (
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
) : (
<p>{product.description}</p>
)}
</div>
)}
{/* Product Options (skip Shopify's placeholder "Title: Default Title" option) */}
{product.options
.filter(option => !(option.name === 'Title' && option.values.length === 1 && option.values[0] === 'Default Title'))
.map(option => (
<div key={option.id} className="mb-6">
<label className="block text-sm font-medium text-gray-900 mb-2">
{option.name}
</label>
<div className="flex flex-wrap gap-2">
{option.values.map(value => (
<Button
key={value}
onClick={() => onOptionChange(option.name, value)}
variant={selectedOptions[option.name] === value ? 'default' : 'outline'}
>
{value}
</Button>
))}
</div>
</div>
))}
{/* Quantity Selector */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-900 mb-2">
Quantity
</label>
<div className="flex items-center border border-gray-200 rounded-md w-fit">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
variant="ghost"
size="icon-sm"
disabled={quantity <= 1}
>
<RiSubtractLine size={16} />
</Button>
<span className="w-10 text-center text-sm font-medium">{quantity}</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon-sm"
>
<RiAddLine size={16} />
</Button>
</div>
</div>
{/* Add to Bag Button */}
<Button
onClick={handleAddToCart}
disabled={!selectedVariant?.availableForSale || loading}
size="lg"
className="w-full h-14 rounded-full text-base"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<Loader size={16} />
<span>Adding...</span>
</span>
) : selectedVariant?.availableForSale ? (
'Add to Bag'
) : (
'Out of Stock'
)}
</Button>
{/* Additional Info */}
<div className="mt-8 pt-8 border-t border-gray-200">
<div className="space-y-3 text-sm text-gray-600">
<div className="flex items-center space-x-2">
<RiTruckLine size={16} />
<span>Free shipping on orders over $100</span>
</div>
<div className="flex items-center space-x-2">
<RiArrowGoBackLine size={16} />
<span>30-day return policy</span>
</div>
<div className="flex items-center space-x-2">
<RiSecurePaymentLine size={16} />
<span>Secure payment</span>
</div>
</div>
</div>
</div>
);
};
export default ProductDetailInfo;

View File

@@ -0,0 +1,55 @@
'use client';
import React from 'react';
import { useProductRecommendations } from '@/hooks/use-shopify-products';
import ProductCard from './product-card';
interface ProductRecommendationsProps {
productId: string;
}
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({ productId }) => {
const { recommendations, loading, error } = useProductRecommendations(productId);
// Don't show section if we're not loading and have no recommendations
if (!loading && (!recommendations || recommendations.length === 0)) {
return null;
}
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
You Might Also Like
</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="mt-3 h-4 w-2/3 bg-gray-200 rounded"></div>
<div className="mt-2 h-4 w-1/4 bg-gray-200 rounded"></div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-gray-500">Recommendations could not be loaded</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{recommendations.slice(0, 3).map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</div>
);
};
export default ProductRecommendations;

View File

@@ -0,0 +1,235 @@
'use client';
import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProducts } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
interface ProductImage {
url: string;
altText?: string;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
}
interface Product {
id: string;
title: string;
description?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
}
interface ProductsProps {
title?: string;
limit?: number;
showLoadMore?: boolean;
}
const Products: React.FC<ProductsProps> = ({
title = "Shop All",
limit = 12,
showLoadMore = true
}) => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreProducts, setHasMoreProducts] = useState(true);
const fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => {
try {
if (loadMore) {
setLoadingMore(true);
} else {
setLoading(true);
setError(null);
}
const newProducts = await getProducts({
first: limit,
sortKey: 'CREATED_AT',
reverse: true
});
if (loadMore) {
// Filter out products that already exist
const existingIds = new Set(currentProducts.map(p => p.id));
const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id));
if (uniqueNewProducts.length === 0) {
setHasMoreProducts(false);
} else {
setProducts(prev => [...prev, ...uniqueNewProducts]);
}
} else {
setProducts(newProducts);
setHasMoreProducts(newProducts.length === limit);
}
} catch (err) {
console.error('Error fetching products:', err);
setError(err instanceof Error ? err.message : 'Failed to load products');
} finally {
setLoading(false);
setLoadingMore(false);
}
};
useEffect(() => {
fetchProducts();
}, [limit]);
const handleAddToCart = async (product: Product) => {
// Here you would typically integrate with cart functionality
console.log('Adding to cart:', product);
};
const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) {
fetchProducts(products, true);
}
};
if (loading) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
{title}
</h2>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="mt-3 h-4 w-2/3 bg-gray-200 rounded"></div>
<div className="mt-2 h-4 w-1/4 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-medium tracking-tight mb-8 text-gray-900 font-heading">
{title}
</h2>
<div className="bg-red-50 border border-red-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-error-warning-line text-4xl text-red-500 mb-4 block"></i>
<h3 className="text-lg font-medium text-red-800 mb-2">
Failed to Load Products
</h3>
<p className="text-red-600 mb-4">
{error}
</p>
<Button
onClick={() => fetchProducts()}
variant="destructive"
>
Try Again
</Button>
</div>
</div>
</div>
);
}
if (products.length === 0) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-medium tracking-tight mb-8 text-gray-900 font-heading">
{title}
</h2>
<div className="bg-gray-50 border border-gray-200 rounded-md p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-medium text-gray-600 mb-2">
No Products Found
</h3>
<p className="text-gray-500">
Check back later or configure your Shopify store connection.
</p>
</div>
</div>
</div>
);
}
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-medium tracking-tight text-center mb-12 text-gray-900 font-heading">
{title}
</h2>
{/* Products Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
{/* Load More Button */}
{showLoadMore && hasMoreProducts && (
<div className="text-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
size="lg"
className="font-heading"
>
{loadingMore ? (
<span className="flex items-center space-x-2">
<Loader size={16} />
<span>Loading...</span>
</span>
) : (
'Load More Products'
)}
</Button>
</div>
)}
</div>
</div>
);
};
export default Products;

View File

@@ -0,0 +1,55 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
} from '@/components/ui/carousel';
const PROMOS = [
{ text: 'Send a Gift Card', href: '/' },
{ text: 'Free shipping on orders over $100', href: '/' },
{ text: '30-day return policy', href: '/' },
];
const PromoBanner: React.FC = () => {
return (
<div className="bg-muted">
<Carousel opts={{ loop: true }} className="container mx-auto px-4">
<CarouselContent>
{PROMOS.map((promo) => (
<CarouselItem key={promo.text}>
<div className="h-10 flex items-center justify-center">
<Link
href={promo.href}
className="text-xs font-medium text-gray-900 underline underline-offset-2 hover:text-gray-600 transition-colors"
>
{promo.text}
</Link>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious
variant="ghost"
className="left-0 size-7 text-gray-900 hover:text-gray-600"
>
<RiArrowLeftSLine size={16} />
</CarouselPrevious>
<CarouselNext
variant="ghost"
className="right-0 size-7 text-gray-900 hover:text-gray-600"
>
<RiArrowRightSLine size={16} />
</CarouselNext>
</Carousel>
</div>
);
};
export default PromoBanner;

View File

@@ -0,0 +1,114 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Command } from 'cmdk';
import { RiSearchLine } from '@remixicon/react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Loader } from '@/components/ui/loader';
import { getProducts, type Product } from '@/hooks/use-shopify-products';
interface SearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onOpenChange }) => {
const router = useRouter();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
// Load the catalog once per open; cmdk filters it as the user types
useEffect(() => {
if (!open) {
setQuery('');
return;
}
let cancelled = false;
setLoading(true);
getProducts({ first: 100, sortKey: 'TITLE' })
.then((data) => {
if (!cancelled) setProducts(data);
})
.catch((err) => {
console.error('Failed to load products for search:', err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open]);
const handleSelect = (handle: string) => {
onOpenChange(false);
router.push(`/products/${handle}`);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0 overflow-hidden [&>button]:hidden">
<DialogTitle className="sr-only">Search products</DialogTitle>
<Command label="Search products">
<div className="flex items-center gap-2 border-b border-gray-200 px-4">
<RiSearchLine size={16} className="text-gray-400 shrink-0" />
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Search products..."
className="h-12 w-full bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400"
/>
</div>
<Command.List className="max-h-80 overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader size={20} />
</div>
) : (
<>
<Command.Empty className="py-8 text-center text-sm text-gray-500">
No products found.
</Command.Empty>
{products.map((product) => {
const image = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
return (
<Command.Item
key={product.id}
value={product.title}
onSelect={() => handleSelect(product.handle)}
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm data-[selected=true]:bg-gray-100"
>
<div className="w-10 h-10 bg-gray-100 overflow-hidden shrink-0">
{image && (
<img
src={image.url}
alt={image.altText || product.title}
className="w-full h-full object-cover"
/>
)}
</div>
<span className="flex-1 truncate font-medium text-gray-900">
{product.title}
</span>
<span className="text-gray-500 shrink-0">
${parseFloat(price.amount).toFixed(2)}
</span>
</Command.Item>
);
})}
</>
)}
</Command.List>
</Command>
</DialogContent>
</Dialog>
);
};
export default SearchDialog;

View File

@@ -0,0 +1,16 @@
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="bg-white border-t border-gray-200 py-6">
<div className="container mx-auto px-4 flex items-center justify-between text-sm text-gray-500">
<span className="font-medium text-gray-900 font-heading">
Store
</span>
<p>&copy; 2025 Store. All rights reserved.</p>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,83 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import config from '@/lib/config.json';
import { RiSearchLine, RiShoppingBagLine } from '@remixicon/react';
import SearchDialog from './search-dialog';
const CartIcon: React.FC = () => {
const { toggleCart, itemCount } = useShopifyCart();
return (
<button
onClick={toggleCart}
className="relative p-1 text-black hover:text-gray-600 transition-colors"
>
<RiShoppingBagLine size={20} />
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 bg-black text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center font-medium">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</button>
);
};
const Header: React.FC = () => {
const [searchOpen, setSearchOpen] = useState(false);
return (
<nav className="bg-white/70 backdrop-blur-md border-b border-gray-200/60 sticky top-0 z-30 h-14">
<div className="container mx-auto px-4 h-full">
<div className="flex justify-between items-center h-full">
{/* Logo */}
<Link href="/" className="text-lg font-medium text-black font-heading">
{config.brand.logo.url ? (
<img
src={config.brand.logo.url}
alt={config.brand.logo.alt || 'Store'}
className="h-8"
/>
) : (
'Store'
)}
</Link>
{/* Navigation Links */}
<div className="flex items-center space-x-6">
<Link
href="/"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Products
</Link>
<Link
href="/collections"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Collections
</Link>
{/* Search + Cart Icons */}
<div className="flex items-center space-x-2">
<button
onClick={() => setSearchOpen(true)}
className="p-1 text-black hover:text-gray-600 transition-colors"
aria-label="Search products"
>
<RiSearchLine size={20} />
</button>
<CartIcon />
</div>
</div>
</div>
</div>
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
</nav>
);
};
export default Header;