create-brainerce-store 1.18.0 → 1.19.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 (65) hide show
  1. package/dist/index.js +31 -9
  2. package/messages/en.json +366 -362
  3. package/messages/he.json +366 -362
  4. package/package.json +45 -45
  5. package/templates/nextjs/base/next.config.ts +31 -31
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
  7. package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
  8. package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
  9. package/templates/nextjs/base/src/app/account/page.tsx +122 -122
  10. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
  11. package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
  12. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
  14. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
  15. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
  16. package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
  17. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
  18. package/templates/nextjs/base/src/app/cart/page.tsx +204 -204
  19. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
  20. package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
  21. package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
  22. package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
  23. package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
  24. package/templates/nextjs/base/src/app/login/page.tsx +59 -59
  25. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
  26. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +254 -254
  27. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
  28. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
  29. package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
  30. package/templates/nextjs/base/src/app/products/page.tsx +431 -431
  31. package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
  32. package/templates/nextjs/base/src/app/register/page.tsx +65 -65
  33. package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
  34. package/templates/nextjs/base/src/app/robots.ts +14 -14
  35. package/templates/nextjs/base/src/app/sitemap.ts +25 -25
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
  38. package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
  39. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  40. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
  41. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
  42. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  43. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  44. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  45. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
  46. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
  47. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +519 -519
  48. package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
  49. package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
  50. package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
  51. package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
  52. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  53. package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
  54. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
  55. package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
  56. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
  58. package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
  59. package/templates/nextjs/base/src/lib/auth.ts +149 -149
  60. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
  61. package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
  62. package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
  63. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
  64. package/templates/nextjs/base/src/lib/translations.ts +0 -11
  65. package/templates/nextjs/base/src/middleware.ts +0 -25
