create-brainerce-store 1.50.5 → 1.51.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 (186) hide show
  1. package/dist/index.js +171 -66
  2. package/messages/en.json +4 -1
  3. package/messages/he.json +4 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.claude/commands/design.md +51 -0
  6. package/templates/nextjs/base/.env.local.ejs +5 -0
  7. package/templates/nextjs/base/.eslintrc.json +57 -3
  8. package/templates/nextjs/base/AGENTS.md +41 -0
  9. package/templates/nextjs/base/AI-GUIDE.md +94 -0
  10. package/templates/nextjs/base/CLAUDE.md +42 -0
  11. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  12. package/templates/nextjs/base/next.config.ts +2 -2
  13. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  14. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  15. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  16. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  18. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  19. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  20. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  21. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  22. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  23. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  24. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  25. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  26. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  28. package/templates/nextjs/base/src/app/page.tsx +4 -4
  29. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  30. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  31. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  32. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  33. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  34. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  35. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  38. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  39. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  40. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  41. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  42. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  43. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  44. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  45. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  46. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  47. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  48. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  49. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  50. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  52. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
  53. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  54. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  55. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  56. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  58. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  59. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  60. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  61. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  62. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  63. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  64. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  65. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  66. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  67. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  68. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  69. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  70. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
  71. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
  72. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
  73. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
  74. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
  75. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
  76. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
  77. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
  78. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
  79. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  80. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  81. package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
  82. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  83. package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
  84. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
  85. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  86. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
  87. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
  88. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
  89. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
  90. package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -73
  91. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
  92. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
  93. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
  94. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  95. package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
  96. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
  97. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
  98. package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
  99. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
  100. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  101. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  102. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  103. package/templates/nextjs/base/tailwind.config.ts +12 -0
  104. package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
  105. package/templates/nextjs/designs/passepartout/globals.css +412 -0
  106. package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
  107. package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
  108. package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
  109. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
  110. package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
  111. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
  112. package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
  113. package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
  114. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
  115. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
  116. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
  117. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
  118. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
  119. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
  120. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
  121. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
  122. package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
  123. package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
  124. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
  125. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
  126. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
  127. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
  128. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
  129. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
  130. package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
  131. package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
  132. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
  133. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
  134. package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
  135. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
  136. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
  137. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
  138. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
  139. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
  140. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
  141. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  142. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
  143. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
  144. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
  145. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  146. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  147. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  148. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  149. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  150. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  151. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  152. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
  153. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
  154. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  155. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  156. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
  157. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
  158. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
  159. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
  160. package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
  161. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  162. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  163. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  164. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  165. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
  166. package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
  167. package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
  168. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
  169. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
  170. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
  171. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
  172. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  173. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  174. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  175. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  176. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  177. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  178. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  179. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  180. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  181. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  182. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  183. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
  184. /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
  185. /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
  186. /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
