create-ecom-app 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/index.js +101 -0
- package/package.json +25 -0
- package/templates/ecommerce/app/about/page.tsx +152 -0
- package/templates/ecommerce/app/cart/page.tsx +103 -0
- package/templates/ecommerce/app/checkout/page.tsx +126 -0
- package/templates/ecommerce/app/contact/page.tsx +124 -0
- package/templates/ecommerce/app/globals.css +137 -0
- package/templates/ecommerce/app/layout.tsx +31 -0
- package/templates/ecommerce/app/login/page.tsx +224 -0
- package/templates/ecommerce/app/not-found.tsx +14 -0
- package/templates/ecommerce/app/orders/[id]/page.tsx +138 -0
- package/templates/ecommerce/app/page.tsx +215 -0
- package/templates/ecommerce/app/product/[id]/page.tsx +417 -0
- package/templates/ecommerce/app/products/page.tsx +244 -0
- package/templates/ecommerce/app/profile/page.tsx +273 -0
- package/templates/ecommerce/app/register/page.tsx +221 -0
- package/templates/ecommerce/app/wishlist/page.tsx +89 -0
- package/templates/ecommerce/components/CartItem.tsx +74 -0
- package/templates/ecommerce/components/CheckoutForm.tsx +109 -0
- package/templates/ecommerce/components/Filters.tsx +142 -0
- package/templates/ecommerce/components/Footer.tsx +100 -0
- package/templates/ecommerce/components/Header.tsx +194 -0
- package/templates/ecommerce/components/ProductCard.tsx +158 -0
- package/templates/ecommerce/components/SearchBar.tsx +41 -0
- package/templates/ecommerce/data/products.json +427 -0
- package/templates/ecommerce/lib/localStorage.ts +27 -0
- package/templates/ecommerce/lib/utils.ts +25 -0
- package/templates/ecommerce/next-env.d.ts +5 -0
- package/templates/ecommerce/next.config.js +13 -0
- package/templates/ecommerce/next.config.ts +14 -0
- package/templates/ecommerce/package.json +30 -0
- package/templates/ecommerce/postcss.config.js +7 -0
- package/templates/ecommerce/public/images/about-2.jpg +0 -0
- package/templates/ecommerce/public/images/contact-bg.jpg +0 -0
- package/templates/ecommerce/public/images/hero-bg.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-1.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-10.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-11.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-12.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-13.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-14.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-15.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-16.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-17.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-18.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-19.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-2.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-20.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-21.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-22.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-23.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-24.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-25.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-3.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-4.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-5.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-6.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-7.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-8.jpg +0 -0
- package/templates/ecommerce/public/images/products/product-9.jpg +0 -0
- package/templates/ecommerce/public/service-worker.js +1 -0
- package/templates/ecommerce/store/useAuthStore.ts +56 -0
- package/templates/ecommerce/store/useCartStore.ts +63 -0
- package/templates/ecommerce/store/useOrderStore.ts +43 -0
- package/templates/ecommerce/store/useReviewStore.ts +60 -0
- package/templates/ecommerce/store/useWishlistStore.ts +43 -0
- package/templates/ecommerce/tailwind.config.ts +44 -0
- package/templates/ecommerce/tsconfig.json +22 -0
- package/templates/ecommerce/types/index.ts +69 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { Eye, EyeOff, Zap, ShoppingBag, ArrowRight, Mail, Lock, User } from 'lucide-react';
|
|
7
|
+
import { useAuthStore } from '@/store/useAuthStore';
|
|
8
|
+
|
|
9
|
+
// ⚠️ Defined outside to prevent focus loss bug
|
|
10
|
+
interface InputFieldProps {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
type: string;
|
|
14
|
+
value: string;
|
|
15
|
+
onChange: (v: string) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
icon: React.ElementType;
|
|
18
|
+
suffix?: React.ReactNode;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
function InputField({ id, label, type, value, onChange, placeholder, icon: Icon, suffix, error }: InputFieldProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>
|
|
25
|
+
<div className="relative">
|
|
26
|
+
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
|
27
|
+
<Icon className="w-4 h-4 text-gray-400" />
|
|
28
|
+
</div>
|
|
29
|
+
<input
|
|
30
|
+
id={id}
|
|
31
|
+
type={type}
|
|
32
|
+
value={value}
|
|
33
|
+
onChange={(e) => onChange(e.target.value)}
|
|
34
|
+
placeholder={placeholder}
|
|
35
|
+
className={`input pl-10 pr-10 ${error ? 'border-red-400' : ''}`}
|
|
36
|
+
required
|
|
37
|
+
/>
|
|
38
|
+
{suffix && <div className="absolute inset-y-0 right-3 flex items-center">{suffix}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function RegisterPage() {
|
|
46
|
+
const router = useRouter();
|
|
47
|
+
const { register } = useAuthStore();
|
|
48
|
+
const [name, setName] = useState('');
|
|
49
|
+
const [email, setEmail] = useState('');
|
|
50
|
+
const [password, setPassword] = useState('');
|
|
51
|
+
const [showPass, setShowPass] = useState(false);
|
|
52
|
+
const [error, setError] = useState('');
|
|
53
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
54
|
+
|
|
55
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
setError('');
|
|
58
|
+
if (password.length < 6) { setError('Password must be at least 6 characters.'); return; }
|
|
59
|
+
setIsLoading(true);
|
|
60
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
61
|
+
register(name, email, password);
|
|
62
|
+
const authed = useAuthStore.getState().isAuthenticated;
|
|
63
|
+
setIsLoading(false);
|
|
64
|
+
if (authed) {
|
|
65
|
+
router.push('/');
|
|
66
|
+
} else {
|
|
67
|
+
setError('An account with this email already exists.');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="min-h-screen flex">
|
|
73
|
+
{/* ── LEFT PANEL ── */}
|
|
74
|
+
<div className="hidden lg:flex lg:w-1/2 text-white flex-col justify-between p-12 relative overflow-hidden" style={{ background: 'linear-gradient(160deg, #020617 0%, #0f172a 45%, #1e3a8a 100%)' }}>
|
|
75
|
+
|
|
76
|
+
{/* Blue glow spots */}
|
|
77
|
+
<div className="absolute inset-0 pointer-events-none" style={{
|
|
78
|
+
backgroundImage: `
|
|
79
|
+
radial-gradient(circle at 15% 70%, rgba(59,130,246,0.2) 0%, transparent 50%),
|
|
80
|
+
radial-gradient(circle at 85% 20%, rgba(96,165,250,0.15) 0%, transparent 45%),
|
|
81
|
+
radial-gradient(circle at 50% 95%, rgba(37,99,235,0.18) 0%, transparent 40%)
|
|
82
|
+
`,
|
|
83
|
+
}} />
|
|
84
|
+
|
|
85
|
+
{/* Floating Bubbles */}
|
|
86
|
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
87
|
+
{[
|
|
88
|
+
{ w: 32, h: 32, left: '10%', dur: '8s', delay: '0s', color: 'rgba(59,130,246,0.15)', border: 'rgba(59,130,246,0.3)' },
|
|
89
|
+
{ w: 18, h: 18, left: '25%', dur: '6s', delay: '2s', color: 'rgba(96,165,250,0.15)', border: 'rgba(96,165,250,0.35)' },
|
|
90
|
+
{ w: 44, h: 44, left: '45%', dur: '10s', delay: '0.8s', color: 'rgba(37,99,235,0.1)', border: 'rgba(37,99,235,0.2)' },
|
|
91
|
+
{ w: 22, h: 22, left: '65%', dur: '7s', delay: '3.5s', color: 'rgba(59,130,246,0.15)', border: 'rgba(59,130,246,0.35)'},
|
|
92
|
+
{ w: 14, h: 14, left: '80%', dur: '5s', delay: '1s', color: 'rgba(96,165,250,0.2)', border: 'rgba(96,165,250,0.4)' },
|
|
93
|
+
{ w: 36, h: 36, left: '55%', dur: '9s', delay: '4.5s', color: 'rgba(59,130,246,0.1)', border: 'rgba(59,130,246,0.25)' },
|
|
94
|
+
].map((b, i) => (
|
|
95
|
+
<div
|
|
96
|
+
key={i}
|
|
97
|
+
className="bubble"
|
|
98
|
+
style={{
|
|
99
|
+
width: b.w,
|
|
100
|
+
height: b.h,
|
|
101
|
+
left: b.left,
|
|
102
|
+
background: b.color,
|
|
103
|
+
border: `1px solid ${b.border}`,
|
|
104
|
+
backdropFilter: 'blur(2px)',
|
|
105
|
+
animationDuration: `${b.dur}, ${parseFloat(b.dur) * 1.4}s`,
|
|
106
|
+
animationDelay: `${b.delay}, ${b.delay}`,
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Logo */}
|
|
113
|
+
<div className="relative flex items-center gap-2">
|
|
114
|
+
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #3b82f6, #2563eb)' }}>
|
|
115
|
+
<Zap className="w-5 h-5 text-white" />
|
|
116
|
+
</div>
|
|
117
|
+
<span className="text-xl font-black tracking-tight">GenZ Shop</span>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Center content */}
|
|
121
|
+
<div className="relative">
|
|
122
|
+
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mb-8" style={{ background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.3)' }}>
|
|
123
|
+
<ShoppingBag className="w-10 h-10 text-blue-400" />
|
|
124
|
+
</div>
|
|
125
|
+
<h2 className="text-4xl font-black leading-tight mb-4">
|
|
126
|
+
Join the<br />
|
|
127
|
+
<span className="text-transparent bg-clip-text" style={{ backgroundImage: 'linear-gradient(90deg, #93c5fd, #3b82f6, #60a5fa)' }}>
|
|
128
|
+
Movement.
|
|
129
|
+
</span>
|
|
130
|
+
</h2>
|
|
131
|
+
<p className="text-blue-100/80 leading-relaxed text-lg">
|
|
132
|
+
Create your account and get exclusive deals, early access to drops, and free shipping on your first order.
|
|
133
|
+
</p>
|
|
134
|
+
|
|
135
|
+
<div className="mt-10 space-y-3">
|
|
136
|
+
{['✓ Free shipping on first order', '✓ Exclusive member deals', '✓ Easy 30-day returns'].map((perk) => (
|
|
137
|
+
<div key={perk} className="flex items-center gap-3 rounded-xl px-4 py-2.5" style={{ background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.25)' }}>
|
|
138
|
+
<span className="text-sm font-medium text-white">{perk}</span>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<p className="relative text-blue-200/50 text-sm">© 2024 GenZ Shop</p>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* ── RIGHT PANEL — form ── */}
|
|
148
|
+
<div className="w-full lg:w-1/2 flex flex-col items-center justify-center p-8 bg-white">
|
|
149
|
+
<div className="w-full max-w-md">
|
|
150
|
+
{/* Mobile logo */}
|
|
151
|
+
<div className="flex items-center gap-2 mb-10 lg:hidden">
|
|
152
|
+
<div className="w-8 h-8 bg-gray-900 rounded-lg flex items-center justify-center">
|
|
153
|
+
<Zap className="w-4 h-4 text-white" />
|
|
154
|
+
</div>
|
|
155
|
+
<span className="text-lg font-black text-gray-900">GenZ Shop</span>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="mb-8">
|
|
159
|
+
<h1 className="text-3xl font-black text-gray-900 mb-1">Create Account</h1>
|
|
160
|
+
<p className="text-gray-500">Join thousands of GenZ shoppers today</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
164
|
+
<InputField id="reg-name" label="Full Name" type="text" value={name} onChange={setName} placeholder="Your name" icon={User} />
|
|
165
|
+
<InputField id="reg-email" label="Email Address" type="email" value={email} onChange={setEmail} placeholder="you@example.com" icon={Mail} />
|
|
166
|
+
<InputField
|
|
167
|
+
id="reg-password"
|
|
168
|
+
label="Password"
|
|
169
|
+
type={showPass ? 'text' : 'password'}
|
|
170
|
+
value={password}
|
|
171
|
+
onChange={setPassword}
|
|
172
|
+
placeholder="Min. 6 characters"
|
|
173
|
+
icon={Lock}
|
|
174
|
+
suffix={
|
|
175
|
+
<button type="button" onClick={() => setShowPass(!showPass)} className="text-gray-400 hover:text-gray-700 transition-colors">
|
|
176
|
+
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
177
|
+
</button>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
{error && (
|
|
182
|
+
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3">
|
|
183
|
+
<p className="text-sm text-red-600">{error}</p>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
<button
|
|
188
|
+
type="submit"
|
|
189
|
+
disabled={isLoading}
|
|
190
|
+
className="w-full btn-primary py-3.5 text-base flex items-center justify-center gap-2 mt-2"
|
|
191
|
+
id="register-submit-btn"
|
|
192
|
+
>
|
|
193
|
+
{isLoading ? (
|
|
194
|
+
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
195
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
196
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
197
|
+
</svg>
|
|
198
|
+
) : (
|
|
199
|
+
<>Create Account <ArrowRight className="w-4 h-4" /></>
|
|
200
|
+
)}
|
|
201
|
+
</button>
|
|
202
|
+
|
|
203
|
+
<p className="text-xs text-gray-400 text-center">
|
|
204
|
+
By registering, you agree to our{' '}
|
|
205
|
+
<Link href="#" className="underline hover:text-gray-700">Terms & Conditions</Link>
|
|
206
|
+
</p>
|
|
207
|
+
</form>
|
|
208
|
+
|
|
209
|
+
<div className="mt-6 text-center">
|
|
210
|
+
<p className="text-gray-500 text-sm">
|
|
211
|
+
Already have an account?{' '}
|
|
212
|
+
<Link href="/login" className="font-bold text-gray-900 hover:underline">
|
|
213
|
+
Sign In
|
|
214
|
+
</Link>
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { Heart, X, ShoppingCart, ArrowRight } from 'lucide-react';
|
|
6
|
+
import { useWishlistStore } from '@/store/useWishlistStore';
|
|
7
|
+
import { useCartStore } from '@/store/useCartStore';
|
|
8
|
+
import { formatPrice } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
export default function WishlistPage() {
|
|
11
|
+
const { items, removeItem } = useWishlistStore();
|
|
12
|
+
const addItem = useCartStore((s) => s.addItem);
|
|
13
|
+
|
|
14
|
+
const handleMoveToCart = (productId: string) => {
|
|
15
|
+
const product = items.find((p) => p.id === productId);
|
|
16
|
+
if (product) {
|
|
17
|
+
addItem(product);
|
|
18
|
+
removeItem(productId);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (items.length === 0) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="container-main py-24 text-center animate-fade-in">
|
|
25
|
+
<div className="w-24 h-24 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
26
|
+
<Heart className="w-12 h-12 text-red-400" />
|
|
27
|
+
</div>
|
|
28
|
+
<h1 className="page-title">Your wishlist is empty</h1>
|
|
29
|
+
<p className="text-gray-500 mb-8">Save items you love and come back to them later.</p>
|
|
30
|
+
<Link href="/products" className="btn-primary inline-flex items-center gap-2">
|
|
31
|
+
Browse Products <ArrowRight className="w-4 h-4" />
|
|
32
|
+
</Link>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="container-main py-10 animate-fade-in">
|
|
39
|
+
<div className="mb-8">
|
|
40
|
+
<h1 className="page-title">My Wishlist</h1>
|
|
41
|
+
<p className="text-gray-500">{items.length} saved {items.length === 1 ? 'item' : 'items'}</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
45
|
+
{items.map((product) => (
|
|
46
|
+
<div key={product.id} className="card overflow-hidden group">
|
|
47
|
+
<Link href={`/product/${product.id}`}>
|
|
48
|
+
<div className="relative aspect-square overflow-hidden bg-gray-100">
|
|
49
|
+
<Image
|
|
50
|
+
src={product.image}
|
|
51
|
+
alt={product.name}
|
|
52
|
+
fill
|
|
53
|
+
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
|
54
|
+
sizes="(max-width: 640px) 100vw, 25vw"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</Link>
|
|
58
|
+
|
|
59
|
+
<div className="p-4">
|
|
60
|
+
<p className="text-xs text-gray-400 uppercase tracking-wider">{product.category}</p>
|
|
61
|
+
<h3 className="font-semibold text-gray-900 text-sm mt-1 line-clamp-2">{product.name}</h3>
|
|
62
|
+
<p className="font-bold text-gray-900 mt-2">{formatPrice(product.price)}</p>
|
|
63
|
+
|
|
64
|
+
<div className="flex gap-2 mt-4">
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => handleMoveToCart(product.id)}
|
|
67
|
+
disabled={!product.inStock}
|
|
68
|
+
className="flex-1 flex items-center justify-center gap-1.5 bg-gray-900 text-white text-sm py-2 rounded-lg hover:bg-gray-700 transition-colors active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
69
|
+
id={`move-to-cart-${product.id}`}
|
|
70
|
+
>
|
|
71
|
+
<ShoppingCart className="w-3.5 h-3.5" />
|
|
72
|
+
{product.inStock ? 'Add to Cart' : 'Out of Stock'}
|
|
73
|
+
</button>
|
|
74
|
+
<button
|
|
75
|
+
onClick={() => removeItem(product.id)}
|
|
76
|
+
className="p-2 text-red-400 hover:bg-red-50 rounded-lg transition-colors"
|
|
77
|
+
aria-label="Remove from wishlist"
|
|
78
|
+
id={`remove-wishlist-${product.id}`}
|
|
79
|
+
>
|
|
80
|
+
<X className="w-4 h-4" />
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import { useCartStore } from '@/store/useCartStore';
|
|
5
|
+
import type { CartItem as CartItemType } from '@/types';
|
|
6
|
+
import { formatPrice } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface CartItemProps {
|
|
9
|
+
item: CartItemType;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function CartItem({ item }: CartItemProps) {
|
|
13
|
+
const { removeItem, updateQuantity } = useCartStore();
|
|
14
|
+
const { product, quantity } = item;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex gap-4 p-4 bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow">
|
|
18
|
+
{/* Image */}
|
|
19
|
+
<div className="relative w-20 h-20 flex-shrink-0 rounded-xl overflow-hidden bg-gray-100">
|
|
20
|
+
<Image
|
|
21
|
+
src={product.image}
|
|
22
|
+
alt={product.name}
|
|
23
|
+
fill
|
|
24
|
+
className="object-cover"
|
|
25
|
+
sizes="80px"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
{/* Details */}
|
|
30
|
+
<div className="flex-1 min-w-0">
|
|
31
|
+
<p className="text-xs text-gray-400 uppercase tracking-wider">{product.category}</p>
|
|
32
|
+
<h3 className="font-semibold text-gray-900 text-sm mt-0.5 line-clamp-2">{product.name}</h3>
|
|
33
|
+
<p className="text-sm font-bold text-gray-900 mt-1">{formatPrice(product.price)}</p>
|
|
34
|
+
|
|
35
|
+
{/* Quantity Controls */}
|
|
36
|
+
<div className="flex items-center gap-3 mt-3">
|
|
37
|
+
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden">
|
|
38
|
+
<button
|
|
39
|
+
onClick={() => updateQuantity(product.id, quantity - 1)}
|
|
40
|
+
className="px-3 py-1.5 text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium"
|
|
41
|
+
aria-label="Decrease quantity"
|
|
42
|
+
>
|
|
43
|
+
−
|
|
44
|
+
</button>
|
|
45
|
+
<span className="px-3 py-1.5 text-sm font-semibold text-gray-900 min-w-[2rem] text-center">
|
|
46
|
+
{quantity}
|
|
47
|
+
</span>
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => updateQuantity(product.id, quantity + 1)}
|
|
50
|
+
className="px-3 py-1.5 text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium"
|
|
51
|
+
aria-label="Increase quantity"
|
|
52
|
+
>
|
|
53
|
+
+
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => removeItem(product.id)}
|
|
59
|
+
className="text-xs text-red-400 hover:text-red-500 font-medium transition-colors"
|
|
60
|
+
>
|
|
61
|
+
Remove
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Item Total */}
|
|
67
|
+
<div className="flex-shrink-0 text-right">
|
|
68
|
+
<p className="font-bold text-gray-900">
|
|
69
|
+
{formatPrice(product.price * quantity)}
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Loader2 } from 'lucide-react';
|
|
5
|
+
import type { Address } from '@/types';
|
|
6
|
+
|
|
7
|
+
// ⚠️ Fix: FormField defined OUTSIDE parent component
|
|
8
|
+
// → prevents re-mounting on every keystroke → no focus loss!
|
|
9
|
+
interface FieldProps {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
type?: string;
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string) => void;
|
|
15
|
+
error?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function FormField({ id, label, type = 'text', value, onChange, error, placeholder }: FieldProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
23
|
+
{label}
|
|
24
|
+
</label>
|
|
25
|
+
<input
|
|
26
|
+
id={id}
|
|
27
|
+
type={type}
|
|
28
|
+
value={value}
|
|
29
|
+
onChange={(e) => onChange(e.target.value)}
|
|
30
|
+
placeholder={placeholder}
|
|
31
|
+
className={`input ${error ? 'border-red-400 focus:ring-red-400' : ''}`}
|
|
32
|
+
/>
|
|
33
|
+
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CheckoutFormProps {
|
|
39
|
+
onSubmit: (address: Address) => void;
|
|
40
|
+
isLoading?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function CheckoutForm({ onSubmit, isLoading }: CheckoutFormProps) {
|
|
44
|
+
const [form, setForm] = useState<Address>({
|
|
45
|
+
name: '',
|
|
46
|
+
email: '',
|
|
47
|
+
phone: '',
|
|
48
|
+
address: '',
|
|
49
|
+
city: '',
|
|
50
|
+
state: '',
|
|
51
|
+
pincode: '',
|
|
52
|
+
});
|
|
53
|
+
const [errors, setErrors] = useState<Partial<Address>>({});
|
|
54
|
+
|
|
55
|
+
const validate = (): boolean => {
|
|
56
|
+
const newErrors: Partial<Address> = {};
|
|
57
|
+
if (!form.name.trim()) newErrors.name = 'Name is required';
|
|
58
|
+
if (!form.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) newErrors.email = 'Valid email required';
|
|
59
|
+
if (!form.phone.match(/^\d{10}$/)) newErrors.phone = '10-digit phone required';
|
|
60
|
+
if (!form.address.trim()) newErrors.address = 'Address is required';
|
|
61
|
+
if (!form.city.trim()) newErrors.city = 'City is required';
|
|
62
|
+
if (!form.state.trim()) newErrors.state = 'State is required';
|
|
63
|
+
if (!form.pincode.match(/^\d{6}$/)) newErrors.pincode = '6-digit pincode required';
|
|
64
|
+
setErrors(newErrors);
|
|
65
|
+
return Object.keys(newErrors).length === 0;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleChange = (field: keyof Address, value: string) => {
|
|
69
|
+
setForm((prev) => ({ ...prev, [field]: value }));
|
|
70
|
+
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined }));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
if (validate()) onSubmit(form);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
80
|
+
<FormField id="checkout-name" label="Full Name" value={form.name} onChange={(v) => handleChange('name', v)} error={errors.name} placeholder="John Doe" />
|
|
81
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
82
|
+
<FormField id="checkout-email" label="Email" type="email" value={form.email} onChange={(v) => handleChange('email', v)} error={errors.email} placeholder="john@example.com" />
|
|
83
|
+
<FormField id="checkout-phone" label="Phone" type="tel" value={form.phone} onChange={(v) => handleChange('phone', v)} error={errors.phone} placeholder="9876543210" />
|
|
84
|
+
</div>
|
|
85
|
+
<FormField id="checkout-address" label="Delivery Address" value={form.address} onChange={(v) => handleChange('address', v)} error={errors.address} placeholder="House No, Street, Landmark" />
|
|
86
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
87
|
+
<FormField id="checkout-city" label="City" value={form.city} onChange={(v) => handleChange('city', v)} error={errors.city} placeholder="Chennai" />
|
|
88
|
+
<FormField id="checkout-state" label="State" value={form.state} onChange={(v) => handleChange('state', v)} error={errors.state} placeholder="Tamil Nadu" />
|
|
89
|
+
<FormField id="checkout-pincode" label="Pincode" value={form.pincode} onChange={(v) => handleChange('pincode', v)} error={errors.pincode} placeholder="600001" />
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<button
|
|
93
|
+
type="submit"
|
|
94
|
+
disabled={isLoading}
|
|
95
|
+
className="btn-primary w-full flex items-center justify-center gap-2 py-3.5 text-base"
|
|
96
|
+
id="place-order-btn"
|
|
97
|
+
>
|
|
98
|
+
{isLoading ? (
|
|
99
|
+
<>
|
|
100
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
101
|
+
Placing order...
|
|
102
|
+
</>
|
|
103
|
+
) : (
|
|
104
|
+
'🛍️ Place Order'
|
|
105
|
+
)}
|
|
106
|
+
</button>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Star, RotateCcw } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const CATEGORIES = ['All', 'Clothing', 'Footwear', 'Electronics', 'Accessories', 'Lifestyle'];
|
|
6
|
+
|
|
7
|
+
interface FiltersProps {
|
|
8
|
+
selectedCategory: string;
|
|
9
|
+
onCategoryChange: (cat: string) => void;
|
|
10
|
+
minPrice: number;
|
|
11
|
+
maxPrice: number;
|
|
12
|
+
priceRange: [number, number];
|
|
13
|
+
onPriceChange: (range: [number, number]) => void;
|
|
14
|
+
minRating: number;
|
|
15
|
+
onRatingChange: (rating: number) => void;
|
|
16
|
+
onReset: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function Filters({
|
|
20
|
+
selectedCategory,
|
|
21
|
+
onCategoryChange,
|
|
22
|
+
minPrice,
|
|
23
|
+
maxPrice,
|
|
24
|
+
priceRange,
|
|
25
|
+
onPriceChange,
|
|
26
|
+
minRating,
|
|
27
|
+
onRatingChange,
|
|
28
|
+
onReset,
|
|
29
|
+
}: FiltersProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="bg-white rounded-2xl shadow-sm p-5 space-y-6">
|
|
32
|
+
<div className="flex items-center justify-between">
|
|
33
|
+
<h3 className="font-semibold text-gray-900">Filters</h3>
|
|
34
|
+
<button
|
|
35
|
+
onClick={onReset}
|
|
36
|
+
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-700 transition-colors font-medium"
|
|
37
|
+
>
|
|
38
|
+
<RotateCcw className="w-3 h-3" />
|
|
39
|
+
Reset
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{/* Category */}
|
|
44
|
+
<div>
|
|
45
|
+
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Category</p>
|
|
46
|
+
<div className="flex flex-col gap-1">
|
|
47
|
+
{CATEGORIES.map((cat) => (
|
|
48
|
+
<button
|
|
49
|
+
key={cat}
|
|
50
|
+
onClick={() => onCategoryChange(cat)}
|
|
51
|
+
className={`text-left px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200
|
|
52
|
+
${selectedCategory === cat
|
|
53
|
+
? 'bg-gray-900 text-white'
|
|
54
|
+
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
{cat}
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Price Range */}
|
|
64
|
+
<div>
|
|
65
|
+
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Price Range</p>
|
|
66
|
+
<div className="space-y-3">
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<div className="flex-1">
|
|
69
|
+
<label className="text-xs text-gray-400 mb-1 block">Min (₹)</label>
|
|
70
|
+
<input
|
|
71
|
+
type="number"
|
|
72
|
+
value={priceRange[0]}
|
|
73
|
+
min={minPrice}
|
|
74
|
+
max={priceRange[1] - 1}
|
|
75
|
+
onChange={(e) => onPriceChange([Number(e.target.value), priceRange[1]])}
|
|
76
|
+
className="input text-sm"
|
|
77
|
+
id="price-min"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<span className="text-gray-300 mt-5">—</span>
|
|
81
|
+
<div className="flex-1">
|
|
82
|
+
<label className="text-xs text-gray-400 mb-1 block">Max (₹)</label>
|
|
83
|
+
<input
|
|
84
|
+
type="number"
|
|
85
|
+
value={priceRange[1]}
|
|
86
|
+
min={priceRange[0] + 1}
|
|
87
|
+
max={maxPrice}
|
|
88
|
+
onChange={(e) => onPriceChange([priceRange[0], Number(e.target.value)])}
|
|
89
|
+
className="input text-sm"
|
|
90
|
+
id="price-max"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<input
|
|
95
|
+
type="range"
|
|
96
|
+
min={minPrice}
|
|
97
|
+
max={maxPrice}
|
|
98
|
+
value={priceRange[1]}
|
|
99
|
+
onChange={(e) => onPriceChange([priceRange[0], Number(e.target.value)])}
|
|
100
|
+
className="w-full accent-gray-900"
|
|
101
|
+
id="price-slider"
|
|
102
|
+
/>
|
|
103
|
+
<div className="flex justify-between text-xs text-gray-400">
|
|
104
|
+
<span>₹{minPrice.toLocaleString('en-IN')}</span>
|
|
105
|
+
<span>₹{maxPrice.toLocaleString('en-IN')}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Min Rating */}
|
|
111
|
+
<div>
|
|
112
|
+
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Min Rating</p>
|
|
113
|
+
<div className="flex flex-col gap-1">
|
|
114
|
+
{[0, 3, 3.5, 4, 4.5].map((r) => (
|
|
115
|
+
<button
|
|
116
|
+
key={r}
|
|
117
|
+
onClick={() => onRatingChange(r)}
|
|
118
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition-all duration-200
|
|
119
|
+
${minRating === r
|
|
120
|
+
? 'bg-gray-900 text-white'
|
|
121
|
+
: 'text-gray-600 hover:bg-gray-50'
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
{r === 0 ? (
|
|
125
|
+
<span>All Ratings</span>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
<div className="flex">
|
|
129
|
+
{[1, 2, 3, 4, 5].map((s) => (
|
|
130
|
+
<Star key={s} className={`w-3 h-3 ${s <= r ? 'text-amber-400 fill-amber-400' : (minRating === r ? 'text-gray-200 fill-gray-200' : 'text-gray-300 fill-gray-300')}`} />
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
<span>{r}+ stars</span>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</button>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|