@@ -1,486 +1,486 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useMemo } from 'react';
4
- import Image from 'next/image';
5
- import type {
6
- Product,
7
- ProductVariant,
8
- ProductImage,
9
- ProductMetafield,
10
- DownloadFile,
11
- } from 'brainerce';
12
- import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
13
- import { useCart } from '@/providers/store-provider';
14
- import { PriceDisplay } from '@/components/shared/price-display';
15
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
16
- import { VariantSelector } from '@/components/products/variant-selector';
17
- import { StockBadge } from '@/components/products/stock-badge';
18
- import { RecommendationSection } from '@/components/products/recommendation-section';
19
- import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
20
- import { useTranslations } from '@/lib/translations';
21
- import { cn } from '@/lib/utils';
22
-
23
- /** Render a metafield value based on its type */
24
- function MetafieldValue({ field }: { field: ProductMetafield }) {
25
- const tc = useTranslations('common');
26
- switch (field.type) {
27
- case 'IMAGE': {
28
- if (!field.value) return <span className="text-muted-foreground">-</span>;
29
- return (
30
- <img
31
- src={field.value}
32
- alt={field.definitionName}
33
- className="h-16 w-16 rounded object-cover"
34
- />
35
- );
36
- }
37
- case 'GALLERY': {
38
- let urls: string[] = [];
39
- try {
40
- const parsed = JSON.parse(field.value);
41
- urls = Array.isArray(parsed)
42
- ? parsed.filter((u: unknown) => typeof u === 'string' && u)
43
- : [];
44
- } catch {
45
- urls = field.value ? [field.value] : [];
46
- }
47
- if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
48
- return (
49
- <div className="flex flex-wrap gap-2">
50
- {urls.map((url, i) => (
51
- <img
52
- key={i}
53
- src={url}
54
- alt={`${field.definitionName} ${i + 1}`}
55
- className="h-16 w-16 rounded object-cover"
56
- />
57
- ))}
58
- </div>
59
- );
60
- }
61
- case 'URL':
62
- return field.value ? (
63
- <a
64
- href={field.value}
65
- target="_blank"
66
- rel="noopener noreferrer"
67
- className="text-primary break-all hover:underline"
68
- >
69
- {field.value}
70
- </a>
71
- ) : (
72
- <span className="text-muted-foreground">-</span>
73
- );
74
- case 'COLOR':
75
- return field.value ? (
76
- <span className="inline-flex items-center gap-2">
77
- <span
78
- className="border-border inline-block h-4 w-4 rounded-full border"
79
- style={{ backgroundColor: field.value }}
80
- />
81
- {field.value}
82
- </span>
83
- ) : (
84
- <span className="text-muted-foreground">-</span>
85
- );
86
- case 'BOOLEAN':
87
- return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
88
- case 'DATE':
89
- case 'DATETIME': {
90
- if (!field.value) return <span className="text-muted-foreground">-</span>;
91
- try {
92
- const date = new Date(field.value);
93
- return (
94
- <span>
95
- {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
96
- </span>
97
- );
98
- } catch {
99
- return <span>{field.value}</span>;
100
- }
101
- }
102
- default:
103
- return <span>{field.value || '-'}</span>;
104
- }
105
- }
106
-
107
- interface ProductClientSectionProps {
108
- product: Product;
109
- }
110
-
111
- export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
112
- const { refreshCart } = useCart();
113
- const t = useTranslations('productDetail');
114
-
115
- const product = initialProduct;
116
- const recommendations = product?.recommendations ?? null;
117
-
118
- const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
119
- product.variants && product.variants.length > 0 ? product.variants[0] : null
120
- );
121
- const [selectedImageIndex, setSelectedImageIndex] = useState(0);
122
- const [quantity, setQuantity] = useState(1);
123
- const [addingToCart, setAddingToCart] = useState(false);
124
- const [addedMessage, setAddedMessage] = useState(false);
125
-
126
- // Images list - switch main image when variant changes
127
- const images: ProductImage[] = useMemo(() => {
128
- return product?.images || [];
129
- }, [product]);
130
-
131
- // When variant changes, update selected image to variant image if available
132
- useEffect(() => {
133
- if (!selectedVariant?.image || !product) return;
134
-
135
- const variantImgUrl =
136
- typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
137
-
138
- // Find if variant image exists in product images
139
- const idx = images.findIndex((img) => img.url === variantImgUrl);
140
- if (idx >= 0) {
141
- setSelectedImageIndex(idx);
142
- } else {
143
- // Variant image not in product images - select index 0 as fallback
144
- setSelectedImageIndex(-1);
145
- }
146
- }, [selectedVariant, images, product]);
147
-
148
- // Determine which image to show
149
- const mainImageUrl = useMemo(() => {
150
- if (selectedImageIndex === -1 && selectedVariant?.image) {
151
- const img = selectedVariant.image;
152
- return typeof img === 'string' ? img : img.url;
153
- }
154
- return images[selectedImageIndex]?.url || null;
155
- }, [selectedImageIndex, selectedVariant, images]);
156
-
157
- // Price info - use variant price if selected, else product price
158
- const priceInfo = useMemo(() => {
159
- if (selectedVariant?.price) {
160
- return {
161
- price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
162
- originalPrice: parseFloat(selectedVariant.price),
163
- isOnSale:
164
- selectedVariant.salePrice != null &&
165
- parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
166
- discountPercent:
167
- selectedVariant.salePrice != null &&
168
- parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
169
- ? Math.round(
170
- ((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
171
- parseFloat(selectedVariant.price)) *
172
- 100
173
- )
174
- : 0,
175
- };
176
- }
177
- return getProductPriceInfo(product);
178
- }, [product, selectedVariant]);
179
-
180
- // Inventory: use variant inventory if selected, else product inventory
181
- const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
182
- const canPurchase = inventory?.canPurchase !== false;
183
-
184
- // Description
185
- const description = useMemo(() => {
186
- return product ? getDescriptionContent(product) : null;
187
- }, [product]);
188
-
189
- async function handleAddToCart() {
190
- if (!product || addingToCart) return;
191
-
192
- try {
193
- setAddingToCart(true);
194
- const { getClient } = await import('@/lib/brainerce');
195
- const client = getClient();
196
- await client.smartAddToCart({
197
- productId: product.id,
198
- variantId: selectedVariant?.id,
199
- quantity,
200
- });
201
- await refreshCart();
202
- setAddedMessage(true);
203
- setTimeout(() => setAddedMessage(false), 2000);
204
- } catch (err) {
205
- console.error('Failed to add to cart:', err);
206
- } finally {
207
- setAddingToCart(false);
208
- }
209
- }
210
-
211
- return (
212
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
213
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
214
- {/* Image Gallery */}
215
- <div className="space-y-4">
216
- {/* Main Image */}
217
- <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
218
- {mainImageUrl ? (
219
- <Image
220
- src={mainImageUrl}
221
- alt={product.name}
222
- fill
223
- sizes="(max-width: 1024px) 100vw, 50vw"
224
- className="object-contain"
225
- priority
226
- />
227
- ) : (
228
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
229
- <svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
230
- <path
231
- strokeLinecap="round"
232
- strokeLinejoin="round"
233
- strokeWidth={1}
234
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
235
- />
236
- </svg>
237
- </div>
238
- )}
239
- </div>
240
-
241
- {/* Thumbnails */}
242
- {images.length > 1 && (
243
- <div className="flex gap-2 overflow-x-auto pb-2">
244
- {images.map((img, idx) => (
245
- <button
246
- key={idx}
247
- type="button"
248
- onClick={() => setSelectedImageIndex(idx)}
249
- className={cn(
250
- 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
251
- selectedImageIndex === idx
252
- ? 'border-primary'
253
- : 'border-border hover:border-muted-foreground'
254
- )}
255
- >
256
- <Image
257
- src={img.url}
258
- alt={img.alt || `${product.name} ${idx + 1}`}
259
- fill
260
- sizes="64px"
261
- className="object-cover"
262
- />
263
- </button>
264
- ))}
265
- </div>
266
- )}
267
- </div>
268
-
269
- {/* Product Info */}
270
- <div className="space-y-6">
271
- {/* Categories */}
272
- {product.categories && product.categories.length > 0 && (
273
- <div className="flex flex-wrap gap-2">
274
- {product.categories.map((cat) => (
275
- <span
276
- key={cat.id}
277
- className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
278
- >
279
- {cat.name}
280
- </span>
281
- ))}
282
- </div>
283
- )}
284
-
285
- {/* Title */}
286
- <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
287
-
288
- {/* Price */}
289
- <PriceDisplay
290
- price={priceInfo.originalPrice}
291
- salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
292
- size="lg"
293
- />
294
-
295
- {/* Stock / Digital badge */}
296
- {product.isDownloadable ? (
297
- <span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
298
- <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
299
- <path
300
- strokeLinecap="round"
301
- strokeLinejoin="round"
302
- strokeWidth={2}
303
- d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
304
- />
305
- </svg>
306
- {t('instantDownload')}
307
- </span>
308
- ) : (
309
- <StockBadge inventory={inventory} lowStockThreshold={5} />
310
- )}
311
-
312
- {/* Downloadable files info */}
313
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
314
- <div className="bg-muted/50 rounded-lg border p-4">
315
- <p className="text-foreground mb-2 text-sm font-medium">
316
- {t('filesIncluded')} ({product.downloads.length})
317
- </p>
318
- <ul className="space-y-1.5">
319
- {product.downloads.map((file: DownloadFile) => (
320
- <li
321
- key={file.id}
322
- className="text-muted-foreground flex items-center gap-2 text-sm"
323
- >
324
- <svg
325
- className="h-4 w-4 flex-shrink-0"
326
- fill="none"
327
- viewBox="0 0 24 24"
328
- stroke="currentColor"
329
- >
330
- <path
331
- strokeLinecap="round"
332
- strokeLinejoin="round"
333
- strokeWidth={1.5}
334
- d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
335
- />
336
- </svg>
337
- <span className="truncate">{file.name}</span>
338
- {file.size && (
339
- <span className="flex-shrink-0 text-xs">
340
- (
341
- {file.size < 1024 * 1024
342
- ? `${(file.size / 1024).toFixed(0)} KB`
343
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
344
- )
345
- </span>
346
- )}
347
- </li>
348
- ))}
349
- </ul>
350
- </div>
351
- )}
352
-
353
- {/* Variant Selector */}
354
- {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
355
- <VariantSelector
356
- product={product}
357
- selectedVariant={selectedVariant}
358
- onVariantChange={setSelectedVariant}
359
- />
360
- )}
361
-
362
- {/* Quantity + Add to Cart */}
363
- <div className="flex items-center gap-4">
364
- <div className="border-border flex items-center rounded border">
365
- <button
366
- type="button"
367
- onClick={() => setQuantity((q) => Math.max(1, q - 1))}
368
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
369
- aria-label={t('decreaseQuantity')}
370
- >
371
- -
372
- </button>
373
- <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
374
- {quantity}
375
- </span>
376
- <button
377
- type="button"
378
- onClick={() => setQuantity((q) => q + 1)}
379
- className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
380
- aria-label={t('increaseQuantity')}
381
- >
382
- +
383
- </button>
384
- </div>
385
-
386
- <button
387
- type="button"
388
- onClick={handleAddToCart}
389
- disabled={!canPurchase || addingToCart}
390
- className={cn(
391
- 'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
392
- canPurchase
393
- ? 'bg-primary text-primary-foreground hover:opacity-90'
394
- : 'bg-muted text-muted-foreground cursor-not-allowed'
395
- )}
396
- >
397
- {addingToCart ? (
398
- <span className="inline-flex items-center gap-2">
399
- <LoadingSpinner
400
- size="sm"
401
- className="border-primary-foreground/30 border-t-primary-foreground"
402
- />
403
- {t('addingToCart')}
404
- </span>
405
- ) : addedMessage ? (
406
- t('addedToCart')
407
- ) : !canPurchase ? (
408
- t('outOfStock')
409
- ) : (
410
- t('addToCart')
411
- )}
412
- </button>
413
- </div>
414
-
415
- {/* Download after purchase note */}
416
- {product.isDownloadable && (
417
- <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
418
- )}
419
-
420
- {/* Description */}
421
- {description && (
422
- <div className="border-border border-t pt-4">
423
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
424
- {'html' in description ? (
425
- <div
426
- className="prose prose-sm text-muted-foreground max-w-none"
427
- dangerouslySetInnerHTML={{ __html: description.html }}
428
- />
429
- ) : (
430
- <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
431
- )}
432
- </div>
433
- )}
434
-
435
- {/* Metafields / Specifications */}
436
- {product.metafields && product.metafields.length > 0 && (
437
- <div className="border-border border-t pt-4">
438
- <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
439
- <table className="w-full text-sm">
440
- <tbody>
441
- {product.metafields.map((field) => (
442
- <tr key={field.id} className="border-border border-b last:border-0">
443
- <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
444
- {field.definitionName}
445
- </td>
446
- <td className="text-muted-foreground py-2">
447
- <MetafieldValue field={field} />
448
- </td>
449
- </tr>
450
- ))}
451
- </tbody>
452
- </table>
453
- </div>
454
- )}
455
- </div>
456
- </div>
457
-
458
- {/* Frequently Bought Together (cross-sells) */}
459
- {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
460
- <FrequentlyBoughtTogether
461
- items={recommendations.crossSells}
462
- currentProduct={product}
463
- className="mt-12"
464
- />
465
- )}
466
-
467
- {/* Upsells */}
468
- {recommendations?.upsells && recommendations.upsells.length > 0 && (
469
- <RecommendationSection
470
- title={t('upgradeYourChoice')}
471
- items={recommendations.upsells}
472
- className="mt-12"
473
- />
474
- )}
475
-
476
- {/* Related products */}
477
- {recommendations?.related && recommendations.related.length > 0 && (
478
- <RecommendationSection
479
- title={t('similarProducts')}
480
- items={recommendations.related}
481
- className="mt-12"
482
- />
483
- )}
484
- </div>
485
- );
486
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useMemo } from 'react';
4
+ import Image from 'next/image';
5
+ import type {
6
+ Product,
7
+ ProductVariant,
8
+ ProductImage,
9
+ ProductMetafield,
10
+ DownloadFile,
11
+ } from 'brainerce';
12
+ import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
13
+ import { useCart } from '@/providers/store-provider';
14
+ import { PriceDisplay } from '@/components/shared/price-display';
15
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
16
+ import { VariantSelector } from '@/components/products/variant-selector';
17
+ import { StockBadge } from '@/components/products/stock-badge';
18
+ import { RecommendationSection } from '@/components/products/recommendation-section';
19
+ import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
20
+ import { useTranslations } from '@/lib/translations';
21
+ import { cn } from '@/lib/utils';
22
+
23
+ /** Render a metafield value based on its type */
24
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
25
+ const tc = useTranslations('common');
26
+ switch (field.type) {
27
+ case 'IMAGE': {
28
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
29
+ return (
30
+ <img
31
+ src={field.value}
32
+ alt={field.definitionName}
33
+ className="h-16 w-16 rounded object-cover"
34
+ />
35
+ );
36
+ }
37
+ case 'GALLERY': {
38
+ let urls: string[] = [];
39
+ try {
40
+ const parsed = JSON.parse(field.value);
41
+ urls = Array.isArray(parsed)
42
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
43
+ : [];
44
+ } catch {
45
+ urls = field.value ? [field.value] : [];
46
+ }
47
+ if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
48
+ return (
49
+ <div className="flex flex-wrap gap-2">
50
+ {urls.map((url, i) => (
51
+ <img
52
+ key={i}
53
+ src={url}
54
+ alt={`${field.definitionName} ${i + 1}`}
55
+ className="h-16 w-16 rounded object-cover"
56
+ />
57
+ ))}
58
+ </div>
59
+ );
60
+ }
61
+ case 'URL':
62
+ return field.value ? (
63
+ <a
64
+ href={field.value}
65
+ target="_blank"
66
+ rel="noopener noreferrer"
67
+ className="text-primary break-all hover:underline"
68
+ >
69
+ {field.value}
70
+ </a>
71
+ ) : (
72
+ <span className="text-muted-foreground">-</span>
73
+ );
74
+ case 'COLOR':
75
+ return field.value ? (
76
+ <span className="inline-flex items-center gap-2">
77
+ <span
78
+ className="border-border inline-block h-4 w-4 rounded-full border"
79
+ style={{ backgroundColor: field.value }}
80
+ />
81
+ {field.value}
82
+ </span>
83
+ ) : (
84
+ <span className="text-muted-foreground">-</span>
85
+ );
86
+ case 'BOOLEAN':
87
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
88
+ case 'DATE':
89
+ case 'DATETIME': {
90
+ if (!field.value) return <span className="text-muted-foreground">-</span>;
91
+ try {
92
+ const date = new Date(field.value);
93
+ return (
94
+ <span>
95
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
96
+ </span>
97
+ );
98
+ } catch {
99
+ return <span>{field.value}</span>;
100
+ }
101
+ }
102
+ default:
103
+ return <span>{field.value || '-'}</span>;
104
+ }
105
+ }
106
+
107
+ interface ProductClientSectionProps {
108
+ product: Product;
109
+ }
110
+
111
+ export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
112
+ const { refreshCart } = useCart();
113
+ const t = useTranslations('productDetail');
114
+
115
+ const product = initialProduct;
116
+ const recommendations = product?.recommendations ?? null;
117
+
118
+ const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
119
+ product.variants && product.variants.length > 0 ? product.variants[0] : null
120
+ );
121
+ const [selectedImageIndex, setSelectedImageIndex] = useState(0);
122
+ const [quantity, setQuantity] = useState(1);
123
+ const [addingToCart, setAddingToCart] = useState(false);
124
+ const [addedMessage, setAddedMessage] = useState(false);
125
+
126
+ // Images list - switch main image when variant changes
127
+ const images: ProductImage[] = useMemo(() => {
128
+ return product?.images || [];
129
+ }, [product]);
130
+
131
+ // When variant changes, update selected image to variant image if available
132
+ useEffect(() => {
133
+ if (!selectedVariant?.image || !product) return;
134
+
135
+ const variantImgUrl =
136
+ typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
137
+
138
+ // Find if variant image exists in product images
139
+ const idx = images.findIndex((img) => img.url === variantImgUrl);
140
+ if (idx >= 0) {
141
+ setSelectedImageIndex(idx);
142
+ } else {
143
+ // Variant image not in product images - select index 0 as fallback
144
+ setSelectedImageIndex(-1);
145
+ }
146
+ }, [selectedVariant, images, product]);
147
+
148
+ // Determine which image to show
149
+ const mainImageUrl = useMemo(() => {
150
+ if (selectedImageIndex === -1 && selectedVariant?.image) {
151
+ const img = selectedVariant.image;
152
+ return typeof img === 'string' ? img : img.url;
153
+ }
154
+ return images[selectedImageIndex]?.url || null;
155
+ }, [selectedImageIndex, selectedVariant, images]);
156
+
157
+ // Price info - use variant price if selected, else product price
158
+ const priceInfo = useMemo(() => {
159
+ if (selectedVariant?.price) {
160
+ return {
161
+ price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
162
+ originalPrice: parseFloat(selectedVariant.price),
163
+ isOnSale:
164
+ selectedVariant.salePrice != null &&
165
+ parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
166
+ discountPercent:
167
+ selectedVariant.salePrice != null &&
168
+ parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
169
+ ? Math.round(
170
+ ((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
171
+ parseFloat(selectedVariant.price)) *
172
+ 100
173
+ )
174
+ : 0,
175
+ };
176
+ }
177
+ return getProductPriceInfo(product);
178
+ }, [product, selectedVariant]);
179
+
180
+ // Inventory: use variant inventory if selected, else product inventory
181
+ const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
182
+ const canPurchase = inventory?.canPurchase !== false;
183
+
184
+ // Description
185
+ const description = useMemo(() => {
186
+ return product ? getDescriptionContent(product) : null;
187
+ }, [product]);
188
+
189
+ async function handleAddToCart() {
190
+ if (!product || addingToCart) return;
191
+
192
+ try {
193
+ setAddingToCart(true);
194
+ const { getClient } = await import('@/lib/brainerce');
195
+ const client = getClient();
196
+ await client.smartAddToCart({
197
+ productId: product.id,
198
+ variantId: selectedVariant?.id,
199
+ quantity,
200
+ });
201
+ await refreshCart();
202
+ setAddedMessage(true);
203
+ setTimeout(() => setAddedMessage(false), 2000);
204
+ } catch (err) {
205
+ console.error('Failed to add to cart:', err);
206
+ } finally {
207
+ setAddingToCart(false);
208
+ }
209
+ }
210
+
211
+ return (
212
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
213
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
214
+ {/* Image Gallery */}
215
+ <div className="space-y-4">
216
+ {/* Main Image */}
217
+ <div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
218
+ {mainImageUrl ? (
219
+ <Image
220
+ src={mainImageUrl}
221
+ alt={product.name}
222
+ fill
223
+ sizes="(max-width: 1024px) 100vw, 50vw"
224
+ className="object-contain"
225
+ priority
226
+ />
227
+ ) : (
228
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
229
+ <svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
230
+ <path
231
+ strokeLinecap="round"
232
+ strokeLinejoin="round"
233
+ strokeWidth={1}
234
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
235
+ />
236
+ </svg>
237
+ </div>
238
+ )}
239
+ </div>
240
+
241
+ {/* Thumbnails */}
242
+ {images.length > 1 && (
243
+ <div className="flex gap-2 overflow-x-auto pb-2">
244
+ {images.map((img, idx) => (
245
+ <button
246
+ key={idx}
247
+ type="button"
248
+ onClick={() => setSelectedImageIndex(idx)}
249
+ className={cn(
250
+ 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
251
+ selectedImageIndex === idx
252
+ ? 'border-primary'
253
+ : 'border-border hover:border-muted-foreground'
254
+ )}
255
+ >
256
+ <Image
257
+ src={img.url}
258
+ alt={img.alt || `${product.name} ${idx + 1}`}
259
+ fill
260
+ sizes="64px"
261
+ className="object-cover"
262
+ />
263
+ </button>
264
+ ))}
265
+ </div>
266
+ )}
267
+ </div>
268
+
269
+ {/* Product Info */}
270
+ <div className="space-y-6">
271
+ {/* Categories */}
272
+ {product.categories && product.categories.length > 0 && (
273
+ <div className="flex flex-wrap gap-2">
274
+ {product.categories.map((cat) => (
275
+ <span
276
+ key={cat.id}
277
+ className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
278
+ >
279
+ {cat.name}
280
+ </span>
281
+ ))}
282
+ </div>
283
+ )}
284
+
285
+ {/* Title */}
286
+ <h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
287
+
288
+ {/* Price */}
289
+ <PriceDisplay
290
+ price={priceInfo.originalPrice}
291
+ salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
292
+ size="lg"
293
+ />
294
+
295
+ {/* Stock / Digital badge */}
296
+ {product.isDownloadable ? (
297
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
298
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
299
+ <path
300
+ strokeLinecap="round"
301
+ strokeLinejoin="round"
302
+ strokeWidth={2}
303
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
304
+ />
305
+ </svg>
306
+ {t('instantDownload')}
307
+ </span>
308
+ ) : (
309
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
310
+ )}
311
+
312
+ {/* Downloadable files info */}
313
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
314
+ <div className="bg-muted/50 rounded-lg border p-4">
315
+ <p className="text-foreground mb-2 text-sm font-medium">
316
+ {t('filesIncluded')} ({product.downloads.length})
317
+ </p>
318
+ <ul className="space-y-1.5">
319
+ {product.downloads.map((file: DownloadFile) => (
320
+ <li
321
+ key={file.id}
322
+ className="text-muted-foreground flex items-center gap-2 text-sm"
323
+ >
324
+ <svg
325
+ className="h-4 w-4 flex-shrink-0"
326
+ fill="none"
327
+ viewBox="0 0 24 24"
328
+ stroke="currentColor"
329
+ >
330
+ <path
331
+ strokeLinecap="round"
332
+ strokeLinejoin="round"
333
+ strokeWidth={1.5}
334
+ d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
335
+ />
336
+ </svg>
337
+ <span className="truncate">{file.name}</span>
338
+ {file.size && (
339
+ <span className="flex-shrink-0 text-xs">
340
+ (
341
+ {file.size < 1024 * 1024
342
+ ? `${(file.size / 1024).toFixed(0)} KB`
343
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
344
+ )
345
+ </span>
346
+ )}
347
+ </li>
348
+ ))}
349
+ </ul>
350
+ </div>
351
+ )}
352
+
353
+ {/* Variant Selector */}
354
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
355
+ <VariantSelector
356
+ product={product}
357
+ selectedVariant={selectedVariant}
358
+ onVariantChange={setSelectedVariant}
359
+ />
360
+ )}
361
+
362
+ {/* Quantity + Add to Cart */}
363
+ <div className="flex items-center gap-4">
364
+ <div className="border-border flex items-center rounded border">
365
+ <button
366
+ type="button"
367
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
368
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
369
+ aria-label={t('decreaseQuantity')}
370
+ >
371
+ -
372
+ </button>
373
+ <span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
374
+ {quantity}
375
+ </span>
376
+ <button
377
+ type="button"
378
+ onClick={() => setQuantity((q) => q + 1)}
379
+ className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
380
+ aria-label={t('increaseQuantity')}
381
+ >
382
+ +
383
+ </button>
384
+ </div>
385
+
386
+ <button
387
+ type="button"
388
+ onClick={handleAddToCart}
389
+ disabled={!canPurchase || addingToCart}
390
+ className={cn(
391
+ 'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
392
+ canPurchase
393
+ ? 'bg-primary text-primary-foreground hover:opacity-90'
394
+ : 'bg-muted text-muted-foreground cursor-not-allowed'
395
+ )}
396
+ >
397
+ {addingToCart ? (
398
+ <span className="inline-flex items-center gap-2">
399
+ <LoadingSpinner
400
+ size="sm"
401
+ className="border-primary-foreground/30 border-t-primary-foreground"
402
+ />
403
+ {t('addingToCart')}
404
+ </span>
405
+ ) : addedMessage ? (
406
+ t('addedToCart')
407
+ ) : !canPurchase ? (
408
+ t('outOfStock')
409
+ ) : (
410
+ t('addToCart')
411
+ )}
412
+ </button>
413
+ </div>
414
+
415
+ {/* Download after purchase note */}
416
+ {product.isDownloadable && (
417
+ <p className="text-muted-foreground text-sm">{t('downloadAfterPurchase')}</p>
418
+ )}
419
+
420
+ {/* Description */}
421
+ {description && (
422
+ <div className="border-border border-t pt-4">
423
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
424
+ {'html' in description ? (
425
+ <div
426
+ className="prose prose-sm text-muted-foreground max-w-none"
427
+ dangerouslySetInnerHTML={{ __html: description.html }}
428
+ />
429
+ ) : (
430
+ <p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
431
+ )}
432
+ </div>
433
+ )}
434
+
435
+ {/* Metafields / Specifications */}
436
+ {product.metafields && product.metafields.length > 0 && (
437
+ <div className="border-border border-t pt-4">
438
+ <h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
439
+ <table className="w-full text-sm">
440
+ <tbody>
441
+ {product.metafields.map((field) => (
442
+ <tr key={field.id} className="border-border border-b last:border-0">
443
+ <td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
444
+ {field.definitionName}
445
+ </td>
446
+ <td className="text-muted-foreground py-2">
447
+ <MetafieldValue field={field} />
448
+ </td>
449
+ </tr>
450
+ ))}
451
+ </tbody>
452
+ </table>
453
+ </div>
454
+ )}
455
+ </div>
456
+ </div>
457
+
458
+ {/* Frequently Bought Together (cross-sells) */}
459
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
460
+ <FrequentlyBoughtTogether
461
+ items={recommendations.crossSells}
462
+ currentProduct={product}
463
+ className="mt-12"
464
+ />
465
+ )}
466
+
467
+ {/* Upsells */}
468
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
469
+ <RecommendationSection
470
+ title={t('upgradeYourChoice')}
471
+ items={recommendations.upsells}
472
+ className="mt-12"
473
+ />
474
+ )}
475
+
476
+ {/* Related products */}
477
+ {recommendations?.related && recommendations.related.length > 0 && (
478
+ <RecommendationSection
479
+ title={t('similarProducts')}
480
+ items={recommendations.related}
481
+ className="mt-12"
482
+ />
483
+ )}
484
+ </div>
485
+ );
486
+ }