create-brainerce-store 1.85.0 → 1.86.1

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.
@@ -1,537 +1,539 @@
1
- 'use client';
2
-
3
- import { Download, FileText, Image as ImageIcon } from 'lucide-react';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
6
- import { useProductPage } from '@/core/hooks/use-product-page';
7
- import { PriceDisplay } from '@/ui/product/price-display';
8
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
- import { VariantSelector } from '@/ui/product/variant-selector';
10
- import { StockBadge } from '@/ui/product/stock-badge';
11
- import { DiscountBadge } from '@/ui/product/discount-badge';
12
- import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
13
- import { RecommendationSection } from '@/ui/product/recommendation-section';
14
- import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
15
- import { CustomizationFields } from '@/ui/product/customization-fields';
16
- import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
17
- import { Badge } from '@/components/ui/badge';
18
- import { Button } from '@/components/ui/button';
19
- import { Separator } from '@/components/ui/separator';
20
- import { useTranslations } from '@/core/lib/translations';
21
- import { cn } from '@/core/lib/utils';
22
- import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
23
-
24
- /** Render a metafield value based on its type */
25
- function MetafieldValue({ field }: { field: ProductMetafield }) {
26
- const tc = useTranslations('common');
27
- switch (field.type) {
28
- case 'IMAGE': {
29
- if (!field.value) return <span className="text-muted-foreground">-</span>;
30
- return (
31
- <img
32
- src={field.value}
33
- alt={field.definitionName}
34
- className="h-16 w-16 rounded object-cover"
35
- />
36
- );
37
- }
38
- case 'GALLERY': {
39
- let urls: string[] = [];
40
- try {
41
- const parsed = JSON.parse(field.value);
42
- urls = Array.isArray(parsed)
43
- ? parsed.filter((u: unknown) => typeof u === 'string' && u)
44
- : [];
45
- } catch {
46
- urls = field.value ? [field.value] : [];
47
- }
48
- if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
49
- return (
50
- <div className="flex flex-wrap gap-2">
51
- {urls.map((url, i) => (
52
- <img
53
- key={i}
54
- src={url}
55
- alt={`${field.definitionName} ${i + 1}`}
56
- className="h-16 w-16 rounded object-cover"
57
- />
58
- ))}
59
- </div>
60
- );
61
- }
62
- case 'URL':
63
- return field.value ? (
64
- <a
65
- href={field.value}
66
- target="_blank"
67
- rel="noopener noreferrer"
68
- className="text-primary break-all hover:underline"
69
- >
70
- {field.value}
71
- </a>
72
- ) : (
73
- <span className="text-muted-foreground">-</span>
74
- );
75
- case 'COLOR':
76
- return field.value ? (
77
- <span className="inline-flex items-center gap-2">
78
- <span
79
- className="border-border inline-block h-4 w-4 rounded-full border"
80
- style={{ backgroundColor: field.value }}
81
- />
82
- {field.value}
83
- </span>
84
- ) : (
85
- <span className="text-muted-foreground">-</span>
86
- );
87
- case 'BOOLEAN':
88
- return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
89
- case 'DATE':
90
- case 'DATETIME': {
91
- if (!field.value) return <span className="text-muted-foreground">-</span>;
92
- try {
93
- const date = new Date(field.value);
94
- return (
95
- <span>
96
- {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
97
- </span>
98
- );
99
- } catch {
100
- return <span>{field.value}</span>;
101
- }
102
- }
103
- default:
104
- return <span>{field.value || '-'}</span>;
105
- }
106
- }
107
-
108
- interface ProductClientSectionProps {
109
- product: Product;
110
- /**
111
- * From `getStoreInfo().stockAlertsEnabled` — the merchant's switch for
112
- * back-in-stock alerts on this storefront. Passed down rather than fetched
113
- * here because the page already holds storeInfo and a second call would be a
114
- * waterfall. Undefined on a public-storefront (storeId) client, where the
115
- * setting does not apply and the feature is on.
116
- */
117
- stockAlertsEnabled?: boolean;
118
- }
119
-
120
- export function ProductClientSection({
121
- product: initialProduct,
122
- stockAlertsEnabled,
123
- }: ProductClientSectionProps) {
124
- const t = useTranslations('productDetail');
125
-
126
- const {
127
- product,
128
- recommendations,
129
- images,
130
- selectedImageIndex,
131
- setSelectedImageIndex,
132
- mainImageUrl,
133
- selectedVariant,
134
- setSelectedVariant,
135
- priceInfo,
136
- displayPrice,
137
- inventory,
138
- canPurchase,
139
- description,
140
- quantity,
141
- setQuantity,
142
- addingToCart,
143
- addedMessage,
144
- addToCartError,
145
- handleAddToCart,
146
- customizationFields,
147
- customizationValues,
148
- setCustomizationValues,
149
- customizationErrors,
150
- modifierGroups,
151
- modifierSelections,
152
- setModifierSelection,
153
- modifierError,
154
- } = useProductPage(initialProduct);
155
-
156
- return (
157
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
158
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
159
- {/* Image Gallery */}
160
- <div className="space-y-4">
161
- {/* Main Image */}
162
- <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
163
- {mainImageUrl ? (
164
- <Image
165
- src={mainImageUrl}
166
- alt={product.name}
167
- fill
168
- sizes="(max-width: 1024px) 100vw, 50vw"
169
- className="object-contain"
170
- priority
171
- />
172
- ) : (
173
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
174
- <ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
175
- </div>
176
- )}
177
- </div>
178
-
179
- {/* Thumbnails */}
180
- {images.length > 1 && (
181
- <div className="flex gap-2 overflow-x-auto pb-2">
182
- {images.map((img, idx) => (
183
- <button
184
- key={idx}
185
- type="button"
186
- onClick={() => setSelectedImageIndex(idx)}
187
- className={cn(
188
- 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
189
- selectedImageIndex === idx
190
- ? 'border-primary'
191
- : 'border-border hover:border-muted-foreground'
192
- )}
193
- >
194
- <Image
195
- src={img.url}
196
- alt={img.alt || `${product.name} ${idx + 1}`}
197
- fill
198
- sizes="64px"
199
- className="object-cover"
200
- />
201
- </button>
202
- ))}
203
- </div>
204
- )}
205
- </div>
206
-
207
- {/* Product Info */}
208
- <div className="space-y-6">
209
- {/* Categories */}
210
- {product.categories && product.categories.length > 0 && (
211
- <div className="flex flex-wrap gap-2">
212
- {product.categories.map((cat) => (
213
- <Badge
214
- key={cat.id}
215
- variant="secondary"
216
- className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
217
- >
218
- {cat.name}
219
- </Badge>
220
- ))}
221
- </div>
222
- )}
223
-
224
- {/* Brand */}
225
- {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
226
- (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
227
- <div className="text-muted-foreground text-sm">
228
- {t('by')}{' '}
229
- <span className="text-foreground font-medium">
230
- {(product as { brands: Array<{ id: string; name: string }> }).brands
231
- .map((b) => b.name)
232
- .join(', ')}
233
- </span>
234
- </div>
235
- )}
236
-
237
- {/* Title */}
238
- <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
239
-
240
- {/* Tags */}
241
- {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
242
- (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
243
- 0 && (
244
- <div className="flex flex-wrap gap-1.5">
245
- {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
246
- (tag) => (
247
- <Badge
248
- key={tag.id}
249
- variant="outline"
250
- className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
251
- >
252
- #{tag.name}
253
- </Badge>
254
- )
255
- )}
256
- </div>
257
- )}
258
-
259
- {/* Active discount rule on this product, as a badge. `product.discount`
260
- is returned by getProductBySlug and is null/absent when no rule
261
- applies, and DiscountBadge returns null on a falsy `discount`, so
262
- this renders nothing on a store with no discounts configured. */}
263
- <DiscountBadge discount={product.discount} className="w-fit" />
264
-
265
- {/* Price */}
266
- <PriceDisplay
267
- price={displayPrice.price}
268
- salePrice={displayPrice.salePrice ?? undefined}
269
- currency={displayPrice.currency}
270
- size="lg"
271
- />
272
-
273
- {/* Stock / Digital badge */}
274
- {product.isDownloadable ? (
275
- <Badge
276
- variant="outline"
277
- className="gap-1.5 border-transparent bg-green-100 px-3 py-1 font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400"
278
- >
279
- <Download className="h-3.5 w-3.5" aria-hidden="true" />
280
- {t('instantDownload')}
281
- </Badge>
282
- ) : (
283
- <StockBadge inventory={inventory} />
284
- )}
285
-
286
- {/* Downloadable files info */}
287
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
288
- <div className="bg-muted/50 rounded-lg border p-4">
289
- <p className="text-foreground mb-2 text-sm font-medium">
290
- {t('filesIncluded')} ({product.downloads.length})
291
- </p>
292
- <ul className="space-y-1.5">
293
- {product.downloads.map((file: DownloadFile) => (
294
- <li
295
- key={file.id}
296
- className="text-muted-foreground flex items-center gap-2 text-sm"
297
- >
298
- <FileText
299
- className="h-4 w-4 flex-shrink-0"
300
- strokeWidth={1.5}
301
- aria-hidden="true"
302
- />
303
- <span className="truncate">{file.name}</span>
304
- {file.size && (
305
- <span className="flex-shrink-0 text-xs">
306
- (
307
- {file.size < 1024 * 1024
308
- ? `${(file.size / 1024).toFixed(0)} KB`
309
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
310
- )
311
- </span>
312
- )}
313
- </li>
314
- ))}
315
- </ul>
316
- </div>
317
- )}
318
-
319
- {/* Variant Selector */}
320
- {/* A KIT is bought as ONE line, but the shopper needs to see what is
321
- in the box before deciding. These rows are display only — never add
322
- them to the cart individually; the kit reserves its components on
323
- its own. */}
324
- {product.type === 'KIT' && product.kitComponents?.length ? (
325
- <div className="mb-6">
326
- <h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
327
- <ul className="divide-y rounded-lg border">
328
- {product.kitComponents.map((c) => (
329
- <li
330
- key={`${c.productId}-${c.variantId ?? ''}`}
331
- className="flex items-center gap-3 p-3"
332
- >
333
- {c.image ? (
334
- // eslint-disable-next-line @next/next/no-img-element
335
- <img
336
- src={c.image}
337
- alt={c.name}
338
- className="h-10 w-10 shrink-0 rounded border object-cover"
339
- />
340
- ) : (
341
- <div className="bg-muted h-10 w-10 shrink-0 rounded border" />
342
- )}
343
- <span className="flex-1 text-sm">{c.name}</span>
344
- <span className="text-muted-foreground text-sm">x{c.quantity}</span>
345
- </li>
346
- ))}
347
- </ul>
348
- </div>
349
- ) : null}
350
-
351
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
352
- <VariantSelector
353
- product={product}
354
- selectedVariant={selectedVariant}
355
- onVariantChange={setSelectedVariant}
356
- />
357
- )}
358
-
359
- {/* Customization Fields (buyer input) */}
360
- {customizationFields.length > 0 && (
361
- <CustomizationFields
362
- fields={customizationFields}
363
- values={customizationValues}
364
- onChange={setCustomizationValues}
365
- errors={customizationErrors}
366
- />
367
- )}
368
-
369
- {/* Modifier groups (toppings, sauce, bread type, …) */}
370
- {modifierGroups.length > 0 && (
371
- <div className="space-y-4">
372
- {modifierGroups.map((group) => (
373
- <ModifierGroupSelector
374
- key={group.attachmentId ?? group.id}
375
- group={group}
376
- value={modifierSelections[group.id] ?? []}
377
- onChange={(next) => setModifierSelection(group.id, next)}
378
- disabled={addingToCart}
379
- />
380
- ))}
381
- {modifierError && (
382
- <p className="text-destructive text-sm" role="alert">
383
- {modifierError}
384
- </p>
385
- )}
386
- </div>
387
- )}
388
-
389
- {/* Quantity + Add to Cart */}
390
- <div className="flex items-center gap-4">
391
- <div className="border-border flex items-center rounded border">
392
- <button
393
- type="button"
394
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
395
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
396
- aria-label={t('decreaseQuantity')}
397
- >
398
- -
399
- </button>
400
- <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
401
- {quantity}
402
- </span>
403
- <button
404
- type="button"
405
- onClick={() => setQuantity((q) => q + 1)}
406
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
407
- aria-label={t('increaseQuantity')}
408
- >
409
- +
410
- </button>
411
- </div>
412
-
413
- <Button
414
- type="button"
415
- size="lg"
416
- onClick={handleAddToCart}
417
- disabled={!canPurchase || addingToCart}
418
- className={cn(
419
- 'flex-1 rounded px-6 text-sm',
420
- !canPurchase && 'bg-muted text-muted-foreground hover:bg-muted disabled:opacity-100'
421
- )}
422
- >
423
- {addingToCart ? (
424
- <span className="inline-flex items-center gap-2">
425
- <LoadingSpinner
426
- size="sm"
427
- className="border-primary-foreground/30 border-t-primary-foreground"
428
- />
429
- {t('addingToCart')}
430
- </span>
431
- ) : addedMessage ? (
432
- t('addedToCart')
433
- ) : !canPurchase ? (
434
- t('outOfStock')
435
- ) : (
436
- t('addToCart')
437
- )}
438
- </Button>
439
- </div>
440
-
441
- {/*
442
- Why the add was refused. Deliberately OUTSIDE the modifier block
443
- above: that block only renders when the product HAS modifier
444
- groups, so a refusal shown there would be invisible on almost
445
- every product and the shopper would just watch the button reset.
446
- */}
447
- {addToCartError && (
448
- <p className="text-destructive text-sm" role="alert">
449
- {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
450
- </p>
451
- )}
452
-
453
- {/*
454
- Sold out is not the end of the page. `canOfferStockAlert` is the whole
455
- gate — it also covers the merchant's switch and backorderable items,
456
- where an alert would tell someone to come back and do what they can
457
- already do. The selected variant is passed on purpose: without it a
458
- shopper who wanted the medium gets mailed when the small returns.
459
- */}
460
- {canOfferStockAlert(inventory, stockAlertsEnabled) && (
461
- <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
462
- )}
463
-
464
- {/* Download after purchase note */}
465
- {product.isDownloadable && (
466
- <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
467
- )}
468
-
469
- {/* Description */}
470
- {description && (
471
- <div>
472
- <Separator className="mb-4" />
473
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
474
- {'html' in description ? (
475
- <div
476
- className="prose prose-sm text-muted-foreground max-w-none"
477
- dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
478
- />
479
- ) : (
480
- <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
481
- )}
482
- </div>
483
- )}
484
-
485
- {/* Metafields / Specifications */}
486
- {product.metafields && product.metafields.length > 0 && (
487
- <div>
488
- <Separator className="mb-4" />
489
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
490
- <table className="w-full text-sm">
491
- <tbody>
492
- {product.metafields.map((field) => (
493
- <tr key={field.id} className="border-border border-b last:border-0">
494
- <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
495
- {field.definitionName}
496
- </td>
497
- <td className="text-muted-foreground py-2">
498
- <MetafieldValue field={field} />
499
- </td>
500
- </tr>
501
- ))}
502
- </tbody>
503
- </table>
504
- </div>
505
- )}
506
- </div>
507
- </div>
508
-
509
- {/* Frequently Bought Together (cross-sells) */}
510
- {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
511
- <FrequentlyBoughtTogether
512
- items={recommendations.crossSells}
513
- currentProduct={product}
514
- className="mt-12"
515
- />
516
- )}
517
-
518
- {/* Upsells */}
519
- {recommendations?.upsells && recommendations.upsells.length > 0 && (
520
- <RecommendationSection
521
- title={t('upgradeYourChoice')}
522
- items={recommendations.upsells}
523
- className="mt-12"
524
- />
525
- )}
526
-
527
- {/* Related products */}
528
- {recommendations?.related && recommendations.related.length > 0 && (
529
- <RecommendationSection
530
- title={t('similarProducts')}
531
- items={recommendations.related}
532
- className="mt-12"
533
- />
534
- )}
535
- </div>
536
- );
537
- }
1
+ 'use client';
2
+
3
+ import { Download, FileText, Image as ImageIcon } from 'lucide-react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
6
+ import { useProductPage } from '@/core/hooks/use-product-page';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
+ import { VariantSelector } from '@/ui/product/variant-selector';
10
+ import { StockBadge } from '@/ui/product/stock-badge';
11
+ import { DiscountBadge } from '@/ui/product/discount-badge';
12
+ import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
13
+ import { RecommendationSection } from '@/ui/product/recommendation-section';
14
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
15
+ import { CustomizationFields } from '@/ui/product/customization-fields';
16
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
17
+ import { Badge } from '@/components/ui/badge';
18
+ import { Button } from '@/components/ui/button';
19
+ import { Separator } from '@/components/ui/separator';
20
+ import { useTranslations } from '@/core/lib/translations';
21
+ import { cn } from '@/core/lib/utils';
22
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
23
+
24
+ /** Render a metafield value based on its type */
25
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
26
+ const tc = useTranslations('common');
27
+ switch (field.type) {
28
+ case 'IMAGE': {
29
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
30
+ return (
31
+ <img
32
+ src={field.value}
33
+ alt={field.definitionName}
34
+ className="h-16 w-16 rounded object-cover"
35
+ />
36
+ );
37
+ }
38
+ case 'GALLERY': {
39
+ let urls: string[] = [];
40
+ try {
41
+ const parsed = JSON.parse(field.value);
42
+ urls = Array.isArray(parsed)
43
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
44
+ : [];
45
+ } catch {
46
+ urls = field.value ? [field.value] : [];
47
+ }
48
+ if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
49
+ return (
50
+ <div className="flex flex-wrap gap-2">
51
+ {urls.map((url, i) => (
52
+ <img
53
+ key={i}
54
+ src={url}
55
+ alt={`${field.definitionName} ${i + 1}`}
56
+ className="h-16 w-16 rounded object-cover"
57
+ />
58
+ ))}
59
+ </div>
60
+ );
61
+ }
62
+ case 'URL':
63
+ return field.value ? (
64
+ <a
65
+ href={field.value}
66
+ target="_blank"
67
+ rel="noopener noreferrer"
68
+ className="text-primary break-all hover:underline"
69
+ >
70
+ {field.value}
71
+ </a>
72
+ ) : (
73
+ <span className="text-muted-foreground">-</span>
74
+ );
75
+ case 'COLOR':
76
+ return field.value ? (
77
+ <span className="inline-flex items-center gap-2">
78
+ <span
79
+ className="border-border inline-block h-4 w-4 rounded-full border"
80
+ style={{ backgroundColor: field.value }}
81
+ />
82
+ {field.value}
83
+ </span>
84
+ ) : (
85
+ <span className="text-muted-foreground">-</span>
86
+ );
87
+ case 'BOOLEAN':
88
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
89
+ case 'DATE':
90
+ case 'DATETIME': {
91
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
92
+ try {
93
+ const date = new Date(field.value);
94
+ return (
95
+ <span>
96
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
97
+ </span>
98
+ );
99
+ } catch {
100
+ return <span>{field.value}</span>;
101
+ }
102
+ }
103
+ default:
104
+ return <span>{field.value || '-'}</span>;
105
+ }
106
+ }
107
+
108
+ interface ProductClientSectionProps {
109
+ product: Product;
110
+ /**
111
+ * From `getStoreInfo().stockAlertsEnabled` — the merchant's switch for
112
+ * back-in-stock alerts on this storefront. Passed down rather than fetched
113
+ * here because the page already holds storeInfo and a second call would be a
114
+ * waterfall. Undefined on a public-storefront (storeId) client, where the
115
+ * setting does not apply and the feature is on.
116
+ */
117
+ stockAlertsEnabled?: boolean;
118
+ }
119
+
120
+ export function ProductClientSection({
121
+ product: initialProduct,
122
+ stockAlertsEnabled,
123
+ }: ProductClientSectionProps) {
124
+ const t = useTranslations('productDetail');
125
+
126
+ const {
127
+ product,
128
+ recommendations,
129
+ images,
130
+ selectedImageIndex,
131
+ setSelectedImageIndex,
132
+ mainImageUrl,
133
+ selectedVariant,
134
+ setSelectedVariant,
135
+ priceInfo,
136
+ displayPrice,
137
+ inventory,
138
+ canPurchase,
139
+ description,
140
+ quantity,
141
+ setQuantity,
142
+ addingToCart,
143
+ addedMessage,
144
+ addToCartError,
145
+ handleAddToCart,
146
+ customizationFields,
147
+ customizationValues,
148
+ setCustomizationValues,
149
+ customizationErrors,
150
+ modifierGroups,
151
+ modifierSelections,
152
+ setModifierSelection,
153
+ modifierError,
154
+ } = useProductPage(initialProduct);
155
+
156
+ return (
157
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
158
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
159
+ {/* Image Gallery */}
160
+ <div className="space-y-4">
161
+ {/* Main Image */}
162
+ <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
163
+ {mainImageUrl ? (
164
+ <Image
165
+ src={mainImageUrl}
166
+ alt={product.name}
167
+ fill
168
+ sizes="(max-width: 1024px) 100vw, 50vw"
169
+ className="object-contain"
170
+ priority
171
+ />
172
+ ) : (
173
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
174
+ <ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
175
+ </div>
176
+ )}
177
+ </div>
178
+
179
+ {/* Thumbnails */}
180
+ {images.length > 1 && (
181
+ <div className="flex gap-2 overflow-x-auto pb-2">
182
+ {images.map((img, idx) => (
183
+ <button
184
+ key={idx}
185
+ type="button"
186
+ onClick={() => setSelectedImageIndex(idx)}
187
+ className={cn(
188
+ 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
189
+ selectedImageIndex === idx
190
+ ? 'border-primary'
191
+ : 'border-border hover:border-muted-foreground'
192
+ )}
193
+ >
194
+ <Image
195
+ src={img.url}
196
+ alt={img.alt || `${product.name} ${idx + 1}`}
197
+ fill
198
+ sizes="64px"
199
+ className="object-cover"
200
+ />
201
+ </button>
202
+ ))}
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ {/* Product Info */}
208
+ <div className="space-y-6">
209
+ {/* Categories */}
210
+ {product.categories && product.categories.length > 0 && (
211
+ <div className="flex flex-wrap gap-2">
212
+ {product.categories.map((cat) => (
213
+ <Badge
214
+ key={cat.id}
215
+ variant="secondary"
216
+ className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
217
+ >
218
+ {cat.name}
219
+ </Badge>
220
+ ))}
221
+ </div>
222
+ )}
223
+
224
+ {/* Brand */}
225
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
226
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
227
+ <div className="text-muted-foreground text-sm">
228
+ {t('by')}{' '}
229
+ <span className="text-foreground font-medium">
230
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
231
+ .map((b) => b.name)
232
+ .join(', ')}
233
+ </span>
234
+ </div>
235
+ )}
236
+
237
+ {/* Title */}
238
+ <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
239
+
240
+ {/* Tags */}
241
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
242
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
243
+ 0 && (
244
+ <div className="flex flex-wrap gap-1.5">
245
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
246
+ (tag) => (
247
+ <Badge
248
+ key={tag.id}
249
+ variant="outline"
250
+ className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
251
+ >
252
+ #{tag.name}
253
+ </Badge>
254
+ )
255
+ )}
256
+ </div>
257
+ )}
258
+
259
+ {/* Active discount rule on this product, as a badge. `product.discount`
260
+ is returned by getProductBySlug and is null/absent when no rule
261
+ applies, and DiscountBadge returns null on a falsy `discount`, so
262
+ this renders nothing on a store with no discounts configured. */}
263
+ <DiscountBadge discount={product.discount} className="w-fit" />
264
+
265
+ {/* Price */}
266
+ <PriceDisplay
267
+ price={displayPrice.price}
268
+ salePrice={displayPrice.salePrice ?? undefined}
269
+ currency={displayPrice.currency}
270
+ size="lg"
271
+ />
272
+
273
+ {/* Stock / Digital badge */}
274
+ {product.isDownloadable ? (
275
+ <Badge
276
+ variant="outline"
277
+ className="gap-1.5 border-transparent bg-green-100 px-3 py-1 font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400"
278
+ >
279
+ <Download className="h-3.5 w-3.5" aria-hidden="true" />
280
+ {t('instantDownload')}
281
+ </Badge>
282
+ ) : (
283
+ <StockBadge inventory={inventory} />
284
+ )}
285
+
286
+ {/* Downloadable files info */}
287
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
288
+ <div className="bg-muted/50 rounded-lg border p-4">
289
+ <p className="text-foreground mb-2 text-sm font-medium">
290
+ {t('filesIncluded')} ({product.downloads.length})
291
+ </p>
292
+ <ul className="space-y-1.5">
293
+ {product.downloads.map((file: DownloadFile) => (
294
+ <li
295
+ key={file.id}
296
+ className="text-muted-foreground flex items-center gap-2 text-sm"
297
+ >
298
+ <FileText
299
+ className="h-4 w-4 flex-shrink-0"
300
+ strokeWidth={1.5}
301
+ aria-hidden="true"
302
+ />
303
+ <span className="truncate">{file.name}</span>
304
+ {file.size && (
305
+ <span className="flex-shrink-0 text-xs">
306
+ (
307
+ {file.size < 1024 * 1024
308
+ ? `${(file.size / 1024).toFixed(0)} KB`
309
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
310
+ )
311
+ </span>
312
+ )}
313
+ </li>
314
+ ))}
315
+ </ul>
316
+ </div>
317
+ )}
318
+
319
+ {/* Variant Selector */}
320
+ {/* A KIT is bought as ONE line, but the shopper needs to see what is
321
+ in the box before deciding. These rows are display only — never add
322
+ them to the cart individually; the kit reserves its components on
323
+ its own. */}
324
+ {product.type === 'KIT' && product.kitComponents?.length ? (
325
+ <div className="mb-6">
326
+ <h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
327
+ <ul className="divide-y rounded-lg border">
328
+ {product.kitComponents.map((c) => (
329
+ <li
330
+ key={`${c.productId}-${c.variantId ?? ''}`}
331
+ className="flex items-center gap-3 p-3"
332
+ >
333
+ {c.image ? (
334
+ <img
335
+ src={c.image}
336
+ alt={c.name}
337
+ className="h-10 w-10 shrink-0 rounded border object-cover"
338
+ />
339
+ ) : (
340
+ <div className="bg-muted h-10 w-10 shrink-0 rounded border" />
341
+ )}
342
+ <span className="flex-1 text-sm">{c.name}</span>
343
+ <span className="text-muted-foreground text-sm">x{c.quantity}</span>
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ </div>
348
+ ) : null}
349
+
350
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
351
+ <VariantSelector
352
+ product={product}
353
+ selectedVariant={selectedVariant}
354
+ onVariantChange={setSelectedVariant}
355
+ />
356
+ )}
357
+
358
+ {/* Customization Fields (buyer input) */}
359
+ {customizationFields.length > 0 && (
360
+ <CustomizationFields
361
+ fields={customizationFields}
362
+ values={customizationValues}
363
+ onChange={setCustomizationValues}
364
+ errors={customizationErrors}
365
+ />
366
+ )}
367
+
368
+ {/* Modifier groups (toppings, sauce, bread type, …) */}
369
+ {modifierGroups.length > 0 && (
370
+ <div className="space-y-4">
371
+ {modifierGroups.map((group) => (
372
+ <ModifierGroupSelector
373
+ key={group.attachmentId ?? group.id}
374
+ group={group}
375
+ value={modifierSelections[group.id] ?? []}
376
+ onChange={(next) => setModifierSelection(group.id, next)}
377
+ disabled={addingToCart}
378
+ />
379
+ ))}
380
+ {modifierError && (
381
+ <p className="text-destructive text-sm" role="alert">
382
+ {modifierError}
383
+ </p>
384
+ )}
385
+ </div>
386
+ )}
387
+
388
+ {/* Quantity + Add to Cart */}
389
+ <div className="flex items-center gap-4">
390
+ <div className="border-border flex items-center rounded border">
391
+ <button
392
+ type="button"
393
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
394
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
395
+ aria-label={t('decreaseQuantity')}
396
+ >
397
+ -
398
+ </button>
399
+ <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
400
+ {quantity}
401
+ </span>
402
+ <button
403
+ type="button"
404
+ onClick={() => setQuantity((q) => q + 1)}
405
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
406
+ aria-label={t('increaseQuantity')}
407
+ >
408
+ +
409
+ </button>
410
+ </div>
411
+
412
+ <Button
413
+ type="button"
414
+ size="lg"
415
+ onClick={handleAddToCart}
416
+ disabled={!canPurchase || addingToCart}
417
+ className={cn(
418
+ 'flex-1 rounded px-6 text-sm',
419
+ !canPurchase && 'bg-muted text-muted-foreground hover:bg-muted disabled:opacity-100'
420
+ )}
421
+ >
422
+ {addingToCart ? (
423
+ <span className="inline-flex items-center gap-2">
424
+ <LoadingSpinner
425
+ size="sm"
426
+ className="border-primary-foreground/30 border-t-primary-foreground"
427
+ />
428
+ {t('addingToCart')}
429
+ </span>
430
+ ) : addedMessage ? (
431
+ t('addedToCart')
432
+ ) : !canPurchase ? (
433
+ t('outOfStock')
434
+ ) : (
435
+ t('addToCart')
436
+ )}
437
+ </Button>
438
+ </div>
439
+
440
+ {/*
441
+ Why the add was refused. Deliberately OUTSIDE the modifier block
442
+ above: that block only renders when the product HAS modifier
443
+ groups, so a refusal shown there would be invisible on almost
444
+ every product and the shopper would just watch the button reset.
445
+ */}
446
+ {addToCartError && (
447
+ <p className="text-destructive text-sm" role="alert">
448
+ {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
449
+ </p>
450
+ )}
451
+
452
+ {/*
453
+ Sold out is not the end of the page. `canOfferStockAlert` is the whole
454
+ gate — it also covers the merchant's switch and backorderable items,
455
+ where an alert would tell someone to come back and do what they can
456
+ already do. The selected variant is passed on purpose: without it a
457
+ shopper who wanted the medium gets mailed when the small returns.
458
+ */}
459
+ {canOfferStockAlert(inventory, stockAlertsEnabled) && (
460
+ <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
461
+ )}
462
+
463
+ {/* Download after purchase note */}
464
+ {product.isDownloadable && (
465
+ <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
466
+ )}
467
+
468
+ {/* Description */}
469
+ {description && (
470
+ <div>
471
+ <Separator className="mb-4" />
472
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
473
+ {'html' in description ? (
474
+ // Security: merchant-authored product description HTML,
475
+ // sanitized via `sanitizeProductHtml` (isomorphic-dompurify,
476
+ // allowlisted tags) before injecting.
477
+ <div
478
+ className="prose prose-sm text-muted-foreground max-w-none"
479
+ dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
480
+ />
481
+ ) : (
482
+ <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
483
+ )}
484
+ </div>
485
+ )}
486
+
487
+ {/* Metafields / Specifications */}
488
+ {product.metafields && product.metafields.length > 0 && (
489
+ <div>
490
+ <Separator className="mb-4" />
491
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
492
+ <table className="w-full text-sm">
493
+ <tbody>
494
+ {product.metafields.map((field) => (
495
+ <tr key={field.id} className="border-border border-b last:border-0">
496
+ <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
497
+ {field.definitionName}
498
+ </td>
499
+ <td className="text-muted-foreground py-2">
500
+ <MetafieldValue field={field} />
501
+ </td>
502
+ </tr>
503
+ ))}
504
+ </tbody>
505
+ </table>
506
+ </div>
507
+ )}
508
+ </div>
509
+ </div>
510
+
511
+ {/* Frequently Bought Together (cross-sells) */}
512
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
513
+ <FrequentlyBoughtTogether
514
+ items={recommendations.crossSells}
515
+ currentProduct={product}
516
+ className="mt-12"
517
+ />
518
+ )}
519
+
520
+ {/* Upsells */}
521
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
522
+ <RecommendationSection
523
+ title={t('upgradeYourChoice')}
524
+ items={recommendations.upsells}
525
+ className="mt-12"
526
+ />
527
+ )}
528
+
529
+ {/* Related products */}
530
+ {recommendations?.related && recommendations.related.length > 0 && (
531
+ <RecommendationSection
532
+ title={t('similarProducts')}
533
+ items={recommendations.related}
534
+ className="mt-12"
535
+ />
536
+ )}
537
+ </div>
538
+ );
539
+ }