create-brainerce-store 1.78.0 → 1.79.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 +12 -2
- package/messages/en.json +8 -1
- package/messages/he.json +8 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
|
@@ -1,493 +1,524 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Download, FileText, Image as ImageIcon } from 'lucide-react';
|
|
4
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
-
import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
|
|
6
|
-
import { useProductPage } from '@/core/hooks/use-product-page';
|
|
7
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
8
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
-
import { VariantSelector } from '@/ui/product/variant-selector';
|
|
10
|
-
import { StockBadge } from '@/ui/product/stock-badge';
|
|
11
|
-
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
12
|
-
import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
|
|
13
|
-
import { RecommendationSection } from '@/ui/product/recommendation-section';
|
|
14
|
-
import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
|
|
15
|
-
import { CustomizationFields } from '@/ui/product/customization-fields';
|
|
16
|
-
import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
|
|
17
|
-
import { Badge } from '@/components/ui/badge';
|
|
18
|
-
import { Button } from '@/components/ui/button';
|
|
19
|
-
import { Separator } from '@/components/ui/separator';
|
|
20
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
21
|
-
import { cn } from '@/core/lib/utils';
|
|
22
|
-
import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
|
|
23
|
-
|
|
24
|
-
/** Render a metafield value based on its type */
|
|
25
|
-
function MetafieldValue({ field }: { field: ProductMetafield }) {
|
|
26
|
-
const tc = useTranslations('common');
|
|
27
|
-
switch (field.type) {
|
|
28
|
-
case 'IMAGE': {
|
|
29
|
-
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
30
|
-
return (
|
|
31
|
-
<img
|
|
32
|
-
src={field.value}
|
|
33
|
-
alt={field.definitionName}
|
|
34
|
-
className="h-16 w-16 rounded object-cover"
|
|
35
|
-
/>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
case 'GALLERY': {
|
|
39
|
-
let urls: string[] = [];
|
|
40
|
-
try {
|
|
41
|
-
const parsed = JSON.parse(field.value);
|
|
42
|
-
urls = Array.isArray(parsed)
|
|
43
|
-
? parsed.filter((u: unknown) => typeof u === 'string' && u)
|
|
44
|
-
: [];
|
|
45
|
-
} catch {
|
|
46
|
-
urls = field.value ? [field.value] : [];
|
|
47
|
-
}
|
|
48
|
-
if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
49
|
-
return (
|
|
50
|
-
<div className="flex flex-wrap gap-2">
|
|
51
|
-
{urls.map((url, i) => (
|
|
52
|
-
<img
|
|
53
|
-
key={i}
|
|
54
|
-
src={url}
|
|
55
|
-
alt={`${field.definitionName} ${i + 1}`}
|
|
56
|
-
className="h-16 w-16 rounded object-cover"
|
|
57
|
-
/>
|
|
58
|
-
))}
|
|
59
|
-
</div>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
case 'URL':
|
|
63
|
-
return field.value ? (
|
|
64
|
-
<a
|
|
65
|
-
href={field.value}
|
|
66
|
-
target="_blank"
|
|
67
|
-
rel="noopener noreferrer"
|
|
68
|
-
className="text-primary break-all hover:underline"
|
|
69
|
-
>
|
|
70
|
-
{field.value}
|
|
71
|
-
</a>
|
|
72
|
-
) : (
|
|
73
|
-
<span className="text-muted-foreground">-</span>
|
|
74
|
-
);
|
|
75
|
-
case 'COLOR':
|
|
76
|
-
return field.value ? (
|
|
77
|
-
<span className="inline-flex items-center gap-2">
|
|
78
|
-
<span
|
|
79
|
-
className="border-border inline-block h-4 w-4 rounded-full border"
|
|
80
|
-
style={{ backgroundColor: field.value }}
|
|
81
|
-
/>
|
|
82
|
-
{field.value}
|
|
83
|
-
</span>
|
|
84
|
-
) : (
|
|
85
|
-
<span className="text-muted-foreground">-</span>
|
|
86
|
-
);
|
|
87
|
-
case 'BOOLEAN':
|
|
88
|
-
return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
|
|
89
|
-
case 'DATE':
|
|
90
|
-
case 'DATETIME': {
|
|
91
|
-
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
92
|
-
try {
|
|
93
|
-
const date = new Date(field.value);
|
|
94
|
-
return (
|
|
95
|
-
<span>
|
|
96
|
-
{field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
|
|
97
|
-
</span>
|
|
98
|
-
);
|
|
99
|
-
} catch {
|
|
100
|
-
return <span>{field.value}</span>;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
default:
|
|
104
|
-
return <span>{field.value || '-'}</span>;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
interface ProductClientSectionProps {
|
|
109
|
-
product: Product;
|
|
110
|
-
/**
|
|
111
|
-
* From `getStoreInfo().stockAlertsEnabled` — the merchant's switch for
|
|
112
|
-
* back-in-stock alerts on this storefront. Passed down rather than fetched
|
|
113
|
-
* here because the page already holds storeInfo and a second call would be a
|
|
114
|
-
* waterfall. Undefined on a public-storefront (storeId) client, where the
|
|
115
|
-
* setting does not apply and the feature is on.
|
|
116
|
-
*/
|
|
117
|
-
stockAlertsEnabled?: boolean;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function ProductClientSection({
|
|
121
|
-
product: initialProduct,
|
|
122
|
-
stockAlertsEnabled,
|
|
123
|
-
}: ProductClientSectionProps) {
|
|
124
|
-
const t = useTranslations('productDetail');
|
|
125
|
-
|
|
126
|
-
const {
|
|
127
|
-
product,
|
|
128
|
-
recommendations,
|
|
129
|
-
images,
|
|
130
|
-
selectedImageIndex,
|
|
131
|
-
setSelectedImageIndex,
|
|
132
|
-
mainImageUrl,
|
|
133
|
-
selectedVariant,
|
|
134
|
-
setSelectedVariant,
|
|
135
|
-
priceInfo,
|
|
136
|
-
displayPrice,
|
|
137
|
-
inventory,
|
|
138
|
-
canPurchase,
|
|
139
|
-
description,
|
|
140
|
-
quantity,
|
|
141
|
-
setQuantity,
|
|
142
|
-
addingToCart,
|
|
143
|
-
addedMessage,
|
|
144
|
-
handleAddToCart,
|
|
145
|
-
customizationFields,
|
|
146
|
-
customizationValues,
|
|
147
|
-
setCustomizationValues,
|
|
148
|
-
customizationErrors,
|
|
149
|
-
modifierGroups,
|
|
150
|
-
modifierSelections,
|
|
151
|
-
setModifierSelection,
|
|
152
|
-
modifierError,
|
|
153
|
-
} = useProductPage(initialProduct);
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
157
|
-
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
|
|
158
|
-
{/* Image Gallery */}
|
|
159
|
-
<div className="space-y-4">
|
|
160
|
-
{/* Main Image */}
|
|
161
|
-
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
|
162
|
-
{mainImageUrl ? (
|
|
163
|
-
<Image
|
|
164
|
-
src={mainImageUrl}
|
|
165
|
-
alt={product.name}
|
|
166
|
-
fill
|
|
167
|
-
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
168
|
-
className="object-contain"
|
|
169
|
-
priority
|
|
170
|
-
/>
|
|
171
|
-
) : (
|
|
172
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
173
|
-
<ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
|
|
174
|
-
</div>
|
|
175
|
-
)}
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
{/* Thumbnails */}
|
|
179
|
-
{images.length > 1 && (
|
|
180
|
-
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
181
|
-
{images.map((img, idx) => (
|
|
182
|
-
<button
|
|
183
|
-
key={idx}
|
|
184
|
-
type="button"
|
|
185
|
-
onClick={() => setSelectedImageIndex(idx)}
|
|
186
|
-
className={cn(
|
|
187
|
-
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
|
|
188
|
-
selectedImageIndex === idx
|
|
189
|
-
? 'border-primary'
|
|
190
|
-
: 'border-border hover:border-muted-foreground'
|
|
191
|
-
)}
|
|
192
|
-
>
|
|
193
|
-
<Image
|
|
194
|
-
src={img.url}
|
|
195
|
-
alt={img.alt || `${product.name} ${idx + 1}`}
|
|
196
|
-
fill
|
|
197
|
-
sizes="64px"
|
|
198
|
-
className="object-cover"
|
|
199
|
-
/>
|
|
200
|
-
</button>
|
|
201
|
-
))}
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
{/* Product Info */}
|
|
207
|
-
<div className="space-y-6">
|
|
208
|
-
{/* Categories */}
|
|
209
|
-
{product.categories && product.categories.length > 0 && (
|
|
210
|
-
<div className="flex flex-wrap gap-2">
|
|
211
|
-
{product.categories.map((cat) => (
|
|
212
|
-
<Badge
|
|
213
|
-
key={cat.id}
|
|
214
|
-
variant="secondary"
|
|
215
|
-
className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
|
|
216
|
-
>
|
|
217
|
-
{cat.name}
|
|
218
|
-
</Badge>
|
|
219
|
-
))}
|
|
220
|
-
</div>
|
|
221
|
-
)}
|
|
222
|
-
|
|
223
|
-
{/* Brand */}
|
|
224
|
-
{(product as { brands?: Array<{ id: string; name: string }> }).brands &&
|
|
225
|
-
(product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
|
|
226
|
-
<div className="text-muted-foreground text-sm">
|
|
227
|
-
{t('by')}{' '}
|
|
228
|
-
<span className="text-foreground font-medium">
|
|
229
|
-
{(product as { brands: Array<{ id: string; name: string }> }).brands
|
|
230
|
-
.map((b) => b.name)
|
|
231
|
-
.join(', ')}
|
|
232
|
-
</span>
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
|
-
|
|
236
|
-
{/* Title */}
|
|
237
|
-
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
238
|
-
|
|
239
|
-
{/* Tags */}
|
|
240
|
-
{(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
241
|
-
(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
|
|
242
|
-
0 && (
|
|
243
|
-
<div className="flex flex-wrap gap-1.5">
|
|
244
|
-
{(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
|
|
245
|
-
(tag) => (
|
|
246
|
-
<Badge
|
|
247
|
-
key={tag.id}
|
|
248
|
-
variant="outline"
|
|
249
|
-
className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
|
|
250
|
-
>
|
|
251
|
-
#{tag.name}
|
|
252
|
-
</Badge>
|
|
253
|
-
)
|
|
254
|
-
)}
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
|
|
258
|
-
{/* Active discount rule on this product, as a badge. `product.discount`
|
|
259
|
-
is returned by getProductBySlug and is null/absent when no rule
|
|
260
|
-
applies, and DiscountBadge returns null on a falsy `discount`, so
|
|
261
|
-
this renders nothing on a store with no discounts configured. */}
|
|
262
|
-
<DiscountBadge discount={product.discount} className="w-fit" />
|
|
263
|
-
|
|
264
|
-
{/* Price */}
|
|
265
|
-
<PriceDisplay
|
|
266
|
-
price={displayPrice.price}
|
|
267
|
-
salePrice={displayPrice.salePrice ?? undefined}
|
|
268
|
-
currency={displayPrice.currency}
|
|
269
|
-
size="lg"
|
|
270
|
-
/>
|
|
271
|
-
|
|
272
|
-
{/* Stock / Digital badge */}
|
|
273
|
-
{product.isDownloadable ? (
|
|
274
|
-
<Badge
|
|
275
|
-
variant="outline"
|
|
276
|
-
className="gap-1.5 border-transparent bg-green-100 px-3 py-1 font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400"
|
|
277
|
-
>
|
|
278
|
-
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
|
279
|
-
{t('instantDownload')}
|
|
280
|
-
</Badge>
|
|
281
|
-
) : (
|
|
282
|
-
<StockBadge inventory={inventory} />
|
|
283
|
-
)}
|
|
284
|
-
|
|
285
|
-
{/* Downloadable files info */}
|
|
286
|
-
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
287
|
-
<div className="bg-muted/50 rounded-lg border p-4">
|
|
288
|
-
<p className="text-foreground mb-2 text-sm font-medium">
|
|
289
|
-
{t('filesIncluded')} ({product.downloads.length})
|
|
290
|
-
</p>
|
|
291
|
-
<ul className="space-y-1.5">
|
|
292
|
-
{product.downloads.map((file: DownloadFile) => (
|
|
293
|
-
<li
|
|
294
|
-
key={file.id}
|
|
295
|
-
className="text-muted-foreground flex items-center gap-2 text-sm"
|
|
296
|
-
>
|
|
297
|
-
<FileText
|
|
298
|
-
className="h-4 w-4 flex-shrink-0"
|
|
299
|
-
strokeWidth={1.5}
|
|
300
|
-
aria-hidden="true"
|
|
301
|
-
/>
|
|
302
|
-
<span className="truncate">{file.name}</span>
|
|
303
|
-
{file.size && (
|
|
304
|
-
<span className="flex-shrink-0 text-xs">
|
|
305
|
-
(
|
|
306
|
-
{file.size < 1024 * 1024
|
|
307
|
-
? `${(file.size / 1024).toFixed(0)} KB`
|
|
308
|
-
: `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
|
|
309
|
-
)
|
|
310
|
-
</span>
|
|
311
|
-
)}
|
|
312
|
-
</li>
|
|
313
|
-
))}
|
|
314
|
-
</ul>
|
|
315
|
-
</div>
|
|
316
|
-
)}
|
|
317
|
-
|
|
318
|
-
{/* Variant Selector */}
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Download, FileText, Image as ImageIcon } from 'lucide-react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
|
|
6
|
+
import { useProductPage } from '@/core/hooks/use-product-page';
|
|
7
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { VariantSelector } from '@/ui/product/variant-selector';
|
|
10
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
11
|
+
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
12
|
+
import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
|
|
13
|
+
import { RecommendationSection } from '@/ui/product/recommendation-section';
|
|
14
|
+
import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
|
|
15
|
+
import { CustomizationFields } from '@/ui/product/customization-fields';
|
|
16
|
+
import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
|
|
17
|
+
import { Badge } from '@/components/ui/badge';
|
|
18
|
+
import { Button } from '@/components/ui/button';
|
|
19
|
+
import { Separator } from '@/components/ui/separator';
|
|
20
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
21
|
+
import { cn } from '@/core/lib/utils';
|
|
22
|
+
import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
|
|
23
|
+
|
|
24
|
+
/** Render a metafield value based on its type */
|
|
25
|
+
function MetafieldValue({ field }: { field: ProductMetafield }) {
|
|
26
|
+
const tc = useTranslations('common');
|
|
27
|
+
switch (field.type) {
|
|
28
|
+
case 'IMAGE': {
|
|
29
|
+
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
30
|
+
return (
|
|
31
|
+
<img
|
|
32
|
+
src={field.value}
|
|
33
|
+
alt={field.definitionName}
|
|
34
|
+
className="h-16 w-16 rounded object-cover"
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
case 'GALLERY': {
|
|
39
|
+
let urls: string[] = [];
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(field.value);
|
|
42
|
+
urls = Array.isArray(parsed)
|
|
43
|
+
? parsed.filter((u: unknown) => typeof u === 'string' && u)
|
|
44
|
+
: [];
|
|
45
|
+
} catch {
|
|
46
|
+
urls = field.value ? [field.value] : [];
|
|
47
|
+
}
|
|
48
|
+
if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-wrap gap-2">
|
|
51
|
+
{urls.map((url, i) => (
|
|
52
|
+
<img
|
|
53
|
+
key={i}
|
|
54
|
+
src={url}
|
|
55
|
+
alt={`${field.definitionName} ${i + 1}`}
|
|
56
|
+
className="h-16 w-16 rounded object-cover"
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
case 'URL':
|
|
63
|
+
return field.value ? (
|
|
64
|
+
<a
|
|
65
|
+
href={field.value}
|
|
66
|
+
target="_blank"
|
|
67
|
+
rel="noopener noreferrer"
|
|
68
|
+
className="text-primary break-all hover:underline"
|
|
69
|
+
>
|
|
70
|
+
{field.value}
|
|
71
|
+
</a>
|
|
72
|
+
) : (
|
|
73
|
+
<span className="text-muted-foreground">-</span>
|
|
74
|
+
);
|
|
75
|
+
case 'COLOR':
|
|
76
|
+
return field.value ? (
|
|
77
|
+
<span className="inline-flex items-center gap-2">
|
|
78
|
+
<span
|
|
79
|
+
className="border-border inline-block h-4 w-4 rounded-full border"
|
|
80
|
+
style={{ backgroundColor: field.value }}
|
|
81
|
+
/>
|
|
82
|
+
{field.value}
|
|
83
|
+
</span>
|
|
84
|
+
) : (
|
|
85
|
+
<span className="text-muted-foreground">-</span>
|
|
86
|
+
);
|
|
87
|
+
case 'BOOLEAN':
|
|
88
|
+
return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
|
|
89
|
+
case 'DATE':
|
|
90
|
+
case 'DATETIME': {
|
|
91
|
+
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
92
|
+
try {
|
|
93
|
+
const date = new Date(field.value);
|
|
94
|
+
return (
|
|
95
|
+
<span>
|
|
96
|
+
{field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
return <span>{field.value}</span>;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return <span>{field.value || '-'}</span>;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ProductClientSectionProps {
|
|
109
|
+
product: Product;
|
|
110
|
+
/**
|
|
111
|
+
* From `getStoreInfo().stockAlertsEnabled` — the merchant's switch for
|
|
112
|
+
* back-in-stock alerts on this storefront. Passed down rather than fetched
|
|
113
|
+
* here because the page already holds storeInfo and a second call would be a
|
|
114
|
+
* waterfall. Undefined on a public-storefront (storeId) client, where the
|
|
115
|
+
* setting does not apply and the feature is on.
|
|
116
|
+
*/
|
|
117
|
+
stockAlertsEnabled?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function ProductClientSection({
|
|
121
|
+
product: initialProduct,
|
|
122
|
+
stockAlertsEnabled,
|
|
123
|
+
}: ProductClientSectionProps) {
|
|
124
|
+
const t = useTranslations('productDetail');
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
product,
|
|
128
|
+
recommendations,
|
|
129
|
+
images,
|
|
130
|
+
selectedImageIndex,
|
|
131
|
+
setSelectedImageIndex,
|
|
132
|
+
mainImageUrl,
|
|
133
|
+
selectedVariant,
|
|
134
|
+
setSelectedVariant,
|
|
135
|
+
priceInfo,
|
|
136
|
+
displayPrice,
|
|
137
|
+
inventory,
|
|
138
|
+
canPurchase,
|
|
139
|
+
description,
|
|
140
|
+
quantity,
|
|
141
|
+
setQuantity,
|
|
142
|
+
addingToCart,
|
|
143
|
+
addedMessage,
|
|
144
|
+
handleAddToCart,
|
|
145
|
+
customizationFields,
|
|
146
|
+
customizationValues,
|
|
147
|
+
setCustomizationValues,
|
|
148
|
+
customizationErrors,
|
|
149
|
+
modifierGroups,
|
|
150
|
+
modifierSelections,
|
|
151
|
+
setModifierSelection,
|
|
152
|
+
modifierError,
|
|
153
|
+
} = useProductPage(initialProduct);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
157
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
|
|
158
|
+
{/* Image Gallery */}
|
|
159
|
+
<div className="space-y-4">
|
|
160
|
+
{/* Main Image */}
|
|
161
|
+
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
|
162
|
+
{mainImageUrl ? (
|
|
163
|
+
<Image
|
|
164
|
+
src={mainImageUrl}
|
|
165
|
+
alt={product.name}
|
|
166
|
+
fill
|
|
167
|
+
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
168
|
+
className="object-contain"
|
|
169
|
+
priority
|
|
170
|
+
/>
|
|
171
|
+
) : (
|
|
172
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
173
|
+
<ImageIcon className="h-24 w-24" strokeWidth={1} aria-hidden="true" />
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Thumbnails */}
|
|
179
|
+
{images.length > 1 && (
|
|
180
|
+
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
181
|
+
{images.map((img, idx) => (
|
|
182
|
+
<button
|
|
183
|
+
key={idx}
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => setSelectedImageIndex(idx)}
|
|
186
|
+
className={cn(
|
|
187
|
+
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
|
|
188
|
+
selectedImageIndex === idx
|
|
189
|
+
? 'border-primary'
|
|
190
|
+
: 'border-border hover:border-muted-foreground'
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
<Image
|
|
194
|
+
src={img.url}
|
|
195
|
+
alt={img.alt || `${product.name} ${idx + 1}`}
|
|
196
|
+
fill
|
|
197
|
+
sizes="64px"
|
|
198
|
+
className="object-cover"
|
|
199
|
+
/>
|
|
200
|
+
</button>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Product Info */}
|
|
207
|
+
<div className="space-y-6">
|
|
208
|
+
{/* Categories */}
|
|
209
|
+
{product.categories && product.categories.length > 0 && (
|
|
210
|
+
<div className="flex flex-wrap gap-2">
|
|
211
|
+
{product.categories.map((cat) => (
|
|
212
|
+
<Badge
|
|
213
|
+
key={cat.id}
|
|
214
|
+
variant="secondary"
|
|
215
|
+
className="text-muted-foreground bg-muted rounded px-2 py-1 font-normal"
|
|
216
|
+
>
|
|
217
|
+
{cat.name}
|
|
218
|
+
</Badge>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* Brand */}
|
|
224
|
+
{(product as { brands?: Array<{ id: string; name: string }> }).brands &&
|
|
225
|
+
(product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
|
|
226
|
+
<div className="text-muted-foreground text-sm">
|
|
227
|
+
{t('by')}{' '}
|
|
228
|
+
<span className="text-foreground font-medium">
|
|
229
|
+
{(product as { brands: Array<{ id: string; name: string }> }).brands
|
|
230
|
+
.map((b) => b.name)
|
|
231
|
+
.join(', ')}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Title */}
|
|
237
|
+
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
238
|
+
|
|
239
|
+
{/* Tags */}
|
|
240
|
+
{(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
241
|
+
(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
|
|
242
|
+
0 && (
|
|
243
|
+
<div className="flex flex-wrap gap-1.5">
|
|
244
|
+
{(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
|
|
245
|
+
(tag) => (
|
|
246
|
+
<Badge
|
|
247
|
+
key={tag.id}
|
|
248
|
+
variant="outline"
|
|
249
|
+
className="border-border text-muted-foreground px-2.5 py-0.5 font-normal"
|
|
250
|
+
>
|
|
251
|
+
#{tag.name}
|
|
252
|
+
</Badge>
|
|
253
|
+
)
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Active discount rule on this product, as a badge. `product.discount`
|
|
259
|
+
is returned by getProductBySlug and is null/absent when no rule
|
|
260
|
+
applies, and DiscountBadge returns null on a falsy `discount`, so
|
|
261
|
+
this renders nothing on a store with no discounts configured. */}
|
|
262
|
+
<DiscountBadge discount={product.discount} className="w-fit" />
|
|
263
|
+
|
|
264
|
+
{/* Price */}
|
|
265
|
+
<PriceDisplay
|
|
266
|
+
price={displayPrice.price}
|
|
267
|
+
salePrice={displayPrice.salePrice ?? undefined}
|
|
268
|
+
currency={displayPrice.currency}
|
|
269
|
+
size="lg"
|
|
270
|
+
/>
|
|
271
|
+
|
|
272
|
+
{/* Stock / Digital badge */}
|
|
273
|
+
{product.isDownloadable ? (
|
|
274
|
+
<Badge
|
|
275
|
+
variant="outline"
|
|
276
|
+
className="gap-1.5 border-transparent bg-green-100 px-3 py-1 font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400"
|
|
277
|
+
>
|
|
278
|
+
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
|
279
|
+
{t('instantDownload')}
|
|
280
|
+
</Badge>
|
|
281
|
+
) : (
|
|
282
|
+
<StockBadge inventory={inventory} />
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Downloadable files info */}
|
|
286
|
+
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
287
|
+
<div className="bg-muted/50 rounded-lg border p-4">
|
|
288
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
289
|
+
{t('filesIncluded')} ({product.downloads.length})
|
|
290
|
+
</p>
|
|
291
|
+
<ul className="space-y-1.5">
|
|
292
|
+
{product.downloads.map((file: DownloadFile) => (
|
|
293
|
+
<li
|
|
294
|
+
key={file.id}
|
|
295
|
+
className="text-muted-foreground flex items-center gap-2 text-sm"
|
|
296
|
+
>
|
|
297
|
+
<FileText
|
|
298
|
+
className="h-4 w-4 flex-shrink-0"
|
|
299
|
+
strokeWidth={1.5}
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
/>
|
|
302
|
+
<span className="truncate">{file.name}</span>
|
|
303
|
+
{file.size && (
|
|
304
|
+
<span className="flex-shrink-0 text-xs">
|
|
305
|
+
(
|
|
306
|
+
{file.size < 1024 * 1024
|
|
307
|
+
? `${(file.size / 1024).toFixed(0)} KB`
|
|
308
|
+
: `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
|
|
309
|
+
)
|
|
310
|
+
</span>
|
|
311
|
+
)}
|
|
312
|
+
</li>
|
|
313
|
+
))}
|
|
314
|
+
</ul>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{/* Variant Selector */}
|
|
319
|
+
{/* A KIT is bought as ONE line, but the shopper needs to see what is
|
|
320
|
+
in the box before deciding. These rows are display only — never add
|
|
321
|
+
them to the cart individually; the kit reserves its components on
|
|
322
|
+
its own. */}
|
|
323
|
+
{product.type === 'KIT' && product.kitComponents?.length ? (
|
|
324
|
+
<div className="mb-6">
|
|
325
|
+
<h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
|
|
326
|
+
<ul className="divide-y rounded-lg border">
|
|
327
|
+
{product.kitComponents.map((c) => (
|
|
328
|
+
<li
|
|
329
|
+
key={`${c.productId}-${c.variantId ?? ''}`}
|
|
330
|
+
className="flex items-center gap-3 p-3"
|
|
331
|
+
>
|
|
332
|
+
{c.image ? (
|
|
333
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
334
|
+
<img
|
|
335
|
+
src={c.image}
|
|
336
|
+
alt={c.name}
|
|
337
|
+
className="h-10 w-10 shrink-0 rounded border object-cover"
|
|
338
|
+
/>
|
|
339
|
+
) : (
|
|
340
|
+
<div className="bg-muted h-10 w-10 shrink-0 rounded border" />
|
|
341
|
+
)}
|
|
342
|
+
<span className="flex-1 text-sm">{c.name}</span>
|
|
343
|
+
<span className="text-muted-foreground text-sm">x{c.quantity}</span>
|
|
344
|
+
</li>
|
|
345
|
+
))}
|
|
346
|
+
</ul>
|
|
347
|
+
</div>
|
|
348
|
+
) : null}
|
|
349
|
+
|
|
350
|
+
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
351
|
+
<VariantSelector
|
|
352
|
+
product={product}
|
|
353
|
+
selectedVariant={selectedVariant}
|
|
354
|
+
onVariantChange={setSelectedVariant}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* Customization Fields (buyer input) */}
|
|
359
|
+
{customizationFields.length > 0 && (
|
|
360
|
+
<CustomizationFields
|
|
361
|
+
fields={customizationFields}
|
|
362
|
+
values={customizationValues}
|
|
363
|
+
onChange={setCustomizationValues}
|
|
364
|
+
errors={customizationErrors}
|
|
365
|
+
/>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{/* Modifier groups (toppings, sauce, bread type, …) */}
|
|
369
|
+
{modifierGroups.length > 0 && (
|
|
370
|
+
<div className="space-y-4">
|
|
371
|
+
{modifierGroups.map((group) => (
|
|
372
|
+
<ModifierGroupSelector
|
|
373
|
+
key={group.attachmentId ?? group.id}
|
|
374
|
+
group={group}
|
|
375
|
+
value={modifierSelections[group.id] ?? []}
|
|
376
|
+
onChange={(next) => setModifierSelection(group.id, next)}
|
|
377
|
+
disabled={addingToCart}
|
|
378
|
+
/>
|
|
379
|
+
))}
|
|
380
|
+
{modifierError && (
|
|
381
|
+
<p className="text-destructive text-sm" role="alert">
|
|
382
|
+
{modifierError}
|
|
383
|
+
</p>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{/* Quantity + Add to Cart */}
|
|
389
|
+
<div className="flex items-center gap-4">
|
|
390
|
+
<div className="border-border flex items-center rounded border">
|
|
391
|
+
<button
|
|
392
|
+
type="button"
|
|
393
|
+
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
394
|
+
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
395
|
+
aria-label={t('decreaseQuantity')}
|
|
396
|
+
>
|
|
397
|
+
-
|
|
398
|
+
</button>
|
|
399
|
+
<span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
|
|
400
|
+
{quantity}
|
|
401
|
+
</span>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
onClick={() => setQuantity((q) => q + 1)}
|
|
405
|
+
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
406
|
+
aria-label={t('increaseQuantity')}
|
|
407
|
+
>
|
|
408
|
+
+
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<Button
|
|
413
|
+
type="button"
|
|
414
|
+
size="lg"
|
|
415
|
+
onClick={handleAddToCart}
|
|
416
|
+
disabled={!canPurchase || addingToCart}
|
|
417
|
+
className={cn(
|
|
418
|
+
'flex-1 rounded px-6 text-sm',
|
|
419
|
+
!canPurchase && 'bg-muted text-muted-foreground hover:bg-muted disabled:opacity-100'
|
|
420
|
+
)}
|
|
421
|
+
>
|
|
422
|
+
{addingToCart ? (
|
|
423
|
+
<span className="inline-flex items-center gap-2">
|
|
424
|
+
<LoadingSpinner
|
|
425
|
+
size="sm"
|
|
426
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
427
|
+
/>
|
|
428
|
+
{t('addingToCart')}
|
|
429
|
+
</span>
|
|
430
|
+
) : addedMessage ? (
|
|
431
|
+
t('addedToCart')
|
|
432
|
+
) : !canPurchase ? (
|
|
433
|
+
t('outOfStock')
|
|
434
|
+
) : (
|
|
435
|
+
t('addToCart')
|
|
436
|
+
)}
|
|
437
|
+
</Button>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{/*
|
|
441
|
+
Sold out is not the end of the page. `canOfferStockAlert` is the whole
|
|
442
|
+
gate — it also covers the merchant's switch and backorderable items,
|
|
443
|
+
where an alert would tell someone to come back and do what they can
|
|
444
|
+
already do. The selected variant is passed on purpose: without it a
|
|
445
|
+
shopper who wanted the medium gets mailed when the small returns.
|
|
446
|
+
*/}
|
|
447
|
+
{canOfferStockAlert(inventory, stockAlertsEnabled) && (
|
|
448
|
+
<BackInStockForm productId={product.id} variantId={selectedVariant?.id} />
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{/* Download after purchase note */}
|
|
452
|
+
{product.isDownloadable && (
|
|
453
|
+
<p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{/* Description */}
|
|
457
|
+
{description && (
|
|
458
|
+
<div>
|
|
459
|
+
<Separator className="mb-4" />
|
|
460
|
+
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
|
|
461
|
+
{'html' in description ? (
|
|
462
|
+
<div
|
|
463
|
+
className="prose prose-sm text-muted-foreground max-w-none"
|
|
464
|
+
dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
|
|
465
|
+
/>
|
|
466
|
+
) : (
|
|
467
|
+
<p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{/* Metafields / Specifications */}
|
|
473
|
+
{product.metafields && product.metafields.length > 0 && (
|
|
474
|
+
<div>
|
|
475
|
+
<Separator className="mb-4" />
|
|
476
|
+
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
|
|
477
|
+
<table className="w-full text-sm">
|
|
478
|
+
<tbody>
|
|
479
|
+
{product.metafields.map((field) => (
|
|
480
|
+
<tr key={field.id} className="border-border border-b last:border-0">
|
|
481
|
+
<td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
|
|
482
|
+
{field.definitionName}
|
|
483
|
+
</td>
|
|
484
|
+
<td className="text-muted-foreground py-2">
|
|
485
|
+
<MetafieldValue field={field} />
|
|
486
|
+
</td>
|
|
487
|
+
</tr>
|
|
488
|
+
))}
|
|
489
|
+
</tbody>
|
|
490
|
+
</table>
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
{/* Frequently Bought Together (cross-sells) */}
|
|
497
|
+
{recommendations?.crossSells && recommendations.crossSells.length > 0 && (
|
|
498
|
+
<FrequentlyBoughtTogether
|
|
499
|
+
items={recommendations.crossSells}
|
|
500
|
+
currentProduct={product}
|
|
501
|
+
className="mt-12"
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
|
|
505
|
+
{/* Upsells */}
|
|
506
|
+
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
507
|
+
<RecommendationSection
|
|
508
|
+
title={t('upgradeYourChoice')}
|
|
509
|
+
items={recommendations.upsells}
|
|
510
|
+
className="mt-12"
|
|
511
|
+
/>
|
|
512
|
+
)}
|
|
513
|
+
|
|
514
|
+
{/* Related products */}
|
|
515
|
+
{recommendations?.related && recommendations.related.length > 0 && (
|
|
516
|
+
<RecommendationSection
|
|
517
|
+
title={t('similarProducts')}
|
|
518
|
+
items={recommendations.related}
|
|
519
|
+
className="mt-12"
|
|
520
|
+
/>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|