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