create-brainerce-store 1.85.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.
- package/dist/index.js +1 -1
- package/messages/en.json +6 -2
- package/messages/he.json +6 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
- package/templates/nextjs/base/src/app/icon.svg +11 -0
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +51 -4
- package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
- package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +2 -2
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -1
- package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
<span className="
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
<
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
}
|