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.
- package/dist/index.js +44 -7
- package/messages/en.json +10 -1
- package/messages/he.json +10 -1
- package/package.json +4 -2
- package/templates/nextjs/base/.env.local.ejs +62 -52
- package/templates/nextjs/base/AGENTS.md.ejs +189 -161
- package/templates/nextjs/base/CLAUDE.md.ejs +200 -172
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +37 -0
- package/templates/nextjs/base/src/app/page.tsx +75 -65
- package/templates/nextjs/base/src/app/register/page.tsx +74 -67
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +79 -7
- package/templates/nextjs/base/src/components/account/order-history.tsx +34 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +358 -326
- package/templates/nextjs/base/src/components/referral/referral-greeting.tsx +120 -0
- package/templates/nextjs/base/src/core/lib/auth.ts +162 -155
- package/templates/nextjs/base/src/core/lib/referral.ts +95 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +5 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +5 -0
- package/templates/nextjs/ui-canvas/layout/header-search.tsx +222 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +6 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +5 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +382 -375
|
@@ -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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
case '
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
{/*
|
|
221
|
-
{product.isDownloadable
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
</
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
{
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
+
}
|