create-brainerce-store 1.1.0 → 1.2.0

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/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.1.0",
34
+ version: "1.2.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -1,346 +1,431 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useMemo } from 'react';
4
- import { useParams } from 'next/navigation';
5
- import Image from 'next/image';
6
- import Link from 'next/link';
7
- import type { Product, ProductVariant, ProductImage } from 'brainerce';
8
- import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
9
- import { getClient } from '@/lib/brainerce';
10
- import { useCart } from '@/providers/store-provider';
11
- import { PriceDisplay } from '@/components/shared/price-display';
12
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
13
- import { VariantSelector } from '@/components/products/variant-selector';
14
- import { StockBadge } from '@/components/products/stock-badge';
15
- import { cn } from '@/lib/utils';
16
-
17
- export default function ProductDetailPage() {
18
- const params = useParams();
19
- const slug = params.slug as string;
20
- const { refreshCart } = useCart();
21
- const [product, setProduct] = useState<Product | null>(null);
22
- const [loading, setLoading] = useState(true);
23
- const [error, setError] = useState<string | null>(null);
24
- const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
25
- const [selectedImageIndex, setSelectedImageIndex] = useState(0);
26
- const [quantity, setQuantity] = useState(1);
27
- const [addingToCart, setAddingToCart] = useState(false);
28
- const [addedMessage, setAddedMessage] = useState(false);
29
-
30
- // Load product
31
- useEffect(() => {
32
- async function load() {
33
- try {
34
- setLoading(true);
35
- setError(null);
36
- const client = getClient();
37
- const p = await client.getProductBySlug(slug);
38
- setProduct(p);
39
-
40
- // Auto-select first variant
41
- if (p.variants && p.variants.length > 0) {
42
- setSelectedVariant(p.variants[0]);
43
- }
44
- } catch {
45
- setError('Product not found.');
46
- } finally {
47
- setLoading(false);
48
- }
49
- }
50
- load();
51
- }, [slug]);
52
-
53
- // Images list - switch main image when variant changes
54
- const images: ProductImage[] = useMemo(() => {
55
- return product?.images || [];
56
- }, [product]);
57
-
58
- // When variant changes, update selected image to variant image if available
59
- useEffect(() => {
60
- if (!selectedVariant?.image || !product) return;
61
-
62
- const variantImgUrl =
63
- typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
64
-
65
- // Find if variant image exists in product images
66
- const idx = images.findIndex((img) => img.url === variantImgUrl);
67
- if (idx >= 0) {
68
- setSelectedImageIndex(idx);
69
- } else {
70
- // Variant image not in product images - select index 0 as fallback
71
- // (The variant image will be shown as the main image via override)
72
- setSelectedImageIndex(-1);
73
- }
74
- }, [selectedVariant, images, product]);
75
-
76
- // Determine which image to show
77
- const mainImageUrl = useMemo(() => {
78
- if (selectedImageIndex === -1 && selectedVariant?.image) {
79
- const img = selectedVariant.image;
80
- return typeof img === 'string' ? img : img.url;
81
- }
82
- return images[selectedImageIndex]?.url || null;
83
- }, [selectedImageIndex, selectedVariant, images]);
84
-
85
- // Price info - use variant price if selected, else product price
86
- const priceInfo = useMemo(() => {
87
- if (selectedVariant?.price) {
88
- return {
89
- price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
90
- originalPrice: parseFloat(selectedVariant.price),
91
- isOnSale:
92
- selectedVariant.salePrice != null &&
93
- parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
94
- discountPercent:
95
- selectedVariant.salePrice != null &&
96
- parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
97
- ? Math.round(
98
- ((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
99
- parseFloat(selectedVariant.price)) *
100
- 100
101
- )
102
- : 0,
103
- };
104
- }
105
- return getProductPriceInfo(product);
106
- }, [product, selectedVariant]);
107
-
108
- // Inventory: use variant inventory if selected, else product inventory
109
- const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
110
- const canPurchase = inventory?.canPurchase !== false;
111
-
112
- // Description
113
- const description = useMemo(() => {
114
- return product ? getDescriptionContent(product) : null;
115
- }, [product]);
116
-
117
- async function handleAddToCart() {
118
- if (!product || addingToCart) return;
119
-
120
- try {
121
- setAddingToCart(true);
122
- const client = getClient();
123
- await client.smartAddToCart({
124
- productId: product.id,
125
- variantId: selectedVariant?.id,
126
- quantity,
127
- name: product.name,
128
- price: String(priceInfo.price),
129
- image: mainImageUrl || undefined,
130
- });
131
- await refreshCart();
132
- setAddedMessage(true);
133
- setTimeout(() => setAddedMessage(false), 2000);
134
- } catch (err) {
135
- console.error('Failed to add to cart:', err);
136
- } finally {
137
- setAddingToCart(false);
138
- }
139
- }
140
-
141
- if (loading) {
142
- return (
143
- <div className="flex min-h-[60vh] items-center justify-center">
144
- <LoadingSpinner size="lg" />
145
- </div>
146
- );
147
- }
148
-
149
- if (error || !product) {
150
- return (
151
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
152
- <h1 className="text-foreground text-2xl font-bold">{error || 'Product not found'}</h1>
153
- <Link href="/products" className="text-primary mt-4 inline-block hover:underline">
154
- Back to products
155
- </Link>
156
- </div>
157
- );
158
- }
159
-
160
- return (
161
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
162
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
163
- {/* Image Gallery */}
164
- <div className="space-y-4">
165
- {/* Main Image */}
166
- <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
167
- {mainImageUrl ? (
168
- <Image
169
- src={mainImageUrl}
170
- alt={product.name}
171
- fill
172
- sizes="(max-width: 1024px) 100vw, 50vw"
173
- className="object-contain"
174
- priority
175
- />
176
- ) : (
177
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
178
- <svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
179
- <path
180
- strokeLinecap="round"
181
- strokeLinejoin="round"
182
- strokeWidth={1}
183
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
184
- />
185
- </svg>
186
- </div>
187
- )}
188
- </div>
189
-
190
- {/* Thumbnails */}
191
- {images.length > 1 && (
192
- <div className="flex gap-2 overflow-x-auto pb-2">
193
- {images.map((img, idx) => (
194
- <button
195
- key={idx}
196
- type="button"
197
- onClick={() => setSelectedImageIndex(idx)}
198
- className={cn(
199
- 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
200
- selectedImageIndex === idx
201
- ? 'border-primary'
202
- : 'border-border hover:border-muted-foreground'
203
- )}
204
- >
205
- <Image
206
- src={img.url}
207
- alt={img.alt || `${product.name} ${idx + 1}`}
208
- fill
209
- sizes="64px"
210
- className="object-cover"
211
- />
212
- </button>
213
- ))}
214
- </div>
215
- )}
216
- </div>
217
-
218
- {/* Product Info */}
219
- <div className="space-y-6">
220
- {/* Categories */}
221
- {product.categories && product.categories.length > 0 && (
222
- <div className="flex flex-wrap gap-2">
223
- {product.categories.map((cat) => (
224
- <span
225
- key={cat.id}
226
- className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
227
- >
228
- {cat.name}
229
- </span>
230
- ))}
231
- </div>
232
- )}
233
-
234
- {/* Title */}
235
- <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
236
-
237
- {/* Price */}
238
- <PriceDisplay
239
- price={priceInfo.originalPrice}
240
- salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
241
- size="lg"
242
- />
243
-
244
- {/* Stock */}
245
- <StockBadge inventory={inventory} lowStockThreshold={5} />
246
-
247
- {/* Variant Selector */}
248
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
249
- <VariantSelector
250
- product={product}
251
- selectedVariant={selectedVariant}
252
- onVariantChange={setSelectedVariant}
253
- />
254
- )}
255
-
256
- {/* Quantity + Add to Cart */}
257
- <div className="flex items-center gap-4">
258
- <div className="border-border flex items-center rounded border">
259
- <button
260
- type="button"
261
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
262
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
263
- aria-label="Decrease quantity"
264
- >
265
- -
266
- </button>
267
- <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
268
- {quantity}
269
- </span>
270
- <button
271
- type="button"
272
- onClick={() => setQuantity((q) => q + 1)}
273
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
274
- aria-label="Increase quantity"
275
- >
276
- +
277
- </button>
278
- </div>
279
-
280
- <button
281
- type="button"
282
- onClick={handleAddToCart}
283
- disabled={!canPurchase || addingToCart}
284
- className={cn(
285
- 'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
286
- canPurchase
287
- ? 'bg-primary text-primary-foreground hover:opacity-90'
288
- : 'bg-muted text-muted-foreground cursor-not-allowed'
289
- )}
290
- >
291
- {addingToCart ? (
292
- <span className="inline-flex items-center gap-2">
293
- <LoadingSpinner
294
- size="sm"
295
- className="border-primary-foreground/30 border-t-primary-foreground"
296
- />
297
- Adding...
298
- </span>
299
- ) : addedMessage ? (
300
- 'Added to Cart!'
301
- ) : !canPurchase ? (
302
- 'Out of Stock'
303
- ) : (
304
- 'Add to Cart'
305
- )}
306
- </button>
307
- </div>
308
-
309
- {/* Description */}
310
- {description && (
311
- <div className="border-border border-t pt-4">
312
- <h2 className="text-foreground mb-3 text-lg font-semibold">Description</h2>
313
- {'html' in description ? (
314
- <div
315
- className="prose prose-sm text-muted-foreground max-w-none"
316
- dangerouslySetInnerHTML={{ __html: description.html }}
317
- />
318
- ) : (
319
- <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
320
- )}
321
- </div>
322
- )}
323
-
324
- {/* Metafields / Specifications */}
325
- {product.metafields && product.metafields.length > 0 && (
326
- <div className="border-border border-t pt-4">
327
- <h2 className="text-foreground mb-3 text-lg font-semibold">Specifications</h2>
328
- <table className="w-full text-sm">
329
- <tbody>
330
- {product.metafields.map((field) => (
331
- <tr key={field.id} className="border-border border-b last:border-0">
332
- <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
333
- {field.definitionName}
334
- </td>
335
- <td className="text-muted-foreground py-2">{field.value}</td>
336
- </tr>
337
- ))}
338
- </tbody>
339
- </table>
340
- </div>
341
- )}
342
- </div>
343
- </div>
344
- </div>
345
- );
346
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useMemo } from 'react';
4
+ import { useParams } from 'next/navigation';
5
+ import Image from 'next/image';
6
+ import Link from 'next/link';
7
+ import type { Product, ProductVariant, ProductImage, ProductMetafield } from 'brainerce';
8
+ import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
9
+ import { getClient } from '@/lib/brainerce';
10
+ import { useCart } from '@/providers/store-provider';
11
+ import { PriceDisplay } from '@/components/shared/price-display';
12
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
13
+ import { VariantSelector } from '@/components/products/variant-selector';
14
+ import { StockBadge } from '@/components/products/stock-badge';
15
+ import { cn } from '@/lib/utils';
16
+
17
+ /** Render a metafield value based on its type */
18
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
19
+ switch (field.type) {
20
+ case 'IMAGE': {
21
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
22
+ return (
23
+ <img
24
+ src={field.value}
25
+ alt={field.definitionName}
26
+ className="h-16 w-16 rounded object-cover"
27
+ />
28
+ );
29
+ }
30
+ case 'GALLERY': {
31
+ let urls: string[] = [];
32
+ try {
33
+ const parsed = JSON.parse(field.value);
34
+ urls = Array.isArray(parsed)
35
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
36
+ : [];
37
+ } catch {
38
+ urls = field.value ? [field.value] : [];
39
+ }
40
+ if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
41
+ return (
42
+ <div className="flex flex-wrap gap-2">
43
+ {urls.map((url, i) => (
44
+ <img
45
+ key={i}
46
+ src={url}
47
+ alt={`${field.definitionName} ${i + 1}`}
48
+ className="h-16 w-16 rounded object-cover"
49
+ />
50
+ ))}
51
+ </div>
52
+ );
53
+ }
54
+ case 'URL':
55
+ return field.value ? (
56
+ <a
57
+ href={field.value}
58
+ target="_blank"
59
+ rel="noopener noreferrer"
60
+ className="text-primary break-all hover:underline"
61
+ >
62
+ {field.value}
63
+ </a>
64
+ ) : (
65
+ <span className="text-muted-foreground">-</span>
66
+ );
67
+ case 'COLOR':
68
+ return field.value ? (
69
+ <span className="inline-flex items-center gap-2">
70
+ <span
71
+ className="border-border inline-block h-4 w-4 rounded-full border"
72
+ style={{ backgroundColor: field.value }}
73
+ />
74
+ {field.value}
75
+ </span>
76
+ ) : (
77
+ <span className="text-muted-foreground">-</span>
78
+ );
79
+ case 'BOOLEAN':
80
+ return <span>{field.value === 'true' ? 'Yes' : 'No'}</span>;
81
+ case 'DATE':
82
+ case 'DATETIME': {
83
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
84
+ try {
85
+ const date = new Date(field.value);
86
+ return (
87
+ <span>
88
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
89
+ </span>
90
+ );
91
+ } catch {
92
+ return <span>{field.value}</span>;
93
+ }
94
+ }
95
+ default:
96
+ return <span>{field.value || '-'}</span>;
97
+ }
98
+ }
99
+
100
+ export default function ProductDetailPage() {
101
+ const params = useParams();
102
+ const slug = params.slug as string;
103
+ const { refreshCart } = useCart();
104
+ const [product, setProduct] = useState<Product | null>(null);
105
+ const [loading, setLoading] = useState(true);
106
+ const [error, setError] = useState<string | null>(null);
107
+ const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
108
+ const [selectedImageIndex, setSelectedImageIndex] = useState(0);
109
+ const [quantity, setQuantity] = useState(1);
110
+ const [addingToCart, setAddingToCart] = useState(false);
111
+ const [addedMessage, setAddedMessage] = useState(false);
112
+
113
+ // Load product
114
+ useEffect(() => {
115
+ async function load() {
116
+ try {
117
+ setLoading(true);
118
+ setError(null);
119
+ const client = getClient();
120
+ const p = await client.getProductBySlug(slug);
121
+ setProduct(p);
122
+
123
+ // Auto-select first variant
124
+ if (p.variants && p.variants.length > 0) {
125
+ setSelectedVariant(p.variants[0]);
126
+ }
127
+ } catch {
128
+ setError('Product not found.');
129
+ } finally {
130
+ setLoading(false);
131
+ }
132
+ }
133
+ load();
134
+ }, [slug]);
135
+
136
+ // Images list - switch main image when variant changes
137
+ const images: ProductImage[] = useMemo(() => {
138
+ return product?.images || [];
139
+ }, [product]);
140
+
141
+ // When variant changes, update selected image to variant image if available
142
+ useEffect(() => {
143
+ if (!selectedVariant?.image || !product) return;
144
+
145
+ const variantImgUrl =
146
+ typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
147
+
148
+ // Find if variant image exists in product images
149
+ const idx = images.findIndex((img) => img.url === variantImgUrl);
150
+ if (idx >= 0) {
151
+ setSelectedImageIndex(idx);
152
+ } else {
153
+ // Variant image not in product images - select index 0 as fallback
154
+ // (The variant image will be shown as the main image via override)
155
+ setSelectedImageIndex(-1);
156
+ }
157
+ }, [selectedVariant, images, product]);
158
+
159
+ // Determine which image to show
160
+ const mainImageUrl = useMemo(() => {
161
+ if (selectedImageIndex === -1 && selectedVariant?.image) {
162
+ const img = selectedVariant.image;
163
+ return typeof img === 'string' ? img : img.url;
164
+ }
165
+ return images[selectedImageIndex]?.url || null;
166
+ }, [selectedImageIndex, selectedVariant, images]);
167
+
168
+ // Price info - use variant price if selected, else product price
169
+ const priceInfo = useMemo(() => {
170
+ if (selectedVariant?.price) {
171
+ return {
172
+ price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
173
+ originalPrice: parseFloat(selectedVariant.price),
174
+ isOnSale:
175
+ selectedVariant.salePrice != null &&
176
+ parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
177
+ discountPercent:
178
+ selectedVariant.salePrice != null &&
179
+ parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
180
+ ? Math.round(
181
+ ((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
182
+ parseFloat(selectedVariant.price)) *
183
+ 100
184
+ )
185
+ : 0,
186
+ };
187
+ }
188
+ return getProductPriceInfo(product);
189
+ }, [product, selectedVariant]);
190
+
191
+ // Inventory: use variant inventory if selected, else product inventory
192
+ const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
193
+ const canPurchase = inventory?.canPurchase !== false;
194
+
195
+ // Description
196
+ const description = useMemo(() => {
197
+ return product ? getDescriptionContent(product) : null;
198
+ }, [product]);
199
+
200
+ async function handleAddToCart() {
201
+ if (!product || addingToCart) return;
202
+
203
+ try {
204
+ setAddingToCart(true);
205
+ const client = getClient();
206
+ await client.smartAddToCart({
207
+ productId: product.id,
208
+ variantId: selectedVariant?.id,
209
+ quantity,
210
+ name: product.name,
211
+ price: String(priceInfo.price),
212
+ image: mainImageUrl || undefined,
213
+ });
214
+ await refreshCart();
215
+ setAddedMessage(true);
216
+ setTimeout(() => setAddedMessage(false), 2000);
217
+ } catch (err) {
218
+ console.error('Failed to add to cart:', err);
219
+ } finally {
220
+ setAddingToCart(false);
221
+ }
222
+ }
223
+
224
+ if (loading) {
225
+ return (
226
+ <div className="flex min-h-[60vh] items-center justify-center">
227
+ <LoadingSpinner size="lg" />
228
+ </div>
229
+ );
230
+ }
231
+
232
+ if (error || !product) {
233
+ return (
234
+ <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
235
+ <h1 className="text-foreground text-2xl font-bold">{error || 'Product not found'}</h1>
236
+ <Link href="/products" className="text-primary mt-4 inline-block hover:underline">
237
+ Back to products
238
+ </Link>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ return (
244
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
245
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
246
+ {/* Image Gallery */}
247
+ <div className="space-y-4">
248
+ {/* Main Image */}
249
+ <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
250
+ {mainImageUrl ? (
251
+ <Image
252
+ src={mainImageUrl}
253
+ alt={product.name}
254
+ fill
255
+ sizes="(max-width: 1024px) 100vw, 50vw"
256
+ className="object-contain"
257
+ priority
258
+ />
259
+ ) : (
260
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
261
+ <svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
262
+ <path
263
+ strokeLinecap="round"
264
+ strokeLinejoin="round"
265
+ strokeWidth={1}
266
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
267
+ />
268
+ </svg>
269
+ </div>
270
+ )}
271
+ </div>
272
+
273
+ {/* Thumbnails */}
274
+ {images.length > 1 && (
275
+ <div className="flex gap-2 overflow-x-auto pb-2">
276
+ {images.map((img, idx) => (
277
+ <button
278
+ key={idx}
279
+ type="button"
280
+ onClick={() => setSelectedImageIndex(idx)}
281
+ className={cn(
282
+ 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
283
+ selectedImageIndex === idx
284
+ ? 'border-primary'
285
+ : 'border-border hover:border-muted-foreground'
286
+ )}
287
+ >
288
+ <Image
289
+ src={img.url}
290
+ alt={img.alt || `${product.name} ${idx + 1}`}
291
+ fill
292
+ sizes="64px"
293
+ className="object-cover"
294
+ />
295
+ </button>
296
+ ))}
297
+ </div>
298
+ )}
299
+ </div>
300
+
301
+ {/* Product Info */}
302
+ <div className="space-y-6">
303
+ {/* Categories */}
304
+ {product.categories && product.categories.length > 0 && (
305
+ <div className="flex flex-wrap gap-2">
306
+ {product.categories.map((cat) => (
307
+ <span
308
+ key={cat.id}
309
+ className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
310
+ >
311
+ {cat.name}
312
+ </span>
313
+ ))}
314
+ </div>
315
+ )}
316
+
317
+ {/* Title */}
318
+ <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
319
+
320
+ {/* Price */}
321
+ <PriceDisplay
322
+ price={priceInfo.originalPrice}
323
+ salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
324
+ size="lg"
325
+ />
326
+
327
+ {/* Stock */}
328
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
329
+
330
+ {/* Variant Selector */}
331
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
332
+ <VariantSelector
333
+ product={product}
334
+ selectedVariant={selectedVariant}
335
+ onVariantChange={setSelectedVariant}
336
+ />
337
+ )}
338
+
339
+ {/* Quantity + Add to Cart */}
340
+ <div className="flex items-center gap-4">
341
+ <div className="border-border flex items-center rounded border">
342
+ <button
343
+ type="button"
344
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
345
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
346
+ aria-label="Decrease quantity"
347
+ >
348
+ -
349
+ </button>
350
+ <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
351
+ {quantity}
352
+ </span>
353
+ <button
354
+ type="button"
355
+ onClick={() => setQuantity((q) => q + 1)}
356
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
357
+ aria-label="Increase quantity"
358
+ >
359
+ +
360
+ </button>
361
+ </div>
362
+
363
+ <button
364
+ type="button"
365
+ onClick={handleAddToCart}
366
+ disabled={!canPurchase || addingToCart}
367
+ className={cn(
368
+ 'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
369
+ canPurchase
370
+ ? 'bg-primary text-primary-foreground hover:opacity-90'
371
+ : 'bg-muted text-muted-foreground cursor-not-allowed'
372
+ )}
373
+ >
374
+ {addingToCart ? (
375
+ <span className="inline-flex items-center gap-2">
376
+ <LoadingSpinner
377
+ size="sm"
378
+ className="border-primary-foreground/30 border-t-primary-foreground"
379
+ />
380
+ Adding...
381
+ </span>
382
+ ) : addedMessage ? (
383
+ 'Added to Cart!'
384
+ ) : !canPurchase ? (
385
+ 'Out of Stock'
386
+ ) : (
387
+ 'Add to Cart'
388
+ )}
389
+ </button>
390
+ </div>
391
+
392
+ {/* Description */}
393
+ {description && (
394
+ <div className="border-border border-t pt-4">
395
+ <h2 className="text-foreground mb-3 text-lg font-semibold">Description</h2>
396
+ {'html' in description ? (
397
+ <div
398
+ className="prose prose-sm text-muted-foreground max-w-none"
399
+ dangerouslySetInnerHTML={{ __html: description.html }}
400
+ />
401
+ ) : (
402
+ <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
403
+ )}
404
+ </div>
405
+ )}
406
+
407
+ {/* Metafields / Specifications */}
408
+ {product.metafields && product.metafields.length > 0 && (
409
+ <div className="border-border border-t pt-4">
410
+ <h2 className="text-foreground mb-3 text-lg font-semibold">Specifications</h2>
411
+ <table className="w-full text-sm">
412
+ <tbody>
413
+ {product.metafields.map((field) => (
414
+ <tr key={field.id} className="border-border border-b last:border-0">
415
+ <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
416
+ {field.definitionName}
417
+ </td>
418
+ <td className="text-muted-foreground py-2">
419
+ <MetafieldValue field={field} />
420
+ </td>
421
+ </tr>
422
+ ))}
423
+ </tbody>
424
+ </table>
425
+ </div>
426
+ )}
427
+ </div>
428
+ </div>
429
+ </div>
430
+ );
431
+ }