Update shopify collection
This commit is contained in:
216
components/shopify/cart-drawer.tsx
Normal file
216
components/shopify/cart-drawer.tsx
Normal 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;
|
||||
49
components/shopify/collection-card.tsx
Normal file
49
components/shopify/collection-card.tsx
Normal 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;
|
||||
108
components/shopify/collection-detail.tsx
Normal file
108
components/shopify/collection-detail.tsx
Normal 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'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;
|
||||
100
components/shopify/collections.tsx
Normal file
100
components/shopify/collections.tsx
Normal 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;
|
||||
129
components/shopify/product-card.tsx
Normal file
129
components/shopify/product-card.tsx
Normal 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;
|
||||
3
components/shopify/product-detail.tsx
Normal file
3
components/shopify/product-detail.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import ProductDetail from './product-detail/index';
|
||||
|
||||
export default ProductDetail;
|
||||
193
components/shopify/product-detail/index.tsx
Normal file
193
components/shopify/product-detail/index.tsx
Normal 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;
|
||||
73
components/shopify/product-detail/product-detail-gallery.tsx
Normal file
73
components/shopify/product-detail/product-detail-gallery.tsx
Normal 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;
|
||||
167
components/shopify/product-detail/product-detail-info.tsx
Normal file
167
components/shopify/product-detail/product-detail-info.tsx
Normal 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;
|
||||
55
components/shopify/product-recommendations.tsx
Normal file
55
components/shopify/product-recommendations.tsx
Normal 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;
|
||||
235
components/shopify/products.tsx
Normal file
235
components/shopify/products.tsx
Normal 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;
|
||||
55
components/shopify/promo-banner.tsx
Normal file
55
components/shopify/promo-banner.tsx
Normal 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;
|
||||
114
components/shopify/search-dialog.tsx
Normal file
114
components/shopify/search-dialog.tsx
Normal 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;
|
||||
16
components/shopify/shop-footer.tsx
Normal file
16
components/shopify/shop-footer.tsx
Normal 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>© 2025 Store. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
83
components/shopify/shop-header.tsx
Normal file
83
components/shopify/shop-header.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user