create-brainerce-store 1.0.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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +502 -0
- package/package.json +44 -0
- package/templates/nextjs/base/.env.local.ejs +3 -0
- package/templates/nextjs/base/.eslintrc.json +3 -0
- package/templates/nextjs/base/gitignore +30 -0
- package/templates/nextjs/base/next.config.ts +9 -0
- package/templates/nextjs/base/package.json.ejs +30 -0
- package/templates/nextjs/base/postcss.config.mjs +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +105 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
- package/templates/nextjs/base/src/app/globals.css +30 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
- package/templates/nextjs/base/src/app/login/page.tsx +56 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
- package/templates/nextjs/base/src/app/page.tsx +95 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
- package/templates/nextjs/base/src/app/products/page.tsx +243 -0
- package/templates/nextjs/base/src/app/register/page.tsx +66 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
- package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
- package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
- package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
- package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
- package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
- package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
- package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
- package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
- package/templates/nextjs/base/src/lib/utils.ts +6 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
- package/templates/nextjs/base/tailwind.config.ts +30 -0
- package/templates/nextjs/base/tsconfig.json +23 -0
- package/templates/nextjs/themes/minimal/globals.css +30 -0
- package/templates/nextjs/themes/minimal/theme.json +23 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import Image from 'next/image';
|
|
6
|
+
import { useRouter } from 'next/navigation';
|
|
7
|
+
import type { SearchSuggestions, ProductSuggestion } from 'brainerce';
|
|
8
|
+
import { formatPrice } from 'brainerce';
|
|
9
|
+
import { getClient } from '@/lib/brainerce';
|
|
10
|
+
import { useStoreInfo, useAuth, useCart } from '@/providers/store-provider';
|
|
11
|
+
export function Header() {
|
|
12
|
+
const { storeInfo } = useStoreInfo();
|
|
13
|
+
const { isLoggedIn, logout } = useAuth();
|
|
14
|
+
const { itemCount } = useCart();
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
|
|
17
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
18
|
+
const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(null);
|
|
19
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
20
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
21
|
+
const searchRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
|
|
24
|
+
const currency = storeInfo?.currency || 'USD';
|
|
25
|
+
|
|
26
|
+
// Debounced search suggestions
|
|
27
|
+
const fetchSuggestions = useCallback((query: string) => {
|
|
28
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
29
|
+
|
|
30
|
+
if (query.length < 2) {
|
|
31
|
+
setSuggestions(null);
|
|
32
|
+
setShowSuggestions(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
debounceRef.current = setTimeout(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const client = getClient();
|
|
39
|
+
const result = await client.getSearchSuggestions(query, 5);
|
|
40
|
+
setSuggestions(result);
|
|
41
|
+
setShowSuggestions(true);
|
|
42
|
+
} catch {
|
|
43
|
+
setSuggestions(null);
|
|
44
|
+
}
|
|
45
|
+
}, 300);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Close suggestions on click outside
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
function handleClickOutside(e: MouseEvent) {
|
|
51
|
+
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
|
52
|
+
setShowSuggestions(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
56
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
function handleSearchSubmit(e: React.FormEvent) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (searchQuery.trim()) {
|
|
62
|
+
router.push(`/products?search=${encodeURIComponent(searchQuery.trim())}`);
|
|
63
|
+
setShowSuggestions(false);
|
|
64
|
+
setSearchQuery('');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleSuggestionClick(suggestion: ProductSuggestion) {
|
|
69
|
+
const href = suggestion.slug ? `/products/${suggestion.slug}` : `/products/${suggestion.id}`;
|
|
70
|
+
router.push(href);
|
|
71
|
+
setShowSuggestions(false);
|
|
72
|
+
setSearchQuery('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<header className="bg-background border-border sticky top-0 z-50 border-b">
|
|
77
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
78
|
+
<div className="flex h-16 items-center justify-between gap-4">
|
|
79
|
+
{/* Logo / Store Name */}
|
|
80
|
+
<Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
|
|
81
|
+
{storeInfo?.name || 'Store'}
|
|
82
|
+
</Link>
|
|
83
|
+
|
|
84
|
+
{/* Desktop Navigation */}
|
|
85
|
+
<nav className="hidden items-center gap-6 md:flex">
|
|
86
|
+
<Link
|
|
87
|
+
href="/products"
|
|
88
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
89
|
+
>
|
|
90
|
+
Products
|
|
91
|
+
</Link>
|
|
92
|
+
<Link
|
|
93
|
+
href="/account"
|
|
94
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
95
|
+
>
|
|
96
|
+
Account
|
|
97
|
+
</Link>
|
|
98
|
+
</nav>
|
|
99
|
+
|
|
100
|
+
{/* Search */}
|
|
101
|
+
<div ref={searchRef} className="relative hidden max-w-md flex-1 sm:block">
|
|
102
|
+
<form onSubmit={handleSearchSubmit}>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value={searchQuery}
|
|
106
|
+
onChange={(e) => {
|
|
107
|
+
setSearchQuery(e.target.value);
|
|
108
|
+
fetchSuggestions(e.target.value);
|
|
109
|
+
}}
|
|
110
|
+
onFocus={() => {
|
|
111
|
+
if (suggestions && searchQuery.length >= 2) {
|
|
112
|
+
setShowSuggestions(true);
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
placeholder="Search products..."
|
|
116
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 w-full rounded border px-3 pe-9 text-sm focus:outline-none focus:ring-2"
|
|
117
|
+
/>
|
|
118
|
+
<button
|
|
119
|
+
type="submit"
|
|
120
|
+
className="text-muted-foreground hover:text-foreground absolute end-0 top-0 flex h-9 w-9 items-center justify-center"
|
|
121
|
+
aria-label="Search"
|
|
122
|
+
>
|
|
123
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
124
|
+
<path
|
|
125
|
+
strokeLinecap="round"
|
|
126
|
+
strokeLinejoin="round"
|
|
127
|
+
strokeWidth={2}
|
|
128
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
129
|
+
/>
|
|
130
|
+
</svg>
|
|
131
|
+
</button>
|
|
132
|
+
</form>
|
|
133
|
+
|
|
134
|
+
{/* Search Suggestions Dropdown */}
|
|
135
|
+
{showSuggestions && suggestions && (
|
|
136
|
+
<div className="bg-background border-border absolute top-full z-50 mt-1 w-full overflow-hidden rounded-lg border shadow-lg">
|
|
137
|
+
{suggestions.products.length > 0 && (
|
|
138
|
+
<div>
|
|
139
|
+
<div className="text-muted-foreground bg-muted px-3 py-1.5 text-xs font-medium">
|
|
140
|
+
Products
|
|
141
|
+
</div>
|
|
142
|
+
{suggestions.products.map((product) => (
|
|
143
|
+
<button
|
|
144
|
+
key={product.id}
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => handleSuggestionClick(product)}
|
|
147
|
+
className="hover:bg-muted flex w-full items-center gap-3 px-3 py-2 text-start transition-colors"
|
|
148
|
+
>
|
|
149
|
+
{product.image ? (
|
|
150
|
+
<Image
|
|
151
|
+
src={product.image}
|
|
152
|
+
alt={product.name}
|
|
153
|
+
width={32}
|
|
154
|
+
height={32}
|
|
155
|
+
className="flex-shrink-0 rounded object-cover"
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<div className="bg-muted h-8 w-8 flex-shrink-0 rounded" />
|
|
159
|
+
)}
|
|
160
|
+
<div className="min-w-0 flex-1">
|
|
161
|
+
<p className="text-foreground truncate text-sm">{product.name}</p>
|
|
162
|
+
<p className="text-muted-foreground text-xs">
|
|
163
|
+
{
|
|
164
|
+
formatPrice(product.salePrice || product.price, {
|
|
165
|
+
currency,
|
|
166
|
+
}) as string
|
|
167
|
+
}
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
</button>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{suggestions.categories.length > 0 && (
|
|
176
|
+
<div>
|
|
177
|
+
<div className="text-muted-foreground bg-muted px-3 py-1.5 text-xs font-medium">
|
|
178
|
+
Categories
|
|
179
|
+
</div>
|
|
180
|
+
{suggestions.categories.map((cat) => (
|
|
181
|
+
<button
|
|
182
|
+
key={cat.id}
|
|
183
|
+
type="button"
|
|
184
|
+
onClick={() => {
|
|
185
|
+
router.push(`/products?category=${cat.id}`);
|
|
186
|
+
setShowSuggestions(false);
|
|
187
|
+
setSearchQuery('');
|
|
188
|
+
}}
|
|
189
|
+
className="hover:bg-muted flex w-full items-center justify-between px-3 py-2 text-start transition-colors"
|
|
190
|
+
>
|
|
191
|
+
<span className="text-foreground text-sm">{cat.name}</span>
|
|
192
|
+
<span className="text-muted-foreground text-xs">
|
|
193
|
+
{cat.productCount} products
|
|
194
|
+
</span>
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{suggestions.products.length === 0 && suggestions.categories.length === 0 && (
|
|
201
|
+
<div className="text-muted-foreground px-3 py-4 text-center text-sm">
|
|
202
|
+
No results found
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Right side actions */}
|
|
210
|
+
<div className="flex items-center gap-3">
|
|
211
|
+
{/* Auth */}
|
|
212
|
+
{isLoggedIn ? (
|
|
213
|
+
<button
|
|
214
|
+
onClick={logout}
|
|
215
|
+
className="text-muted-foreground hover:text-foreground hidden text-sm transition-colors sm:inline-flex"
|
|
216
|
+
>
|
|
217
|
+
Logout
|
|
218
|
+
</button>
|
|
219
|
+
) : (
|
|
220
|
+
<Link
|
|
221
|
+
href="/login"
|
|
222
|
+
className="text-muted-foreground hover:text-foreground hidden text-sm transition-colors sm:inline-flex"
|
|
223
|
+
>
|
|
224
|
+
Login
|
|
225
|
+
</Link>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* Cart */}
|
|
229
|
+
<Link
|
|
230
|
+
href="/cart"
|
|
231
|
+
className="text-foreground hover:text-primary relative p-2 transition-colors"
|
|
232
|
+
aria-label={`Cart with ${itemCount} items`}
|
|
233
|
+
>
|
|
234
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
235
|
+
<path
|
|
236
|
+
strokeLinecap="round"
|
|
237
|
+
strokeLinejoin="round"
|
|
238
|
+
strokeWidth={2}
|
|
239
|
+
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
|
240
|
+
/>
|
|
241
|
+
</svg>
|
|
242
|
+
{itemCount > 0 && (
|
|
243
|
+
<span className="bg-primary text-primary-foreground absolute -end-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full text-[10px] font-bold">
|
|
244
|
+
{itemCount > 99 ? '99+' : itemCount}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</Link>
|
|
248
|
+
|
|
249
|
+
{/* Mobile menu button */}
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
253
|
+
className="text-foreground p-2 md:hidden"
|
|
254
|
+
aria-label="Menu"
|
|
255
|
+
>
|
|
256
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
257
|
+
{mobileMenuOpen ? (
|
|
258
|
+
<path
|
|
259
|
+
strokeLinecap="round"
|
|
260
|
+
strokeLinejoin="round"
|
|
261
|
+
strokeWidth={2}
|
|
262
|
+
d="M6 18L18 6M6 6l12 12"
|
|
263
|
+
/>
|
|
264
|
+
) : (
|
|
265
|
+
<path
|
|
266
|
+
strokeLinecap="round"
|
|
267
|
+
strokeLinejoin="round"
|
|
268
|
+
strokeWidth={2}
|
|
269
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
272
|
+
</svg>
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Mobile Menu */}
|
|
278
|
+
{mobileMenuOpen && (
|
|
279
|
+
<div className="border-border space-y-2 border-t py-3 md:hidden">
|
|
280
|
+
<Link
|
|
281
|
+
href="/products"
|
|
282
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
283
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
284
|
+
>
|
|
285
|
+
Products
|
|
286
|
+
</Link>
|
|
287
|
+
<Link
|
|
288
|
+
href="/account"
|
|
289
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
290
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
291
|
+
>
|
|
292
|
+
Account
|
|
293
|
+
</Link>
|
|
294
|
+
{isLoggedIn ? (
|
|
295
|
+
<button
|
|
296
|
+
onClick={() => {
|
|
297
|
+
logout();
|
|
298
|
+
setMobileMenuOpen(false);
|
|
299
|
+
}}
|
|
300
|
+
className="text-foreground hover:bg-muted block w-full rounded px-2 py-2 text-start text-sm"
|
|
301
|
+
>
|
|
302
|
+
Logout
|
|
303
|
+
</button>
|
|
304
|
+
) : (
|
|
305
|
+
<Link
|
|
306
|
+
href="/login"
|
|
307
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
308
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
309
|
+
>
|
|
310
|
+
Login
|
|
311
|
+
</Link>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{/* Mobile search */}
|
|
315
|
+
<form onSubmit={handleSearchSubmit} className="px-2 pt-2">
|
|
316
|
+
<input
|
|
317
|
+
type="text"
|
|
318
|
+
value={searchQuery}
|
|
319
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
320
|
+
placeholder="Search products..."
|
|
321
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
322
|
+
/>
|
|
323
|
+
</form>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</header>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { ProductDiscountBadge } from 'brainerce';
|
|
5
|
+
import { getClient } from '@/lib/brainerce';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface DiscountBadgeProps {
|
|
9
|
+
productId: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function DiscountBadge({ productId, className }: DiscountBadgeProps) {
|
|
14
|
+
const [badge, setBadge] = useState<ProductDiscountBadge | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const client = getClient();
|
|
18
|
+
client
|
|
19
|
+
.getProductDiscountBadge(productId)
|
|
20
|
+
.then(setBadge)
|
|
21
|
+
.catch(() => setBadge(null));
|
|
22
|
+
}, [productId]);
|
|
23
|
+
|
|
24
|
+
if (!badge) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<span
|
|
28
|
+
className={cn(
|
|
29
|
+
'bg-destructive text-destructive-foreground inline-flex items-center rounded px-2 py-1 text-xs font-bold',
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{badge.badgeText}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { Product } from 'brainerce';
|
|
6
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
7
|
+
import { PriceDisplay } from '@/components/shared/price-display';
|
|
8
|
+
import { StockBadge } from '@/components/products/stock-badge';
|
|
9
|
+
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface ProductCardProps {
|
|
13
|
+
product: Product;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
18
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
19
|
+
const mainImage = product.images?.[0];
|
|
20
|
+
const imageUrl = mainImage?.url || null;
|
|
21
|
+
const slug = product.slug || product.id;
|
|
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
|
+
{/* Image */}
|
|
32
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
33
|
+
{imageUrl ? (
|
|
34
|
+
<Image
|
|
35
|
+
src={imageUrl}
|
|
36
|
+
alt={mainImage?.alt || product.name}
|
|
37
|
+
fill
|
|
38
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
39
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
43
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
44
|
+
<path
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
strokeWidth={1.5}
|
|
48
|
+
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"
|
|
49
|
+
/>
|
|
50
|
+
</svg>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{/* Badges */}
|
|
55
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
56
|
+
{isOnSale && (
|
|
57
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
58
|
+
Sale
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
<DiscountBadge productId={product.id} />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{/* Content */}
|
|
66
|
+
<div className="space-y-2 p-3">
|
|
67
|
+
{/* Categories */}
|
|
68
|
+
{product.categories && product.categories.length > 0 && (
|
|
69
|
+
<div className="flex flex-wrap gap-1">
|
|
70
|
+
{product.categories.slice(0, 2).map((cat) => (
|
|
71
|
+
<span
|
|
72
|
+
key={cat.id}
|
|
73
|
+
className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
|
|
74
|
+
>
|
|
75
|
+
{cat.name}
|
|
76
|
+
</span>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{/* Name */}
|
|
82
|
+
<h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
83
|
+
{product.name}
|
|
84
|
+
</h3>
|
|
85
|
+
|
|
86
|
+
{/* Price */}
|
|
87
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
88
|
+
|
|
89
|
+
{/* Stock */}
|
|
90
|
+
<StockBadge inventory={product.inventory} />
|
|
91
|
+
</div>
|
|
92
|
+
</Link>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { ProductCard } from '@/components/products/product-card';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface ProductGridProps {
|
|
8
|
+
products: Product[];
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProductGrid({ products, className }: ProductGridProps) {
|
|
13
|
+
if (products.length === 0) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="py-16 text-center">
|
|
16
|
+
<p className="text-muted-foreground text-lg">No products found.</p>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={cn(
|
|
24
|
+
'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
{products.map((product) => (
|
|
29
|
+
<ProductCard key={product.id} product={product} />
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getStockStatus } from 'brainerce';
|
|
4
|
+
import type { InventoryInfo } from 'brainerce';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface StockBadgeProps {
|
|
8
|
+
inventory: InventoryInfo | null | undefined;
|
|
9
|
+
lowStockThreshold?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
14
|
+
const status = getStockStatus(inventory, { lowStockThreshold });
|
|
15
|
+
|
|
16
|
+
const colorClasses =
|
|
17
|
+
status === 'Out of Stock' || status === 'Unavailable'
|
|
18
|
+
? 'bg-red-100 text-red-800'
|
|
19
|
+
: status === 'Low Stock'
|
|
20
|
+
? 'bg-yellow-100 text-yellow-800'
|
|
21
|
+
: 'bg-green-100 text-green-800';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className={cn(
|
|
26
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
27
|
+
colorClasses,
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{status}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Product, ProductVariant } from 'brainerce';
|
|
5
|
+
import { getVariantOptions, getStockStatus, formatPrice } from 'brainerce';
|
|
6
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface VariantSelectorProps {
|
|
10
|
+
product: Product;
|
|
11
|
+
selectedVariant: ProductVariant | null;
|
|
12
|
+
onVariantChange: (variant: ProductVariant) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AttributeGroup {
|
|
17
|
+
name: string;
|
|
18
|
+
values: Array<{
|
|
19
|
+
value: string;
|
|
20
|
+
variants: ProductVariant[];
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function VariantSelector({
|
|
25
|
+
product,
|
|
26
|
+
selectedVariant,
|
|
27
|
+
onVariantChange,
|
|
28
|
+
className,
|
|
29
|
+
}: VariantSelectorProps) {
|
|
30
|
+
const { storeInfo } = useStoreInfo();
|
|
31
|
+
const currency = storeInfo?.currency || 'USD';
|
|
32
|
+
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
33
|
+
|
|
34
|
+
// Build attribute groups from product attribute options or variant data
|
|
35
|
+
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
36
|
+
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
37
|
+
|
|
38
|
+
for (const variant of variants) {
|
|
39
|
+
const options = getVariantOptions(variant);
|
|
40
|
+
for (const { name, value } of options) {
|
|
41
|
+
if (!groups.has(name)) {
|
|
42
|
+
groups.set(name, new Map());
|
|
43
|
+
}
|
|
44
|
+
const valuesMap = groups.get(name)!;
|
|
45
|
+
if (!valuesMap.has(value)) {
|
|
46
|
+
valuesMap.set(value, []);
|
|
47
|
+
}
|
|
48
|
+
valuesMap.get(value)!.push(variant);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => ({
|
|
53
|
+
name,
|
|
54
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => ({
|
|
55
|
+
value,
|
|
56
|
+
variants: variantList,
|
|
57
|
+
})),
|
|
58
|
+
}));
|
|
59
|
+
}, [variants]);
|
|
60
|
+
|
|
61
|
+
// Get currently selected attribute values
|
|
62
|
+
const selectedOptions = useMemo(() => {
|
|
63
|
+
if (!selectedVariant) return new Map<string, string>();
|
|
64
|
+
const opts = getVariantOptions(selectedVariant);
|
|
65
|
+
return new Map(opts.map(({ name, value }) => [name, value]));
|
|
66
|
+
}, [selectedVariant]);
|
|
67
|
+
|
|
68
|
+
// Find the variant that matches all selected attributes
|
|
69
|
+
function findMatchingVariant(
|
|
70
|
+
attributeName: string,
|
|
71
|
+
newValue: string
|
|
72
|
+
): ProductVariant | undefined {
|
|
73
|
+
const nextSelection = new Map(selectedOptions);
|
|
74
|
+
nextSelection.set(attributeName, newValue);
|
|
75
|
+
|
|
76
|
+
return variants.find((v) => {
|
|
77
|
+
const opts = getVariantOptions(v);
|
|
78
|
+
return Array.from(nextSelection.entries()).every(([name, value]) =>
|
|
79
|
+
opts.some((o) => o.name === name && o.value === value)
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (attributeGroups.length === 0) return null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn('space-y-4', className)}>
|
|
88
|
+
{attributeGroups.map((group) => (
|
|
89
|
+
<div key={group.name}>
|
|
90
|
+
<label className="text-foreground mb-2 block text-sm font-medium">
|
|
91
|
+
{group.name}
|
|
92
|
+
{selectedOptions.get(group.name) && (
|
|
93
|
+
<span className="text-muted-foreground ms-1 font-normal">
|
|
94
|
+
: {selectedOptions.get(group.name)}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
</label>
|
|
98
|
+
<div className="flex flex-wrap gap-2">
|
|
99
|
+
{group.values.map(({ value, variants: matchingVariants }) => {
|
|
100
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
101
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
102
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
key={value}
|
|
107
|
+
type="button"
|
|
108
|
+
disabled={!isAvailable}
|
|
109
|
+
onClick={() => {
|
|
110
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
111
|
+
if (variant) onVariantChange(variant);
|
|
112
|
+
}}
|
|
113
|
+
className={cn(
|
|
114
|
+
'rounded border px-4 py-2 text-sm transition-colors',
|
|
115
|
+
isSelected
|
|
116
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
117
|
+
: isAvailable
|
|
118
|
+
? 'border-border bg-background text-foreground hover:border-primary'
|
|
119
|
+
: 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{value}
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
|
|
130
|
+
{/* Variant-specific info */}
|
|
131
|
+
{selectedVariant && (
|
|
132
|
+
<div className="text-muted-foreground flex items-center gap-3 pt-1 text-sm">
|
|
133
|
+
{selectedVariant.price && (
|
|
134
|
+
<span>
|
|
135
|
+
{
|
|
136
|
+
formatPrice(selectedVariant.salePrice || selectedVariant.price, {
|
|
137
|
+
currency,
|
|
138
|
+
}) as string
|
|
139
|
+
}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
<span>{getStockStatus(selectedVariant.inventory)}</span>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|