create-brainerce-store 1.50.5 → 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 (230) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +146 -100
  3. package/messages/en.json +36 -1
  4. package/messages/he.json +36 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +60 -0
  7. package/templates/nextjs/base/.env.local.ejs +5 -0
  8. package/templates/nextjs/base/.eslintrc.json +57 -3
  9. package/templates/nextjs/base/AGENTS.md +41 -0
  10. package/templates/nextjs/base/AI-GUIDE.md +143 -0
  11. package/templates/nextjs/base/CLAUDE.md +52 -0
  12. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  13. package/templates/nextjs/base/next.config.ts +2 -2
  14. package/templates/nextjs/base/package.json.ejs +52 -39
  15. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  16. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  18. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  19. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  20. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  21. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  23. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  24. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  25. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  26. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/globals.css +36 -30
  28. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  29. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  30. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  31. package/templates/nextjs/base/src/app/page.tsx +4 -4
  32. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  33. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  34. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  35. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  36. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  37. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  38. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  39. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  40. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  41. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  42. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  43. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  44. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  45. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  46. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  47. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  48. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  49. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  50. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  52. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  53. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  54. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  55. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
  56. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  57. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  58. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  59. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  60. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  61. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  62. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  63. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  64. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  65. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  66. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  67. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  68. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  69. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  70. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  71. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  72. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  73. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  74. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  75. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  76. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  77. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  78. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  79. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  80. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  81. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  82. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  83. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  84. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  85. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  86. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  87. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  88. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  89. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
  90. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
  91. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
  92. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
  93. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
  94. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
  95. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
  96. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
  97. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
  98. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  99. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  100. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
  101. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  102. package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
  103. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
  104. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
  105. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
  106. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  107. package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
  108. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
  109. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
  110. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
  111. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
  112. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
  113. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
  114. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
  115. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
  116. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  117. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
  118. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
  119. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
  120. package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
  121. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
  122. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  123. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  124. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  125. package/templates/nextjs/base/tailwind.config.ts +69 -30
  126. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
  127. package/templates/nextjs/designs/atelier/globals.css +396 -0
  128. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  129. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  130. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
  131. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  132. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  133. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  134. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  135. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
  136. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  137. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  138. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -0
  139. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
  140. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  141. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  142. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  143. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  144. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  145. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  146. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  147. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  148. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  149. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  150. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  151. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  152. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  153. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  154. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  155. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  156. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  157. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  158. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  159. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -0
  160. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
  161. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  162. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  163. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  164. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  165. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  166. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  167. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  168. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -0
  169. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  170. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  171. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  172. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  173. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -0
  174. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  175. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  176. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  177. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  178. package/templates/nextjs/themes/luxury/globals.css +405 -399
  179. package/templates/nextjs/themes/minimal/globals.css +36 -30
  180. package/templates/nextjs/themes/playful/globals.css +406 -400
  181. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -0
  182. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
  183. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
  184. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
  185. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
  186. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  187. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
  188. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
  189. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
  190. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  191. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  192. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  193. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  194. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  195. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  196. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  197. package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
  198. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
  199. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  200. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  201. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
  202. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
  203. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
  204. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
  205. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
  206. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  207. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  208. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  209. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  210. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
  211. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
  212. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
  213. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
  214. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
  215. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
  216. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
  217. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  218. package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
  219. package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
  220. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  221. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  222. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  223. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  224. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  225. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  226. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  227. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  228. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  229. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  230. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +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). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
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. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
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(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
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
+ }