@@ -0,0 +1,352 @@
1
+ 'use client';
2
+
3
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
4
+ import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
5
+ import { useProductPage } from '@/core/hooks/use-product-page';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { VariantSelector } from '@/ui/product/variant-selector';
8
+ import { StockBadge } from '@/ui/product/stock-badge';
9
+ import { RecommendationSection } from '@/ui/product/recommendation-section';
10
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
11
+ import { CustomizationFields } from '@/ui/product/customization-fields';
12
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
13
+ import { useTranslations } from '@/core/lib/translations';
14
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
15
+
16
+ /** Render a metafield value based on its type */
17
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
18
+ const tc = useTranslations('common');
19
+ switch (field.type) {
20
+ case 'IMAGE': {
21
+ if (!field.value) return <span>-</span>;
22
+ return <img src={field.value} alt={field.definitionName} className="h-16 w-16" />;
23
+ }
24
+ case 'GALLERY': {
25
+ let urls: string[] = [];
26
+ try {
27
+ const parsed = JSON.parse(field.value);
28
+ urls = Array.isArray(parsed)
29
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
30
+ : [];
31
+ } catch {
32
+ urls = field.value ? [field.value] : [];
33
+ }
34
+ if (urls.length === 0) return <span>-</span>;
35
+ return (
36
+ <span className="flex flex-wrap gap-2">
37
+ {urls.map((url, i) => (
38
+ <img key={i} src={url} alt={`${field.definitionName} ${i + 1}`} className="h-16 w-16" />
39
+ ))}
40
+ </span>
41
+ );
42
+ }
43
+ case 'URL':
44
+ return field.value ? (
45
+ <a href={field.value} target="_blank" rel="noopener noreferrer">
46
+ {field.value}
47
+ </a>
48
+ ) : (
49
+ <span>-</span>
50
+ );
51
+ case 'COLOR':
52
+ return field.value ? <span>{field.value}</span> : <span>-</span>;
53
+ case 'BOOLEAN':
54
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
55
+ case 'DATE':
56
+ case 'DATETIME': {
57
+ if (!field.value) return <span>-</span>;
58
+ try {
59
+ const date = new Date(field.value);
60
+ return (
61
+ <span>
62
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
63
+ </span>
64
+ );
65
+ } catch {
66
+ return <span>{field.value}</span>;
67
+ }
68
+ }
69
+ default:
70
+ return <span>{field.value || '-'}</span>;
71
+ }
72
+ }
73
+
74
+ interface ProductClientSectionProps {
75
+ product: Product;
76
+ }
77
+
78
+ export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
79
+ const t = useTranslations('productDetail');
80
+
81
+ const {
82
+ product,
83
+ recommendations,
84
+ images,
85
+ selectedImageIndex,
86
+ setSelectedImageIndex,
87
+ mainImageUrl,
88
+ selectedVariant,
89
+ setSelectedVariant,
90
+ priceInfo,
91
+ inventory,
92
+ canPurchase,
93
+ description,
94
+ quantity,
95
+ setQuantity,
96
+ addingToCart,
97
+ addedMessage,
98
+ handleAddToCart,
99
+ customizationFields,
100
+ customizationValues,
101
+ setCustomizationValues,
102
+ customizationErrors,
103
+ modifierGroups,
104
+ modifierSelections,
105
+ setModifierSelection,
106
+ modifierError,
107
+ } = useProductPage(initialProduct);
108
+
109
+ return (
110
+ <article>
111
+ {/* DESIGN ME — product page (PDP): gallery, buy box (price/stock/variants/customization/modifiers/quantity/add-to-cart), description, specs, cross-sells; everything comes from useProductPage(initialProduct) */}
112
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
113
+ {/* Image Gallery */}
114
+ <figure>
115
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
116
+ <div className="relative aspect-square">
117
+ {mainImageUrl ? (
118
+ <Image
119
+ src={mainImageUrl}
120
+ alt={product.name}
121
+ fill
122
+ sizes="(max-width: 1024px) 100vw, 50vw"
123
+ priority
124
+ />
125
+ ) : (
126
+ <span className="sr-only">{product.name}</span>
127
+ )}
128
+ </div>
129
+
130
+ {/* Thumbnails */}
131
+ {images.length > 1 && (
132
+ <div className="flex flex-wrap gap-2">
133
+ {images.map((img, idx) => (
134
+ <button
135
+ key={idx}
136
+ type="button"
137
+ onClick={() => setSelectedImageIndex(idx)}
138
+ aria-pressed={selectedImageIndex === idx}
139
+ className="relative h-16 w-16"
140
+ >
141
+ <Image
142
+ src={img.url}
143
+ alt={img.alt || `${product.name} ${idx + 1}`}
144
+ fill
145
+ sizes="64px"
146
+ />
147
+ </button>
148
+ ))}
149
+ </div>
150
+ )}
151
+ <figcaption className="sr-only">{product.name}</figcaption>
152
+ </figure>
153
+
154
+ {/* Product Info / buy box */}
155
+ <section>
156
+ {/* Categories */}
157
+ {product.categories && product.categories.length > 0 && (
158
+ <ul className="flex flex-wrap gap-2">
159
+ {product.categories.map((cat) => (
160
+ <li key={cat.id}>{cat.name}</li>
161
+ ))}
162
+ </ul>
163
+ )}
164
+
165
+ {/* Brand */}
166
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
167
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
168
+ <p>
169
+ {t('by')}{' '}
170
+ <span>
171
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
172
+ .map((b) => b.name)
173
+ .join(', ')}
174
+ </span>
175
+ </p>
176
+ )}
177
+
178
+ {/* Title */}
179
+ <h1>{product.name}</h1>
180
+
181
+ {/* Tags */}
182
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
183
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
184
+ 0 && (
185
+ <ul className="flex flex-wrap gap-1">
186
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
187
+ (tag) => (
188
+ <li key={tag.id}>#{tag.name}</li>
189
+ )
190
+ )}
191
+ </ul>
192
+ )}
193
+
194
+ {/* Price */}
195
+ <PriceDisplay
196
+ price={priceInfo.originalPrice}
197
+ salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
198
+ size="lg"
199
+ />
200
+
201
+ {/* Stock / Digital badge */}
202
+ {product.isDownloadable ? (
203
+ <p>{t('instantDownload')}</p>
204
+ ) : (
205
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
206
+ )}
207
+
208
+ {/* Downloadable files info */}
209
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
210
+ <section>
211
+ <h2>
212
+ {t('filesIncluded')} ({product.downloads.length})
213
+ </h2>
214
+ <ul>
215
+ {product.downloads.map((file: DownloadFile) => (
216
+ <li key={file.id}>
217
+ <span>{file.name}</span>
218
+ {file.size && (
219
+ <span>
220
+ {' '}
221
+ (
222
+ {file.size < 1024 * 1024
223
+ ? `${(file.size / 1024).toFixed(0)} KB`
224
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
225
+ )
226
+ </span>
227
+ )}
228
+ </li>
229
+ ))}
230
+ </ul>
231
+ </section>
232
+ )}
233
+
234
+ {/* Variant Selector */}
235
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
236
+ <VariantSelector
237
+ product={product}
238
+ selectedVariant={selectedVariant}
239
+ onVariantChange={setSelectedVariant}
240
+ />
241
+ )}
242
+
243
+ {/* Customization Fields (buyer input) */}
244
+ {customizationFields.length > 0 && (
245
+ <CustomizationFields
246
+ fields={customizationFields}
247
+ values={customizationValues}
248
+ onChange={setCustomizationValues}
249
+ errors={customizationErrors}
250
+ />
251
+ )}
252
+
253
+ {/* Modifier groups (toppings, sauce, bread type, …) */}
254
+ {modifierGroups.length > 0 && (
255
+ <div>
256
+ {modifierGroups.map((group) => (
257
+ <ModifierGroupSelector
258
+ key={group.attachmentId ?? group.id}
259
+ group={group}
260
+ value={modifierSelections[group.id] ?? []}
261
+ onChange={(next) => setModifierSelection(group.id, next)}
262
+ disabled={addingToCart}
263
+ />
264
+ ))}
265
+ {modifierError && <p role="alert">{modifierError}</p>}
266
+ </div>
267
+ )}
268
+
269
+ {/* Quantity + Add to Cart */}
270
+ <div className="flex items-center gap-4">
271
+ <div className="flex items-center gap-2">
272
+ <button
273
+ type="button"
274
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
275
+ aria-label={t('decreaseQuantity')}
276
+ >
277
+ -
278
+ </button>
279
+ <span aria-live="polite">{quantity}</span>
280
+ <button
281
+ type="button"
282
+ onClick={() => setQuantity((q) => q + 1)}
283
+ aria-label={t('increaseQuantity')}
284
+ >
285
+ +
286
+ </button>
287
+ </div>
288
+
289
+ <button type="button" onClick={handleAddToCart} disabled={!canPurchase || addingToCart}>
290
+ {addingToCart
291
+ ? t('addingToCart')
292
+ : addedMessage
293
+ ? t('addedToCart')
294
+ : !canPurchase
295
+ ? t('outOfStock')
296
+ : t('addToCart')}
297
+ </button>
298
+ </div>
299
+
300
+ {/* Download after purchase note */}
301
+ {product.isDownloadable && <p>{t('downloadAfterPurchase')}</p>}
302
+
303
+ {/* Description */}
304
+ {description && (
305
+ <section>
306
+ <h2>{t('description')}</h2>
307
+ {'html' in description ? (
308
+ <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }} />
309
+ ) : (
310
+ <p className="whitespace-pre-wrap">{description.text}</p>
311
+ )}
312
+ </section>
313
+ )}
314
+
315
+ {/* Metafields / Specifications */}
316
+ {product.metafields && product.metafields.length > 0 && (
317
+ <section>
318
+ <h2>{t('specifications')}</h2>
319
+ <table>
320
+ <tbody>
321
+ {product.metafields.map((field) => (
322
+ <tr key={field.id}>
323
+ <th scope="row">{field.definitionName}</th>
324
+ <td>
325
+ <MetafieldValue field={field} />
326
+ </td>
327
+ </tr>
328
+ ))}
329
+ </tbody>
330
+ </table>
331
+ </section>
332
+ )}
333
+ </section>
334
+ </div>
335
+
336
+ {/* Frequently Bought Together (cross-sells) */}
337
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
338
+ <FrequentlyBoughtTogether items={recommendations.crossSells} currentProduct={product} />
339
+ )}
340
+
341
+ {/* Upsells */}
342
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
343
+ <RecommendationSection title={t('upgradeYourChoice')} items={recommendations.upsells} />
344
+ )}
345
+
346
+ {/* Related products */}
347
+ {recommendations?.related && recommendations.related.length > 0 && (
348
+ <RecommendationSection title={t('similarProducts')} items={recommendations.related} />
349
+ )}
350
+ </article>
351
+ );
352
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import type { Product } from 'brainerce';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { ProductCard } from '@/ui/product/product-card';
6
+ import { cn } from '@/core/lib/utils';
7
+
8
+ interface ProductGridProps {
9
+ products: Product[];
10
+ className?: string;
11
+ }
12
+
13
+ export function ProductGrid({ products, className }: ProductGridProps) {
14
+ const t = useTranslations('products');
15
+ if (products.length === 0) {
16
+ return <p>{t('noProducts')}</p>;
17
+ }
18
+
19
+ return (
20
+ <div className={cn('grid grid-cols-2 gap-4 lg:grid-cols-4', className)}>
21
+ {/* DESIGN ME — product grid: lays out ProductCard units; products arrive as a prop from the listing/home hooks */}
22
+ {products.map((product) => (
23
+ <ProductCard key={product.id} product={product} />
24
+ ))}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ useProductListing,
6
+ sortOptions,
7
+ type CategoryNode,
8
+ } from '@/core/hooks/use-product-listing';
9
+ import { ProductGrid } from '@/ui/product/product-grid';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { useTranslations } from '@/core/lib/translations';
12
+
13
+ /**
14
+ * Flatten the category tree into indented <option>s so nested categories
15
+ * stay selectable in a plain, semantic <select>. Replace with any control
16
+ * you like (chips, dropdown tree, sidebar) — keep `handleCategorySelect`.
17
+ */
18
+ function CategoryOptions({ items, depth }: { items: CategoryNode[]; depth: number }) {
19
+ return (
20
+ <>
21
+ {items.map((node) => (
22
+ <React.Fragment key={node.id}>
23
+ <option value={node.id}>
24
+ {`${'— '.repeat(depth)}${node.name}`}
25
+ </option>
26
+ {node.children.length > 0 && <CategoryOptions items={node.children} depth={depth + 1} />}
27
+ </React.Fragment>
28
+ ))}
29
+ </>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Product listing page (bare skeleton): filters + sort + grid + load-more.
35
+ * All data and URL-state behavior comes from `useProductListing`; this
36
+ * component is presentation only. Must be rendered inside `<Suspense>`
37
+ * (URL params).
38
+ */
39
+ export function ProductListing() {
40
+ const t = useTranslations('products');
41
+ const tc = useTranslations('common');
42
+
43
+ const {
44
+ products,
45
+ loading,
46
+ loadingMore,
47
+ page,
48
+ totalPages,
49
+ total,
50
+ categories,
51
+ brands,
52
+ tags,
53
+ metafieldDefs,
54
+ searchQuery,
55
+ categoryId,
56
+ brandId,
57
+ tagId,
58
+ sortIndex,
59
+ handleLoadMore,
60
+ updateParam,
61
+ handleCategorySelect,
62
+ getSelectedMetafieldValues,
63
+ setMetafieldValue,
64
+ } = useProductListing();
65
+
66
+ return (
67
+ <section>
68
+ {/* DESIGN ME — product listing page: header, category/brand/tag/custom-field filters, sort, product grid, load-more; everything comes from useProductListing() */}
69
+ <header>
70
+ <h1>{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}</h1>
71
+ {!loading && (
72
+ <p>
73
+ {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
74
+ </p>
75
+ )}
76
+ </header>
77
+
78
+ {/* Filters and Sort */}
79
+ <div className="flex flex-wrap items-center gap-3">
80
+ {/* Category filter — nested tree flattened into a select */}
81
+ {categories.length > 0 && (
82
+ <label>
83
+ <span className="sr-only">{tc('all')}</span>
84
+ <select
85
+ value={categoryId}
86
+ onChange={(e) =>
87
+ e.target.value ? handleCategorySelect(e.target.value) : updateParam('category', '')
88
+ }
89
+ >
90
+ <option value="">{tc('all')}</option>
91
+ <CategoryOptions items={categories} depth={0} />
92
+ </select>
93
+ </label>
94
+ )}
95
+
96
+ {/* Brand Filter */}
97
+ {brands.length > 0 && (
98
+ <select
99
+ value={brandId}
100
+ onChange={(e) => updateParam('brand', e.target.value)}
101
+ aria-label={t('allBrands')}
102
+ >
103
+ <option value="">{t('allBrands')}</option>
104
+ {brands.map((b) => (
105
+ <option key={b.id} value={b.id}>
106
+ {b.name}
107
+ </option>
108
+ ))}
109
+ </select>
110
+ )}
111
+
112
+ {/* Tag Filter */}
113
+ {tags.length > 0 && (
114
+ <select
115
+ value={tagId}
116
+ onChange={(e) => updateParam('tag', e.target.value)}
117
+ aria-label={t('allTags')}
118
+ >
119
+ <option value="">{t('allTags')}</option>
120
+ {tags.map((tg) => (
121
+ <option key={tg.id} value={tg.id}>
122
+ {tg.name}
123
+ </option>
124
+ ))}
125
+ </select>
126
+ )}
127
+
128
+ {/* Custom-field (metafield) facets — rendered per definition type.
129
+ Only definitions flagged `filterable: true` and published to
130
+ this sales channel reach this list (see `metafieldDefs` filter
131
+ in the hook). The merchant's display name (`def.name`) is shown
132
+ as-is — `enumValues` come from the merchant's dashboard config. */}
133
+ {metafieldDefs.map((def) => {
134
+ const selected = getSelectedMetafieldValues(def.key);
135
+
136
+ if (def.type === 'BOOLEAN') {
137
+ const isOn = selected.includes('true');
138
+ return (
139
+ <label key={def.id} className="inline-flex items-center gap-2">
140
+ <input
141
+ type="checkbox"
142
+ checked={isOn}
143
+ onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
144
+ />
145
+ {def.name}
146
+ </label>
147
+ );
148
+ }
149
+
150
+ if (def.type === 'SELECT') {
151
+ return (
152
+ <select
153
+ key={def.id}
154
+ value={selected[0] || ''}
155
+ onChange={(e) =>
156
+ setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
157
+ }
158
+ aria-label={def.name}
159
+ >
160
+ <option value="">{`${tc('all')} ${def.name}`}</option>
161
+ {(def.enumValues || []).map((v) => (
162
+ <option key={v.value} value={v.value}>
163
+ {v.label || v.value}
164
+ </option>
165
+ ))}
166
+ </select>
167
+ );
168
+ }
169
+
170
+ // MULTI_SELECT — checkbox toggles. Each click adds/removes one value.
171
+ return (
172
+ <fieldset key={def.id} className="inline-flex items-center gap-2">
173
+ <legend>{def.name}</legend>
174
+ {(def.enumValues || []).map((v) => {
175
+ const active = selected.includes(v.value);
176
+ return (
177
+ <label key={v.value} className="inline-flex items-center gap-1">
178
+ <input
179
+ type="checkbox"
180
+ checked={active}
181
+ onChange={() =>
182
+ setMetafieldValue(
183
+ def.key,
184
+ active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
185
+ )
186
+ }
187
+ />
188
+ {v.label || v.value}
189
+ </label>
190
+ );
191
+ })}
192
+ </fieldset>
193
+ );
194
+ })}
195
+
196
+ {/* Sort */}
197
+ <label htmlFor="sort">{tc('sortBy')}</label>
198
+ <select id="sort" value={sortIndex} onChange={(e) => updateParam('sort', e.target.value)}>
199
+ {sortOptions.map((opt, idx) => (
200
+ <option key={idx} value={idx}>
201
+ {t(opt.labelKey)}
202
+ </option>
203
+ ))}
204
+ </select>
205
+ </div>
206
+
207
+ {/* Products Grid */}
208
+ {loading ? (
209
+ <LoadingSpinner size="lg" />
210
+ ) : (
211
+ <>
212
+ <ProductGrid products={products} />
213
+
214
+ {/* Load More */}
215
+ {page < totalPages && (
216
+ <button type="button" onClick={handleLoadMore} disabled={loadingMore}>
217
+ {loadingMore ? tc('loading') : t('loadMore')}
218
+ </button>
219
+ )}
220
+ </>
221
+ )}
222
+ </section>
223
+ );
224
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { ProductRecommendation } from 'brainerce';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface RecommendationCardProps {
10
+ item: ProductRecommendation;
11
+ className?: string;
12
+ }
13
+
14
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
15
+ const firstImage = item.images?.[0];
16
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
+ const slug = item.slug || item.id;
18
+ const basePrice = parseFloat(item.basePrice);
19
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
20
+ const isOnSale = salePrice != null && salePrice < basePrice;
21
+
22
+ return (
23
+ <Link href={`/products/${slug}`} className={cn(className)}>
24
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
25
+ <span className="relative block aspect-square">
26
+ {imageUrl ? (
27
+ <Image
28
+ src={imageUrl}
29
+ alt={item.name}
30
+ fill
31
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
32
+ />
33
+ ) : null}
34
+ </span>
35
+ <span className="block">
36
+ <span className="block">{item.name}</span>
37
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
38
+ </span>
39
+ </Link>
40
+ );
41
+ }
42
+
43
+ interface RecommendationSectionProps {
44
+ title: string;
45
+ items: ProductRecommendation[];
46
+ className?: string;
47
+ }
48
+
49
+ /**
50
+ * DESIGN ME — recommendation strip on the product page (upsells / related);
51
+ * items come from useProductPage().recommendations, title is passed in.
52
+ */
53
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
54
+ if (items.length === 0) return null;
55
+
56
+ return (
57
+ <section className={cn(className)}>
58
+ <h2>{title}</h2>
59
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
60
+ {items.map((item) => (
61
+ <RecommendationCard key={item.id} item={item} />
62
+ ))}
63
+ </div>
64
+ </section>
65
+ );
66
+ }
67
+
68
+ interface CartRecommendationSectionProps {
69
+ title: string;
70
+ items: ProductRecommendation[];
71
+ className?: string;
72
+ }
73
+
74
+ /**
75
+ * DESIGN ME — cross-sell strip on the cart page ("you might also need");
76
+ * items come from useCartPage().cartRecs.
77
+ */
78
+ export function CartRecommendationSection({
79
+ title,
80
+ items,
81
+ className,
82
+ }: CartRecommendationSectionProps) {
83
+ if (items.length === 0) return null;
84
+
85
+ return (
86
+ <section className={cn(className)}>
87
+ <h2>{title}</h2>
88
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
89
+ {items.map((item) => (
90
+ <RecommendationCard key={item.id} item={item} />
91
+ ))}
92
+ </div>
93
+ </section>
94
+ );
95
+ }