create-brainerce-store 1.74.0 → 1.76.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.
@@ -1,375 +1,382 @@
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 { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
10
- import { RecommendationSection } from '@/ui/product/recommendation-section';
11
- import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
12
- import { CustomizationFields } from '@/ui/product/customization-fields';
13
- import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
14
- import { useTranslations } from '@/core/lib/translations';
15
- import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
16
-
17
- /** Render a metafield value based on its type */
18
- function MetafieldValue({ field }: { field: ProductMetafield }) {
19
- const tc = useTranslations('common');
20
- switch (field.type) {
21
- case 'IMAGE': {
22
- if (!field.value) return <span>-</span>;
23
- return <img src={field.value} alt={field.definitionName} className="h-16 w-16" />;
24
- }
25
- case 'GALLERY': {
26
- let urls: string[] = [];
27
- try {
28
- const parsed = JSON.parse(field.value);
29
- urls = Array.isArray(parsed)
30
- ? parsed.filter((u: unknown) => typeof u === 'string' && u)
31
- : [];
32
- } catch {
33
- urls = field.value ? [field.value] : [];
34
- }
35
- if (urls.length === 0) return <span>-</span>;
36
- return (
37
- <span className="flex flex-wrap gap-2">
38
- {urls.map((url, i) => (
39
- <img key={i} src={url} alt={`${field.definitionName} ${i + 1}`} className="h-16 w-16" />
40
- ))}
41
- </span>
42
- );
43
- }
44
- case 'URL':
45
- return field.value ? (
46
- <a href={field.value} target="_blank" rel="noopener noreferrer">
47
- {field.value}
48
- </a>
49
- ) : (
50
- <span>-</span>
51
- );
52
- case 'COLOR':
53
- return field.value ? <span>{field.value}</span> : <span>-</span>;
54
- case 'BOOLEAN':
55
- return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
56
- case 'DATE':
57
- case 'DATETIME': {
58
- if (!field.value) return <span>-</span>;
59
- try {
60
- const date = new Date(field.value);
61
- return (
62
- <span>
63
- {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
64
- </span>
65
- );
66
- } catch {
67
- return <span>{field.value}</span>;
68
- }
69
- }
70
- default:
71
- return <span>{field.value || '-'}</span>;
72
- }
73
- }
74
-
75
- interface ProductClientSectionProps {
76
- product: Product;
77
- /**
78
- * From `getStoreInfo().stockAlertsEnabled` — the merchant switch for
79
- * back-in-stock alerts on this storefront. Undefined on a public-storefront
80
- * (storeId) client, where the setting does not apply and the feature is on.
81
- */
82
- stockAlertsEnabled?: boolean;
83
- }
84
-
85
- export function ProductClientSection({
86
- product: initialProduct,
87
- stockAlertsEnabled,
88
- }: ProductClientSectionProps) {
89
- const t = useTranslations('productDetail');
90
-
91
- const {
92
- product,
93
- recommendations,
94
- images,
95
- selectedImageIndex,
96
- setSelectedImageIndex,
97
- mainImageUrl,
98
- selectedVariant,
99
- setSelectedVariant,
100
- priceInfo,
101
- displayPrice,
102
- inventory,
103
- canPurchase,
104
- description,
105
- quantity,
106
- setQuantity,
107
- addingToCart,
108
- addedMessage,
109
- handleAddToCart,
110
- customizationFields,
111
- customizationValues,
112
- setCustomizationValues,
113
- customizationErrors,
114
- modifierGroups,
115
- modifierSelections,
116
- setModifierSelection,
117
- modifierError,
118
- } = useProductPage(initialProduct);
119
-
120
- return (
121
- <article>
122
- {/* 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. */}
123
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
124
- {/* Image Gallery */}
125
- <figure>
126
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
127
- <div className="relative aspect-square">
128
- {mainImageUrl ? (
129
- <Image
130
- src={mainImageUrl}
131
- alt={product.name}
132
- fill
133
- sizes="(max-width: 1024px) 100vw, 50vw"
134
- priority
135
- />
136
- ) : (
137
- <span className="sr-only">{product.name}</span>
138
- )}
139
- </div>
140
-
141
- {/* Thumbnails */}
142
- {images.length > 1 && (
143
- <div className="flex flex-wrap gap-2">
144
- {images.map((img, idx) => (
145
- <button
146
- key={idx}
147
- type="button"
148
- onClick={() => setSelectedImageIndex(idx)}
149
- aria-pressed={selectedImageIndex === idx}
150
- className="relative h-16 w-16"
151
- >
152
- <Image
153
- src={img.url}
154
- alt={img.alt || `${product.name} ${idx + 1}`}
155
- fill
156
- sizes="64px"
157
- />
158
- </button>
159
- ))}
160
- </div>
161
- )}
162
- <figcaption className="sr-only">{product.name}</figcaption>
163
- </figure>
164
-
165
- {/* Product Info / buy box */}
166
- <section>
167
- {/* Categories */}
168
- {product.categories && product.categories.length > 0 && (
169
- <ul className="flex flex-wrap gap-2">
170
- {product.categories.map((cat) => (
171
- <li key={cat.id}>{cat.name}</li>
172
- ))}
173
- </ul>
174
- )}
175
-
176
- {/* Brand */}
177
- {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
178
- (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
179
- <p>
180
- {t('by')}{' '}
181
- <span>
182
- {(product as { brands: Array<{ id: string; name: string }> }).brands
183
- .map((b) => b.name)
184
- .join(', ')}
185
- </span>
186
- </p>
187
- )}
188
-
189
- {/* Title */}
190
- <h1>{product.name}</h1>
191
-
192
- {/* Tags */}
193
- {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
194
- (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
195
- 0 && (
196
- <ul className="flex flex-wrap gap-1">
197
- {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
198
- (tag) => (
199
- <li key={tag.id}>#{tag.name}</li>
200
- )
201
- )}
202
- </ul>
203
- )}
204
-
205
- {/* Price */}
206
- <PriceDisplay
207
- price={displayPrice.price}
208
- salePrice={displayPrice.salePrice ?? undefined}
209
- currency={displayPrice.currency}
210
- size="lg"
211
- />
212
-
213
- {/* Stock / Digital badge */}
214
- {product.isDownloadable ? (
215
- <p>{t('instantDownload')}</p>
216
- ) : (
217
- <StockBadge inventory={inventory} />
218
- )}
219
-
220
- {/* Downloadable files info */}
221
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
222
- <section>
223
- <h2>
224
- {t('filesIncluded')} ({product.downloads.length})
225
- </h2>
226
- <ul>
227
- {product.downloads.map((file: DownloadFile) => (
228
- <li key={file.id}>
229
- <span>{file.name}</span>
230
- {file.size && (
231
- <span>
232
- {' '}
233
- (
234
- {file.size < 1024 * 1024
235
- ? `${(file.size / 1024).toFixed(0)} KB`
236
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
237
- )
238
- </span>
239
- )}
240
- </li>
241
- ))}
242
- </ul>
243
- </section>
244
- )}
245
-
246
- {/* Variant Selector */}
247
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
248
- <VariantSelector
249
- product={product}
250
- selectedVariant={selectedVariant}
251
- onVariantChange={setSelectedVariant}
252
- />
253
- )}
254
-
255
- {/* Customization Fields (buyer input) */}
256
- {customizationFields.length > 0 && (
257
- <CustomizationFields
258
- fields={customizationFields}
259
- values={customizationValues}
260
- onChange={setCustomizationValues}
261
- errors={customizationErrors}
262
- />
263
- )}
264
-
265
- {/* Modifier groups (toppings, sauce, bread type, …) */}
266
- {modifierGroups.length > 0 && (
267
- <div>
268
- {modifierGroups.map((group) => (
269
- <ModifierGroupSelector
270
- key={group.attachmentId ?? group.id}
271
- group={group}
272
- value={modifierSelections[group.id] ?? []}
273
- onChange={(next) => setModifierSelection(group.id, next)}
274
- disabled={addingToCart}
275
- />
276
- ))}
277
- {modifierError && <p role="alert">{modifierError}</p>}
278
- </div>
279
- )}
280
-
281
- {/* Quantity + Add to Cart */}
282
- <div className="flex items-center gap-4">
283
- <div className="flex items-center gap-2">
284
- <button
285
- type="button"
286
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
287
- aria-label={t('decreaseQuantity')}
288
- >
289
- -
290
- </button>
291
- <span aria-live="polite">{quantity}</span>
292
- <button
293
- type="button"
294
- onClick={() => setQuantity((q) => q + 1)}
295
- aria-label={t('increaseQuantity')}
296
- >
297
- +
298
- </button>
299
- </div>
300
-
301
- <button type="button" onClick={handleAddToCart} disabled={!canPurchase || addingToCart}>
302
- {addingToCart
303
- ? t('addingToCart')
304
- : addedMessage
305
- ? t('addedToCart')
306
- : !canPurchase
307
- ? t('outOfStock')
308
- : t('addToCart')}
309
- </button>
310
- </div>
311
-
312
- {/*
313
- Sold out is not the end of the page. `canOfferStockAlert` is the whole
314
- gate — it also covers the merchant's switch and backorderable items,
315
- where an alert would tell someone to come back and do what they can
316
- already do. Pass the selected variant: without it a shopper who wanted
317
- the medium is mailed when the small returns.
318
- */}
319
- {canOfferStockAlert(inventory, stockAlertsEnabled) && (
320
- <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
321
- )}
322
-
323
- {/* Download after purchase note */}
324
- {product.isDownloadable && <p>{t('downloadAfterPurchase')}</p>}
325
-
326
- {/* Description */}
327
- {description && (
328
- <section>
329
- <h2>{t('description')}</h2>
330
- {'html' in description ? (
331
- <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }} />
332
- ) : (
333
- <p className="whitespace-pre-wrap">{description.text}</p>
334
- )}
335
- </section>
336
- )}
337
-
338
- {/* Metafields / Specifications */}
339
- {product.metafields && product.metafields.length > 0 && (
340
- <section>
341
- <h2>{t('specifications')}</h2>
342
- <table>
343
- <tbody>
344
- {product.metafields.map((field) => (
345
- <tr key={field.id}>
346
- <th scope="row">{field.definitionName}</th>
347
- <td>
348
- <MetafieldValue field={field} />
349
- </td>
350
- </tr>
351
- ))}
352
- </tbody>
353
- </table>
354
- </section>
355
- )}
356
- </section>
357
- </div>
358
-
359
- {/* Frequently Bought Together (cross-sells) */}
360
- {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
361
- <FrequentlyBoughtTogether items={recommendations.crossSells} currentProduct={product} />
362
- )}
363
-
364
- {/* Upsells */}
365
- {recommendations?.upsells && recommendations.upsells.length > 0 && (
366
- <RecommendationSection title={t('upgradeYourChoice')} items={recommendations.upsells} />
367
- )}
368
-
369
- {/* Related products */}
370
- {recommendations?.related && recommendations.related.length > 0 && (
371
- <RecommendationSection title={t('similarProducts')} items={recommendations.related} />
372
- )}
373
- </article>
374
- );
375
- }
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
+ handleAddToCart,
111
+ customizationFields,
112
+ customizationValues,
113
+ setCustomizationValues,
114
+ customizationErrors,
115
+ modifierGroups,
116
+ modifierSelections,
117
+ setModifierSelection,
118
+ modifierError,
119
+ } = useProductPage(initialProduct);
120
+
121
+ return (
122
+ <article>
123
+ {/* 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. */}
124
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
125
+ {/* Image Gallery */}
126
+ <figure>
127
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
128
+ <div className="relative aspect-square">
129
+ {mainImageUrl ? (
130
+ <Image
131
+ src={mainImageUrl}
132
+ alt={product.name}
133
+ fill
134
+ sizes="(max-width: 1024px) 100vw, 50vw"
135
+ priority
136
+ />
137
+ ) : (
138
+ <span className="sr-only">{product.name}</span>
139
+ )}
140
+ </div>
141
+
142
+ {/* Thumbnails */}
143
+ {images.length > 1 && (
144
+ <div className="flex flex-wrap gap-2">
145
+ {images.map((img, idx) => (
146
+ <button
147
+ key={idx}
148
+ type="button"
149
+ onClick={() => setSelectedImageIndex(idx)}
150
+ aria-pressed={selectedImageIndex === idx}
151
+ className="relative h-16 w-16"
152
+ >
153
+ <Image
154
+ src={img.url}
155
+ alt={img.alt || `${product.name} ${idx + 1}`}
156
+ fill
157
+ sizes="64px"
158
+ />
159
+ </button>
160
+ ))}
161
+ </div>
162
+ )}
163
+ <figcaption className="sr-only">{product.name}</figcaption>
164
+ </figure>
165
+
166
+ {/* Product Info / buy box */}
167
+ <section>
168
+ {/* Categories */}
169
+ {product.categories && product.categories.length > 0 && (
170
+ <ul className="flex flex-wrap gap-2">
171
+ {product.categories.map((cat) => (
172
+ <li key={cat.id}>{cat.name}</li>
173
+ ))}
174
+ </ul>
175
+ )}
176
+
177
+ {/* Brand */}
178
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
179
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
180
+ <p>
181
+ {t('by')}{' '}
182
+ <span>
183
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
184
+ .map((b) => b.name)
185
+ .join(', ')}
186
+ </span>
187
+ </p>
188
+ )}
189
+
190
+ {/* Title */}
191
+ <h1>{product.name}</h1>
192
+
193
+ {/* Tags */}
194
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
195
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
196
+ 0 && (
197
+ <ul className="flex flex-wrap gap-1">
198
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
199
+ (tag) => (
200
+ <li key={tag.id}>#{tag.name}</li>
201
+ )
202
+ )}
203
+ </ul>
204
+ )}
205
+
206
+ {/* Active discount rule on this product, as a badge. `product.discount`
207
+ is returned by getProductBySlug and is null/absent when no rule
208
+ applies, and DiscountBadge returns null on a falsy `discount`, so
209
+ this renders nothing on a store with no discounts configured. */}
210
+ <DiscountBadge discount={product.discount} />
211
+
212
+ {/* Price */}
213
+ <PriceDisplay
214
+ price={displayPrice.price}
215
+ salePrice={displayPrice.salePrice ?? undefined}
216
+ currency={displayPrice.currency}
217
+ size="lg"
218
+ />
219
+
220
+ {/* Stock / Digital badge */}
221
+ {product.isDownloadable ? (
222
+ <p>{t('instantDownload')}</p>
223
+ ) : (
224
+ <StockBadge inventory={inventory} />
225
+ )}
226
+
227
+ {/* Downloadable files info */}
228
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
229
+ <section>
230
+ <h2>
231
+ {t('filesIncluded')} ({product.downloads.length})
232
+ </h2>
233
+ <ul>
234
+ {product.downloads.map((file: DownloadFile) => (
235
+ <li key={file.id}>
236
+ <span>{file.name}</span>
237
+ {file.size && (
238
+ <span>
239
+ {' '}
240
+ (
241
+ {file.size < 1024 * 1024
242
+ ? `${(file.size / 1024).toFixed(0)} KB`
243
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
244
+ )
245
+ </span>
246
+ )}
247
+ </li>
248
+ ))}
249
+ </ul>
250
+ </section>
251
+ )}
252
+
253
+ {/* Variant Selector */}
254
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
255
+ <VariantSelector
256
+ product={product}
257
+ selectedVariant={selectedVariant}
258
+ onVariantChange={setSelectedVariant}
259
+ />
260
+ )}
261
+
262
+ {/* Customization Fields (buyer input) */}
263
+ {customizationFields.length > 0 && (
264
+ <CustomizationFields
265
+ fields={customizationFields}
266
+ values={customizationValues}
267
+ onChange={setCustomizationValues}
268
+ errors={customizationErrors}
269
+ />
270
+ )}
271
+
272
+ {/* Modifier groups (toppings, sauce, bread type, …) */}
273
+ {modifierGroups.length > 0 && (
274
+ <div>
275
+ {modifierGroups.map((group) => (
276
+ <ModifierGroupSelector
277
+ key={group.attachmentId ?? group.id}
278
+ group={group}
279
+ value={modifierSelections[group.id] ?? []}
280
+ onChange={(next) => setModifierSelection(group.id, next)}
281
+ disabled={addingToCart}
282
+ />
283
+ ))}
284
+ {modifierError && <p role="alert">{modifierError}</p>}
285
+ </div>
286
+ )}
287
+
288
+ {/* Quantity + Add to Cart */}
289
+ <div className="flex items-center gap-4">
290
+ <div className="flex items-center gap-2">
291
+ <button
292
+ type="button"
293
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
294
+ aria-label={t('decreaseQuantity')}
295
+ >
296
+ -
297
+ </button>
298
+ <span aria-live="polite">{quantity}</span>
299
+ <button
300
+ type="button"
301
+ onClick={() => setQuantity((q) => q + 1)}
302
+ aria-label={t('increaseQuantity')}
303
+ >
304
+ +
305
+ </button>
306
+ </div>
307
+
308
+ <button type="button" onClick={handleAddToCart} disabled={!canPurchase || addingToCart}>
309
+ {addingToCart
310
+ ? t('addingToCart')
311
+ : addedMessage
312
+ ? t('addedToCart')
313
+ : !canPurchase
314
+ ? t('outOfStock')
315
+ : t('addToCart')}
316
+ </button>
317
+ </div>
318
+
319
+ {/*
320
+ Sold out is not the end of the page. `canOfferStockAlert` is the whole
321
+ gate — it also covers the merchant's switch and backorderable items,
322
+ where an alert would tell someone to come back and do what they can
323
+ already do. Pass the selected variant: without it a shopper who wanted
324
+ the medium is mailed when the small returns.
325
+ */}
326
+ {canOfferStockAlert(inventory, stockAlertsEnabled) && (
327
+ <BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
328
+ )}
329
+
330
+ {/* Download after purchase note */}
331
+ {product.isDownloadable && <p>{t('downloadAfterPurchase')}</p>}
332
+
333
+ {/* Description */}
334
+ {description && (
335
+ <section>
336
+ <h2>{t('description')}</h2>
337
+ {'html' in description ? (
338
+ <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }} />
339
+ ) : (
340
+ <p className="whitespace-pre-wrap">{description.text}</p>
341
+ )}
342
+ </section>
343
+ )}
344
+
345
+ {/* Metafields / Specifications */}
346
+ {product.metafields && product.metafields.length > 0 && (
347
+ <section>
348
+ <h2>{t('specifications')}</h2>
349
+ <table>
350
+ <tbody>
351
+ {product.metafields.map((field) => (
352
+ <tr key={field.id}>
353
+ <th scope="row">{field.definitionName}</th>
354
+ <td>
355
+ <MetafieldValue field={field} />
356
+ </td>
357
+ </tr>
358
+ ))}
359
+ </tbody>
360
+ </table>
361
+ </section>
362
+ )}
363
+ </section>
364
+ </div>
365
+
366
+ {/* Frequently Bought Together (cross-sells) */}
367
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
368
+ <FrequentlyBoughtTogether items={recommendations.crossSells} currentProduct={product} />
369
+ )}
370
+
371
+ {/* Upsells */}
372
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
373
+ <RecommendationSection title={t('upgradeYourChoice')} items={recommendations.upsells} />
374
+ )}
375
+
376
+ {/* Related products */}
377
+ {recommendations?.related && recommendations.related.length > 0 && (
378
+ <RecommendationSection title={t('similarProducts')} items={recommendations.related} />
379
+ )}
380
+ </article>
381
+ );
382
+ }