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
@@ -1,627 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { Suspense, useEffect, useMemo, useState, useCallback, useRef } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
- import { useRouter } from '@/lib/navigation';
6
- import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
7
- import { getClient } from '@/lib/brainerce';
8
- import { ProductGrid } from '@/components/products/product-grid';
9
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
10
- import { useTranslations } from '@/lib/translations';
11
- import { cn } from '@/lib/utils';
12
-
13
- const PAGE_SIZE = 20;
14
-
15
- /**
16
- * Metafield (custom-field) values are passed in the URL as `mf_<key>=v1,v2`
17
- * for readability (no JSON encoding). The SDK assembles them back into
18
- * `{ [key]: string[] }` before calling `client.getProducts`.
19
- *
20
- * Only definitions with `filterable: true` of type SELECT / MULTI_SELECT /
21
- * BOOLEAN are honored server-side — the storefront renders facets only for
22
- * those.
23
- */
24
- const METAFIELD_URL_PREFIX = 'mf_';
25
- const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
26
-
27
- type SortOption = {
28
- labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
29
- sortBy: ProductQueryParams['sortBy'];
30
- sortOrder: ProductQueryParams['sortOrder'];
31
- };
32
-
33
- const sortOptions: SortOption[] = [
34
- { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
35
- { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
36
- { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
37
- { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
38
- { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
39
- ];
40
-
41
- interface CategoryNode {
42
- id: string;
43
- name: string;
44
- image?: string | null;
45
- parentId?: string | null;
46
- children: CategoryNode[];
47
- }
48
-
49
- /** Collect all descendant IDs (including self) */
50
- function getAllDescendantIds(node: CategoryNode): string[] {
51
- const ids = [node.id];
52
- for (const child of node.children) {
53
- ids.push(...getAllDescendantIds(child));
54
- }
55
- return ids;
56
- }
57
-
58
- /** Check if a category or any of its descendants matches the selected ID */
59
- function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
60
- if (node.id === selectedId) return true;
61
- return node.children.some((child) => isActiveInTree(child, selectedId));
62
- }
63
-
64
- /** Chevron down SVG */
65
- function ChevronDown({ className }: { className?: string }) {
66
- return (
67
- <svg
68
- className={className}
69
- width="12"
70
- height="12"
71
- viewBox="0 0 12 12"
72
- fill="none"
73
- stroke="currentColor"
74
- strokeWidth="2"
75
- strokeLinecap="round"
76
- strokeLinejoin="round"
77
- >
78
- <path d="M3 4.5L6 7.5L9 4.5" />
79
- </svg>
80
- );
81
- }
82
-
83
- /** Recursive dropdown items for nested categories */
84
- function CategoryDropdownItems({
85
- items,
86
- depth,
87
- selectedId,
88
- onSelect,
89
- }: {
90
- items: CategoryNode[];
91
- depth: number;
92
- selectedId: string;
93
- onSelect: (id: string) => void;
94
- }) {
95
- return (
96
- <>
97
- {items.map((child) => (
98
- <div key={child.id}>
99
- <button
100
- onClick={() => onSelect(child.id)}
101
- className={cn(
102
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
103
- selectedId === child.id && 'bg-primary/10 text-primary font-medium'
104
- )}
105
- style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
106
- >
107
- {child.name}
108
- </button>
109
- {child.children.length > 0 && (
110
- <CategoryDropdownItems
111
- items={child.children}
112
- depth={depth + 1}
113
- selectedId={selectedId}
114
- onSelect={onSelect}
115
- />
116
- )}
117
- </div>
118
- ))}
119
- </>
120
- );
121
- }
122
-
123
- /** Category chip with dropdown for subcategories */
124
- function CategoryChip({
125
- category,
126
- selectedId,
127
- onSelect,
128
- tc,
129
- }: {
130
- category: CategoryNode;
131
- selectedId: string;
132
- onSelect: (id: string) => void;
133
- tc: (key: string) => string;
134
- }) {
135
- const [open, setOpen] = useState(false);
136
- const ref = useRef<HTMLDivElement>(null);
137
- const hasChildren = category.children.length > 0;
138
- const isActive = isActiveInTree(category, selectedId);
139
-
140
- // Find the display name for the selected subcategory
141
- function findName(nodes: CategoryNode[], id: string): string | null {
142
- for (const n of nodes) {
143
- if (n.id === id) return n.name;
144
- const found = findName(n.children, id);
145
- if (found) return found;
146
- }
147
- return null;
148
- }
149
-
150
- const selectedChildName =
151
- isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
152
-
153
- // Close dropdown on outside click
154
- useEffect(() => {
155
- if (!open) return;
156
- function handleClick(e: MouseEvent) {
157
- if (ref.current && !ref.current.contains(e.target as Node)) {
158
- setOpen(false);
159
- }
160
- }
161
- document.addEventListener('mousedown', handleClick);
162
- return () => document.removeEventListener('mousedown', handleClick);
163
- }, [open]);
164
-
165
- if (!hasChildren) {
166
- return (
167
- <button
168
- onClick={() => onSelect(category.id)}
169
- className={cn(
170
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
171
- selectedId === category.id
172
- ? 'bg-primary text-primary-foreground border-primary'
173
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
174
- )}
175
- >
176
- {category.name}
177
- </button>
178
- );
179
- }
180
-
181
- return (
182
- <div ref={ref} className="relative">
183
- <button
184
- onClick={() => setOpen((prev) => !prev)}
185
- className={cn(
186
- 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
187
- isActive
188
- ? 'bg-primary text-primary-foreground border-primary'
189
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
190
- )}
191
- >
192
- {category.name}
193
- {selectedChildName && (
194
- <span className="opacity-80">
195
- {'·'} {selectedChildName}
196
- </span>
197
- )}
198
- <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
199
- </button>
200
-
201
- {open && (
202
- <div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
203
- {/* "All in [category]" option */}
204
- <button
205
- onClick={() => {
206
- onSelect(category.id);
207
- setOpen(false);
208
- }}
209
- className={cn(
210
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
211
- selectedId === category.id && 'bg-primary/10 text-primary'
212
- )}
213
- >
214
- {tc('all')} {category.name}
215
- </button>
216
- <div className="bg-border mx-2 h-px" />
217
- {/* Recursive children */}
218
- <div onClick={() => setOpen(false)}>
219
- <CategoryDropdownItems
220
- items={category.children}
221
- depth={0}
222
- selectedId={selectedId}
223
- onSelect={onSelect}
224
- />
225
- </div>
226
- </div>
227
- )}
228
- </div>
229
- );
230
- }
231
-
232
- function ProductsContent() {
233
- const searchParams = useSearchParams();
234
- const router = useRouter();
235
- const t = useTranslations('products');
236
- const tc = useTranslations('common');
237
-
238
- const searchQuery = searchParams.get('search') || '';
239
- const categoryId = searchParams.get('category') || '';
240
- const brandId = searchParams.get('brand') || '';
241
- const tagId = searchParams.get('tag') || '';
242
- const sortParam = searchParams.get('sort') || '0';
243
-
244
- const [products, setProducts] = useState<Product[]>([]);
245
- const [loading, setLoading] = useState(true);
246
- const [loadingMore, setLoadingMore] = useState(false);
247
- const [page, setPage] = useState(1);
248
- const [totalPages, setTotalPages] = useState(1);
249
- const [total, setTotal] = useState(0);
250
- const [categories, setCategories] = useState<CategoryNode[]>([]);
251
- const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
252
- const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
253
- const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
254
-
255
- const sortIndex = parseInt(sortParam, 10) || 0;
256
- const currentSort = sortOptions[sortIndex] || sortOptions[0];
257
-
258
- // Load categories, brands, tags, and metafield definitions (for custom-field facets).
259
- // The metafield-definitions endpoint already returns only definitions
260
- // published to this sales channel; we filter client-side to the ones flagged
261
- // `filterable: true` and of a UI-renderable type.
262
- useEffect(() => {
263
- async function loadFilters() {
264
- const client = getClient();
265
- const [catRes, brandRes, tagRes, mfRes] = await Promise.allSettled([
266
- client.getCategories(),
267
- client.getBrands(),
268
- client.getTags(),
269
- client.getPublicMetafieldDefinitions(),
270
- ]);
271
- if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
272
- if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
273
- if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
274
- if (mfRes.status === 'fulfilled') {
275
- setMetafieldDefs(
276
- mfRes.value.definitions.filter(
277
- (d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
278
- )
279
- );
280
- }
281
- }
282
- loadFilters();
283
- }, []);
284
-
285
- // Stable serialized signature of the selected metafield facets — used as
286
- // the `loadProducts` dependency (and to derive the SDK shape). When no
287
- // facets are selected, this is `''` so the SDK call omits `metafields`
288
- // entirely and the backend short-circuits the filter branch.
289
- const metafieldsKey = useMemo(() => {
290
- if (metafieldDefs.length === 0) return '';
291
- const parts: string[] = [];
292
- for (const def of metafieldDefs) {
293
- const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${def.key}`);
294
- if (raw) parts.push(`${def.key}=${raw}`);
295
- }
296
- return parts.sort().join('&');
297
- }, [metafieldDefs, searchParams]);
298
-
299
- // Load products when filters change
300
- const loadProducts = useCallback(
301
- async (pageNum: number, append: boolean) => {
302
- try {
303
- if (append) {
304
- setLoadingMore(true);
305
- } else {
306
- setLoading(true);
307
- }
308
-
309
- const client = getClient();
310
- const params: ProductQueryParams = {
311
- page: pageNum,
312
- limit: PAGE_SIZE,
313
- sortBy: currentSort.sortBy,
314
- sortOrder: currentSort.sortOrder,
315
- };
316
-
317
- if (searchQuery) params.search = searchQuery;
318
- if (categoryId) params.categories = categoryId;
319
- if (brandId) params.brands = brandId;
320
- if (tagId) params.tags = tagId;
321
- // Build the `metafields` shape from `metafieldsKey` (each segment is
322
- // `key=v1,v2`). Equivalent to reading the URL params directly but
323
- // tied to the stable signature already in this callback's deps.
324
- if (metafieldsKey) {
325
- const mf: Record<string, string[]> = {};
326
- for (const segment of metafieldsKey.split('&')) {
327
- const eq = segment.indexOf('=');
328
- if (eq < 0) continue;
329
- const k = segment.slice(0, eq);
330
- const v = segment
331
- .slice(eq + 1)
332
- .split(',')
333
- .map((s) => s.trim())
334
- .filter(Boolean);
335
- if (v.length > 0) mf[k] = v;
336
- }
337
- if (Object.keys(mf).length > 0) params.metafields = mf;
338
- }
339
-
340
- const result = await client.getProducts(params);
341
-
342
- if (append) {
343
- setProducts((prev) => [...prev, ...result.data]);
344
- } else {
345
- setProducts(result.data);
346
- }
347
- setTotalPages(result.meta.totalPages);
348
- setTotal(result.meta.total);
349
- setPage(pageNum);
350
- } catch (err) {
351
- console.error('Failed to load products:', err);
352
- } finally {
353
- setLoading(false);
354
- setLoadingMore(false);
355
- }
356
- },
357
- [
358
- searchQuery,
359
- categoryId,
360
- brandId,
361
- tagId,
362
- currentSort.sortBy,
363
- currentSort.sortOrder,
364
- metafieldsKey,
365
- ]
366
- );
367
-
368
- useEffect(() => {
369
- loadProducts(1, false);
370
- }, [loadProducts]);
371
-
372
- function handleLoadMore() {
373
- if (page < totalPages && !loadingMore) {
374
- loadProducts(page + 1, true);
375
- }
376
- }
377
-
378
- function updateParam(key: string, value: string) {
379
- const params = new URLSearchParams(searchParams.toString());
380
- if (value) {
381
- params.set(key, value);
382
- } else {
383
- params.delete(key);
384
- }
385
- router.push(`/products?${params.toString()}`);
386
- }
387
-
388
- function handleCategorySelect(id: string) {
389
- updateParam('category', id);
390
- }
391
-
392
- /** Read the currently-selected values for a given metafield definition. */
393
- function getSelectedMetafieldValues(key: string): string[] {
394
- const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${key}`);
395
- if (!raw) return [];
396
- return raw
397
- .split(',')
398
- .map((v) => v.trim())
399
- .filter(Boolean);
400
- }
401
-
402
- /**
403
- * Toggle one value on/off in a metafield facet's URL param. Multi-select
404
- * adds/removes from the list; single-select / boolean replace the value.
405
- */
406
- function setMetafieldValue(key: string, values: string[]) {
407
- updateParam(`${METAFIELD_URL_PREFIX}${key}`, values.join(','));
408
- }
409
-
410
- return (
411
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
412
- {/* Page Header */}
413
- <div className="mb-8">
414
- <h1 className="text-foreground text-3xl font-bold">
415
- {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
416
- </h1>
417
- {!loading && (
418
- <p className="text-muted-foreground mt-1 text-sm">
419
- {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
420
- </p>
421
- )}
422
- </div>
423
-
424
- {/* Filters and Sort */}
425
- <div className="mb-6 flex flex-col gap-4">
426
- {/* Category Filter */}
427
- {categories.length > 0 && (
428
- <div className="flex flex-wrap gap-2">
429
- <button
430
- onClick={() => updateParam('category', '')}
431
- className={cn(
432
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
433
- !categoryId
434
- ? 'bg-primary text-primary-foreground border-primary'
435
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
436
- )}
437
- >
438
- {tc('all')}
439
- </button>
440
- {categories.map((cat) => (
441
- <CategoryChip
442
- key={cat.id}
443
- category={cat}
444
- selectedId={categoryId}
445
- onSelect={handleCategorySelect}
446
- tc={tc as (key: string) => string}
447
- />
448
- ))}
449
- </div>
450
- )}
451
-
452
- {/* Brand, Tag & Sort row */}
453
- <div className="flex flex-wrap items-center gap-3">
454
- {/* Brand Filter */}
455
- {brands.length > 0 && (
456
- <select
457
- value={brandId}
458
- onChange={(e) => updateParam('brand', e.target.value)}
459
- className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
460
- >
461
- <option value="">{t('allBrands')}</option>
462
- {brands.map((b) => (
463
- <option key={b.id} value={b.id}>
464
- {b.name}
465
- </option>
466
- ))}
467
- </select>
468
- )}
469
-
470
- {/* Tag Filter */}
471
- {tags.length > 0 && (
472
- <select
473
- value={tagId}
474
- onChange={(e) => updateParam('tag', e.target.value)}
475
- className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
476
- >
477
- <option value="">{t('allTags')}</option>
478
- {tags.map((tg) => (
479
- <option key={tg.id} value={tg.id}>
480
- {tg.name}
481
- </option>
482
- ))}
483
- </select>
484
- )}
485
-
486
- {/* Custom-field (metafield) facets — rendered per definition type.
487
- Only definitions flagged `filterable: true` and published to
488
- this sales channel reach this list (see `metafieldDefs` filter
489
- above). The merchant's display name (`def.name`) is shown as-is
490
- — `enumValues` come from the merchant's dashboard config. */}
491
- {metafieldDefs.map((def) => {
492
- const selected = getSelectedMetafieldValues(def.key);
493
-
494
- if (def.type === 'BOOLEAN') {
495
- const isOn = selected.includes('true');
496
- return (
497
- <label
498
- key={def.id}
499
- className="border-border text-foreground hover:border-primary inline-flex h-9 cursor-pointer items-center gap-2 rounded border px-3 text-sm transition-colors"
500
- >
501
- <input
502
- type="checkbox"
503
- checked={isOn}
504
- onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
505
- className="accent-primary h-4 w-4"
506
- />
507
- {def.name}
508
- </label>
509
- );
510
- }
511
-
512
- if (def.type === 'SELECT') {
513
- return (
514
- <select
515
- key={def.id}
516
- value={selected[0] || ''}
517
- onChange={(e) =>
518
- setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
519
- }
520
- aria-label={def.name}
521
- className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
522
- >
523
- <option value="">{`${tc('all')} ${def.name}`}</option>
524
- {(def.enumValues || []).map((v) => (
525
- <option key={v} value={v}>
526
- {v}
527
- </option>
528
- ))}
529
- </select>
530
- );
531
- }
532
-
533
- // MULTI_SELECT — chip toggles. Each click adds/removes one value.
534
- return (
535
- <div
536
- key={def.id}
537
- className="border-border inline-flex items-center gap-1 rounded border px-2 py-1"
538
- aria-label={def.name}
539
- >
540
- <span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
541
- {(def.enumValues || []).map((v) => {
542
- const active = selected.includes(v);
543
- return (
544
- <button
545
- key={v}
546
- type="button"
547
- onClick={() =>
548
- setMetafieldValue(
549
- def.key,
550
- active ? selected.filter((s) => s !== v) : [...selected, v]
551
- )
552
- }
553
- className={cn(
554
- 'rounded-full border px-2 py-0.5 text-xs transition-colors',
555
- active
556
- ? 'bg-primary text-primary-foreground border-primary'
557
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
558
- )}
559
- >
560
- {v}
561
- </button>
562
- );
563
- })}
564
- </div>
565
- );
566
- })}
567
-
568
- {/* Sort */}
569
- <div className="flex items-center gap-2 sm:ms-auto">
570
- <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
571
- {tc('sortBy')}
572
- </label>
573
- <select
574
- id="sort"
575
- value={sortIndex}
576
- onChange={(e) => updateParam('sort', e.target.value)}
577
- className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
578
- >
579
- {sortOptions.map((opt, idx) => (
580
- <option key={idx} value={idx}>
581
- {t(opt.labelKey)}
582
- </option>
583
- ))}
584
- </select>
585
- </div>
586
- </div>
587
- </div>
588
-
589
- {/* Products Grid */}
590
- {loading ? (
591
- <div className="flex items-center justify-center py-20">
592
- <LoadingSpinner size="lg" />
593
- </div>
594
- ) : (
595
- <>
596
- <ProductGrid products={products} />
597
-
598
- {/* Load More */}
599
- {page < totalPages && (
600
- <div className="mt-10 flex justify-center">
601
- <button
602
- onClick={handleLoadMore}
603
- disabled={loadingMore}
604
- className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded px-6 py-2.5 font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
605
- >
606
- {loadingMore ? (
607
- <>
608
- <LoadingSpinner
609
- size="sm"
610
- className="border-primary-foreground/30 border-t-primary-foreground"
611
- />
612
- {tc('loading')}
613
- </>
614
- ) : (
615
- t('loadMore')
616
- )}
617
- </button>
618
- </div>
619
- )}
620
- </>
621
- )}
622
- </div>
623
- );
624
- }
3
+ import { Suspense } from 'react';
4
+ import { ProductListing } from '@/ui/product/product-listing';
5
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
625
6
 
626
7
  export default function ProductsPage() {
627
8
  return (
@@ -632,7 +13,7 @@ export default function ProductsPage() {
632
13
  </div>
633
14
  }
634
15
  >
635
- <ProductsContent />
16
+ <ProductListing />
636
17
  </Suspense>
637
18
  );
638
19
  }
@@ -1,12 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { useRouter, Link } from '@/lib/navigation';
5
- import { useAuth } from '@/providers/store-provider';
6
- import { proxyRegister } from '@/lib/auth';
4
+ import { useRouter, Link } from '@/core/lib/navigation';
5
+ import { useAuth } from '@/core/providers/store-provider';
6
+ import { proxyRegister } from '@/core/lib/auth';
7
7
  import { RegisterForm } from '@/components/auth/register-form';
8
8
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
9
- import { useTranslations } from '@/lib/translations';
9
+ import { useTranslations } from '@/core/lib/translations';
10
10
 
11
11
  export default function RegisterPage() {
12
12
  const router = useRouter();
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { useRouter, Link } from '@/lib/navigation';
5
- import { proxyResetPassword } from '@/lib/auth';
6
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
7
- import { useTranslations } from '@/lib/translations';
8
- import { getPasswordError } from '@/lib/validation';
4
+ import { useRouter, Link } from '@/core/lib/navigation';
5
+ import { proxyResetPassword } from '@/core/lib/auth';
6
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
7
+ import { useTranslations } from '@/core/lib/translations';
8
+ import { getPasswordError } from '@/core/lib/validation';
9
9
 
10
10
  export default function ResetPasswordPage() {
11
11
  const router = useRouter();
@@ -1,5 +1,5 @@
1
1
  import type { MetadataRoute } from 'next';
2
- import { getServerClient } from '@/lib/brainerce';
2
+ import { getServerClient } from '@/core/lib/brainerce';
3
3
 
4
4
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
5
5
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';