Update default shopify site design
This commit is contained in:
@@ -7,7 +7,7 @@ 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 ProductRecommendations from '../product-recommendations';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import { RiErrorWarningLine } from '@remixicon/react';
|
||||
@@ -38,7 +38,7 @@ interface ProductVariant {
|
||||
};
|
||||
}
|
||||
|
||||
export type { Product };
|
||||
export type { Product, ProductVariant };
|
||||
|
||||
interface ProductDetailProps {
|
||||
handle?: string;
|
||||
@@ -54,7 +54,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [addingToCart, setAddingToCart] = useState(false);
|
||||
|
||||
// Initialize variant when product loads
|
||||
@@ -86,17 +85,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
|
||||
if (matchingVariant) {
|
||||
setSelectedVariant(matchingVariant.node);
|
||||
|
||||
// Update image if variant has an associated image
|
||||
if (matchingVariant.node.image && product) {
|
||||
const variantImageUrl = matchingVariant.node.image.url;
|
||||
const imageIndex = product.images.edges.findIndex(
|
||||
edge => edge.node.url === variantImageUrl
|
||||
);
|
||||
if (imageIndex !== -1) {
|
||||
setSelectedImageIndex(imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,15 +105,12 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Image Gallery Skeleton */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div>
|
||||
))}
|
||||
</div>
|
||||
<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 */}
|
||||
@@ -146,7 +131,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
<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-semibold">
|
||||
<AlertTitle className="text-lg font-medium">
|
||||
Product Not Found
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mb-4">
|
||||
@@ -174,33 +159,29 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/shop">Shop</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{product.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<ProductDetailGallery
|
||||
images={product.images.edges.map(edge => edge.node)}
|
||||
selectedImageIndex={selectedImageIndex}
|
||||
onImageSelect={setSelectedImageIndex}
|
||||
/>
|
||||
<ProductDetailInfo
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
selectedOptions={selectedOptions}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
handleAddToCart={handleAddToCart}
|
||||
onOptionChange={handleOptionChange}
|
||||
loading={addingToCart}
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
'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;
|
||||
@@ -9,59 +12,62 @@ interface ProductImage {
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: ProductImage[];
|
||||
selectedImageIndex?: number;
|
||||
onImageSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
images,
|
||||
selectedImageIndex = 0,
|
||||
onImageSelect
|
||||
}) => {
|
||||
const selectedImage = selectedImageIndex;
|
||||
const setSelectedImage = onImageSelect || (() => {});
|
||||
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>
|
||||
{/* Main Image */}
|
||||
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden mb-4">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<RiImageLine size={60} />
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
selectedImage === index
|
||||
? 'border-black'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product thumbnail'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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;
|
||||
export default ProductDetailGallery;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Product, ProductVariant } from './index.tsx';
|
||||
import { Product, ProductVariant } from './index';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import {
|
||||
RiSubtractLine,
|
||||
RiAddLine,
|
||||
@@ -45,18 +45,18 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4 font-heading">
|
||||
<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-4 mb-6">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
<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-xl text-gray-500 line-through">
|
||||
<span className="text-lg text-gray-400 line-through">
|
||||
{formatPrice(compareAtPrice)}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
@@ -77,10 +77,12 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Options */}
|
||||
{product.options.map(option => (
|
||||
{/* 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-semibold text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
{option.name}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -99,10 +101,10 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center border border-gray-300 rounded-lg w-fit">
|
||||
<div className="flex items-center border border-gray-200 rounded-md w-fit">
|
||||
<Button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
variant="ghost"
|
||||
@@ -111,7 +113,7 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
>
|
||||
<RiSubtractLine size={16} />
|
||||
</Button>
|
||||
<span className="w-10 text-center font-semibold">{quantity}</span>
|
||||
<span className="w-10 text-center text-sm font-medium">{quantity}</span>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
@@ -122,20 +124,20 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
{/* Add to Bag Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedVariant?.availableForSale || loading}
|
||||
size="lg"
|
||||
className="w-full text-lg"
|
||||
className="w-full h-14 rounded-full text-base"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<Loader size={16} />
|
||||
<span>Adding...</span>
|
||||
</span>
|
||||
) : selectedVariant?.availableForSale ? (
|
||||
'Add to Cart'
|
||||
'Add to Bag'
|
||||
) : (
|
||||
'Out of Stock'
|
||||
)}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
'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="bg-gray-50 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold 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 xl:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-square bg-gray-200"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
</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 xl:grid-cols-4 gap-8">
|
||||
{recommendations.slice(0, 4).map((recommendedProduct) => (
|
||||
<ProductCard
|
||||
key={recommendedProduct.id}
|
||||
product={recommendedProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductRecommendations;
|
||||
Reference in New Issue
Block a user