create-brainerce-store 1.84.0 → 1.86.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 (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -1,426 +1,428 @@
1
- 'use client';
2
-
3
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
4
- import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
5
- import { useProductPage } from '@/core/hooks/use-product-page';
6
- import { PriceDisplay } from '@/ui/product/price-display';
7
- import { VariantSelector } from '@/ui/product/variant-selector';
8
- import { StockBadge } from '@/ui/product/stock-badge';
9
- import { DiscountBadge } from '@/ui/product/discount-badge';
10
- import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
11
- import { RecommendationSection } from '@/ui/product/recommendation-section';
12
- import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
13
- import { CustomizationFields } from '@/ui/product/customization-fields';
14
- import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
15
- import { useTranslations } from '@/core/lib/translations';
16
- import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
17
-
18
- /** Render a metafield value based on its type */
19
- function MetafieldValue({ field }: { field: ProductMetafield }) {
20
- const tc = useTranslations('common');
21
- switch (field.type) {
22
- case 'IMAGE': {
23
- if (!field.value) return <span>-</span>;
24
- return <img src={field.value} alt={field.definitionName} className="h-16 w-16" />;
25
- }
26
- case 'GALLERY': {
27
- let urls: string[] = [];
28
- try {
29
- const parsed = JSON.parse(field.value);
30
- urls = Array.isArray(parsed)
31
- ? parsed.filter((u: unknown) => typeof u === 'string' && u)
32
- : [];
33
- } catch {
34
- urls = field.value ? [field.value] : [];
35
- }
36
- if (urls.length === 0) return <span>-</span>;
37
- return (
38
- <span className="flex flex-wrap gap-2">
39
- {urls.map((url, i) => (
40
- <img key={i} src={url} alt={`${field.definitionName} ${i + 1}`} className="h-16 w-16" />
41
- ))}
42
- </span>
43
- );
44
- }
45
- case 'URL':
46
- return field.value ? (
47
- <a href={field.value} target="_blank" rel="noopener noreferrer">
48
- {field.value}
49
- </a>
50
- ) : (
51
- <span>-</span>
52
- );
53
- case 'COLOR':
54
- return field.value ? <span>{field.value}</span> : <span>-</span>;
55
- case 'BOOLEAN':
56
- return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
57
- case 'DATE':
58
- case 'DATETIME': {
59
- if (!field.value) return <span>-</span>;
60
- try {
61
- const date = new Date(field.value);
62
- return (
63
- <span>
64
- {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
65
- </span>
66
- );
67
- } catch {
68
- return <span>{field.value}</span>;
69
- }
70
- }
71
- default:
72
- return <span>{field.value || '-'}</span>;
73
- }
74
- }
75
-
76
- interface ProductClientSectionProps {
77
- product: Product;
78
- /**
79
- * From `getStoreInfo().stockAlertsEnabled` — the merchant switch for
80
- * back-in-stock alerts on this storefront. Undefined on a public-storefront
81
- * (storeId) client, where the setting does not apply and the feature is on.
82
- */
83
- stockAlertsEnabled?: boolean;
84
- }
85
-
86
- export function ProductClientSection({
87
- product: initialProduct,
88
- stockAlertsEnabled,
89
- }: ProductClientSectionProps) {
90
- const t = useTranslations('productDetail');
91
-
92
- const {
93
- product,
94
- recommendations,
95
- images,
96
- selectedImageIndex,
97
- setSelectedImageIndex,
98
- mainImageUrl,
99
- selectedVariant,
100
- setSelectedVariant,
101
- priceInfo,
102
- displayPrice,
103
- inventory,
104
- canPurchase,
105
- description,
106
- quantity,
107
- setQuantity,
108
- addingToCart,
109
- addedMessage,
110
- addToCartError,
111
- handleAddToCart,
112
- customizationFields,
113
- customizationValues,
114
- setCustomizationValues,
115
- customizationErrors,
116
- modifierGroups,
117
- modifierSelections,
118
- setModifierSelection,
119
- modifierError,
120
- } = useProductPage(initialProduct);
121
-
122
- return (
123
- <article>
124
- {/* DESIGN ME — product page (PDP): gallery, buy box (price/stock/variants/customization/modifiers/quantity/add-to-cart), description, specs, cross-sells; everything comes from useProductPage(initialProduct). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
125
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
126
- {/* Image Gallery */}
127
- <figure>
128
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
129
- <div className="relative aspect-square">
130
- {mainImageUrl ? (
131
- <Image
132
- src={mainImageUrl}
133
- alt={product.name}
134
- fill
135
- sizes="(max-width: 1024px) 100vw, 50vw"
136
- priority
137
- />
138
- ) : (
139
- <span className="sr-only">{product.name}</span>
140
- )}
141
- </div>
142
-
143
- {/* Thumbnails */}
144
- {images.length > 1 && (
145
- <div className="flex flex-wrap gap-2">
146
- {images.map((img, idx) => (
147
- <button
148
- key={idx}
149
- type="button"
150
- onClick={() => setSelectedImageIndex(idx)}
151
- aria-pressed={selectedImageIndex === idx}
152
- className="relative h-16 w-16"
153
- >
154
- <Image
155
- src={img.url}
156
- alt={img.alt || `${product.name} ${idx + 1}`}
157
- fill
158
- sizes="64px"
159
- />
160
- </button>
161
- ))}
162
- </div>
163
- )}
164
- <figcaption className="sr-only">{product.name}</figcaption>
165
- </figure>
166
-
167
- {/* Product Info / buy box */}
168
- <section>
169
- {/* Categories */}
170
- {product.categories && product.categories.length > 0 && (
171
- <ul className="flex flex-wrap gap-2">
172
- {product.categories.map((cat) => (
173
- <li key={cat.id}>{cat.name}</li>
174
- ))}
175
- </ul>
176
- )}
177
-
178
- {/* Brand */}
179
- {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
180
- (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
181
- <p>
182
- {t('by')}{' '}
183
- <span>
184
- {(product as { brands: Array<{ id: string; name: string }> }).brands
185
- .map((b) => b.name)
186
- .join(', ')}
187
- </span>
188
- </p>
189
- )}
190
-
191
- {/* Title */}
192
- <h1>{product.name}</h1>
193
-
194
- {/* Tags */}
195
- {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
196
- (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
197
- 0 && (
198
- <ul className="flex flex-wrap gap-1">
199
- {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
200
- (tag) => (
201
- <li key={tag.id}>#{tag.name}</li>
202
- )
203
- )}
204
- </ul>
205
- )}
206
-
207
- {/* Active discount rule on this product, as a badge. `product.discount`
208
- is returned by getProductBySlug and is null/absent when no rule
209
- applies, and DiscountBadge returns null on a falsy `discount`, so
210
- this renders nothing on a store with no discounts configured. */}
211
- <DiscountBadge discount={product.discount} />
212
-
213
- {/* Price */}
214
- <PriceDisplay
215
- price={displayPrice.price}
216
- salePrice={displayPrice.salePrice ?? undefined}
217
- currency={displayPrice.currency}
218
- size="lg"
219
- />
220
-
221
- {/* Stock / Digital badge */}
222
- {product.isDownloadable ? (
223
- <p>{t('instantDownload')}</p>
224
- ) : (
225
- <StockBadge inventory={inventory} />
226
- )}
227
-
228
- {/* Downloadable files info */}
229
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
230
- <section>
231
- <h2>
232
- {t('filesIncluded')} ({product.downloads.length})
233
- </h2>
234
- <ul>
235
- {product.downloads.map((file: DownloadFile) => (
236
- <li key={file.id}>
237
- <span>{file.name}</span>
238
- {file.size && (
239
- <span>
240
- {' '}
241
- (
242
- {file.size < 1024 * 1024
243
- ? `${(file.size / 1024).toFixed(0)} KB`
244
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
245
- )
246
- </span>
247
- )}
248
- </li>
249
- ))}
250
- </ul>
251
- </section>
252
- )}
253
-
254
- {/* Variant Selector */}
255
- {/* A KIT is bought as ONE line, but the shopper needs to see what is
256
- in the box before deciding. These rows are display only — never add
257
- them to the cart individually; the kit reserves its components on
258
- its own. */}
259
- {product.type === 'KIT' && product.kitComponents?.length ? (
260
- <div className="mb-6">
261
- <h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
262
- <ul className="divide-y rounded-lg border">
263
- {product.kitComponents.map((c) => (
264
- <li
265
- key={`${c.productId}-${c.variantId ?? ''}`}
266
- className="flex items-center gap-3 p-3"
267
- >
268
- {c.image ? (
269
- // eslint-disable-next-line @next/next/no-img-element
270
- <img
271
- src={c.image}
272
- alt={c.name}
273
- className="h-10 w-10 shrink-0 rounded border object-cover"
274
- />
275
- ) : (
276
- <div className="bg-muted h-10 w-10 shrink-0 rounded border" />
277
- )}
278
- <span className="flex-1 text-sm">{c.name}</span>
279
- <span className="text-muted-foreground text-sm">x{c.quantity}</span>
280
- </li>
281
- ))}
282
- </ul>
283
- </div>
284
- ) : null}
285
-
286
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
287
- <VariantSelector
288
- product={product}
289
- selectedVariant={selectedVariant}
290
- onVariantChange={setSelectedVariant}
291
- />
292
- )}
293
-
294
- {/* Customization Fields (buyer input) */}
295
- {customizationFields.length > 0 && (
296
- <CustomizationFields
297
- fields={customizationFields}
298
- values={customizationValues}
299
- onChange={setCustomizationValues}
300
- errors={customizationErrors}
301
- />
302
- )}
303
-
304
- {/* Modifier groups (toppings, sauce, bread type, …) */}
305
- {modifierGroups.length > 0 && (
306
- <div>
307
- {modifierGroups.map((group) => (
308
- <ModifierGroupSelector
309
- key={group.attachmentId ?? group.id}
310
- group={group}
311
- value={modifierSelections[group.id] ?? []}
312
- onChange={(next) => setModifierSelection(group.id, next)}
313
- disabled={addingToCart}
314
- />
315
- ))}
316
- {modifierError && <p role="alert">{modifierError}</p>}
317
- </div>
318
- )}
319
-
320
- {/* Quantity + Add to Cart */}
321
- <div className="flex items-center gap-4">
322
- <div className="flex items-center gap-2">
323
- <button
324
- type="button"
325
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
326
- aria-label={t('decreaseQuantity')}
327
- >
328
- -
329
- </button>
330
- <span aria-live="polite">{quantity}</span>
331
- <button
332
- type="button"
333
- onClick={() => setQuantity((q) => q + 1)}
334
- aria-label={t('increaseQuantity')}
335
- >
336
- +
337
- </button>
338
- </div>
339
-
340
- <button type="button" onClick={handleAddToCart} disabled={!canPurchase || addingToCart}>
341
- {addingToCart
342
- ? t('addingToCart')
343
- : addedMessage
344
- ? t('addedToCart')
345
- : !canPurchase
346
- ? t('outOfStock')
347
- : t('addToCart')}
348
- </button>
349
- </div>
350
-
351
- {/*
352
- Why the add was refused. Deliberately OUTSIDE the modifier block
353
- above: that block only renders when the product HAS modifier
354
- groups, so a refusal shown there would be invisible on almost
355
- every product and the shopper would just watch the button reset.
356
- */}
357
- {addToCartError && (
358
- <p role="alert">
359
- {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
360
- </p>
361
- )}
362
-
363
- {/*
364
- Sold out is not the end of the page. `canOfferStockAlert` is the whole
365
- gate — it also covers the merchant's switch and backorderable items,
366
- where an alert would tell someone to come back and do what they can
367
- already do. Pass the selected variant: without it a shopper who wanted
368
- the medium is mailed when the small returns.
369
- */}
370
- {canOfferStockAlert(inventory, stockAlertsEnabled) && (
371
- <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
372
- )}
373
-
374
- {/* Download after purchase note */}
375
- {product.isDownloadable && <p>{t('downloadAfterPurchase')}</p>}
376
-
377
- {/* Description */}
378
- {description && (
379
- <section>
380
- <h2>{t('description')}</h2>
381
- {'html' in description ? (
382
- <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }} />
383
- ) : (
384
- <p className="whitespace-pre-wrap">{description.text}</p>
385
- )}
386
- </section>
387
- )}
388
-
389
- {/* Metafields / Specifications */}
390
- {product.metafields && product.metafields.length > 0 && (
391
- <section>
392
- <h2>{t('specifications')}</h2>
393
- <table>
394
- <tbody>
395
- {product.metafields.map((field) => (
396
- <tr key={field.id}>
397
- <th scope="row">{field.definitionName}</th>
398
- <td>
399
- <MetafieldValue field={field} />
400
- </td>
401
- </tr>
402
- ))}
403
- </tbody>
404
- </table>
405
- </section>
406
- )}
407
- </section>
408
- </div>
409
-
410
- {/* Frequently Bought Together (cross-sells) */}
411
- {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
412
- <FrequentlyBoughtTogether items={recommendations.crossSells} currentProduct={product} />
413
- )}
414
-
415
- {/* Upsells */}
416
- {recommendations?.upsells && recommendations.upsells.length > 0 && (
417
- <RecommendationSection title={t('upgradeYourChoice')} items={recommendations.upsells} />
418
- )}
419
-
420
- {/* Related products */}
421
- {recommendations?.related && recommendations.related.length > 0 && (
422
- <RecommendationSection title={t('similarProducts')} items={recommendations.related} />
423
- )}
424
- </article>
425
- );
426
- }
1
+ 'use client';
2
+
3
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
4
+ import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
5
+ import { useProductPage } from '@/core/hooks/use-product-page';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { VariantSelector } from '@/ui/product/variant-selector';
8
+ import { StockBadge } from '@/ui/product/stock-badge';
9
+ import { DiscountBadge } from '@/ui/product/discount-badge';
10
+ import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
11
+ import { RecommendationSection } from '@/ui/product/recommendation-section';
12
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
13
+ import { CustomizationFields } from '@/ui/product/customization-fields';
14
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
15
+ import { useTranslations } from '@/core/lib/translations';
16
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
17
+
18
+ /** Render a metafield value based on its type */
19
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
20
+ const tc = useTranslations('common');
21
+ switch (field.type) {
22
+ case 'IMAGE': {
23
+ if (!field.value) return <span>-</span>;
24
+ return <img src={field.value} alt={field.definitionName} className="h-16 w-16" />;
25
+ }
26
+ case 'GALLERY': {
27
+ let urls: string[] = [];
28
+ try {
29
+ const parsed = JSON.parse(field.value);
30
+ urls = Array.isArray(parsed)
31
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
32
+ : [];
33
+ } catch {
34
+ urls = field.value ? [field.value] : [];
35
+ }
36
+ if (urls.length === 0) return <span>-</span>;
37
+ return (
38
+ <span className="flex flex-wrap gap-2">
39
+ {urls.map((url, i) => (
40
+ <img key={i} src={url} alt={`${field.definitionName} ${i + 1}`} className="h-16 w-16" />
41
+ ))}
42
+ </span>
43
+ );
44
+ }
45
+ case 'URL':
46
+ return field.value ? (
47
+ <a href={field.value} target="_blank" rel="noopener noreferrer">
48
+ {field.value}
49
+ </a>
50
+ ) : (
51
+ <span>-</span>
52
+ );
53
+ case 'COLOR':
54
+ return field.value ? <span>{field.value}</span> : <span>-</span>;
55
+ case 'BOOLEAN':
56
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
57
+ case 'DATE':
58
+ case 'DATETIME': {
59
+ if (!field.value) return <span>-</span>;
60
+ try {
61
+ const date = new Date(field.value);
62
+ return (
63
+ <span>
64
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
65
+ </span>
66
+ );
67
+ } catch {
68
+ return <span>{field.value}</span>;
69
+ }
70
+ }
71
+ default:
72
+ return <span>{field.value || '-'}</span>;
73
+ }
74
+ }
75
+
76
+ interface ProductClientSectionProps {
77
+ product: Product;
78
+ /**
79
+ * From `getStoreInfo().stockAlertsEnabled` — the merchant switch for
80
+ * back-in-stock alerts on this storefront. Undefined on a public-storefront
81
+ * (storeId) client, where the setting does not apply and the feature is on.
82
+ */
83
+ stockAlertsEnabled?: boolean;
84
+ }
85
+
86
+ export function ProductClientSection({
87
+ product: initialProduct,
88
+ stockAlertsEnabled,
89
+ }: ProductClientSectionProps) {
90
+ const t = useTranslations('productDetail');
91
+
92
+ const {
93
+ product,
94
+ recommendations,
95
+ images,
96
+ selectedImageIndex,
97
+ setSelectedImageIndex,
98
+ mainImageUrl,
99
+ selectedVariant,
100
+ setSelectedVariant,
101
+ priceInfo,
102
+ displayPrice,
103
+ inventory,
104
+ canPurchase,
105
+ description,
106
+ quantity,
107
+ setQuantity,
108
+ addingToCart,
109
+ addedMessage,
110
+ addToCartError,
111
+ handleAddToCart,
112
+ customizationFields,
113
+ customizationValues,
114
+ setCustomizationValues,
115
+ customizationErrors,
116
+ modifierGroups,
117
+ modifierSelections,
118
+ setModifierSelection,
119
+ modifierError,
120
+ } = useProductPage(initialProduct);
121
+
122
+ return (
123
+ <article>
124
+ {/* DESIGN ME — product page (PDP): gallery, buy box (price/stock/variants/customization/modifiers/quantity/add-to-cart), description, specs, cross-sells; everything comes from useProductPage(initialProduct). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
125
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
126
+ {/* Image Gallery */}
127
+ <figure>
128
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
129
+ <div className="relative aspect-square">
130
+ {mainImageUrl ? (
131
+ <Image
132
+ src={mainImageUrl}
133
+ alt={product.name}
134
+ fill
135
+ sizes="(max-width: 1024px) 100vw, 50vw"
136
+ priority
137
+ />
138
+ ) : (
139
+ <span className="sr-only">{product.name}</span>
140
+ )}
141
+ </div>
142
+
143
+ {/* Thumbnails */}
144
+ {images.length > 1 && (
145
+ <div className="flex flex-wrap gap-2">
146
+ {images.map((img, idx) => (
147
+ <button
148
+ key={idx}
149
+ type="button"
150
+ onClick={() => setSelectedImageIndex(idx)}
151
+ aria-pressed={selectedImageIndex === idx}
152
+ className="relative h-16 w-16"
153
+ >
154
+ <Image
155
+ src={img.url}
156
+ alt={img.alt || `${product.name} ${idx + 1}`}
157
+ fill
158
+ sizes="64px"
159
+ />
160
+ </button>
161
+ ))}
162
+ </div>
163
+ )}
164
+ <figcaption className="sr-only">{product.name}</figcaption>
165
+ </figure>
166
+
167
+ {/* Product Info / buy box */}
168
+ <section>
169
+ {/* Categories */}
170
+ {product.categories && product.categories.length > 0 && (
171
+ <ul className="flex flex-wrap gap-2">
172
+ {product.categories.map((cat) => (
173
+ <li key={cat.id}>{cat.name}</li>
174
+ ))}
175
+ </ul>
176
+ )}
177
+
178
+ {/* Brand */}
179
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
180
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
181
+ <p>
182
+ {t('by')}{' '}
183
+ <span>
184
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
185
+ .map((b) => b.name)
186
+ .join(', ')}
187
+ </span>
188
+ </p>
189
+ )}
190
+
191
+ {/* Title */}
192
+ <h1>{product.name}</h1>
193
+
194
+ {/* Tags */}
195
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
196
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
197
+ 0 && (
198
+ <ul className="flex flex-wrap gap-1">
199
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
200
+ (tag) => (
201
+ <li key={tag.id}>#{tag.name}</li>
202
+ )
203
+ )}
204
+ </ul>
205
+ )}
206
+
207
+ {/* Active discount rule on this product, as a badge. `product.discount`
208
+ is returned by getProductBySlug and is null/absent when no rule
209
+ applies, and DiscountBadge returns null on a falsy `discount`, so
210
+ this renders nothing on a store with no discounts configured. */}
211
+ <DiscountBadge discount={product.discount} />
212
+
213
+ {/* Price */}
214
+ <PriceDisplay
215
+ price={displayPrice.price}
216
+ salePrice={displayPrice.salePrice ?? undefined}
217
+ currency={displayPrice.currency}
218
+ size="lg"
219
+ />
220
+
221
+ {/* Stock / Digital badge */}
222
+ {product.isDownloadable ? (
223
+ <p>{t('instantDownload')}</p>
224
+ ) : (
225
+ <StockBadge inventory={inventory} />
226
+ )}
227
+
228
+ {/* Downloadable files info */}
229
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
230
+ <section>
231
+ <h2>
232
+ {t('filesIncluded')} ({product.downloads.length})
233
+ </h2>
234
+ <ul>
235
+ {product.downloads.map((file: DownloadFile) => (
236
+ <li key={file.id}>
237
+ <span>{file.name}</span>
238
+ {file.size && (
239
+ <span>
240
+ {' '}
241
+ (
242
+ {file.size < 1024 * 1024
243
+ ? `${(file.size / 1024).toFixed(0)} KB`
244
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
245
+ )
246
+ </span>
247
+ )}
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ </section>
252
+ )}
253
+
254
+ {/* Variant Selector */}
255
+ {/* A KIT is bought as ONE line, but the shopper needs to see what is
256
+ in the box before deciding. These rows are display only — never add
257
+ them to the cart individually; the kit reserves its components on
258
+ its own. */}
259
+ {product.type === 'KIT' && product.kitComponents?.length ? (
260
+ <div className="mb-6">
261
+ <h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
262
+ <ul className="divide-y rounded-lg border">
263
+ {product.kitComponents.map((c) => (
264
+ <li
265
+ key={`${c.productId}-${c.variantId ?? ''}`}
266
+ className="flex items-center gap-3 p-3"
267
+ >
268
+ {c.image ? (
269
+ <img
270
+ src={c.image}
271
+ alt={c.name}
272
+ className="h-10 w-10 shrink-0 rounded border object-cover"
273
+ />
274
+ ) : (
275
+ <div className="bg-muted h-10 w-10 shrink-0 rounded border" />
276
+ )}
277
+ <span className="flex-1 text-sm">{c.name}</span>
278
+ <span className="text-muted-foreground text-sm">x{c.quantity}</span>
279
+ </li>
280
+ ))}
281
+ </ul>
282
+ </div>
283
+ ) : null}
284
+
285
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
286
+ <VariantSelector
287
+ product={product}
288
+ selectedVariant={selectedVariant}
289
+ onVariantChange={setSelectedVariant}
290
+ />
291
+ )}
292
+
293
+ {/* Customization Fields (buyer input) */}
294
+ {customizationFields.length > 0 && (
295
+ <CustomizationFields
296
+ fields={customizationFields}
297
+ values={customizationValues}
298
+ onChange={setCustomizationValues}
299
+ errors={customizationErrors}
300
+ />
301
+ )}
302
+
303
+ {/* Modifier groups (toppings, sauce, bread type, …) */}
304
+ {modifierGroups.length > 0 && (
305
+ <div>
306
+ {modifierGroups.map((group) => (
307
+ <ModifierGroupSelector
308
+ key={group.attachmentId ?? group.id}
309
+ group={group}
310
+ value={modifierSelections[group.id] ?? []}
311
+ onChange={(next) => setModifierSelection(group.id, next)}
312
+ disabled={addingToCart}
313
+ />
314
+ ))}
315
+ {modifierError && <p role="alert">{modifierError}</p>}
316
+ </div>
317
+ )}
318
+
319
+ {/* Quantity + Add to Cart */}
320
+ <div className="flex items-center gap-4">
321
+ <div className="flex items-center gap-2">
322
+ <button
323
+ type="button"
324
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
325
+ aria-label={t('decreaseQuantity')}
326
+ >
327
+ -
328
+ </button>
329
+ <span aria-live="polite">{quantity}</span>
330
+ <button
331
+ type="button"
332
+ onClick={() => setQuantity((q) => q + 1)}
333
+ aria-label={t('increaseQuantity')}
334
+ >
335
+ +
336
+ </button>
337
+ </div>
338
+
339
+ <button type="button" onClick={handleAddToCart} disabled={!canPurchase || addingToCart}>
340
+ {addingToCart
341
+ ? t('addingToCart')
342
+ : addedMessage
343
+ ? t('addedToCart')
344
+ : !canPurchase
345
+ ? t('outOfStock')
346
+ : t('addToCart')}
347
+ </button>
348
+ </div>
349
+
350
+ {/*
351
+ Why the add was refused. Deliberately OUTSIDE the modifier block
352
+ above: that block only renders when the product HAS modifier
353
+ groups, so a refusal shown there would be invisible on almost
354
+ every product and the shopper would just watch the button reset.
355
+ */}
356
+ {addToCartError && (
357
+ <p role="alert">
358
+ {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
359
+ </p>
360
+ )}
361
+
362
+ {/*
363
+ Sold out is not the end of the page. `canOfferStockAlert` is the whole
364
+ gate — it also covers the merchant's switch and backorderable items,
365
+ where an alert would tell someone to come back and do what they can
366
+ already do. Pass the selected variant: without it a shopper who wanted
367
+ the medium is mailed when the small returns.
368
+ */}
369
+ {canOfferStockAlert(inventory, stockAlertsEnabled) && (
370
+ <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
371
+ )}
372
+
373
+ {/* Download after purchase note */}
374
+ {product.isDownloadable && <p>{t('downloadAfterPurchase')}</p>}
375
+
376
+ {/* Description */}
377
+ {description && (
378
+ <section>
379
+ <h2>{t('description')}</h2>
380
+ {'html' in description ? (
381
+ // Security: merchant-authored product description HTML,
382
+ // sanitized via `sanitizeProductHtml` (isomorphic-dompurify,
383
+ // allowlisted tags) before injecting.
384
+ <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }} />
385
+ ) : (
386
+ <p className="whitespace-pre-wrap">{description.text}</p>
387
+ )}
388
+ </section>
389
+ )}
390
+
391
+ {/* Metafields / Specifications */}
392
+ {product.metafields && product.metafields.length > 0 && (
393
+ <section>
394
+ <h2>{t('specifications')}</h2>
395
+ <table>
396
+ <tbody>
397
+ {product.metafields.map((field) => (
398
+ <tr key={field.id}>
399
+ <th scope="row">{field.definitionName}</th>
400
+ <td>
401
+ <MetafieldValue field={field} />
402
+ </td>
403
+ </tr>
404
+ ))}
405
+ </tbody>
406
+ </table>
407
+ </section>
408
+ )}
409
+ </section>
410
+ </div>
411
+
412
+ {/* Frequently Bought Together (cross-sells) */}
413
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
414
+ <FrequentlyBoughtTogether items={recommendations.crossSells} currentProduct={product} />
415
+ )}
416
+
417
+ {/* Upsells */}
418
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
419
+ <RecommendationSection title={t('upgradeYourChoice')} items={recommendations.upsells} />
420
+ )}
421
+
422
+ {/* Related products */}
423
+ {recommendations?.related && recommendations.related.length > 0 && (
424
+ <RecommendationSection title={t('similarProducts')} items={recommendations.related} />
425
+ )}
426
+ </article>
427
+ );
428
+ }