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,51 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { Product, DiscountBanner } from 'brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+
7
+ export interface UseHomeDataResult {
8
+ /** Newest products for the featured grid (max 8). */
9
+ products: Product[];
10
+ /** Active store-wide discount banners. */
11
+ banners: DiscountBanner[];
12
+ /** True while the initial fetch is in flight. */
13
+ loading: boolean;
14
+ }
15
+
16
+ /**
17
+ * Homepage data: featured products + discount banners.
18
+ * Pure data/behavior — rendering lives in `ui/home/`.
19
+ */
20
+ export function useHomeData(): UseHomeDataResult {
21
+ const [products, setProducts] = useState<Product[]>([]);
22
+ const [banners, setBanners] = useState<DiscountBanner[]>([]);
23
+ const [loading, setLoading] = useState(true);
24
+
25
+ useEffect(() => {
26
+ async function load() {
27
+ try {
28
+ const client = getClient();
29
+ const [productsRes, bannersRes] = await Promise.allSettled([
30
+ client.getProducts({ limit: 8, sortBy: 'createdAt', sortOrder: 'desc' }),
31
+ client.getDiscountBanners(),
32
+ ]);
33
+
34
+ if (productsRes.status === 'fulfilled') {
35
+ setProducts(productsRes.value.data);
36
+ }
37
+ if (bannersRes.status === 'fulfilled') {
38
+ setBanners(bannersRes.value);
39
+ }
40
+ } catch (err) {
41
+ console.error('Failed to load home page data:', err);
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ }
46
+
47
+ load();
48
+ }, []);
49
+
50
+ return { products, banners, loading };
51
+ }
@@ -0,0 +1,295 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState, useCallback } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { useRouter } from '@/core/lib/navigation';
6
+ import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+
9
+ const PAGE_SIZE = 20;
10
+
11
+ /**
12
+ * Metafield (custom-field) values are passed in the URL as `mf_<key>=v1,v2`
13
+ * for readability (no JSON encoding). The SDK assembles them back into
14
+ * `{ [key]: string[] }` before calling `client.getProducts`.
15
+ *
16
+ * Only definitions with `filterable: true` of type SELECT / MULTI_SELECT /
17
+ * BOOLEAN are honored server-side — the storefront renders facets only for
18
+ * those.
19
+ */
20
+ const METAFIELD_URL_PREFIX = 'mf_';
21
+ const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
22
+
23
+ export type SortOption = {
24
+ labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
25
+ sortBy: ProductQueryParams['sortBy'];
26
+ sortOrder: ProductQueryParams['sortOrder'];
27
+ };
28
+
29
+ export const sortOptions: SortOption[] = [
30
+ { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
31
+ { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
32
+ { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
33
+ { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
34
+ { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
35
+ ];
36
+
37
+ export interface CategoryNode {
38
+ id: string;
39
+ name: string;
40
+ image?: string | null;
41
+ parentId?: string | null;
42
+ children: CategoryNode[];
43
+ }
44
+
45
+ /** Collect all descendant IDs (including self) */
46
+ export function getAllDescendantIds(node: CategoryNode): string[] {
47
+ const ids = [node.id];
48
+ for (const child of node.children) {
49
+ ids.push(...getAllDescendantIds(child));
50
+ }
51
+ return ids;
52
+ }
53
+
54
+ /** Check if a category or any of its descendants matches the selected ID */
55
+ export function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
56
+ if (node.id === selectedId) return true;
57
+ return node.children.some((child) => isActiveInTree(child, selectedId));
58
+ }
59
+
60
+ export interface UseProductListingResult {
61
+ /** Loaded products (accumulates across "load more" pages). */
62
+ products: Product[];
63
+ loading: boolean;
64
+ loadingMore: boolean;
65
+ page: number;
66
+ totalPages: number;
67
+ total: number;
68
+ /** Facet sources. */
69
+ categories: CategoryNode[];
70
+ brands: Array<{ id: string; name: string }>;
71
+ tags: Array<{ id: string; name: string }>;
72
+ metafieldDefs: PublicMetafieldDefinition[];
73
+ /** Current URL filter state. */
74
+ searchQuery: string;
75
+ categoryId: string;
76
+ brandId: string;
77
+ tagId: string;
78
+ sortIndex: number;
79
+ /** Handlers. */
80
+ handleLoadMore: () => void;
81
+ /** Set/clear a raw URL query param (`category`, `brand`, `tag`, `sort`, …). */
82
+ updateParam: (key: string, value: string) => void;
83
+ handleCategorySelect: (id: string) => void;
84
+ /** Read the currently-selected values for a given metafield definition. */
85
+ getSelectedMetafieldValues: (key: string) => string[];
86
+ /** Replace the selected values of a metafield facet. */
87
+ setMetafieldValue: (key: string, values: string[]) => void;
88
+ }
89
+
90
+ /**
91
+ * Product-listing behavior: URL-driven filters (search / category / brand /
92
+ * tag / sort / metafield facets), product loading, and load-more pagination.
93
+ * Pure data/behavior — rendering lives in `ui/product/product-listing.tsx`.
94
+ *
95
+ * Must be rendered inside a `<Suspense>` boundary (uses `useSearchParams`).
96
+ */
97
+ export function useProductListing(): UseProductListingResult {
98
+ const searchParams = useSearchParams();
99
+ const router = useRouter();
100
+
101
+ const searchQuery = searchParams.get('search') || '';
102
+ const categoryId = searchParams.get('category') || '';
103
+ const brandId = searchParams.get('brand') || '';
104
+ const tagId = searchParams.get('tag') || '';
105
+ const sortParam = searchParams.get('sort') || '0';
106
+
107
+ const [products, setProducts] = useState<Product[]>([]);
108
+ const [loading, setLoading] = useState(true);
109
+ const [loadingMore, setLoadingMore] = useState(false);
110
+ const [page, setPage] = useState(1);
111
+ const [totalPages, setTotalPages] = useState(1);
112
+ const [total, setTotal] = useState(0);
113
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
114
+ const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
115
+ const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
116
+ const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
117
+
118
+ const sortIndex = parseInt(sortParam, 10) || 0;
119
+ const currentSort = sortOptions[sortIndex] || sortOptions[0];
120
+
121
+ // Load categories, brands, tags, and metafield definitions (for custom-field facets).
122
+ // The metafield-definitions endpoint already returns only definitions
123
+ // published to this sales channel; we filter client-side to the ones flagged
124
+ // `filterable: true` and of a UI-renderable type.
125
+ useEffect(() => {
126
+ async function loadFilters() {
127
+ const client = getClient();
128
+ const [catRes, brandRes, tagRes, mfRes] = await Promise.allSettled([
129
+ client.getCategories(),
130
+ client.getBrands(),
131
+ client.getTags(),
132
+ client.getPublicMetafieldDefinitions(),
133
+ ]);
134
+ if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
135
+ if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
136
+ if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
137
+ if (mfRes.status === 'fulfilled') {
138
+ setMetafieldDefs(
139
+ mfRes.value.definitions.filter(
140
+ (d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
141
+ )
142
+ );
143
+ }
144
+ }
145
+ loadFilters();
146
+ }, []);
147
+
148
+ // Stable serialized signature of the selected metafield facets — used as
149
+ // the `loadProducts` dependency (and to derive the SDK shape). When no
150
+ // facets are selected, this is `''` so the SDK call omits `metafields`
151
+ // entirely and the backend short-circuits the filter branch.
152
+ const metafieldsKey = useMemo(() => {
153
+ if (metafieldDefs.length === 0) return '';
154
+ const parts: string[] = [];
155
+ for (const def of metafieldDefs) {
156
+ const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${def.key}`);
157
+ if (raw) parts.push(`${def.key}=${raw}`);
158
+ }
159
+ return parts.sort().join('&');
160
+ }, [metafieldDefs, searchParams]);
161
+
162
+ // Load products when filters change
163
+ const loadProducts = useCallback(
164
+ async (pageNum: number, append: boolean) => {
165
+ try {
166
+ if (append) {
167
+ setLoadingMore(true);
168
+ } else {
169
+ setLoading(true);
170
+ }
171
+
172
+ const client = getClient();
173
+ const params: ProductQueryParams = {
174
+ page: pageNum,
175
+ limit: PAGE_SIZE,
176
+ sortBy: currentSort.sortBy,
177
+ sortOrder: currentSort.sortOrder,
178
+ };
179
+
180
+ if (searchQuery) params.search = searchQuery;
181
+ if (categoryId) params.categories = categoryId;
182
+ if (brandId) params.brands = brandId;
183
+ if (tagId) params.tags = tagId;
184
+ // Build the `metafields` shape from `metafieldsKey` (each segment is
185
+ // `key=v1,v2`). Equivalent to reading the URL params directly but
186
+ // tied to the stable signature already in this callback's deps.
187
+ if (metafieldsKey) {
188
+ const mf: Record<string, string[]> = {};
189
+ for (const segment of metafieldsKey.split('&')) {
190
+ const eq = segment.indexOf('=');
191
+ if (eq < 0) continue;
192
+ const k = segment.slice(0, eq);
193
+ const v = segment
194
+ .slice(eq + 1)
195
+ .split(',')
196
+ .map((s) => s.trim())
197
+ .filter(Boolean);
198
+ if (v.length > 0) mf[k] = v;
199
+ }
200
+ if (Object.keys(mf).length > 0) params.metafields = mf;
201
+ }
202
+
203
+ const result = await client.getProducts(params);
204
+
205
+ if (append) {
206
+ setProducts((prev) => [...prev, ...result.data]);
207
+ } else {
208
+ setProducts(result.data);
209
+ }
210
+ setTotalPages(result.meta.totalPages);
211
+ setTotal(result.meta.total);
212
+ setPage(pageNum);
213
+ } catch (err) {
214
+ console.error('Failed to load products:', err);
215
+ } finally {
216
+ setLoading(false);
217
+ setLoadingMore(false);
218
+ }
219
+ },
220
+ [
221
+ searchQuery,
222
+ categoryId,
223
+ brandId,
224
+ tagId,
225
+ currentSort.sortBy,
226
+ currentSort.sortOrder,
227
+ metafieldsKey,
228
+ ]
229
+ );
230
+
231
+ useEffect(() => {
232
+ loadProducts(1, false);
233
+ }, [loadProducts]);
234
+
235
+ function handleLoadMore() {
236
+ if (page < totalPages && !loadingMore) {
237
+ loadProducts(page + 1, true);
238
+ }
239
+ }
240
+
241
+ function updateParam(key: string, value: string) {
242
+ const params = new URLSearchParams(searchParams.toString());
243
+ if (value) {
244
+ params.set(key, value);
245
+ } else {
246
+ params.delete(key);
247
+ }
248
+ router.push(`/products?${params.toString()}`);
249
+ }
250
+
251
+ function handleCategorySelect(id: string) {
252
+ updateParam('category', id);
253
+ }
254
+
255
+ /** Read the currently-selected values for a given metafield definition. */
256
+ function getSelectedMetafieldValues(key: string): string[] {
257
+ const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${key}`);
258
+ if (!raw) return [];
259
+ return raw
260
+ .split(',')
261
+ .map((v) => v.trim())
262
+ .filter(Boolean);
263
+ }
264
+
265
+ /**
266
+ * Toggle one value on/off in a metafield facet's URL param. Multi-select
267
+ * adds/removes from the list; single-select / boolean replace the value.
268
+ */
269
+ function setMetafieldValue(key: string, values: string[]) {
270
+ updateParam(`${METAFIELD_URL_PREFIX}${key}`, values.join(','));
271
+ }
272
+
273
+ return {
274
+ products,
275
+ loading,
276
+ loadingMore,
277
+ page,
278
+ totalPages,
279
+ total,
280
+ categories,
281
+ brands,
282
+ tags,
283
+ metafieldDefs,
284
+ searchQuery,
285
+ categoryId,
286
+ brandId,
287
+ tagId,
288
+ sortIndex,
289
+ handleLoadMore,
290
+ updateParam,
291
+ handleCategorySelect,
292
+ getSelectedMetafieldValues,
293
+ setMetafieldValue,
294
+ };
295
+ }
@@ -0,0 +1,278 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useMemo } from 'react';
4
+ import type {
5
+ Product,
6
+ ProductCustomizationField,
7
+ ProductImage,
8
+ ProductRecommendationsResponse,
9
+ ProductVariant,
10
+ InventoryInfo,
11
+ ModifierGroup,
12
+ } from 'brainerce';
13
+ import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
14
+ import { useCart } from '@/core/providers/store-provider';
15
+ import {
16
+ buildInitialSelections,
17
+ toModifierSelections,
18
+ validateCustomization,
19
+ validateSelections,
20
+ type CustomizationValues,
21
+ } from '@/core/lib/product-options';
22
+
23
+ export interface ProductPriceInfo {
24
+ price: number;
25
+ originalPrice: number;
26
+ isOnSale: boolean;
27
+ discountAmount?: number;
28
+ discountPercent: number;
29
+ }
30
+
31
+ export interface UseProductPageResult {
32
+ product: Product;
33
+ recommendations: ProductRecommendationsResponse | null;
34
+ /** Product image list. */
35
+ images: ProductImage[];
36
+ /** Index into `images`; -1 means "variant image not in the list". */
37
+ selectedImageIndex: number;
38
+ setSelectedImageIndex: (index: number) => void;
39
+ /** URL of the image to show as the main image (variant-aware). */
40
+ mainImageUrl: string | null;
41
+ /** Variant selection. */
42
+ selectedVariant: ProductVariant | null;
43
+ setSelectedVariant: (variant: ProductVariant | null) => void;
44
+ /** Effective price info (variant price + discount-rule overlay). */
45
+ priceInfo: ProductPriceInfo;
46
+ /** Variant inventory when a variant is selected, else product inventory. */
47
+ inventory: InventoryInfo | null;
48
+ canPurchase: boolean;
49
+ /** Resolved description content ({ html } or { text }). */
50
+ description: ReturnType<typeof getDescriptionContent> | null;
51
+ /** Quantity picker. */
52
+ quantity: number;
53
+ setQuantity: (update: number | ((q: number) => number)) => void;
54
+ /** Add-to-cart state machine. */
55
+ addingToCart: boolean;
56
+ addedMessage: boolean;
57
+ handleAddToCart: () => Promise<void>;
58
+ /** Customization (buyer input) sub-contract. */
59
+ customizationFields: ProductCustomizationField[];
60
+ customizationValues: CustomizationValues;
61
+ setCustomizationValues: (values: CustomizationValues) => void;
62
+ customizationErrors: Record<string, string>;
63
+ /** Modifier groups (toppings, sauce, …) sub-contract. */
64
+ modifierGroups: ModifierGroup[];
65
+ modifierSelections: Record<string, string[]>;
66
+ setModifierSelection: (groupId: string, next: string[]) => void;
67
+ modifierError: string | null;
68
+ }
69
+
70
+ /**
71
+ * Product-page behavior: variant selection, variant-aware image + price +
72
+ * inventory resolution, quantity, customization + modifier state and
73
+ * validation, and the add-to-cart state machine. Pure data/behavior —
74
+ * rendering lives in `ui/product/product-client-section.tsx`.
75
+ */
76
+ export function useProductPage(initialProduct: Product): UseProductPageResult {
77
+ const { refreshCart } = useCart();
78
+
79
+ const product = initialProduct;
80
+ const recommendations = product?.recommendations ?? null;
81
+
82
+ const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
83
+ product.variants && product.variants.length > 0 ? product.variants[0] : null
84
+ );
85
+ const [selectedImageIndex, setSelectedImageIndex] = useState(0);
86
+ const [quantity, setQuantity] = useState(1);
87
+ const [addingToCart, setAddingToCart] = useState(false);
88
+ const [addedMessage, setAddedMessage] = useState(false);
89
+ const customizationFields = product.customizationFields ?? [];
90
+ const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
91
+ const initial: CustomizationValues = {};
92
+ for (const field of customizationFields) {
93
+ if (field.defaultValue != null) initial[field.key] = field.defaultValue;
94
+ }
95
+ return initial;
96
+ });
97
+ const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
98
+
99
+ // Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
100
+ const modifierGroups: ModifierGroup[] = useMemo(
101
+ () => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
102
+ [product]
103
+ );
104
+ const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
105
+ buildInitialSelections(modifierGroups)
106
+ );
107
+ const [modifierError, setModifierError] = useState<string | null>(null);
108
+
109
+ function setModifierSelection(groupId: string, next: string[]) {
110
+ setModifierSelections((prev) => ({ ...prev, [groupId]: next }));
111
+ }
112
+
113
+ // Images list - switch main image when variant changes
114
+ const images: ProductImage[] = useMemo(() => {
115
+ return product?.images || [];
116
+ }, [product]);
117
+
118
+ // When variant changes, update selected image to variant image if available
119
+ useEffect(() => {
120
+ if (!selectedVariant?.image || !product) return;
121
+
122
+ const variantImgUrl =
123
+ typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
124
+
125
+ // Find if variant image exists in product images
126
+ const idx = images.findIndex((img) => img.url === variantImgUrl);
127
+ if (idx >= 0) {
128
+ setSelectedImageIndex(idx);
129
+ } else {
130
+ // Variant image not in product images - select index 0 as fallback
131
+ setSelectedImageIndex(-1);
132
+ }
133
+ }, [selectedVariant, images, product]);
134
+
135
+ // Determine which image to show
136
+ const mainImageUrl = useMemo(() => {
137
+ if (selectedImageIndex === -1 && selectedVariant?.image) {
138
+ const img = selectedVariant.image;
139
+ return typeof img === 'string' ? img : img.url;
140
+ }
141
+ return images[selectedImageIndex]?.url || null;
142
+ }, [selectedImageIndex, selectedVariant, images]);
143
+
144
+ // Price info - use variant price if selected, else product price
145
+ const priceInfo = useMemo(() => {
146
+ if (selectedVariant?.price) {
147
+ const variantBase = parseFloat(selectedVariant.price);
148
+ const variantSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
149
+ const variantEffective =
150
+ variantSale != null && variantSale < variantBase ? variantSale : variantBase;
151
+
152
+ // Overlay any product-level discount rule onto the variant price using the rule's ratio
153
+ if (product.discount) {
154
+ const ruleOriginal = parseFloat(product.discount.originalPrice) || 0;
155
+ const ruleDiscounted = parseFloat(product.discount.discountedPrice) || 0;
156
+ const ratio = ruleOriginal > 0 ? ruleDiscounted / ruleOriginal : 1;
157
+ const discounted = variantEffective * ratio;
158
+ const amount = Math.max(0, variantEffective - discounted);
159
+ return {
160
+ price: discounted,
161
+ originalPrice: variantEffective,
162
+ isOnSale: discounted < variantEffective,
163
+ discountAmount: amount,
164
+ discountPercent: variantEffective > 0 ? Math.round((amount / variantEffective) * 100) : 0,
165
+ };
166
+ }
167
+
168
+ return {
169
+ price: variantEffective,
170
+ originalPrice: variantBase,
171
+ isOnSale: variantEffective < variantBase,
172
+ discountPercent:
173
+ variantEffective < variantBase && variantBase > 0
174
+ ? Math.round(((variantBase - variantEffective) / variantBase) * 100)
175
+ : 0,
176
+ };
177
+ }
178
+ return getProductPriceInfo(product);
179
+ }, [product, selectedVariant]);
180
+
181
+ // Inventory: use variant inventory if selected, else product inventory
182
+ const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
183
+ const canPurchase = inventory?.canPurchase !== false;
184
+
185
+ // Description
186
+ const description = useMemo(() => {
187
+ return product ? getDescriptionContent(product) : null;
188
+ }, [product]);
189
+
190
+ async function handleAddToCart() {
191
+ if (!product || addingToCart) return;
192
+
193
+ if (customizationFields.length > 0) {
194
+ const errs = validateCustomization(customizationFields, customizationValues);
195
+ if (Object.keys(errs).length > 0) {
196
+ setCustomizationErrors(errs);
197
+ return;
198
+ }
199
+ }
200
+ setCustomizationErrors({});
201
+
202
+ // Client-side modifier validation mirrors the server's checks. The server
203
+ // is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
204
+ // failure is the source of truth — but pre-flighting catches the obvious
205
+ // issues without a round-trip.
206
+ if (modifierGroups.length > 0) {
207
+ const error = validateSelections(modifierGroups, modifierSelections);
208
+ if (error) {
209
+ setModifierError(error);
210
+ return;
211
+ }
212
+ }
213
+ setModifierError(null);
214
+
215
+ const selections =
216
+ modifierGroups.length > 0
217
+ ? toModifierSelections(modifierGroups, modifierSelections)
218
+ : undefined;
219
+
220
+ try {
221
+ setAddingToCart(true);
222
+ const { getClient } = await import('@/core/lib/brainerce');
223
+ const client = getClient();
224
+ await client.smartAddToCart({
225
+ productId: product.id,
226
+ variantId: selectedVariant?.id,
227
+ quantity,
228
+ metadata:
229
+ customizationFields.length > 0 && Object.keys(customizationValues).length > 0
230
+ ? customizationValues
231
+ : undefined,
232
+ ...(selections && selections.length > 0 ? { selections } : {}),
233
+ });
234
+ await refreshCart();
235
+ setAddedMessage(true);
236
+ setTimeout(() => setAddedMessage(false), 2000);
237
+ } catch (err) {
238
+ // Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
239
+ const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
240
+ const validationErrors = e?.details?.errors;
241
+ if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
242
+ setModifierError(validationErrors.map((v) => v.message).join('; '));
243
+ } else {
244
+ console.error('Failed to add to cart:', err);
245
+ }
246
+ } finally {
247
+ setAddingToCart(false);
248
+ }
249
+ }
250
+
251
+ return {
252
+ product,
253
+ recommendations,
254
+ images,
255
+ selectedImageIndex,
256
+ setSelectedImageIndex,
257
+ mainImageUrl,
258
+ selectedVariant,
259
+ setSelectedVariant,
260
+ priceInfo,
261
+ inventory,
262
+ canPurchase,
263
+ description,
264
+ quantity,
265
+ setQuantity,
266
+ addingToCart,
267
+ addedMessage,
268
+ handleAddToCart,
269
+ customizationFields,
270
+ customizationValues,
271
+ setCustomizationValues,
272
+ customizationErrors,
273
+ modifierGroups,
274
+ modifierSelections,
275
+ setModifierSelection,
276
+ modifierError,
277
+ };
278
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
4
  import type { SearchSuggestions } from 'brainerce';
5
- import { getClient } from '@/lib/brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
6
 
7
7
  const DEBOUNCE_MS = 300;
8
8
  const MIN_QUERY_LENGTH = 2;
@@ -1,6 +1,6 @@
1
1
  import { BrainerceClient } from 'brainerce';
2
2
  import { cache } from 'react';
3
- import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
3
+ import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
4
4
 
5
5
  // Read either env var name. The new one is preferred; the old one is a soft
6
6
  // alias kept for backwards compatibility — both are accepted by the SDK.