create-brainerce-store 1.78.0 → 1.80.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.
Files changed (36) hide show
  1. package/dist/index.js +27 -2
  2. package/messages/en.json +12 -3
  3. package/messages/he.json +12 -3
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  13. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  14. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  15. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  16. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  17. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  18. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  19. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  23. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  24. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  28. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  29. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  30. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  35. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  36. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
@@ -1,493 +1,524 @@
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
- handleAddToCart,
145
- customizationFields,
146
- customizationValues,
147
- setCustomizationValues,
148
- customizationErrors,
149
- modifierGroups,
150
- modifierSelections,
151
- setModifierSelection,
152
- modifierError,
153
- } = useProductPage(initialProduct);
154
-
155
- return (
156
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
157
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
158
- {/* Image Gallery */}
159
- <div className="space-y-4">
160
- {/* Main Image */}
161
- <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
162
- {mainImageUrl ? (
163
- <Image
164
- src={mainImageUrl}
165
- alt={product.name}
166
- fill
167
- sizes="(max-width: 1024px) 100vw, 50vw"
168
- className="object-contain"
169
- priority
170
- />
171
- ) : (
172
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
173
- <ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
174
- </div>
175
- )}
176
- </div>
177
-
178
- {/* Thumbnails */}
179
- {images.length > 1 && (
180
- <div className="flex gap-2 overflow-x-auto pb-2">
181
- {images.map((img, idx) => (
182
- <button
183
- key={idx}
184
- type="button"
185
- onClick={() => setSelectedImageIndex(idx)}
186
- className={cn(
187
- 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
188
- selectedImageIndex === idx
189
- ? 'border-primary'
190
- : 'border-border hover:border-muted-foreground'
191
- )}
192
- >
193
- <Image
194
- src={img.url}
195
- alt={img.alt || `${product.name} ${idx + 1}`}
196
- fill
197
- sizes="64px"
198
- className="object-cover"
199
- />
200
- </button>
201
- ))}
202
- </div>
203
- )}
204
- </div>
205
-
206
- {/* Product Info */}
207
- <div className="space-y-6">
208
- {/* Categories */}
209
- {product.categories && product.categories.length > 0 && (
210
- <div className="flex flex-wrap gap-2">
211
- {product.categories.map((cat) => (
212
- <Badge
213
- key={cat.id}
214
- variant="secondary"
215
- className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
216
- >
217
- {cat.name}
218
- </Badge>
219
- ))}
220
- </div>
221
- )}
222
-
223
- {/* Brand */}
224
- {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
225
- (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
226
- <div className="text-muted-foreground text-sm">
227
- {t('by')}{' '}
228
- <span className="text-foreground font-medium">
229
- {(product as { brands: Array<{ id: string; name: string }> }).brands
230
- .map((b) => b.name)
231
- .join(', ')}
232
- </span>
233
- </div>
234
- )}
235
-
236
- {/* Title */}
237
- <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
238
-
239
- {/* Tags */}
240
- {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
241
- (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
242
- 0 && (
243
- <div className="flex flex-wrap gap-1.5">
244
- {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
245
- (tag) => (
246
- <Badge
247
- key={tag.id}
248
- variant="outline"
249
- className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
250
- >
251
- #{tag.name}
252
- </Badge>
253
- )
254
- )}
255
- </div>
256
- )}
257
-
258
- {/* Active discount rule on this product, as a badge. `product.discount`
259
- is returned by getProductBySlug and is null/absent when no rule
260
- applies, and DiscountBadge returns null on a falsy `discount`, so
261
- this renders nothing on a store with no discounts configured. */}
262
- <DiscountBadge discount={product.discount} className="w-fit" />
263
-
264
- {/* Price */}
265
- <PriceDisplay
266
- price={displayPrice.price}
267
- salePrice={displayPrice.salePrice ?? undefined}
268
- currency={displayPrice.currency}
269
- size="lg"
270
- />
271
-
272
- {/* Stock / Digital badge */}
273
- {product.isDownloadable ? (
274
- <Badge
275
- variant="outline"
276
- 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"
277
- >
278
- <Download className="h-3.5 w-3.5" aria-hidden="true" />
279
- {t('instantDownload')}
280
- </Badge>
281
- ) : (
282
- <StockBadge inventory={inventory} />
283
- )}
284
-
285
- {/* Downloadable files info */}
286
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
287
- <div className="bg-muted/50 rounded-lg border p-4">
288
- <p className="text-foreground mb-2 text-sm font-medium">
289
- {t('filesIncluded')} ({product.downloads.length})
290
- </p>
291
- <ul className="space-y-1.5">
292
- {product.downloads.map((file: DownloadFile) => (
293
- <li
294
- key={file.id}
295
- className="text-muted-foreground flex items-center gap-2 text-sm"
296
- >
297
- <FileText
298
- className="h-4 w-4 flex-shrink-0"
299
- strokeWidth={1.5}
300
- aria-hidden="true"
301
- />
302
- <span className="truncate">{file.name}</span>
303
- {file.size && (
304
- <span className="flex-shrink-0 text-xs">
305
- (
306
- {file.size < 1024 * 1024
307
- ? `${(file.size / 1024).toFixed(0)} KB`
308
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
309
- )
310
- </span>
311
- )}
312
- </li>
313
- ))}
314
- </ul>
315
- </div>
316
- )}
317
-
318
- {/* Variant Selector */}
319
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
320
- <VariantSelector
321
- product={product}
322
- selectedVariant={selectedVariant}
323
- onVariantChange={setSelectedVariant}
324
- />
325
- )}
326
-
327
- {/* Customization Fields (buyer input) */}
328
- {customizationFields.length > 0 && (
329
- <CustomizationFields
330
- fields={customizationFields}
331
- values={customizationValues}
332
- onChange={setCustomizationValues}
333
- errors={customizationErrors}
334
- />
335
- )}
336
-
337
- {/* Modifier groups (toppings, sauce, bread type, …) */}
338
- {modifierGroups.length > 0 && (
339
- <div className="space-y-4">
340
- {modifierGroups.map((group) => (
341
- <ModifierGroupSelector
342
- key={group.attachmentId ?? group.id}
343
- group={group}
344
- value={modifierSelections[group.id] ?? []}
345
- onChange={(next) => setModifierSelection(group.id, next)}
346
- disabled={addingToCart}
347
- />
348
- ))}
349
- {modifierError && (
350
- <p className="text-destructive text-sm" role="alert">
351
- {modifierError}
352
- </p>
353
- )}
354
- </div>
355
- )}
356
-
357
- {/* Quantity + Add to Cart */}
358
- <div className="flex items-center gap-4">
359
- <div className="border-border flex items-center rounded border">
360
- <button
361
- type="button"
362
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
363
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
364
- aria-label={t('decreaseQuantity')}
365
- >
366
- -
367
- </button>
368
- <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
369
- {quantity}
370
- </span>
371
- <button
372
- type="button"
373
- onClick={() => setQuantity((q) => q + 1)}
374
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
375
- aria-label={t('increaseQuantity')}
376
- >
377
- +
378
- </button>
379
- </div>
380
-
381
- <Button
382
- type="button"
383
- size="lg"
384
- onClick={handleAddToCart}
385
- disabled={!canPurchase || addingToCart}
386
- className={cn(
387
- 'flex-1 rounded px-6 text-sm',
388
- !canPurchase && 'bg-muted text-muted-foreground hover:bg-muted disabled:opacity-100'
389
- )}
390
- >
391
- {addingToCart ? (
392
- <span className="inline-flex items-center gap-2">
393
- <LoadingSpinner
394
- size="sm"
395
- className="border-primary-foreground/30 border-t-primary-foreground"
396
- />
397
- {t('addingToCart')}
398
- </span>
399
- ) : addedMessage ? (
400
- t('addedToCart')
401
- ) : !canPurchase ? (
402
- t('outOfStock')
403
- ) : (
404
- t('addToCart')
405
- )}
406
- </Button>
407
- </div>
408
-
409
- {/*
410
- Sold out is not the end of the page. `canOfferStockAlert` is the whole
411
- gate — it also covers the merchant's switch and backorderable items,
412
- where an alert would tell someone to come back and do what they can
413
- already do. The selected variant is passed on purpose: without it a
414
- shopper who wanted the medium gets mailed when the small returns.
415
- */}
416
- {canOfferStockAlert(inventory, stockAlertsEnabled) && (
417
- <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
418
- )}
419
-
420
- {/* Download after purchase note */}
421
- {product.isDownloadable && (
422
- <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
423
- )}
424
-
425
- {/* Description */}
426
- {description && (
427
- <div>
428
- <Separator className="mb-4" />
429
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
430
- {'html' in description ? (
431
- <div
432
- className="prose prose-sm text-muted-foreground max-w-none"
433
- dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
434
- />
435
- ) : (
436
- <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
437
- )}
438
- </div>
439
- )}
440
-
441
- {/* Metafields / Specifications */}
442
- {product.metafields && product.metafields.length > 0 && (
443
- <div>
444
- <Separator className="mb-4" />
445
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
446
- <table className="w-full text-sm">
447
- <tbody>
448
- {product.metafields.map((field) => (
449
- <tr key={field.id} className="border-border border-b last:border-0">
450
- <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
451
- {field.definitionName}
452
- </td>
453
- <td className="text-muted-foreground py-2">
454
- <MetafieldValue field={field} />
455
- </td>
456
- </tr>
457
- ))}
458
- </tbody>
459
- </table>
460
- </div>
461
- )}
462
- </div>
463
- </div>
464
-
465
- {/* Frequently Bought Together (cross-sells) */}
466
- {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
467
- <FrequentlyBoughtTogether
468
- items={recommendations.crossSells}
469
- currentProduct={product}
470
- className="mt-12"
471
- />
472
- )}
473
-
474
- {/* Upsells */}
475
- {recommendations?.upsells && recommendations.upsells.length > 0 && (
476
- <RecommendationSection
477
- title={t('upgradeYourChoice')}
478
- items={recommendations.upsells}
479
- className="mt-12"
480
- />
481
- )}
482
-
483
- {/* Related products */}
484
- {recommendations?.related && recommendations.related.length > 0 && (
485
- <RecommendationSection
486
- title={t('similarProducts')}
487
- items={recommendations.related}
488
- className="mt-12"
489
- />
490
- )}
491
- </div>
492
- );
493
- }
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
+ handleAddToCart,
145
+ customizationFields,
146
+ customizationValues,
147
+ setCustomizationValues,
148
+ customizationErrors,
149
+ modifierGroups,
150
+ modifierSelections,
151
+ setModifierSelection,
152
+ modifierError,
153
+ } = useProductPage(initialProduct);
154
+
155
+ return (
156
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
157
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
158
+ {/* Image Gallery */}
159
+ <div className="space-y-4">
160
+ {/* Main Image */}
161
+ <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
162
+ {mainImageUrl ? (
163
+ <Image
164
+ src={mainImageUrl}
165
+ alt={product.name}
166
+ fill
167
+ sizes="(max-width: 1024px) 100vw, 50vw"
168
+ className="object-contain"
169
+ priority
170
+ />
171
+ ) : (
172
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
173
+ <ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
174
+ </div>
175
+ )}
176
+ </div>
177
+
178
+ {/* Thumbnails */}
179
+ {images.length > 1 && (
180
+ <div className="flex gap-2 overflow-x-auto pb-2">
181
+ {images.map((img, idx) => (
182
+ <button
183
+ key={idx}
184
+ type="button"
185
+ onClick={() => setSelectedImageIndex(idx)}
186
+ className={cn(
187
+ 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
188
+ selectedImageIndex === idx
189
+ ? 'border-primary'
190
+ : 'border-border hover:border-muted-foreground'
191
+ )}
192
+ >
193
+ <Image
194
+ src={img.url}
195
+ alt={img.alt || `${product.name} ${idx + 1}`}
196
+ fill
197
+ sizes="64px"
198
+ className="object-cover"
199
+ />
200
+ </button>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+
206
+ {/* Product Info */}
207
+ <div className="space-y-6">
208
+ {/* Categories */}
209
+ {product.categories && product.categories.length > 0 && (
210
+ <div className="flex flex-wrap gap-2">
211
+ {product.categories.map((cat) => (
212
+ <Badge
213
+ key={cat.id}
214
+ variant="secondary"
215
+ className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
216
+ >
217
+ {cat.name}
218
+ </Badge>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {/* Brand */}
224
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
225
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
226
+ <div className="text-muted-foreground text-sm">
227
+ {t('by')}{' '}
228
+ <span className="text-foreground font-medium">
229
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
230
+ .map((b) => b.name)
231
+ .join(', ')}
232
+ </span>
233
+ </div>
234
+ )}
235
+
236
+ {/* Title */}
237
+ <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
238
+
239
+ {/* Tags */}
240
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
241
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
242
+ 0 && (
243
+ <div className="flex flex-wrap gap-1.5">
244
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
245
+ (tag) => (
246
+ <Badge
247
+ key={tag.id}
248
+ variant="outline"
249
+ className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
250
+ >
251
+ #{tag.name}
252
+ </Badge>
253
+ )
254
+ )}
255
+ </div>
256
+ )}
257
+
258
+ {/* Active discount rule on this product, as a badge. `product.discount`
259
+ is returned by getProductBySlug and is null/absent when no rule
260
+ applies, and DiscountBadge returns null on a falsy `discount`, so
261
+ this renders nothing on a store with no discounts configured. */}
262
+ <DiscountBadge discount={product.discount} className="w-fit" />
263
+
264
+ {/* Price */}
265
+ <PriceDisplay
266
+ price={displayPrice.price}
267
+ salePrice={displayPrice.salePrice ?? undefined}
268
+ currency={displayPrice.currency}
269
+ size="lg"
270
+ />
271
+
272
+ {/* Stock / Digital badge */}
273
+ {product.isDownloadable ? (
274
+ <Badge
275
+ variant="outline"
276
+ 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"
277
+ >
278
+ <Download className="h-3.5 w-3.5" aria-hidden="true" />
279
+ {t('instantDownload')}
280
+ </Badge>
281
+ ) : (
282
+ <StockBadge inventory={inventory} />
283
+ )}
284
+
285
+ {/* Downloadable files info */}
286
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
287
+ <div className="bg-muted/50 rounded-lg border p-4">
288
+ <p className="text-foreground mb-2 text-sm font-medium">
289
+ {t('filesIncluded')} ({product.downloads.length})
290
+ </p>
291
+ <ul className="space-y-1.5">
292
+ {product.downloads.map((file: DownloadFile) => (
293
+ <li
294
+ key={file.id}
295
+ className="text-muted-foreground flex items-center gap-2 text-sm"
296
+ >
297
+ <FileText
298
+ className="h-4 w-4 flex-shrink-0"
299
+ strokeWidth={1.5}
300
+ aria-hidden="true"
301
+ />
302
+ <span className="truncate">{file.name}</span>
303
+ {file.size && (
304
+ <span className="flex-shrink-0 text-xs">
305
+ (
306
+ {file.size < 1024 * 1024
307
+ ? `${(file.size / 1024).toFixed(0)} KB`
308
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
309
+ )
310
+ </span>
311
+ )}
312
+ </li>
313
+ ))}
314
+ </ul>
315
+ </div>
316
+ )}
317
+
318
+ {/* Variant Selector */}
319
+ {/* A KIT is bought as ONE line, but the shopper needs to see what is
320
+ in the box before deciding. These rows are display only — never add
321
+ them to the cart individually; the kit reserves its components on
322
+ its own. */}
323
+ {product.type === 'KIT' && product.kitComponents?.length ? (
324
+ <div className="mb-6">
325
+ <h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
326
+ <ul className="divide-y rounded-lg border">
327
+ {product.kitComponents.map((c) => (
328
+ <li
329
+ key={`${c.productId}-${c.variantId ?? ''}`}
330
+ className="flex items-center gap-3 p-3"
331
+ >
332
+ {c.image ? (
333
+ // eslint-disable-next-line @next/next/no-img-element
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
+ Sold out is not the end of the page. `canOfferStockAlert` is the whole
442
+ gate it also covers the merchant's switch and backorderable items,
443
+ where an alert would tell someone to come back and do what they can
444
+ already do. The selected variant is passed on purpose: without it a
445
+ shopper who wanted the medium gets mailed when the small returns.
446
+ */}
447
+ {canOfferStockAlert(inventory, stockAlertsEnabled) && (
448
+ <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
449
+ )}
450
+
451
+ {/* Download after purchase note */}
452
+ {product.isDownloadable && (
453
+ <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
454
+ )}
455
+
456
+ {/* Description */}
457
+ {description && (
458
+ <div>
459
+ <Separator className="mb-4" />
460
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
461
+ {'html' in description ? (
462
+ <div
463
+ className="prose prose-sm text-muted-foreground max-w-none"
464
+ dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
465
+ />
466
+ ) : (
467
+ <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
468
+ )}
469
+ </div>
470
+ )}
471
+
472
+ {/* Metafields / Specifications */}
473
+ {product.metafields && product.metafields.length > 0 && (
474
+ <div>
475
+ <Separator className="mb-4" />
476
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
477
+ <table className="w-full text-sm">
478
+ <tbody>
479
+ {product.metafields.map((field) => (
480
+ <tr key={field.id} className="border-border border-b last:border-0">
481
+ <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
482
+ {field.definitionName}
483
+ </td>
484
+ <td className="text-muted-foreground py-2">
485
+ <MetafieldValue field={field} />
486
+ </td>
487
+ </tr>
488
+ ))}
489
+ </tbody>
490
+ </table>
491
+ </div>
492
+ )}
493
+ </div>
494
+ </div>
495
+
496
+ {/* Frequently Bought Together (cross-sells) */}
497
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
498
+ <FrequentlyBoughtTogether
499
+ items={recommendations.crossSells}
500
+ currentProduct={product}
501
+ className="mt-12"
502
+ />
503
+ )}
504
+
505
+ {/* Upsells */}
506
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
507
+ <RecommendationSection
508
+ title={t('upgradeYourChoice')}
509
+ items={recommendations.upsells}
510
+ className="mt-12"
511
+ />
512
+ )}
513
+
514
+ {/* Related products */}
515
+ {recommendations?.related && recommendations.related.length > 0 && (
516
+ <RecommendationSection
517
+ title={t('similarProducts')}
518
+ items={recommendations.related}
519
+ className="mt-12"
520
+ />
521
+ )}
522
+ </div>
523
+ );
524
+ }