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