@instockng/storefront-ui 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/components/Checkout.d.ts.map +1 -1
- package/dist/components/ShoppingCart.d.ts.map +1 -1
- package/dist/contexts/CartContext.d.ts.map +1 -1
- package/dist/index10.mjs +144 -141
- package/dist/index101.mjs +1 -1
- package/dist/index102.mjs +3 -3
- package/dist/index103.mjs +3 -3
- package/dist/index105.mjs +1 -1
- package/dist/index111.mjs +1 -1
- package/dist/index20.mjs +2 -2
- package/dist/index21.mjs +1 -1
- package/dist/index28.mjs +11 -11
- package/dist/index3.mjs +88 -78
- package/dist/index37.mjs +1 -1
- package/dist/index41.mjs +36 -23
- package/dist/index42.mjs +44 -36
- package/dist/index43.mjs +99 -44
- package/dist/index44.mjs +112 -99
- package/dist/index45.mjs +44 -80
- package/dist/index46.mjs +64 -53
- package/dist/index47.mjs +65 -48
- package/dist/index48.mjs +54 -73
- package/dist/index49.mjs +52 -63
- package/dist/index50.mjs +14 -70
- package/dist/index51.mjs +13 -14
- package/dist/index52.mjs +58 -13
- package/dist/index53.mjs +101 -34
- package/dist/index54.mjs +99 -95
- package/dist/index55.mjs +22 -132
- package/dist/index62.mjs +30 -231
- package/dist/index63.mjs +42 -5
- package/dist/index64.mjs +228 -127
- package/dist/index65.mjs +4 -66
- package/dist/index66.mjs +124 -77
- package/dist/index67.mjs +65 -26
- package/dist/index68.mjs +84 -6
- package/dist/index69.mjs +26 -72
- package/dist/index70.mjs +8 -3
- package/dist/index71.mjs +75 -2
- package/dist/index72.mjs +3 -82
- package/dist/index73.mjs +2 -54
- package/dist/index74.mjs +82 -5
- package/dist/index75.mjs +53 -4
- package/dist/index76.mjs +5 -178
- package/dist/index77.mjs +5 -53
- package/dist/index78.mjs +178 -68
- package/dist/index79.mjs +50 -31
- package/dist/index8.mjs +8 -7
- package/dist/index80.mjs +69 -43
- package/dist/index81.mjs +2 -2
- package/dist/index82.mjs +1 -1
- package/dist/index83.mjs +6 -6
- package/dist/index84.mjs +2 -2
- package/dist/index85.mjs +2 -2
- package/dist/index87.mjs +2 -2
- package/dist/index88.mjs +2 -2
- package/dist/index89.mjs +1 -1
- package/dist/index91.mjs +4 -4
- package/dist/index92.mjs +3 -3
- package/dist/index93.mjs +12 -30
- package/dist/index94.mjs +7 -11
- package/dist/index95.mjs +30 -3
- package/dist/index96.mjs +10 -3
- package/dist/index97.mjs +4 -13
- package/dist/index98.mjs +4 -7
- package/dist/index99.mjs +1 -1
- package/dist/styles.css +1 -0
- package/package.json +14 -13
- package/src/components/CartItem.stories.tsx +94 -0
- package/src/components/CartItem.tsx +141 -0
- package/src/components/Checkout.stories.tsx +380 -0
- package/src/components/Checkout.tsx +954 -0
- package/src/components/DiscountCodeInput.stories.tsx +76 -0
- package/src/components/DiscountCodeInput.tsx +162 -0
- package/src/components/OrderConfirmation.stories.tsx +142 -0
- package/src/components/OrderConfirmation.tsx +301 -0
- package/src/components/ProductCard.stories.tsx +112 -0
- package/src/components/ProductCard.tsx +195 -0
- package/src/components/ProductGrid.stories.tsx +137 -0
- package/src/components/ProductGrid.tsx +141 -0
- package/src/components/ShoppingCart.stories.tsx +459 -0
- package/src/components/ShoppingCart.tsx +263 -0
- package/src/components/ui/badge.tsx +37 -0
- package/src/components/ui/button.tsx +71 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/form-input.tsx +78 -0
- package/src/components/ui/form-select.tsx +73 -0
- package/src/components/ui/modal.tsx +181 -0
- package/src/contexts/CartContext.tsx +316 -0
- package/src/hooks/usePaystackPayment.ts +137 -0
- package/src/index.ts +51 -0
- package/src/lib/utils.ts +45 -0
- package/src/paystack.svg +67 -0
- package/src/providers/StorefrontProvider.tsx +70 -0
- package/src/styles.css +1 -0
- package/src/test-utils/MockCartProvider.tsx +424 -0
- package/src/vite-env.d.ts +12 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ProductCard, type ProductCardProduct } from './ProductCard';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ProductCard',
|
|
6
|
+
component: ProductCard,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
},
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
} satisfies Meta<typeof ProductCard>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
// Mock product data
|
|
17
|
+
const mockProduct: ProductCardProduct = {
|
|
18
|
+
id: '1',
|
|
19
|
+
name: 'Premium Cotton T-Shirt',
|
|
20
|
+
description: 'Comfortable and stylish cotton t-shirt perfect for everyday wear',
|
|
21
|
+
imageUrl: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400',
|
|
22
|
+
brand: {
|
|
23
|
+
name: 'Fashion Brand',
|
|
24
|
+
logoUrl: 'https://via.placeholder.com/100x50',
|
|
25
|
+
},
|
|
26
|
+
variants: [
|
|
27
|
+
{
|
|
28
|
+
id: 'v1',
|
|
29
|
+
name: 'Small - Blue',
|
|
30
|
+
price: 29.99,
|
|
31
|
+
sku: 'TSHIRT-SM-BLUE',
|
|
32
|
+
stock: 15,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'v2',
|
|
36
|
+
name: 'Medium - Blue',
|
|
37
|
+
price: 29.99,
|
|
38
|
+
sku: 'TSHIRT-MD-BLUE',
|
|
39
|
+
stock: 20,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'v3',
|
|
43
|
+
name: 'Large - Blue',
|
|
44
|
+
price: 29.99,
|
|
45
|
+
sku: 'TSHIRT-LG-BLUE',
|
|
46
|
+
stock: 8,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Default: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
product: mockProduct,
|
|
54
|
+
onAddToCart: (variantId, quantity) => {
|
|
55
|
+
console.log('Add to cart:', variantId, quantity);
|
|
56
|
+
},
|
|
57
|
+
showStock: true,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const OutOfStock: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
product: {
|
|
64
|
+
...mockProduct,
|
|
65
|
+
variants: mockProduct.variants.map((v) => ({ ...v, stock: 0 })),
|
|
66
|
+
},
|
|
67
|
+
showStock: true,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const LowStock: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
product: {
|
|
74
|
+
...mockProduct,
|
|
75
|
+
variants: [
|
|
76
|
+
{
|
|
77
|
+
id: 'v1',
|
|
78
|
+
name: 'Last One!',
|
|
79
|
+
price: 29.99,
|
|
80
|
+
sku: 'TSHIRT-SM-RED',
|
|
81
|
+
stock: 2,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
showStock: true,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const SingleVariant: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
product: {
|
|
92
|
+
...mockProduct,
|
|
93
|
+
variants: [mockProduct.variants[0]],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const NoImage: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
product: {
|
|
101
|
+
...mockProduct,
|
|
102
|
+
imageUrl: undefined,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const Loading: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
product: mockProduct,
|
|
110
|
+
isAddingToCart: true,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProductCard Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a product with image, name, price, and add to cart button.
|
|
5
|
+
* Handles variant selection if product has multiple variants.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import { Card, CardContent, CardFooter } from './ui/card';
|
|
10
|
+
import { Button } from './ui/button';
|
|
11
|
+
import { Badge } from './ui/badge';
|
|
12
|
+
import { ShoppingCart, Package } from 'lucide-react';
|
|
13
|
+
import { formatCurrency, cn } from '../lib/utils';
|
|
14
|
+
|
|
15
|
+
// Simplified product type based on common OMS structure
|
|
16
|
+
export interface ProductCardProduct {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
imageUrl?: string;
|
|
21
|
+
brand?: {
|
|
22
|
+
name: string;
|
|
23
|
+
logoUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
variants: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
price: number;
|
|
29
|
+
sku: string;
|
|
30
|
+
stock?: number;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProductCardProps {
|
|
35
|
+
product: ProductCardProduct;
|
|
36
|
+
/** Callback when add to cart is clicked */
|
|
37
|
+
onAddToCart?: (variantId: string, quantity: number) => void;
|
|
38
|
+
/** Show stock information */
|
|
39
|
+
showStock?: boolean;
|
|
40
|
+
/** Custom class names */
|
|
41
|
+
className?: string;
|
|
42
|
+
/** Image class name */
|
|
43
|
+
imageClassName?: string;
|
|
44
|
+
/** Disable add to cart button */
|
|
45
|
+
disableAddToCart?: boolean;
|
|
46
|
+
/** Loading state for add to cart */
|
|
47
|
+
isAddingToCart?: boolean;
|
|
48
|
+
/** Click handler for card */
|
|
49
|
+
onClick?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ProductCard({
|
|
53
|
+
product,
|
|
54
|
+
onAddToCart,
|
|
55
|
+
showStock = true,
|
|
56
|
+
className,
|
|
57
|
+
imageClassName,
|
|
58
|
+
disableAddToCart = false,
|
|
59
|
+
isAddingToCart = false,
|
|
60
|
+
onClick,
|
|
61
|
+
}: ProductCardProps) {
|
|
62
|
+
const [selectedVariantId, setSelectedVariantId] = useState<string>(
|
|
63
|
+
product.variants[0]?.id || ''
|
|
64
|
+
);
|
|
65
|
+
const [quantity, setQuantity] = useState(1);
|
|
66
|
+
|
|
67
|
+
const selectedVariant = product.variants.find((v) => v.id === selectedVariantId);
|
|
68
|
+
const hasMultipleVariants = product.variants.length > 1;
|
|
69
|
+
|
|
70
|
+
// Check if product is in stock
|
|
71
|
+
const isInStock = selectedVariant
|
|
72
|
+
? selectedVariant.stock === undefined || selectedVariant.stock > 0
|
|
73
|
+
: false;
|
|
74
|
+
|
|
75
|
+
const handleAddToCart = (e: React.MouseEvent) => {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
if (selectedVariant && onAddToCart) {
|
|
78
|
+
onAddToCart(selectedVariant.id, quantity);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Card
|
|
84
|
+
className={cn(
|
|
85
|
+
'group overflow-hidden transition-all hover:shadow-lg',
|
|
86
|
+
onClick && 'cursor-pointer',
|
|
87
|
+
className
|
|
88
|
+
)}
|
|
89
|
+
onClick={onClick}
|
|
90
|
+
>
|
|
91
|
+
{/* Product Image */}
|
|
92
|
+
<div className="relative aspect-square overflow-hidden bg-gray-100">
|
|
93
|
+
{product.imageUrl ? (
|
|
94
|
+
<img
|
|
95
|
+
src={product.imageUrl}
|
|
96
|
+
alt={product.name}
|
|
97
|
+
className={cn(
|
|
98
|
+
'h-full w-full object-cover transition-transform group-hover:scale-105',
|
|
99
|
+
imageClassName
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
) : (
|
|
103
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
104
|
+
<Package className="h-16 w-16 text-gray-400" />
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Out of stock badge */}
|
|
109
|
+
{!isInStock && (
|
|
110
|
+
<Badge className="absolute right-2 top-2 bg-red-600">Out of Stock</Badge>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/* Stock badge */}
|
|
114
|
+
{showStock && isInStock && selectedVariant?.stock !== undefined && selectedVariant.stock < 10 && (
|
|
115
|
+
<Badge className="absolute right-2 top-2 bg-orange-600">
|
|
116
|
+
Only {selectedVariant.stock} left
|
|
117
|
+
</Badge>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<CardContent className="p-4">
|
|
122
|
+
{/* Product Name */}
|
|
123
|
+
<h3 className="mb-1 font-semibold line-clamp-2">{product.name}</h3>
|
|
124
|
+
|
|
125
|
+
{/* Description */}
|
|
126
|
+
{product.description && (
|
|
127
|
+
<p className="mb-2 text-sm text-gray-600 line-clamp-2">{product.description}</p>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Variant Selector */}
|
|
131
|
+
{hasMultipleVariants && (
|
|
132
|
+
<div className="mb-3">
|
|
133
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">
|
|
134
|
+
Select Option
|
|
135
|
+
</label>
|
|
136
|
+
<select
|
|
137
|
+
value={selectedVariantId}
|
|
138
|
+
onChange={(e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
setSelectedVariantId(e.target.value);
|
|
141
|
+
}}
|
|
142
|
+
className="w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
143
|
+
onClick={(e) => e.stopPropagation()}
|
|
144
|
+
>
|
|
145
|
+
{product.variants.map((variant) => (
|
|
146
|
+
<option key={variant.id} value={variant.id}>
|
|
147
|
+
{variant.name} - {formatCurrency(variant.price)}
|
|
148
|
+
</option>
|
|
149
|
+
))}
|
|
150
|
+
</select>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{/* Price */}
|
|
155
|
+
<div className="mb-3 text-2xl font-bold text-blue-600">
|
|
156
|
+
{selectedVariant && formatCurrency(selectedVariant.price)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Quantity Selector */}
|
|
160
|
+
<div className="flex items-center gap-2">
|
|
161
|
+
<label className="text-xs font-medium text-gray-700">Qty:</label>
|
|
162
|
+
<input
|
|
163
|
+
type="number"
|
|
164
|
+
min="1"
|
|
165
|
+
max={selectedVariant?.stock || 999}
|
|
166
|
+
value={quantity}
|
|
167
|
+
onChange={(e) => {
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
setQuantity(Math.max(1, parseInt(e.target.value) || 1));
|
|
170
|
+
}}
|
|
171
|
+
onClick={(e) => e.stopPropagation()}
|
|
172
|
+
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</CardContent>
|
|
176
|
+
|
|
177
|
+
<CardFooter className="p-4 pt-0">
|
|
178
|
+
<Button
|
|
179
|
+
onClick={handleAddToCart}
|
|
180
|
+
disabled={disableAddToCart || !isInStock || isAddingToCart}
|
|
181
|
+
className="w-full"
|
|
182
|
+
>
|
|
183
|
+
{isAddingToCart ? (
|
|
184
|
+
<>Adding...</>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<ShoppingCart className="mr-2 h-4 w-4" />
|
|
188
|
+
Add to Cart
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
</Button>
|
|
192
|
+
</CardFooter>
|
|
193
|
+
</Card>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { ProductGrid } from './ProductGrid';
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { fn } from '@storybook/test';
|
|
5
|
+
|
|
6
|
+
// Mock products data
|
|
7
|
+
const mockProductsResponse = {
|
|
8
|
+
data: [
|
|
9
|
+
{
|
|
10
|
+
id: '1',
|
|
11
|
+
name: 'Premium Cotton T-Shirt',
|
|
12
|
+
description: 'Comfortable and stylish cotton t-shirt',
|
|
13
|
+
thumbnailUrl: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400',
|
|
14
|
+
variants: [
|
|
15
|
+
{ id: 'v1', name: 'Small - Blue', price: 29.99, sku: 'TSHIRT-SM-BLUE', stock: 15 },
|
|
16
|
+
{ id: 'v2', name: 'Medium - Blue', price: 29.99, sku: 'TSHIRT-MD-BLUE', stock: 20 },
|
|
17
|
+
{ id: 'v3', name: 'Large - Blue', price: 29.99, sku: 'TSHIRT-LG-BLUE', stock: 8 },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: '2',
|
|
22
|
+
name: 'Slim Fit Jeans',
|
|
23
|
+
description: 'Classic denim jeans with modern fit',
|
|
24
|
+
thumbnailUrl: 'https://images.unsplash.com/photo-1542272604-787c3835535d?w=400',
|
|
25
|
+
variants: [
|
|
26
|
+
{ id: 'v4', name: 'Medium - Black', price: 59.99, sku: 'JEANS-MD-BLACK', stock: 12 },
|
|
27
|
+
{ id: 'v5', name: 'Large - Black', price: 59.99, sku: 'JEANS-LG-BLACK', stock: 5 },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: '3',
|
|
32
|
+
name: 'Casual Sneakers',
|
|
33
|
+
description: 'Comfortable everyday sneakers',
|
|
34
|
+
thumbnailUrl: 'https://images.unsplash.com/photo-1549298916-b41d501d3772?w=400',
|
|
35
|
+
variants: [
|
|
36
|
+
{ id: 'v6', name: 'Size 9 - White', price: 79.99, sku: 'SNEAKER-9-WHITE', stock: 0 },
|
|
37
|
+
{ id: 'v7', name: 'Size 10 - White', price: 79.99, sku: 'SNEAKER-10-WHITE', stock: 3 },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: '4',
|
|
42
|
+
name: 'Leather Wallet',
|
|
43
|
+
description: 'Premium leather wallet with multiple compartments',
|
|
44
|
+
thumbnailUrl: 'https://images.unsplash.com/photo-1627123424574-724758594e93?w=400',
|
|
45
|
+
variants: [
|
|
46
|
+
{ id: 'v8', name: 'Brown', price: 39.99, sku: 'WALLET-BROWN', stock: 25 },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const queryClient = new QueryClient({
|
|
53
|
+
defaultOptions: {
|
|
54
|
+
queries: {
|
|
55
|
+
retry: false,
|
|
56
|
+
queryFn: async () => mockProductsResponse,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const meta: Meta<typeof ProductGrid> = {
|
|
62
|
+
title: 'Components/ProductGrid',
|
|
63
|
+
component: ProductGrid,
|
|
64
|
+
parameters: {
|
|
65
|
+
layout: 'padded',
|
|
66
|
+
},
|
|
67
|
+
tags: ['autodocs'],
|
|
68
|
+
decorators: [
|
|
69
|
+
(Story) => (
|
|
70
|
+
<QueryClientProvider client={queryClient}>
|
|
71
|
+
<Story />
|
|
72
|
+
</QueryClientProvider>
|
|
73
|
+
),
|
|
74
|
+
],
|
|
75
|
+
} satisfies Meta<typeof ProductGrid>;
|
|
76
|
+
|
|
77
|
+
export default meta;
|
|
78
|
+
type Story = StoryObj<typeof meta>;
|
|
79
|
+
|
|
80
|
+
export const ThreeColumns: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
brandId: 'brand-1',
|
|
83
|
+
columns: 3,
|
|
84
|
+
onAddToCart: fn(),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const TwoColumns: Story = {
|
|
89
|
+
args: {
|
|
90
|
+
brandId: 'brand-1',
|
|
91
|
+
columns: 2,
|
|
92
|
+
onAddToCart: fn(),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const FourColumns: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
brandId: 'brand-1',
|
|
99
|
+
columns: 4,
|
|
100
|
+
onAddToCart: fn(),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const WithStockInfo: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
brandId: 'brand-1',
|
|
107
|
+
columns: 3,
|
|
108
|
+
showStock: true,
|
|
109
|
+
onAddToCart: fn(),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const WithoutStockInfo: Story = {
|
|
114
|
+
args: {
|
|
115
|
+
brandId: 'brand-1',
|
|
116
|
+
columns: 3,
|
|
117
|
+
showStock: false,
|
|
118
|
+
onAddToCart: fn(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const WithClickHandler: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
brandId: 'brand-1',
|
|
125
|
+
columns: 3,
|
|
126
|
+
onAddToCart: fn(),
|
|
127
|
+
onProductClick: fn(),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const CustomCurrency: Story = {
|
|
132
|
+
args: {
|
|
133
|
+
brandId: 'brand-1',
|
|
134
|
+
columns: 3,
|
|
135
|
+
onAddToCart: fn(),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProductGrid Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a grid of products using ProductCard.
|
|
5
|
+
* Handles loading, empty states, and integrates with useGetProducts hook.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useGetProducts } from '@oms/api-client';
|
|
9
|
+
import { ProductCard, type ProductCardProduct } from './ProductCard';
|
|
10
|
+
import { Loader2, Package } from 'lucide-react';
|
|
11
|
+
import { cn } from '../lib/utils';
|
|
12
|
+
|
|
13
|
+
export interface ProductGridProps {
|
|
14
|
+
/** Brand UUID to fetch products for */
|
|
15
|
+
brandId: string;
|
|
16
|
+
/** Callback when add to cart is clicked */
|
|
17
|
+
onAddToCart?: (variantId: string, quantity: number) => void;
|
|
18
|
+
/** Callback when product card is clicked */
|
|
19
|
+
onProductClick?: (product: ProductCardProduct) => void;
|
|
20
|
+
/** Number of columns in grid */
|
|
21
|
+
columns?: 2 | 3 | 4;
|
|
22
|
+
/** Show stock information */
|
|
23
|
+
showStock?: boolean;
|
|
24
|
+
/** Custom class name for grid container */
|
|
25
|
+
className?: string;
|
|
26
|
+
/** Custom class name for product cards */
|
|
27
|
+
cardClassName?: string;
|
|
28
|
+
/** Show loading state */
|
|
29
|
+
showLoading?: boolean;
|
|
30
|
+
/** Custom empty state message */
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
/** Custom error message */
|
|
33
|
+
errorMessage?: string;
|
|
34
|
+
/** Loading variant ID (to show loading state on specific card) */
|
|
35
|
+
loadingVariantId?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ProductGrid({
|
|
39
|
+
brandId,
|
|
40
|
+
onAddToCart,
|
|
41
|
+
onProductClick,
|
|
42
|
+
columns = 3,
|
|
43
|
+
showStock = true,
|
|
44
|
+
className,
|
|
45
|
+
cardClassName,
|
|
46
|
+
showLoading = true,
|
|
47
|
+
emptyMessage = 'No products available at the moment.',
|
|
48
|
+
errorMessage = 'Failed to load products. Please try again later.',
|
|
49
|
+
loadingVariantId,
|
|
50
|
+
}: ProductGridProps) {
|
|
51
|
+
const { data, isLoading, error } = useGetProducts(brandId);
|
|
52
|
+
|
|
53
|
+
// Loading state
|
|
54
|
+
if (isLoading && showLoading) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex min-h-[400px] items-center justify-center">
|
|
57
|
+
<div className="text-center">
|
|
58
|
+
<Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-blue-600" />
|
|
59
|
+
<p className="text-gray-600">Loading products...</p>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Error state
|
|
66
|
+
if (error) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex min-h-[400px] items-center justify-center">
|
|
69
|
+
<div className="text-center">
|
|
70
|
+
<Package className="mx-auto mb-4 h-12 w-12 text-red-600" />
|
|
71
|
+
<p className="text-red-600">{errorMessage}</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Get products array from response
|
|
78
|
+
const products = data || [];
|
|
79
|
+
|
|
80
|
+
// Empty state
|
|
81
|
+
if (products.length === 0) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex min-h-[400px] items-center justify-center">
|
|
84
|
+
<div className="text-center">
|
|
85
|
+
<Package className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
|
86
|
+
<p className="text-gray-600">{emptyMessage}</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Column class mapping
|
|
93
|
+
const gridColsClass = {
|
|
94
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
95
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
96
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
'grid gap-6',
|
|
103
|
+
gridColsClass[columns],
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{products.map((product: any) => {
|
|
108
|
+
// Convert product to ProductCardProduct format
|
|
109
|
+
const productCardData: ProductCardProduct = {
|
|
110
|
+
id: product.id,
|
|
111
|
+
name: product.name,
|
|
112
|
+
description: product.description || undefined,
|
|
113
|
+
imageUrl: product.thumbnailUrl || undefined,
|
|
114
|
+
brand: undefined, // Brand info not included in product response
|
|
115
|
+
variants: product.variants.map((v: any) => ({
|
|
116
|
+
id: v.id,
|
|
117
|
+
name: v.name || v.sku,
|
|
118
|
+
price: typeof v.price === 'string' ? parseFloat(v.price) : v.price,
|
|
119
|
+
sku: v.sku,
|
|
120
|
+
stock: v.stock || undefined,
|
|
121
|
+
})),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Check if any variant of this product is currently being added to cart
|
|
125
|
+
const isAddingToCart = product.variants.some((v: any) => v.id === loadingVariantId);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<ProductCard
|
|
129
|
+
key={product.id}
|
|
130
|
+
product={productCardData}
|
|
131
|
+
onAddToCart={onAddToCart}
|
|
132
|
+
onClick={onProductClick ? () => onProductClick(productCardData) : undefined}
|
|
133
|
+
showStock={showStock}
|
|
134
|
+
className={cardClassName}
|
|
135
|
+
isAddingToCart={isAddingToCart}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|