create-brainerce-store 1.51.0 → 1.52.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 (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -0,0 +1,471 @@
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 { RecommendationSection } from '@/ui/product/recommendation-section';
11
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
12
+ import { CustomizationFields } from '@/ui/product/customization-fields';
13
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
14
+ import { useTranslations } from '@/core/lib/translations';
15
+ import { flyToCart, firstVisible } from '@/ui/shared/fly-to-cart';
16
+ import { openCartDrawer } from '@/ui/cart/cart-drawer';
17
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
18
+ import { cn } from '@/core/lib/utils';
19
+ import {
20
+ IconBag,
21
+ IconCheck,
22
+ IconMinus,
23
+ IconPlus,
24
+ IconTruck,
25
+ IconGem,
26
+ IconDownload,
27
+ } from '@/ui/shared/icons';
28
+
29
+ /** Render a metafield value based on its type */
30
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
31
+ const tc = useTranslations('common');
32
+ switch (field.type) {
33
+ case 'IMAGE': {
34
+ if (!field.value) return <span>-</span>;
35
+ return (
36
+ <img
37
+ src={field.value}
38
+ alt={field.definitionName}
39
+ className="h-16 w-16 rounded-lg border object-cover"
40
+ />
41
+ );
42
+ }
43
+ case 'GALLERY': {
44
+ let urls: string[] = [];
45
+ try {
46
+ const parsed = JSON.parse(field.value);
47
+ urls = Array.isArray(parsed)
48
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
49
+ : [];
50
+ } catch {
51
+ urls = field.value ? [field.value] : [];
52
+ }
53
+ if (urls.length === 0) return <span>-</span>;
54
+ return (
55
+ <span className="flex flex-wrap gap-2">
56
+ {urls.map((url, i) => (
57
+ <img
58
+ key={i}
59
+ src={url}
60
+ alt={`${field.definitionName} ${i + 1}`}
61
+ className="h-16 w-16 rounded-lg border object-cover"
62
+ />
63
+ ))}
64
+ </span>
65
+ );
66
+ }
67
+ case 'URL':
68
+ return field.value ? (
69
+ <a
70
+ href={field.value}
71
+ target="_blank"
72
+ rel="noopener noreferrer"
73
+ className="text-primary underline underline-offset-2"
74
+ >
75
+ {field.value}
76
+ </a>
77
+ ) : (
78
+ <span>-</span>
79
+ );
80
+ case 'COLOR':
81
+ return field.value ? (
82
+ <span className="inline-flex items-center gap-2">
83
+ <span
84
+ aria-hidden="true"
85
+ className="inline-block h-4 w-4 rounded-full border"
86
+ style={{ backgroundColor: field.value }}
87
+ />
88
+ {field.value}
89
+ </span>
90
+ ) : (
91
+ <span>-</span>
92
+ );
93
+ case 'BOOLEAN':
94
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
95
+ case 'DATE':
96
+ case 'DATETIME': {
97
+ if (!field.value) return <span>-</span>;
98
+ try {
99
+ const date = new Date(field.value);
100
+ return (
101
+ <span>
102
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
103
+ </span>
104
+ );
105
+ } catch {
106
+ return <span>{field.value}</span>;
107
+ }
108
+ }
109
+ default:
110
+ return <span>{field.value || '-'}</span>;
111
+ }
112
+ }
113
+
114
+ interface ProductClientSectionProps {
115
+ product: Product;
116
+ }
117
+
118
+ export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
119
+ const t = useTranslations('productDetail');
120
+
121
+ const {
122
+ product,
123
+ recommendations,
124
+ images,
125
+ selectedImageIndex,
126
+ setSelectedImageIndex,
127
+ mainImageUrl,
128
+ selectedVariant,
129
+ setSelectedVariant,
130
+ priceInfo,
131
+ inventory,
132
+ canPurchase,
133
+ description,
134
+ quantity,
135
+ setQuantity,
136
+ addingToCart,
137
+ addedMessage,
138
+ handleAddToCart,
139
+ customizationFields,
140
+ customizationValues,
141
+ setCustomizationValues,
142
+ customizationErrors,
143
+ modifierGroups,
144
+ modifierSelections,
145
+ setModifierSelection,
146
+ modifierError,
147
+ } = useProductPage(initialProduct);
148
+
149
+ // `addedMessage` flips true only after the server confirms the add (it
150
+ // stays false on validation failures), so it is the reliable moment to
151
+ // slide the mini-cart open.
152
+ useEffect(() => {
153
+ if (addedMessage) openCartDrawer();
154
+ }, [addedMessage]);
155
+
156
+ return (
157
+ <article className="container-narrow py-8 lg:py-12">
158
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-14">
159
+ {/* Image Gallery */}
160
+ <figure className="lg:sticky lg:top-24 lg:self-start">
161
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
162
+ <div data-pdp-gallery className="relative aspect-square overflow-hidden rounded-3xl border bg-secondary shadow-[0_28px_60px_-30px_hsl(var(--primary)/0.3)]">
163
+ {mainImageUrl ? (
164
+ <Image
165
+ src={mainImageUrl}
166
+ alt={product.name}
167
+ fill
168
+ sizes="(max-width: 1024px) 100vw, 50vw"
169
+ priority
170
+ className="object-cover"
171
+ />
172
+ ) : (
173
+ <span className="sr-only">{product.name}</span>
174
+ )}
175
+ </div>
176
+
177
+ {/* Thumbnails */}
178
+ {images.length > 1 && (
179
+ <div className="mt-3 flex flex-wrap gap-2.5">
180
+ {images.map((img, idx) => (
181
+ <button
182
+ key={idx}
183
+ type="button"
184
+ onClick={() => setSelectedImageIndex(idx)}
185
+ aria-pressed={selectedImageIndex === idx}
186
+ className={cn(
187
+ 'relative h-[4.5rem] w-[4.5rem] overflow-hidden rounded-lg border-2 bg-secondary transition-colors',
188
+ selectedImageIndex === idx
189
+ ? 'border-primary'
190
+ : 'border-transparent hover:border-border'
191
+ )}
192
+ >
193
+ <Image
194
+ src={img.url}
195
+ alt={img.alt || `${product.name} ${idx + 1}`}
196
+ fill
197
+ sizes="72px"
198
+ className="object-cover"
199
+ />
200
+ </button>
201
+ ))}
202
+ </div>
203
+ )}
204
+ <figcaption className="sr-only">{product.name}</figcaption>
205
+ </figure>
206
+
207
+ {/* Product Info / buy box */}
208
+ <section className="max-w-xl">
209
+ {/* Categories */}
210
+ {product.categories && product.categories.length > 0 && (
211
+ <ul className="mb-3 flex flex-wrap gap-1.5">
212
+ {product.categories.map((cat) => (
213
+ <li key={cat.id} className="badge-soft">
214
+ {cat.name}
215
+ </li>
216
+ ))}
217
+ </ul>
218
+ )}
219
+
220
+ {/* Brand */}
221
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
222
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
223
+ <p className="mb-1 text-sm text-muted-foreground">
224
+ {t('by')}{' '}
225
+ <span className="font-medium text-foreground">
226
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
227
+ .map((b) => b.name)
228
+ .join(', ')}
229
+ </span>
230
+ </p>
231
+ )}
232
+
233
+ {/* Title */}
234
+ <h1 className="text-3xl sm:text-4xl">{product.name}</h1>
235
+
236
+ {/* Tags */}
237
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
238
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
239
+ 0 && (
240
+ <ul className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
241
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
242
+ (tag) => (
243
+ <li key={tag.id}>#{tag.name}</li>
244
+ )
245
+ )}
246
+ </ul>
247
+ )}
248
+
249
+ {/* Price + stock */}
250
+ <div className="mt-5 flex flex-wrap items-center gap-4 border-b pb-6">
251
+ <PriceDisplay
252
+ price={priceInfo.originalPrice}
253
+ salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
254
+ size="lg"
255
+ />
256
+ {product.isDownloadable ? (
257
+ <span className="badge-soft">
258
+ <IconDownload size={16} />
259
+ {t('instantDownload')}
260
+ </span>
261
+ ) : (
262
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
263
+ )}
264
+ </div>
265
+
266
+ {/* Downloadable files info */}
267
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
268
+ <section className="mt-6 rounded-xl border bg-secondary/60 p-4">
269
+ <h2 className="text-sm font-semibold">
270
+ {t('filesIncluded').replace('{count}', String(product.downloads.length))}
271
+ </h2>
272
+ <ul className="mt-2 space-y-1.5 text-sm text-muted-foreground">
273
+ {product.downloads.map((file: DownloadFile) => (
274
+ <li key={file.id} className="flex items-center gap-2">
275
+ <IconDownload size={16} />
276
+ <span>{file.name}</span>
277
+ {file.size && (
278
+ <span className="text-xs">
279
+ (
280
+ {file.size < 1024 * 1024
281
+ ? `${(file.size / 1024).toFixed(0)} KB`
282
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
283
+ )
284
+ </span>
285
+ )}
286
+ </li>
287
+ ))}
288
+ </ul>
289
+ </section>
290
+ )}
291
+
292
+ {/* Variant Selector */}
293
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
294
+ <VariantSelector
295
+ product={product}
296
+ selectedVariant={selectedVariant}
297
+ onVariantChange={setSelectedVariant}
298
+ className="mt-6"
299
+ />
300
+ )}
301
+
302
+ {/* Customization Fields (buyer input) */}
303
+ {customizationFields.length > 0 && (
304
+ <CustomizationFields
305
+ fields={customizationFields}
306
+ values={customizationValues}
307
+ onChange={setCustomizationValues}
308
+ errors={customizationErrors}
309
+ />
310
+ )}
311
+
312
+ {/* Modifier groups (gift wrap, engraving options, …) */}
313
+ {modifierGroups.length > 0 && (
314
+ <div className="mt-6 space-y-5">
315
+ {modifierGroups.map((group) => (
316
+ <ModifierGroupSelector
317
+ key={group.attachmentId ?? group.id}
318
+ group={group}
319
+ value={modifierSelections[group.id] ?? []}
320
+ onChange={(next) => setModifierSelection(group.id, next)}
321
+ disabled={addingToCart}
322
+ />
323
+ ))}
324
+ {modifierError && (
325
+ <p role="alert" className="text-sm font-medium text-destructive">
326
+ {modifierError}
327
+ </p>
328
+ )}
329
+ </div>
330
+ )}
331
+
332
+ {/* Quantity + Add to Cart */}
333
+ <div className="mt-7 flex flex-wrap items-center gap-3">
334
+ <div
335
+ className="flex h-12 items-center rounded-full border"
336
+ role="group"
337
+ aria-label={t('quantityLabel')}
338
+ >
339
+ <button
340
+ type="button"
341
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
342
+ aria-label={t('decreaseQuantity')}
343
+ className="flex h-full w-11 items-center justify-center rounded-s-full text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
344
+ disabled={quantity <= 1}
345
+ >
346
+ <IconMinus size={16} />
347
+ </button>
348
+ <span aria-live="polite" className="min-w-8 text-center text-sm font-semibold">
349
+ {quantity}
350
+ </span>
351
+ <button
352
+ type="button"
353
+ onClick={() => setQuantity((q) => q + 1)}
354
+ aria-label={t('increaseQuantity')}
355
+ className="flex h-full w-11 items-center justify-center rounded-e-full text-foreground transition-colors hover:bg-secondary"
356
+ >
357
+ <IconPlus size={16} />
358
+ </button>
359
+ </div>
360
+
361
+ <button
362
+ type="button"
363
+ onClick={() => { flyToCart(firstVisible('[data-pdp-gallery]')); handleAddToCart(); }}
364
+ disabled={!canPurchase || addingToCart}
365
+ className={cn(
366
+ 'btn-primary btn-lg flex-1 sm:min-w-64 sm:flex-none',
367
+ addedMessage && 'bg-primary/90'
368
+ )}
369
+ >
370
+ {addedMessage ? <IconCheck size={20} /> : <IconBag size={20} />}
371
+ {addingToCart
372
+ ? t('addingToCart')
373
+ : addedMessage
374
+ ? t('addedToCart')
375
+ : !canPurchase
376
+ ? t('outOfStock')
377
+ : t('addToCart')}
378
+ </button>
379
+ </div>
380
+
381
+ {/* Reassurance row */}
382
+ <ul className="mt-6 space-y-2 border-t pt-5 text-sm text-muted-foreground">
383
+ <li className="flex items-center gap-2.5">
384
+ <span className="text-primary">
385
+ <IconTruck size={20} />
386
+ </span>
387
+ {t('shippingNote')}
388
+ </li>
389
+ <li className="flex items-center gap-2.5">
390
+ <span className="text-primary">
391
+ <IconGem size={20} />
392
+ </span>
393
+ {t('guaranteeNote')}
394
+ </li>
395
+ </ul>
396
+
397
+ {/* Download after purchase note */}
398
+ {product.isDownloadable && (
399
+ <p className="mt-4 text-sm text-muted-foreground">{t('downloadAfterPurchase')}</p>
400
+ )}
401
+
402
+ {/* Description */}
403
+ {description && (
404
+ <section className="mt-8">
405
+ <h2 className="mb-3 text-xl">{t('description')}</h2>
406
+ {'html' in description ? (
407
+ <div
408
+ className="prose-store"
409
+ dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
410
+ />
411
+ ) : (
412
+ <p className="prose-store whitespace-pre-wrap">{description.text}</p>
413
+ )}
414
+ </section>
415
+ )}
416
+
417
+ {/* Metafields / Specifications */}
418
+ {product.metafields && product.metafields.length > 0 && (
419
+ <section className="mt-8">
420
+ <h2 className="mb-3 text-xl">{t('specifications')}</h2>
421
+ <table className="w-full overflow-hidden rounded-xl border text-sm">
422
+ <tbody className="divide-y">
423
+ {product.metafields.map((field) => (
424
+ <tr key={field.id} className="odd:bg-secondary/50">
425
+ <th
426
+ scope="row"
427
+ className="w-1/3 px-4 py-3 text-start font-medium text-muted-foreground"
428
+ >
429
+ {field.definitionName}
430
+ </th>
431
+ <td className="px-4 py-3">
432
+ <MetafieldValue field={field} />
433
+ </td>
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </table>
438
+ </section>
439
+ )}
440
+ </section>
441
+ </div>
442
+
443
+ {/* Frequently Bought Together (cross-sells) */}
444
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
445
+ <FrequentlyBoughtTogether
446
+ items={recommendations.crossSells}
447
+ currentProduct={product}
448
+ className="mt-14"
449
+ />
450
+ )}
451
+
452
+ {/* Upsells */}
453
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
454
+ <RecommendationSection
455
+ title={t('upgradeYourChoice')}
456
+ items={recommendations.upsells}
457
+ className="mt-14"
458
+ />
459
+ )}
460
+
461
+ {/* Related products */}
462
+ {recommendations?.related && recommendations.related.length > 0 && (
463
+ <RecommendationSection
464
+ title={t('similarProducts')}
465
+ items={recommendations.related}
466
+ className="mt-14"
467
+ />
468
+ )}
469
+ </article>
470
+ );
471
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import type { Product } from 'brainerce';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { ProductCard } from '@/ui/product/product-card';
7
+ import { cn } from '@/core/lib/utils';
8
+ import { IconGem } from '@/ui/shared/icons';
9
+
10
+ interface ProductGridProps {
11
+ products: Product[];
12
+ className?: string;
13
+ }
14
+
15
+ export function ProductGrid({ products, className }: ProductGridProps) {
16
+ const t = useTranslations('products');
17
+ const tc = useTranslations('common');
18
+
19
+ // Designed empty state — never a bare sentence in blank space.
20
+ if (products.length === 0) {
21
+ return (
22
+ <div className="card mx-auto flex max-w-md flex-col items-center gap-3 px-8 py-14 text-center">
23
+ <span className="flex h-14 w-14 items-center justify-center rounded-full bg-secondary text-primary">
24
+ <IconGem size={28} />
25
+ </span>
26
+ <p className="font-display text-xl text-foreground">{t('emptyTitle')}</p>
27
+ <p className="text-sm text-muted-foreground">{t('emptyBody')}</p>
28
+ <Link href="/products" className="btn-soft btn-sm mt-2">
29
+ {tc('viewAll')}
30
+ </Link>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div
37
+ className={cn(
38
+ 'grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4 lg:gap-6',
39
+ className
40
+ )}
41
+ >
42
+ {products.map((product) => (
43
+ <ProductCard key={product.id} product={product} />
44
+ ))}
45
+ </div>
46
+ );
47
+ }