create-brainerce-store 1.51.0 → 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 (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -1,35 +0,0 @@
1
- 'use client';
2
-
3
- import type { Product } from 'brainerce';
4
- import { useTranslations } from '@/core/lib/translations';
5
- import { ProductCard } from '@/ui/product/product-card';
6
- import { cn } from '@/core/lib/utils';
7
-
8
- interface ProductGridProps {
9
- products: Product[];
10
- className?: string;
11
- }
12
-
13
- export function ProductGrid({ products, className }: ProductGridProps) {
14
- const t = useTranslations('products');
15
- if (products.length === 0) {
16
- return (
17
- <div className="py-16 text-center">
18
- <p className="text-muted-foreground text-lg">{t('noProducts')}</p>
19
- </div>
20
- );
21
- }
22
-
23
- return (
24
- <div
25
- className={cn(
26
- 'grid grid-cols-2 gap-x-4 gap-y-12 lg:grid-cols-4 lg:gap-x-6 lg:gap-y-16',
27
- className
28
- )}
29
- >
30
- {products.map((product) => (
31
- <ProductCard key={product.id} product={product} />
32
- ))}
33
- </div>
34
- );
35
- }
@@ -1,427 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useRef } from 'react';
4
- import {
5
- useProductListing,
6
- sortOptions,
7
- isActiveInTree,
8
- type CategoryNode,
9
- } from '@/core/hooks/use-product-listing';
10
- import { ProductGrid } from '@/ui/product/product-grid';
11
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
12
- import { useTranslations } from '@/core/lib/translations';
13
- import { cn } from '@/core/lib/utils';
14
-
15
- /** Chevron down SVG */
16
- function ChevronDown({ className }: { className?: string }) {
17
- return (
18
- <svg
19
- className={className}
20
- width="12"
21
- height="12"
22
- viewBox="0 0 12 12"
23
- fill="none"
24
- stroke="currentColor"
25
- strokeWidth="2"
26
- strokeLinecap="round"
27
- strokeLinejoin="round"
28
- >
29
- <path d="M3 4.5L6 7.5L9 4.5" />
30
- </svg>
31
- );
32
- }
33
-
34
- /** Recursive dropdown items for nested categories */
35
- function CategoryDropdownItems({
36
- items,
37
- depth,
38
- selectedId,
39
- onSelect,
40
- }: {
41
- items: CategoryNode[];
42
- depth: number;
43
- selectedId: string;
44
- onSelect: (id: string) => void;
45
- }) {
46
- return (
47
- <>
48
- {items.map((child) => (
49
- <div key={child.id}>
50
- <button
51
- onClick={() => onSelect(child.id)}
52
- className={cn(
53
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
54
- selectedId === child.id && 'bg-muted text-foreground font-medium'
55
- )}
56
- style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
57
- >
58
- {child.name}
59
- </button>
60
- {child.children.length > 0 && (
61
- <CategoryDropdownItems
62
- items={child.children}
63
- depth={depth + 1}
64
- selectedId={selectedId}
65
- onSelect={onSelect}
66
- />
67
- )}
68
- </div>
69
- ))}
70
- </>
71
- );
72
- }
73
-
74
- /** Category chip with dropdown for subcategories */
75
- function CategoryChip({
76
- category,
77
- selectedId,
78
- onSelect,
79
- tc,
80
- }: {
81
- category: CategoryNode;
82
- selectedId: string;
83
- onSelect: (id: string) => void;
84
- tc: (key: string) => string;
85
- }) {
86
- const [open, setOpen] = useState(false);
87
- const ref = useRef<HTMLDivElement>(null);
88
- const hasChildren = category.children.length > 0;
89
- const isActive = isActiveInTree(category, selectedId);
90
-
91
- // Find the display name for the selected subcategory
92
- function findName(nodes: CategoryNode[], id: string): string | null {
93
- for (const n of nodes) {
94
- if (n.id === id) return n.name;
95
- const found = findName(n.children, id);
96
- if (found) return found;
97
- }
98
- return null;
99
- }
100
-
101
- const selectedChildName =
102
- isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
103
-
104
- // Close dropdown on outside click
105
- useEffect(() => {
106
- if (!open) return;
107
- function handleClick(e: MouseEvent) {
108
- if (ref.current && !ref.current.contains(e.target as Node)) {
109
- setOpen(false);
110
- }
111
- }
112
- document.addEventListener('mousedown', handleClick);
113
- return () => document.removeEventListener('mousedown', handleClick);
114
- }, [open]);
115
-
116
- if (!hasChildren) {
117
- return (
118
- <button
119
- onClick={() => onSelect(category.id)}
120
- className={cn(
121
- 'border px-3 py-1.5 text-xs transition-colors',
122
- selectedId === category.id
123
- ? 'bg-primary text-primary-foreground border-primary'
124
- : 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
125
- )}
126
- >
127
- {category.name}
128
- </button>
129
- );
130
- }
131
-
132
- return (
133
- <div ref={ref} className="relative">
134
- <button
135
- onClick={() => setOpen((prev) => !prev)}
136
- className={cn(
137
- 'inline-flex items-center gap-1 border px-3 py-1.5 text-xs transition-colors',
138
- isActive
139
- ? 'bg-primary text-primary-foreground border-primary'
140
- : 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
141
- )}
142
- >
143
- {category.name}
144
- {selectedChildName && (
145
- <span className="opacity-80">
146
- {'·'} {selectedChildName}
147
- </span>
148
- )}
149
- <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
150
- </button>
151
-
152
- {open && (
153
- <div className="bg-surface border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden border">
154
- {/* "All in [category]" option */}
155
- <button
156
- onClick={() => {
157
- onSelect(category.id);
158
- setOpen(false);
159
- }}
160
- className={cn(
161
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
162
- selectedId === category.id && 'bg-muted text-foreground'
163
- )}
164
- >
165
- {tc('all')} {category.name}
166
- </button>
167
- <div className="bg-border mx-2 h-px" />
168
- {/* Recursive children */}
169
- <div onClick={() => setOpen(false)}>
170
- <CategoryDropdownItems
171
- items={category.children}
172
- depth={0}
173
- selectedId={selectedId}
174
- onSelect={onSelect}
175
- />
176
- </div>
177
- </div>
178
- )}
179
- </div>
180
- );
181
- }
182
-
183
- /**
184
- * Product listing page (filters + sort + grid + load-more). All data and
185
- * URL-state behavior comes from `useProductListing`; this component is
186
- * presentation only. Must be rendered inside `<Suspense>` (URL params).
187
- */
188
- export function ProductListing() {
189
- const t = useTranslations('products');
190
- const tc = useTranslations('common');
191
-
192
- const {
193
- products,
194
- loading,
195
- loadingMore,
196
- page,
197
- totalPages,
198
- total,
199
- categories,
200
- brands,
201
- tags,
202
- metafieldDefs,
203
- searchQuery,
204
- categoryId,
205
- brandId,
206
- tagId,
207
- sortIndex,
208
- handleLoadMore,
209
- updateParam,
210
- handleCategorySelect,
211
- getSelectedMetafieldValues,
212
- setMetafieldValue,
213
- } = useProductListing();
214
-
215
- return (
216
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
217
- {/* Page Header */}
218
- <div className="mb-10 pt-6">
219
- <h1 className="text-foreground font-serif text-3xl font-light">
220
- {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
221
- </h1>
222
- {!loading && (
223
- <p className="text-muted-foreground tabular mt-2 text-[0.6875rem] tracking-[0.08em]">
224
- {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
225
- </p>
226
- )}
227
- <div className="border-border mt-6 border-b" />
228
- </div>
229
-
230
- {/* Filters and Sort */}
231
- <div className="mb-6 flex flex-col gap-4">
232
- {/* Category Filter */}
233
- {categories.length > 0 && (
234
- <div className="flex flex-wrap gap-2">
235
- <button
236
- onClick={() => updateParam('category', '')}
237
- className={cn(
238
- 'border px-3 py-1.5 text-xs transition-colors',
239
- !categoryId
240
- ? 'bg-primary text-primary-foreground border-primary'
241
- : 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
242
- )}
243
- >
244
- {tc('all')}
245
- </button>
246
- {categories.map((cat) => (
247
- <CategoryChip
248
- key={cat.id}
249
- category={cat}
250
- selectedId={categoryId}
251
- onSelect={handleCategorySelect}
252
- tc={tc as (key: string) => string}
253
- />
254
- ))}
255
- </div>
256
- )}
257
-
258
- {/* Brand, Tag & Sort row */}
259
- <div className="flex flex-wrap items-center gap-3">
260
- {/* Brand Filter */}
261
- {brands.length > 0 && (
262
- <select
263
- value={brandId}
264
- onChange={(e) => updateParam('brand', e.target.value)}
265
- className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
266
- >
267
- <option value="">{t('allBrands')}</option>
268
- {brands.map((b) => (
269
- <option key={b.id} value={b.id}>
270
- {b.name}
271
- </option>
272
- ))}
273
- </select>
274
- )}
275
-
276
- {/* Tag Filter */}
277
- {tags.length > 0 && (
278
- <select
279
- value={tagId}
280
- onChange={(e) => updateParam('tag', e.target.value)}
281
- className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
282
- >
283
- <option value="">{t('allTags')}</option>
284
- {tags.map((tg) => (
285
- <option key={tg.id} value={tg.id}>
286
- {tg.name}
287
- </option>
288
- ))}
289
- </select>
290
- )}
291
-
292
- {/* Custom-field (metafield) facets — rendered per definition type.
293
- Only definitions flagged `filterable: true` and published to
294
- this sales channel reach this list (see `metafieldDefs` filter
295
- in the hook). The merchant's display name (`def.name`) is shown
296
- as-is — `enumValues` come from the merchant's dashboard config. */}
297
- {metafieldDefs.map((def) => {
298
- const selected = getSelectedMetafieldValues(def.key);
299
-
300
- if (def.type === 'BOOLEAN') {
301
- const isOn = selected.includes('true');
302
- return (
303
- <label
304
- key={def.id}
305
- className="border-border text-foreground hover:border-foreground inline-flex h-9 cursor-pointer items-center gap-2 border px-3 text-sm transition-colors"
306
- >
307
- <input
308
- type="checkbox"
309
- checked={isOn}
310
- onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
311
- className="accent-primary h-4 w-4"
312
- />
313
- {def.name}
314
- </label>
315
- );
316
- }
317
-
318
- if (def.type === 'SELECT') {
319
- return (
320
- <select
321
- key={def.id}
322
- value={selected[0] || ''}
323
- onChange={(e) =>
324
- setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
325
- }
326
- aria-label={def.name}
327
- className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
328
- >
329
- <option value="">{`${tc('all')} ${def.name}`}</option>
330
- {(def.enumValues || []).map((v) => (
331
- <option key={v.value} value={v.value}>
332
- {v.label || v.value}
333
- </option>
334
- ))}
335
- </select>
336
- );
337
- }
338
-
339
- // MULTI_SELECT — chip toggles. Each click adds/removes one value.
340
- return (
341
- <div
342
- key={def.id}
343
- className="border-border inline-flex items-center gap-1 border px-2 py-1"
344
- aria-label={def.name}
345
- >
346
- <span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
347
- {(def.enumValues || []).map((v) => {
348
- const active = selected.includes(v.value);
349
- return (
350
- <button
351
- key={v.value}
352
- type="button"
353
- onClick={() =>
354
- setMetafieldValue(
355
- def.key,
356
- active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
357
- )
358
- }
359
- className={cn(
360
- 'border px-2 py-0.5 text-xs transition-colors',
361
- active
362
- ? 'bg-primary text-primary-foreground border-primary'
363
- : 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
364
- )}
365
- >
366
- {v.label || v.value}
367
- </button>
368
- );
369
- })}
370
- </div>
371
- );
372
- })}
373
-
374
- {/* Sort */}
375
- <div className="flex items-center gap-2 sm:ms-auto">
376
- <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
377
- {tc('sortBy')}
378
- </label>
379
- <select
380
- id="sort"
381
- value={sortIndex}
382
- onChange={(e) => updateParam('sort', e.target.value)}
383
- className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
384
- >
385
- {sortOptions.map((opt, idx) => (
386
- <option key={idx} value={idx}>
387
- {t(opt.labelKey)}
388
- </option>
389
- ))}
390
- </select>
391
- </div>
392
- </div>
393
- </div>
394
-
395
- {/* Products Grid */}
396
- {loading ? (
397
- <div className="flex items-center justify-center py-20">
398
- <LoadingSpinner size="lg" />
399
- </div>
400
- ) : (
401
- <>
402
- <ProductGrid products={products} />
403
-
404
- {/* Load More */}
405
- {page < totalPages && (
406
- <div className="mt-10 flex justify-center">
407
- <button
408
- onClick={handleLoadMore}
409
- disabled={loadingMore}
410
- className="bg-primary text-primary-foreground inline-flex items-center gap-2 px-6 py-2.5 text-[0.8125rem] font-semibold transition-colors disabled:opacity-50"
411
- >
412
- {loadingMore ? (
413
- <>
414
- <LoadingSpinner size="sm" className="bg-primary-foreground" />
415
- {tc('loading')}
416
- </>
417
- ) : (
418
- t('loadMore')
419
- )}
420
- </button>
421
- </div>
422
- )}
423
- </>
424
- )}
425
- </div>
426
- );
427
- }
@@ -1,113 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useState } from '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 href={`/products/${slug}`} className={cn('group block', className)}>
25
- <div className="bg-surface border-border relative aspect-square overflow-hidden border">
26
- {imageUrl ? (
27
- <Image
28
- src={imageUrl}
29
- alt={item.name}
30
- fill
31
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
32
- className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
33
- />
34
- ) : (
35
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
36
- <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
37
- <path
38
- strokeLinecap="round"
39
- strokeLinejoin="round"
40
- strokeWidth={1.5}
41
- 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"
42
- />
43
- </svg>
44
- </div>
45
- )}
46
- </div>
47
- <div className="space-y-1.5 pt-3 text-start">
48
- <h3 className="text-foreground line-clamp-2 font-serif text-sm font-normal">
49
- {item.name}
50
- </h3>
51
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
52
- </div>
53
- </Link>
54
- );
55
- }
56
-
57
- interface RecommendationSectionProps {
58
- title: string;
59
- items: ProductRecommendation[];
60
- className?: string;
61
- /** Catalog number line above the title, e.g. 'מס׳ 03'. */
62
- kicker?: string;
63
- }
64
-
65
- export function RecommendationSection({
66
- title,
67
- items,
68
- className,
69
- kicker,
70
- }: RecommendationSectionProps) {
71
- if (items.length === 0) return null;
72
-
73
- return (
74
- <div className={cn('', className)}>
75
- <div>
76
- {kicker ? <p className="placard tabular">{kicker}</p> : null}
77
- <h2 className="text-foreground mt-1 font-serif text-xl font-normal">{title}</h2>
78
- </div>
79
- <div className="border-border mb-8 mt-5 border-b" />
80
- <div className="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-4">
81
- {items.map((item) => (
82
- <RecommendationCard key={item.id} item={item} />
83
- ))}
84
- </div>
85
- </div>
86
- );
87
- }
88
-
89
- interface CartRecommendationSectionProps {
90
- title: string;
91
- items: ProductRecommendation[];
92
- className?: string;
93
- }
94
-
95
- export function CartRecommendationSection({
96
- title,
97
- items,
98
- className,
99
- }: CartRecommendationSectionProps) {
100
- if (items.length === 0) return null;
101
-
102
- return (
103
- <div className={cn('', className)}>
104
- <h2 className="text-foreground font-serif text-lg font-normal">{title}</h2>
105
- <div className="border-border mb-6 mt-4 border-b" />
106
- <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4">
107
- {items.map((item) => (
108
- <RecommendationCard key={item.id} item={item} />
109
- ))}
110
- </div>
111
- </div>
112
- );
113
- }
@@ -1,52 +0,0 @@
1
- 'use client';
2
-
3
- import type { InventoryInfo } from 'brainerce';
4
- import { cn } from '@/core/lib/utils';
5
- import { useTranslations } from '@/core/lib/translations';
6
-
7
- interface StockBadgeProps {
8
- inventory: InventoryInfo | null | undefined;
9
- lowStockThreshold?: number;
10
- className?: string;
11
- }
12
-
13
- /**
14
- * Stock as a museum placard caption — silence means available.
15
- * The placard only speaks when scarcity or absence needs saying:
16
- * low stock gets a quiet terracotta dot, absence gets terracotta text.
17
- */
18
- export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
19
- const t = useTranslations('productDetail');
20
-
21
- const caption = 'text-[0.6875rem] tracking-[0.08em]';
22
-
23
- if (!inventory) {
24
- return <span className={cn('text-destructive', caption, className)}>{t('outOfStock')}</span>;
25
- }
26
-
27
- const { trackingMode, inStock, available } = inventory;
28
-
29
- if (trackingMode === 'DISABLED') {
30
- return <span className={cn('text-destructive', caption, className)}>{t('unavailable')}</span>;
31
- }
32
- if (!inStock) {
33
- return <span className={cn('text-destructive', caption, className)}>{t('outOfStock')}</span>;
34
- }
35
- if (trackingMode === 'TRACKED' && available <= lowStockThreshold) {
36
- return (
37
- <span
38
- className={cn(
39
- 'text-muted-foreground inline-flex items-center gap-1.5',
40
- caption,
41
- className
42
- )}
43
- >
44
- <span aria-hidden="true" className="bg-destructive h-1.5 w-1.5 rounded-full" />
45
- {t('onlyLeft').replace('{available}', String(available))}
46
- </span>
47
- );
48
- }
49
-
50
- // In stock, no scarcity — the placard stays silent.
51
- return null;
52
- }