@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.
@@ -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
+ };