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,9 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import type { Product } from 'brainerce';
4
- import { useTranslations } from '@/lib/translations';
5
- import { ProductCard } from '@/components/products/product-card';
6
- import { cn } from '@/lib/utils';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { ProductCard } from '@/ui/product/product-card';
6
+ import { cn } from '@/core/lib/utils';
7
7
 
8
8
  interface ProductGridProps {
9
9
  products: Product[];
@@ -0,0 +1,446 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+ import { ChevronDown } from 'lucide-react';
5
+ import {
6
+ useProductListing,
7
+ sortOptions,
8
+ isActiveInTree,
9
+ type CategoryNode,
10
+ } from '@/core/hooks/use-product-listing';
11
+ import { ProductGrid } from '@/ui/product/product-grid';
12
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
13
+ import { Button } from '@/components/ui/button';
14
+ import { Checkbox } from '@/components/ui/checkbox';
15
+ import { Label } from '@/components/ui/label';
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '@/components/ui/select';
23
+ import { useTranslations } from '@/core/lib/translations';
24
+ import { cn } from '@/core/lib/utils';
25
+
26
+ /** Radix Select items can't carry an empty-string value, so "no filter"
27
+ * round-trips through this sentinel and is written back as ''. */
28
+ const ALL = '__all__';
29
+
30
+ /** Recursive dropdown items for nested categories */
31
+ function CategoryDropdownItems({
32
+ items,
33
+ depth,
34
+ selectedId,
35
+ onSelect,
36
+ }: {
37
+ items: CategoryNode[];
38
+ depth: number;
39
+ selectedId: string;
40
+ onSelect: (id: string) => void;
41
+ }) {
42
+ return (
43
+ <>
44
+ {items.map((child) => (
45
+ <div key={child.id}>
46
+ <button
47
+ onClick={() => onSelect(child.id)}
48
+ className={cn(
49
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
50
+ selectedId === child.id && 'bg-primary/10 text-primary font-medium'
51
+ )}
52
+ style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
53
+ >
54
+ {child.name}
55
+ </button>
56
+ {child.children.length > 0 && (
57
+ <CategoryDropdownItems
58
+ items={child.children}
59
+ depth={depth + 1}
60
+ selectedId={selectedId}
61
+ onSelect={onSelect}
62
+ />
63
+ )}
64
+ </div>
65
+ ))}
66
+ </>
67
+ );
68
+ }
69
+
70
+ /** Category chip with dropdown for subcategories */
71
+ function CategoryChip({
72
+ category,
73
+ selectedId,
74
+ onSelect,
75
+ tc,
76
+ }: {
77
+ category: CategoryNode;
78
+ selectedId: string;
79
+ onSelect: (id: string) => void;
80
+ tc: (key: string) => string;
81
+ }) {
82
+ const [open, setOpen] = useState(false);
83
+ const ref = useRef<HTMLDivElement>(null);
84
+ const hasChildren = category.children.length > 0;
85
+ const isActive = isActiveInTree(category, selectedId);
86
+
87
+ // Find the display name for the selected subcategory
88
+ function findName(nodes: CategoryNode[], id: string): string | null {
89
+ for (const n of nodes) {
90
+ if (n.id === id) return n.name;
91
+ const found = findName(n.children, id);
92
+ if (found) return found;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ const selectedChildName =
98
+ isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
99
+
100
+ // Close dropdown on outside click
101
+ useEffect(() => {
102
+ if (!open) return;
103
+ function handleClick(e: MouseEvent) {
104
+ if (ref.current && !ref.current.contains(e.target as Node)) {
105
+ setOpen(false);
106
+ }
107
+ }
108
+ document.addEventListener('mousedown', handleClick);
109
+ return () => document.removeEventListener('mousedown', handleClick);
110
+ }, [open]);
111
+
112
+ if (!hasChildren) {
113
+ return (
114
+ <button
115
+ onClick={() => onSelect(category.id)}
116
+ className={cn(
117
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
118
+ selectedId === category.id
119
+ ? 'bg-primary text-primary-foreground border-primary'
120
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
121
+ )}
122
+ >
123
+ {category.name}
124
+ </button>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <div ref={ref} className="relative">
130
+ <button
131
+ onClick={() => setOpen((prev) => !prev)}
132
+ className={cn(
133
+ 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
134
+ isActive
135
+ ? 'bg-primary text-primary-foreground border-primary'
136
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
137
+ )}
138
+ >
139
+ {category.name}
140
+ {selectedChildName && (
141
+ <span className="opacity-80">
142
+ {'·'} {selectedChildName}
143
+ </span>
144
+ )}
145
+ <ChevronDown
146
+ className={cn('ms-0.5 h-3 w-3 transition-transform', open && 'rotate-180')}
147
+ aria-hidden="true"
148
+ />
149
+ </button>
150
+
151
+ {open && (
152
+ <div className="bg-popover text-popover-foreground border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
153
+ {/* "All in [category]" option */}
154
+ <button
155
+ onClick={() => {
156
+ onSelect(category.id);
157
+ setOpen(false);
158
+ }}
159
+ className={cn(
160
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
161
+ selectedId === category.id && 'bg-primary/10 text-primary'
162
+ )}
163
+ >
164
+ {tc('all')} {category.name}
165
+ </button>
166
+ <div className="bg-border mx-2 h-px" />
167
+ {/* Recursive children */}
168
+ <div onClick={() => setOpen(false)}>
169
+ <CategoryDropdownItems
170
+ items={category.children}
171
+ depth={0}
172
+ selectedId={selectedId}
173
+ onSelect={onSelect}
174
+ />
175
+ </div>
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Product listing page (filters + sort + grid + load-more). All data and
184
+ * URL-state behavior comes from `useProductListing`; this component is
185
+ * presentation only. Must be rendered inside `<Suspense>` (URL params).
186
+ */
187
+ export function ProductListing() {
188
+ const t = useTranslations('products');
189
+ const tc = useTranslations('common');
190
+
191
+ const {
192
+ products,
193
+ loading,
194
+ loadingMore,
195
+ page,
196
+ totalPages,
197
+ total,
198
+ categories,
199
+ brands,
200
+ tags,
201
+ metafieldDefs,
202
+ searchQuery,
203
+ categoryId,
204
+ brandId,
205
+ tagId,
206
+ sortIndex,
207
+ handleLoadMore,
208
+ updateParam,
209
+ handleCategorySelect,
210
+ getSelectedMetafieldValues,
211
+ setMetafieldValue,
212
+ } = useProductListing();
213
+
214
+ return (
215
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
216
+ {/* Page Header */}
217
+ <div className="mb-8">
218
+ <h1 className="text-foreground text-3xl font-bold">
219
+ {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
220
+ </h1>
221
+ {!loading && (
222
+ <p className="text-muted-foreground mt-1 text-sm">
223
+ {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
224
+ </p>
225
+ )}
226
+ </div>
227
+
228
+ {/* Filters and Sort */}
229
+ <div className="mb-6 flex flex-col gap-4">
230
+ {/* Category Filter */}
231
+ {categories.length > 0 && (
232
+ <div className="flex flex-wrap gap-2">
233
+ <button
234
+ onClick={() => updateParam('category', '')}
235
+ className={cn(
236
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
237
+ !categoryId
238
+ ? 'bg-primary text-primary-foreground border-primary'
239
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
240
+ )}
241
+ >
242
+ {tc('all')}
243
+ </button>
244
+ {categories.map((cat) => (
245
+ <CategoryChip
246
+ key={cat.id}
247
+ category={cat}
248
+ selectedId={categoryId}
249
+ onSelect={handleCategorySelect}
250
+ tc={tc as (key: string) => string}
251
+ />
252
+ ))}
253
+ </div>
254
+ )}
255
+
256
+ {/* Brand, Tag & Sort row */}
257
+ <div className="flex flex-wrap items-center gap-3">
258
+ {/* Brand Filter */}
259
+ {brands.length > 0 && (
260
+ <Select
261
+ value={brandId || ALL}
262
+ onValueChange={(v) => updateParam('brand', v === ALL ? '' : v)}
263
+ >
264
+ <SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allBrands')}>
265
+ <SelectValue />
266
+ </SelectTrigger>
267
+ <SelectContent>
268
+ <SelectItem value={ALL}>{t('allBrands')}</SelectItem>
269
+ {brands.map((b) => (
270
+ <SelectItem key={b.id} value={b.id}>
271
+ {b.name}
272
+ </SelectItem>
273
+ ))}
274
+ </SelectContent>
275
+ </Select>
276
+ )}
277
+
278
+ {/* Tag Filter */}
279
+ {tags.length > 0 && (
280
+ <Select
281
+ value={tagId || ALL}
282
+ onValueChange={(v) => updateParam('tag', v === ALL ? '' : v)}
283
+ >
284
+ <SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allTags')}>
285
+ <SelectValue />
286
+ </SelectTrigger>
287
+ <SelectContent>
288
+ <SelectItem value={ALL}>{t('allTags')}</SelectItem>
289
+ {tags.map((tg) => (
290
+ <SelectItem key={tg.id} value={tg.id}>
291
+ {tg.name}
292
+ </SelectItem>
293
+ ))}
294
+ </SelectContent>
295
+ </Select>
296
+ )}
297
+
298
+ {/* Custom-field (metafield) facets — rendered per definition type.
299
+ Only definitions flagged `filterable: true` and published to
300
+ this sales channel reach this list (see `metafieldDefs` filter
301
+ in the hook). The merchant's display name (`def.name`) is shown
302
+ as-is — `enumValues` come from the merchant's dashboard config. */}
303
+ {metafieldDefs.map((def) => {
304
+ const selected = getSelectedMetafieldValues(def.key);
305
+
306
+ if (def.type === 'BOOLEAN') {
307
+ const isOn = selected.includes('true');
308
+ return (
309
+ <Label
310
+ key={def.id}
311
+ className="border-border text-foreground hover:border-primary inline-flex h-9 cursor-pointer items-center gap-2 rounded border px-3 text-sm font-normal transition-colors"
312
+ >
313
+ <Checkbox
314
+ checked={isOn}
315
+ onCheckedChange={(checked) =>
316
+ setMetafieldValue(def.key, checked === true ? ['true'] : [])
317
+ }
318
+ />
319
+ {def.name}
320
+ </Label>
321
+ );
322
+ }
323
+
324
+ if (def.type === 'SELECT') {
325
+ return (
326
+ <Select
327
+ key={def.id}
328
+ value={selected[0] || ALL}
329
+ onValueChange={(v) => setMetafieldValue(def.key, v === ALL ? [] : [v])}
330
+ >
331
+ <SelectTrigger
332
+ className="h-9 w-auto min-w-[8rem] rounded"
333
+ aria-label={def.name}
334
+ >
335
+ <SelectValue />
336
+ </SelectTrigger>
337
+ <SelectContent>
338
+ <SelectItem value={ALL}>{`${tc('all')} ${def.name}`}</SelectItem>
339
+ {(def.enumValues || []).map((v) => (
340
+ <SelectItem key={v.value} value={v.value}>
341
+ {v.label || v.value}
342
+ </SelectItem>
343
+ ))}
344
+ </SelectContent>
345
+ </Select>
346
+ );
347
+ }
348
+
349
+ // MULTI_SELECT — chip toggles. Each click adds/removes one value.
350
+ return (
351
+ <div
352
+ key={def.id}
353
+ className="border-border inline-flex items-center gap-1 rounded border px-2 py-1"
354
+ aria-label={def.name}
355
+ >
356
+ <span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
357
+ {(def.enumValues || []).map((v) => {
358
+ const active = selected.includes(v.value);
359
+ return (
360
+ <button
361
+ key={v.value}
362
+ type="button"
363
+ onClick={() =>
364
+ setMetafieldValue(
365
+ def.key,
366
+ active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
367
+ )
368
+ }
369
+ className={cn(
370
+ 'rounded-full border px-2 py-0.5 text-xs transition-colors',
371
+ active
372
+ ? 'bg-primary text-primary-foreground border-primary'
373
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
374
+ )}
375
+ >
376
+ {v.label || v.value}
377
+ </button>
378
+ );
379
+ })}
380
+ </div>
381
+ );
382
+ })}
383
+
384
+ {/* Sort */}
385
+ <div className="flex items-center gap-2 sm:ms-auto">
386
+ <Label
387
+ htmlFor="sort"
388
+ className="text-muted-foreground whitespace-nowrap font-normal"
389
+ >
390
+ {tc('sortBy')}
391
+ </Label>
392
+ <Select
393
+ value={String(sortIndex)}
394
+ onValueChange={(v) => updateParam('sort', v)}
395
+ >
396
+ <SelectTrigger id="sort" className="h-9 w-auto min-w-[8rem] rounded">
397
+ <SelectValue />
398
+ </SelectTrigger>
399
+ <SelectContent>
400
+ {sortOptions.map((opt, idx) => (
401
+ <SelectItem key={idx} value={String(idx)}>
402
+ {t(opt.labelKey)}
403
+ </SelectItem>
404
+ ))}
405
+ </SelectContent>
406
+ </Select>
407
+ </div>
408
+ </div>
409
+ </div>
410
+
411
+ {/* Products Grid */}
412
+ {loading ? (
413
+ <div className="flex items-center justify-center py-20">
414
+ <LoadingSpinner size="lg" />
415
+ </div>
416
+ ) : (
417
+ <>
418
+ <ProductGrid products={products} />
419
+
420
+ {/* Load More */}
421
+ {page < totalPages && (
422
+ <div className="mt-10 flex justify-center">
423
+ <Button
424
+ onClick={handleLoadMore}
425
+ disabled={loadingMore}
426
+ className="rounded px-6 py-2.5"
427
+ >
428
+ {loadingMore ? (
429
+ <>
430
+ <LoadingSpinner
431
+ size="sm"
432
+ className="border-primary-foreground/30 border-t-primary-foreground"
433
+ />
434
+ {tc('loading')}
435
+ </>
436
+ ) : (
437
+ t('loadMore')
438
+ )}
439
+ </Button>
440
+ </div>
441
+ )}
442
+ </>
443
+ )}
444
+ </div>
445
+ );
446
+ }
@@ -1,107 +1,102 @@
1
- 'use client';
2
-
3
- import { useEffect, useState } from 'react';
4
- import { Link } from '@/lib/navigation';
5
- import { CdnImage as Image } from '@/components/shared/cdn-image';
6
- import type { ProductRecommendation } from 'brainerce';
7
- import { PriceDisplay } from '@/components/shared/price-display';
8
- import { cn } from '@/lib/utils';
9
-
10
- interface RecommendationCardProps {
11
- item: ProductRecommendation;
12
- className?: string;
13
- }
14
-
15
- function RecommendationCard({ item, className }: RecommendationCardProps) {
16
- const firstImage = item.images?.[0];
17
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
- const slug = item.slug || item.id;
19
- const basePrice = parseFloat(item.basePrice);
20
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
21
- const isOnSale = salePrice != null && salePrice < basePrice;
22
-
23
- return (
24
- <Link
25
- href={`/products/${slug}`}
26
- className={cn(
27
- 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
28
- className
29
- )}
30
- >
31
- <div className="bg-muted relative aspect-square overflow-hidden">
32
- {imageUrl ? (
33
- <Image
34
- src={imageUrl}
35
- alt={item.name}
36
- fill
37
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
38
- className="object-cover transition-transform duration-300 group-hover:scale-105"
39
- />
40
- ) : (
41
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
42
- <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
- <path
44
- strokeLinecap="round"
45
- strokeLinejoin="round"
46
- strokeWidth={1.5}
47
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
48
- />
49
- </svg>
50
- </div>
51
- )}
52
- </div>
53
- <div className="space-y-1.5 p-3">
54
- <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
55
- {item.name}
56
- </h3>
57
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
58
- </div>
59
- </Link>
60
- );
61
- }
62
-
63
- interface RecommendationSectionProps {
64
- title: string;
65
- items: ProductRecommendation[];
66
- className?: string;
67
- }
68
-
69
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
70
- if (items.length === 0) return null;
71
-
72
- return (
73
- <div className={cn('', className)}>
74
- <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
75
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
76
- {items.map((item) => (
77
- <RecommendationCard key={item.id} item={item} />
78
- ))}
79
- </div>
80
- </div>
81
- );
82
- }
83
-
84
- interface CartRecommendationSectionProps {
85
- title: string;
86
- items: ProductRecommendation[];
87
- className?: string;
88
- }
89
-
90
- export function CartRecommendationSection({
91
- title,
92
- items,
93
- className,
94
- }: CartRecommendationSectionProps) {
95
- if (items.length === 0) return null;
96
-
97
- return (
98
- <div className={cn('', className)}>
99
- <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
100
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
101
- {items.map((item) => (
102
- <RecommendationCard key={item.id} item={item} />
103
- ))}
104
- </div>
105
- </div>
106
- );
107
- }
1
+ 'use client';
2
+
3
+ import { Image as ImageIcon } from 'lucide-react';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { ProductRecommendation } from 'brainerce';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface RecommendationCardProps {
11
+ item: ProductRecommendation;
12
+ className?: string;
13
+ }
14
+
15
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
16
+ const firstImage = item.images?.[0];
17
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
+ const slug = item.slug || item.id;
19
+ const basePrice = parseFloat(item.basePrice);
20
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
21
+ const isOnSale = salePrice != null && salePrice < basePrice;
22
+
23
+ return (
24
+ <Link
25
+ href={`/products/${slug}`}
26
+ className={cn(
27
+ // Card-equivalent shell (bg-card + border + rounded-lg) as a link —
28
+ // the whole recommendation tile is clickable.
29
+ 'bg-card text-card-foreground group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
30
+ className
31
+ )}
32
+ >
33
+ <div className="bg-muted relative aspect-square overflow-hidden">
34
+ {imageUrl ? (
35
+ <Image
36
+ src={imageUrl}
37
+ alt={item.name}
38
+ fill
39
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
40
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
41
+ />
42
+ ) : (
43
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
44
+ <ImageIcon className="h-10 w-10" strokeWidth={1.5} aria-hidden="true" />
45
+ </div>
46
+ )}
47
+ </div>
48
+ <div className="space-y-1.5 p-3">
49
+ <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
50
+ {item.name}
51
+ </h3>
52
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
53
+ </div>
54
+ </Link>
55
+ );
56
+ }
57
+
58
+ interface RecommendationSectionProps {
59
+ title: string;
60
+ items: ProductRecommendation[];
61
+ className?: string;
62
+ }
63
+
64
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
65
+ if (items.length === 0) return null;
66
+
67
+ return (
68
+ <div className={cn('', className)}>
69
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
70
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
71
+ {items.map((item) => (
72
+ <RecommendationCard key={item.id} item={item} />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ interface CartRecommendationSectionProps {
80
+ title: string;
81
+ items: ProductRecommendation[];
82
+ className?: string;
83
+ }
84
+
85
+ export function CartRecommendationSection({
86
+ title,
87
+ items,
88
+ className,
89
+ }: CartRecommendationSectionProps) {
90
+ if (items.length === 0) return null;
91
+
92
+ return (
93
+ <div className={cn('', className)}>
94
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
95
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
96
+ {items.map((item) => (
97
+ <RecommendationCard key={item.id} item={item} />
98
+ ))}
99
+ </div>
100
+ </div>
101
+ );
102
+ }