@zevcommerce/theme-starter 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/package.json +27 -0
- package/src/components/ThemeStyles.tsx +92 -0
- package/src/helpers/format-price.ts +50 -0
- package/src/index.ts +26 -0
- package/src/preset.json +126 -0
- package/src/registry.ts +35 -0
- package/src/sections/Announcement.tsx +30 -0
- package/src/sections/CartSection.tsx +156 -0
- package/src/sections/CheckoutSection.tsx +21 -0
- package/src/sections/ContactInfo.tsx +129 -0
- package/src/sections/FeaturedProducts.tsx +114 -0
- package/src/sections/Footer.tsx +167 -0
- package/src/sections/Header.tsx +307 -0
- package/src/sections/Hero.tsx +83 -0
- package/src/sections/ProductDetail.tsx +252 -0
- package/src/sections/ProductList.tsx +76 -0
- package/src/settings.ts +140 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +9 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useTheme, useCartStore, resolveMenuUrl, getStorePermalink, getProducts } from '@zevcommerce/storefront-api';
|
|
6
|
+
import { useRouter, useParams } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
export default function Header() {
|
|
9
|
+
const { theme, storeConfig, menus } = useTheme();
|
|
10
|
+
const { openCart, items } = useCartStore();
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const params = useParams();
|
|
13
|
+
|
|
14
|
+
const header = theme?.settings?.header;
|
|
15
|
+
const logoSrc = header?.logo || storeConfig?.storeLogo;
|
|
16
|
+
const logoHeight = header?.logoHeight || 36;
|
|
17
|
+
const sticky = header?.sticky !== false;
|
|
18
|
+
const showSearch = header?.showSearch !== false;
|
|
19
|
+
const menuHandle = header?.menuHandle || 'main-menu';
|
|
20
|
+
|
|
21
|
+
const domain = (params?.domain as string) || storeConfig?.handle || '';
|
|
22
|
+
const storeName = storeConfig?.name || 'Store';
|
|
23
|
+
|
|
24
|
+
// Resolve menu
|
|
25
|
+
const availableMenus = Object.values(menus || {});
|
|
26
|
+
const defaultMenu = availableMenus.find((m: any) => m.isDefault);
|
|
27
|
+
const activeMenu = (menuHandle && menus?.[menuHandle]) || defaultMenu || availableMenus[0];
|
|
28
|
+
const menuItems = (activeMenu as any)?.items || [];
|
|
29
|
+
|
|
30
|
+
// UI state
|
|
31
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
32
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
33
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
34
|
+
const [suggestions, setSuggestions] = useState<any[]>([]);
|
|
35
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
|
|
37
|
+
const cartCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
38
|
+
|
|
39
|
+
// Lock body scroll
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
document.body.style.overflow = (mobileOpen || searchOpen) ? 'hidden' : '';
|
|
42
|
+
return () => { document.body.style.overflow = ''; };
|
|
43
|
+
}, [mobileOpen, searchOpen]);
|
|
44
|
+
|
|
45
|
+
// Focus search input
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (searchOpen) searchRef.current?.focus();
|
|
48
|
+
}, [searchOpen]);
|
|
49
|
+
|
|
50
|
+
// Escape key handler
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const handler = (e: KeyboardEvent) => {
|
|
53
|
+
if (e.key === 'Escape') {
|
|
54
|
+
setSearchOpen(false);
|
|
55
|
+
setMobileOpen(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
window.addEventListener('keydown', handler);
|
|
59
|
+
return () => window.removeEventListener('keydown', handler);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// Search suggestions
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const timer = setTimeout(async () => {
|
|
65
|
+
if (searchQuery.length > 2) {
|
|
66
|
+
try {
|
|
67
|
+
const { data } = await getProducts(domain, 1, 5, searchQuery);
|
|
68
|
+
setSuggestions(data);
|
|
69
|
+
} catch {
|
|
70
|
+
setSuggestions([]);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
setSuggestions([]);
|
|
74
|
+
}
|
|
75
|
+
}, 300);
|
|
76
|
+
return () => clearTimeout(timer);
|
|
77
|
+
}, [searchQuery, domain]);
|
|
78
|
+
|
|
79
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
if (!searchQuery.trim()) return;
|
|
82
|
+
router.push(getStorePermalink(domain, `/search?q=${encodeURIComponent(searchQuery)}`));
|
|
83
|
+
setSearchOpen(false);
|
|
84
|
+
setSearchQuery('');
|
|
85
|
+
setSuggestions([]);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<header
|
|
91
|
+
style={{
|
|
92
|
+
backgroundColor: 'var(--color-background)',
|
|
93
|
+
borderBottom: '1px solid #e5e7eb',
|
|
94
|
+
position: sticky ? 'sticky' : 'relative',
|
|
95
|
+
top: 0,
|
|
96
|
+
zIndex: 50,
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<div className="container mx-auto px-4 sm:px-6">
|
|
100
|
+
<div className="flex items-center justify-between h-16">
|
|
101
|
+
{/* Logo */}
|
|
102
|
+
<Link href={getStorePermalink(domain, '/')} className="flex-shrink-0">
|
|
103
|
+
{logoSrc ? (
|
|
104
|
+
<img
|
|
105
|
+
src={logoSrc}
|
|
106
|
+
alt={storeName}
|
|
107
|
+
style={{ height: `${logoHeight}px`, width: 'auto', objectFit: 'contain' }}
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<span
|
|
111
|
+
className="text-xl font-bold"
|
|
112
|
+
style={{ color: 'var(--color-text)' }}
|
|
113
|
+
>
|
|
114
|
+
{storeName}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</Link>
|
|
118
|
+
|
|
119
|
+
{/* Desktop Nav */}
|
|
120
|
+
<nav className="hidden md:flex items-center gap-6">
|
|
121
|
+
{menuItems.map((item: any) => (
|
|
122
|
+
<Link
|
|
123
|
+
key={item.id}
|
|
124
|
+
href={resolveMenuUrl(item, domain)}
|
|
125
|
+
className="text-sm font-medium transition-colors hover:opacity-70"
|
|
126
|
+
style={{ color: 'var(--color-text)' }}
|
|
127
|
+
>
|
|
128
|
+
{item.title}
|
|
129
|
+
</Link>
|
|
130
|
+
))}
|
|
131
|
+
</nav>
|
|
132
|
+
|
|
133
|
+
{/* Icons */}
|
|
134
|
+
<div className="flex items-center gap-3">
|
|
135
|
+
{/* Search */}
|
|
136
|
+
{showSearch && (
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => setSearchOpen(true)}
|
|
139
|
+
aria-label="Search"
|
|
140
|
+
className="p-2 transition-opacity hover:opacity-70"
|
|
141
|
+
style={{ color: 'var(--color-text)' }}
|
|
142
|
+
>
|
|
143
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
144
|
+
<circle cx="11" cy="11" r="8" />
|
|
145
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
146
|
+
</svg>
|
|
147
|
+
</button>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Cart */}
|
|
151
|
+
<button
|
|
152
|
+
onClick={openCart}
|
|
153
|
+
aria-label="Cart"
|
|
154
|
+
className="relative p-2 transition-opacity hover:opacity-70"
|
|
155
|
+
style={{ color: 'var(--color-text)' }}
|
|
156
|
+
>
|
|
157
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
158
|
+
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
|
|
159
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
160
|
+
<path d="M16 10a4 4 0 01-8 0" />
|
|
161
|
+
</svg>
|
|
162
|
+
{cartCount > 0 && (
|
|
163
|
+
<span
|
|
164
|
+
className="absolute -top-0.5 -right-0.5 text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center rounded-full px-1"
|
|
165
|
+
style={{ backgroundColor: 'var(--color-primary)', color: '#fff' }}
|
|
166
|
+
>
|
|
167
|
+
{cartCount}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
{/* Mobile hamburger */}
|
|
173
|
+
<button
|
|
174
|
+
onClick={() => setMobileOpen(true)}
|
|
175
|
+
aria-label="Open menu"
|
|
176
|
+
className="md:hidden p-2 transition-opacity hover:opacity-70"
|
|
177
|
+
style={{ color: 'var(--color-text)' }}
|
|
178
|
+
>
|
|
179
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
180
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
181
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
182
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
183
|
+
</svg>
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
{/* Search Overlay */}
|
|
191
|
+
{searchOpen && (
|
|
192
|
+
<div
|
|
193
|
+
className="fixed inset-0 z-[200] flex items-start justify-center pt-24"
|
|
194
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
195
|
+
onClick={(e) => { if (e.target === e.currentTarget) { setSearchOpen(false); setSuggestions([]); } }}
|
|
196
|
+
>
|
|
197
|
+
<div className="w-full max-w-lg mx-4 rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--color-background)' }}>
|
|
198
|
+
<form onSubmit={handleSearch} className="flex items-center px-4 py-3 border-b" style={{ borderColor: '#e5e7eb' }}>
|
|
199
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="mr-3 flex-shrink-0" style={{ color: 'var(--color-text)', opacity: 0.4 }}>
|
|
200
|
+
<circle cx="11" cy="11" r="8" />
|
|
201
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
202
|
+
</svg>
|
|
203
|
+
<input
|
|
204
|
+
ref={searchRef}
|
|
205
|
+
type="text"
|
|
206
|
+
value={searchQuery}
|
|
207
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
208
|
+
placeholder="Search products..."
|
|
209
|
+
className="flex-1 text-sm outline-none bg-transparent"
|
|
210
|
+
style={{ color: 'var(--color-text)' }}
|
|
211
|
+
/>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => { setSearchOpen(false); setSuggestions([]); setSearchQuery(''); }}
|
|
215
|
+
className="p-1 ml-2"
|
|
216
|
+
style={{ color: 'var(--color-text)', opacity: 0.4 }}
|
|
217
|
+
>
|
|
218
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
219
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
220
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
</form>
|
|
224
|
+
{suggestions.length > 0 && (
|
|
225
|
+
<div className="max-h-80 overflow-y-auto">
|
|
226
|
+
{suggestions.map((product) => (
|
|
227
|
+
<Link
|
|
228
|
+
key={product.id}
|
|
229
|
+
href={getStorePermalink(domain, `/products/${product.slug}`)}
|
|
230
|
+
onClick={() => { setSearchOpen(false); setSuggestions([]); setSearchQuery(''); }}
|
|
231
|
+
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-gray-50"
|
|
232
|
+
>
|
|
233
|
+
{product.media?.[0]?.url && (
|
|
234
|
+
<img src={product.media[0].url} alt={product.title} className="w-10 h-10 object-cover rounded" />
|
|
235
|
+
)}
|
|
236
|
+
<div className="flex-1 min-w-0">
|
|
237
|
+
<p className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>{product.title}</p>
|
|
238
|
+
</div>
|
|
239
|
+
</Link>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{/* Mobile Menu */}
|
|
248
|
+
{mobileOpen && (
|
|
249
|
+
<div className="fixed inset-0 z-[200] md:hidden">
|
|
250
|
+
<div
|
|
251
|
+
className="absolute inset-0"
|
|
252
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
253
|
+
onClick={() => setMobileOpen(false)}
|
|
254
|
+
/>
|
|
255
|
+
<div
|
|
256
|
+
className="absolute left-0 top-0 bottom-0 w-[80%] max-w-[320px] flex flex-col"
|
|
257
|
+
style={{ backgroundColor: 'var(--color-background)' }}
|
|
258
|
+
>
|
|
259
|
+
{/* Mobile header */}
|
|
260
|
+
<div className="flex items-center justify-between px-4 py-4 border-b" style={{ borderColor: '#e5e7eb' }}>
|
|
261
|
+
<Link href={getStorePermalink(domain, '/')} onClick={() => setMobileOpen(false)}>
|
|
262
|
+
{logoSrc ? (
|
|
263
|
+
<img src={logoSrc} alt={storeName} style={{ height: '28px', width: 'auto' }} />
|
|
264
|
+
) : (
|
|
265
|
+
<span className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{storeName}</span>
|
|
266
|
+
)}
|
|
267
|
+
</Link>
|
|
268
|
+
<button
|
|
269
|
+
onClick={() => setMobileOpen(false)}
|
|
270
|
+
aria-label="Close menu"
|
|
271
|
+
className="p-1"
|
|
272
|
+
style={{ color: 'var(--color-text)' }}
|
|
273
|
+
>
|
|
274
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
275
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
276
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
277
|
+
</svg>
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{/* Links */}
|
|
282
|
+
<nav className="flex-1 overflow-y-auto px-4 py-4">
|
|
283
|
+
{menuItems.map((item: any) => (
|
|
284
|
+
<Link
|
|
285
|
+
key={item.id}
|
|
286
|
+
href={resolveMenuUrl(item, domain)}
|
|
287
|
+
onClick={() => setMobileOpen(false)}
|
|
288
|
+
className="block py-3 text-sm font-medium border-b transition-opacity hover:opacity-70"
|
|
289
|
+
style={{ color: 'var(--color-text)', borderColor: '#f3f4f6' }}
|
|
290
|
+
>
|
|
291
|
+
{item.title}
|
|
292
|
+
</Link>
|
|
293
|
+
))}
|
|
294
|
+
</nav>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const schema = {
|
|
303
|
+
type: 'header',
|
|
304
|
+
name: 'Header',
|
|
305
|
+
limit: 1,
|
|
306
|
+
settings: [],
|
|
307
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useTheme, getStorePermalink } from '@zevcommerce/storefront-api';
|
|
5
|
+
import { useParams } from 'next/navigation';
|
|
6
|
+
|
|
7
|
+
export default function Hero() {
|
|
8
|
+
const { theme, storeConfig } = useTheme();
|
|
9
|
+
const params = useParams();
|
|
10
|
+
const domain = (params?.domain as string) || storeConfig?.handle || '';
|
|
11
|
+
|
|
12
|
+
const hero = theme?.settings?.hero;
|
|
13
|
+
if (!hero?.enabled) return null;
|
|
14
|
+
|
|
15
|
+
const heading = hero.heading || 'Welcome to our store';
|
|
16
|
+
const subheading = hero.subheading || '';
|
|
17
|
+
const buttonText = hero.buttonText || 'Shop Now';
|
|
18
|
+
const buttonLink = hero.buttonLink || '/collections/all';
|
|
19
|
+
const overlayOpacity = (hero.overlayOpacity ?? 50) / 100;
|
|
20
|
+
const overlayColor = hero.overlayColor || '#000000';
|
|
21
|
+
const textColor = hero.textColor || '#ffffff';
|
|
22
|
+
|
|
23
|
+
const resolveImage = (img: any) => {
|
|
24
|
+
if (!img) return undefined;
|
|
25
|
+
if (typeof img === 'string') return img;
|
|
26
|
+
if (typeof img === 'object' && img.url) return img.url;
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const bgImage = resolveImage(hero.backgroundImage);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section
|
|
34
|
+
className="relative flex items-center justify-center min-h-[60vh] md:min-h-[75vh]"
|
|
35
|
+
style={{
|
|
36
|
+
backgroundImage: bgImage ? `url("${bgImage}")` : undefined,
|
|
37
|
+
backgroundSize: 'cover',
|
|
38
|
+
backgroundPosition: 'center',
|
|
39
|
+
backgroundColor: !bgImage ? '#1f2937' : undefined,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{/* Overlay */}
|
|
43
|
+
<div
|
|
44
|
+
className="absolute inset-0"
|
|
45
|
+
style={{ backgroundColor: overlayColor, opacity: overlayOpacity }}
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
{/* Content */}
|
|
49
|
+
<div className="relative z-10 container mx-auto px-4 sm:px-6 py-16 text-center">
|
|
50
|
+
<h1
|
|
51
|
+
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-4 leading-tight"
|
|
52
|
+
style={{ color: textColor }}
|
|
53
|
+
>
|
|
54
|
+
{heading}
|
|
55
|
+
</h1>
|
|
56
|
+
{subheading && (
|
|
57
|
+
<p
|
|
58
|
+
className="text-base sm:text-lg md:text-xl max-w-2xl mx-auto mb-8 opacity-90"
|
|
59
|
+
style={{ color: textColor }}
|
|
60
|
+
>
|
|
61
|
+
{subheading}
|
|
62
|
+
</p>
|
|
63
|
+
)}
|
|
64
|
+
{buttonText && (
|
|
65
|
+
<Link
|
|
66
|
+
href={getStorePermalink(domain, buttonLink)}
|
|
67
|
+
className="btn-primary inline-flex items-center px-6 py-3 sm:px-8 sm:py-3.5 text-sm sm:text-base font-semibold rounded-lg transition-opacity hover:opacity-90"
|
|
68
|
+
style={{ backgroundColor: 'var(--color-primary)', color: '#ffffff' }}
|
|
69
|
+
>
|
|
70
|
+
{buttonText}
|
|
71
|
+
</Link>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const schema = {
|
|
79
|
+
type: 'hero',
|
|
80
|
+
name: 'Hero Banner',
|
|
81
|
+
limit: 1,
|
|
82
|
+
settings: [],
|
|
83
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useTheme, useProduct, useCartStore, getStorePermalink } from '@zevcommerce/storefront-api';
|
|
6
|
+
import { useParams } from 'next/navigation';
|
|
7
|
+
import { formatPrice } from '../helpers/format-price';
|
|
8
|
+
|
|
9
|
+
export default function ProductDetail() {
|
|
10
|
+
const { theme, storeConfig } = useTheme();
|
|
11
|
+
const { product, selectedVariant, quantity, setQuantity, setSelectedVariant } = useProduct();
|
|
12
|
+
const { addItem, openCart } = useCartStore();
|
|
13
|
+
const params = useParams();
|
|
14
|
+
const domain = (params?.domain as string) || storeConfig?.handle || '';
|
|
15
|
+
const currency = storeConfig?.currency || 'NGN';
|
|
16
|
+
|
|
17
|
+
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
|
18
|
+
const [addedToCart, setAddedToCart] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Reset state when product changes
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setActiveImageIndex(0);
|
|
23
|
+
setAddedToCart(false);
|
|
24
|
+
}, [product?.id]);
|
|
25
|
+
|
|
26
|
+
if (!product) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="py-24 text-center" style={{ backgroundColor: 'var(--color-background)' }}>
|
|
29
|
+
<p className="text-sm" style={{ color: 'var(--color-text)', opacity: 0.5 }}>
|
|
30
|
+
Product not found
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const images = product.media || [];
|
|
37
|
+
const variants = product.variants || [];
|
|
38
|
+
const hasVariants = variants.length > 1;
|
|
39
|
+
|
|
40
|
+
const currentVariant = selectedVariant || variants[0];
|
|
41
|
+
const price = parseFloat(currentVariant?.price || product.price || '0');
|
|
42
|
+
const compareAtPrice = parseFloat(currentVariant?.compareAtPrice || product.compareAtPrice || '0');
|
|
43
|
+
const isOnSale = compareAtPrice > 0 && compareAtPrice > price;
|
|
44
|
+
|
|
45
|
+
// Group variant options
|
|
46
|
+
const optionGroups: Record<string, string[]> = {};
|
|
47
|
+
variants.forEach((v: any) => {
|
|
48
|
+
(v.options || []).forEach((opt: any) => {
|
|
49
|
+
if (!optionGroups[opt.name]) optionGroups[opt.name] = [];
|
|
50
|
+
if (!optionGroups[opt.name].includes(opt.value)) {
|
|
51
|
+
optionGroups[opt.name].push(opt.value);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const handleAddToCart = () => {
|
|
57
|
+
if (!currentVariant) return;
|
|
58
|
+
addItem({
|
|
59
|
+
variantId: currentVariant.id,
|
|
60
|
+
productId: product.id,
|
|
61
|
+
title: product.title,
|
|
62
|
+
variantTitle: currentVariant.title || '',
|
|
63
|
+
price: currentVariant.price,
|
|
64
|
+
quantity,
|
|
65
|
+
image: images[0]?.url || '',
|
|
66
|
+
slug: product.slug,
|
|
67
|
+
});
|
|
68
|
+
setAddedToCart(true);
|
|
69
|
+
openCart();
|
|
70
|
+
setTimeout(() => setAddedToCart(false), 2000);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section className="py-8 md:py-16" style={{ backgroundColor: 'var(--color-background)' }}>
|
|
75
|
+
<div className="container mx-auto px-4 sm:px-6 max-w-6xl">
|
|
76
|
+
{/* Breadcrumb */}
|
|
77
|
+
<nav className="flex items-center gap-2 text-xs mb-8" style={{ color: 'var(--color-text)', opacity: 0.5 }}>
|
|
78
|
+
<Link href={getStorePermalink(domain, '/')} className="hover:opacity-70 transition-opacity">Home</Link>
|
|
79
|
+
<span>/</span>
|
|
80
|
+
<Link href={getStorePermalink(domain, '/collections/all')} className="hover:opacity-70 transition-opacity">Products</Link>
|
|
81
|
+
<span>/</span>
|
|
82
|
+
<span style={{ opacity: 1, color: 'var(--color-text)' }}>{product.title}</span>
|
|
83
|
+
</nav>
|
|
84
|
+
|
|
85
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
|
86
|
+
{/* Image Gallery */}
|
|
87
|
+
<div>
|
|
88
|
+
{/* Main Image */}
|
|
89
|
+
{images.length > 0 && (
|
|
90
|
+
<div className="relative aspect-square mb-3 rounded-lg overflow-hidden" style={{ backgroundColor: '#f3f4f6' }}>
|
|
91
|
+
<img
|
|
92
|
+
src={images[activeImageIndex]?.url}
|
|
93
|
+
alt={product.title}
|
|
94
|
+
className="w-full h-full object-cover"
|
|
95
|
+
/>
|
|
96
|
+
{isOnSale && (
|
|
97
|
+
<span
|
|
98
|
+
className="absolute top-3 left-3 text-xs font-semibold px-2.5 py-1 rounded"
|
|
99
|
+
style={{ backgroundColor: '#ef4444', color: '#fff' }}
|
|
100
|
+
>
|
|
101
|
+
Sale
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* Thumbnails */}
|
|
108
|
+
{images.length > 1 && (
|
|
109
|
+
<div className="flex gap-2 overflow-x-auto">
|
|
110
|
+
{images.map((img: any, i: number) => (
|
|
111
|
+
<button
|
|
112
|
+
key={i}
|
|
113
|
+
onClick={() => setActiveImageIndex(i)}
|
|
114
|
+
className="flex-shrink-0 w-16 h-16 rounded overflow-hidden border-2 transition-colors"
|
|
115
|
+
style={{
|
|
116
|
+
borderColor: i === activeImageIndex ? 'var(--color-primary)' : 'transparent',
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<img src={img.url} alt="" className="w-full h-full object-cover" />
|
|
120
|
+
</button>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Product Info */}
|
|
127
|
+
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
128
|
+
{/* Vendor */}
|
|
129
|
+
{product.vendor && (
|
|
130
|
+
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--color-primary)' }}>
|
|
131
|
+
{product.vendor}
|
|
132
|
+
</p>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* Title */}
|
|
136
|
+
<h1
|
|
137
|
+
className="text-2xl md:text-3xl font-bold mb-3"
|
|
138
|
+
style={{ color: 'var(--color-text)' }}
|
|
139
|
+
>
|
|
140
|
+
{product.title}
|
|
141
|
+
</h1>
|
|
142
|
+
|
|
143
|
+
{/* Price */}
|
|
144
|
+
<div className="flex items-center gap-3 mb-6">
|
|
145
|
+
<span className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>
|
|
146
|
+
{formatPrice(price, currency)}
|
|
147
|
+
</span>
|
|
148
|
+
{isOnSale && (
|
|
149
|
+
<span className="text-base line-through" style={{ color: 'var(--color-text)', opacity: 0.4 }}>
|
|
150
|
+
{formatPrice(compareAtPrice, currency)}
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Variant Selectors */}
|
|
156
|
+
{hasVariants && Object.entries(optionGroups).map(([optionName, values]) => {
|
|
157
|
+
const currentValue = currentVariant?.options?.find((o: any) => o.name === optionName)?.value;
|
|
158
|
+
return (
|
|
159
|
+
<div key={optionName} className="mb-5">
|
|
160
|
+
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text)' }}>
|
|
161
|
+
{optionName}: <span className="font-normal">{currentValue}</span>
|
|
162
|
+
</label>
|
|
163
|
+
<div className="flex flex-wrap gap-2">
|
|
164
|
+
{values.map((val) => {
|
|
165
|
+
const isSelected = currentValue === val;
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
key={val}
|
|
169
|
+
onClick={() => {
|
|
170
|
+
const match = variants.find((v: any) =>
|
|
171
|
+
v.options?.some((o: any) => o.name === optionName && o.value === val)
|
|
172
|
+
);
|
|
173
|
+
if (match) setSelectedVariant(match);
|
|
174
|
+
}}
|
|
175
|
+
className="px-4 py-2 text-sm border rounded transition-colors"
|
|
176
|
+
style={{
|
|
177
|
+
backgroundColor: isSelected ? 'var(--color-primary)' : 'transparent',
|
|
178
|
+
color: isSelected ? '#fff' : 'var(--color-text)',
|
|
179
|
+
borderColor: isSelected ? 'var(--color-primary)' : '#d1d5db',
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{val}
|
|
183
|
+
</button>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
|
|
191
|
+
{/* Quantity */}
|
|
192
|
+
<div className="mb-6">
|
|
193
|
+
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text)' }}>
|
|
194
|
+
Quantity
|
|
195
|
+
</label>
|
|
196
|
+
<div className="inline-flex items-center border rounded" style={{ borderColor: '#d1d5db' }}>
|
|
197
|
+
<button
|
|
198
|
+
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
199
|
+
className="px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
|
|
200
|
+
style={{ color: 'var(--color-text)' }}
|
|
201
|
+
>
|
|
202
|
+
-
|
|
203
|
+
</button>
|
|
204
|
+
<span className="px-4 py-2 text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
|
205
|
+
{quantity}
|
|
206
|
+
</span>
|
|
207
|
+
<button
|
|
208
|
+
onClick={() => setQuantity(quantity + 1)}
|
|
209
|
+
className="px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
|
|
210
|
+
style={{ color: 'var(--color-text)' }}
|
|
211
|
+
>
|
|
212
|
+
+
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Add to Cart */}
|
|
218
|
+
<button
|
|
219
|
+
onClick={handleAddToCart}
|
|
220
|
+
className="w-full py-3.5 text-sm font-semibold rounded-lg transition-opacity hover:opacity-90"
|
|
221
|
+
style={{ backgroundColor: 'var(--color-primary)', color: '#fff' }}
|
|
222
|
+
>
|
|
223
|
+
{addedToCart ? 'Added!' : 'Add to Cart'}
|
|
224
|
+
</button>
|
|
225
|
+
|
|
226
|
+
{/* Description */}
|
|
227
|
+
{product.description && (
|
|
228
|
+
<div className="mt-8 pt-8 border-t" style={{ borderColor: '#e5e7eb' }}>
|
|
229
|
+
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
|
|
230
|
+
Description
|
|
231
|
+
</h3>
|
|
232
|
+
<div
|
|
233
|
+
className="text-sm leading-relaxed prose prose-sm max-w-none"
|
|
234
|
+
style={{ color: 'var(--color-text)', opacity: 0.7 }}
|
|
235
|
+
dangerouslySetInnerHTML={{ __html: product.description }}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</section>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const schema = {
|
|
247
|
+
type: 'product-detail',
|
|
248
|
+
name: 'Product Detail',
|
|
249
|
+
settings: [],
|
|
250
|
+
disabled_on: { templates: ['*'] },
|
|
251
|
+
enabled_on: { templates: ['product_detail'] },
|
|
252
|
+
};
|