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,100 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Zap, Instagram, Twitter, Youtube, Github } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const SOCIAL = [
|
|
5
|
+
{ icon: Instagram, href: '#', label: 'Instagram' },
|
|
6
|
+
{ icon: Twitter, href: '#', label: 'Twitter' },
|
|
7
|
+
{ icon: Youtube, href: '#', label: 'YouTube' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export default function Footer() {
|
|
11
|
+
return (
|
|
12
|
+
<footer className="bg-gray-900 text-gray-300 mt-20">
|
|
13
|
+
<div className="container-main py-14">
|
|
14
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-10">
|
|
15
|
+
{/* Brand */}
|
|
16
|
+
<div className="col-span-1 sm:col-span-2 md:col-span-1">
|
|
17
|
+
<div className="flex items-center gap-2 mb-3">
|
|
18
|
+
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
|
19
|
+
<Zap className="w-4 h-4 text-gray-900" />
|
|
20
|
+
</div>
|
|
21
|
+
<span className="text-xl font-black text-white tracking-tight">GenZ Shop</span>
|
|
22
|
+
</div>
|
|
23
|
+
<p className="text-sm text-gray-400 leading-relaxed mb-5">
|
|
24
|
+
Shop Different. Shop Smarter. Curated fashion and lifestyle for the next generation.
|
|
25
|
+
</p>
|
|
26
|
+
<div className="flex gap-3">
|
|
27
|
+
{SOCIAL.map(({ icon: Icon, href, label }) => (
|
|
28
|
+
<a
|
|
29
|
+
key={label}
|
|
30
|
+
href={href}
|
|
31
|
+
aria-label={label}
|
|
32
|
+
className="w-9 h-9 bg-gray-800 rounded-xl flex items-center justify-center text-gray-400 hover:bg-gray-700 hover:text-white transition-all"
|
|
33
|
+
>
|
|
34
|
+
<Icon className="w-4 h-4" />
|
|
35
|
+
</a>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{/* Shop */}
|
|
41
|
+
<div>
|
|
42
|
+
<h4 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">Shop</h4>
|
|
43
|
+
<ul className="space-y-2.5">
|
|
44
|
+
{[
|
|
45
|
+
{ label: 'All Products', href: '/products' },
|
|
46
|
+
{ label: 'New Arrivals', href: '/products' },
|
|
47
|
+
{ label: 'Best Sellers', href: '/products' },
|
|
48
|
+
{ label: 'Sale', href: '/products' },
|
|
49
|
+
].map(({ label, href }) => (
|
|
50
|
+
<li key={label}>
|
|
51
|
+
<Link href={href} className="text-sm text-gray-400 hover:text-white transition-colors">{label}</Link>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Account */}
|
|
58
|
+
<div>
|
|
59
|
+
<h4 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">Account</h4>
|
|
60
|
+
<ul className="space-y-2.5">
|
|
61
|
+
{[
|
|
62
|
+
{ label: 'Login', href: '/login' },
|
|
63
|
+
{ label: 'Register', href: '/register' },
|
|
64
|
+
{ label: 'My Profile', href: '/profile' },
|
|
65
|
+
{ label: 'My Orders', href: '/profile?tab=orders' },
|
|
66
|
+
{ label: 'Wishlist', href: '/wishlist' },
|
|
67
|
+
].map(({ label, href }) => (
|
|
68
|
+
<li key={label}>
|
|
69
|
+
<Link href={href} className="text-sm text-gray-400 hover:text-white transition-colors">{label}</Link>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Company */}
|
|
76
|
+
<div>
|
|
77
|
+
<h4 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">Company</h4>
|
|
78
|
+
<ul className="space-y-2.5">
|
|
79
|
+
{[
|
|
80
|
+
{ label: 'About Us', href: '/about' },
|
|
81
|
+
{ label: 'Contact', href: '/contact' },
|
|
82
|
+
{ label: 'Privacy Policy', href: '#' },
|
|
83
|
+
{ label: 'Terms of Service', href: '#' },
|
|
84
|
+
].map(({ label, href }) => (
|
|
85
|
+
<li key={label}>
|
|
86
|
+
<Link href={href} className="text-sm text-gray-400 hover:text-white transition-colors">{label}</Link>
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="border-t border-gray-800 mt-12 pt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
94
|
+
<p className="text-sm text-gray-500">© 2024 GenZ Shop. All rights reserved.</p>
|
|
95
|
+
<p className="text-sm text-gray-600">Built with Next.js + Tailwind CSS</p>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</footer>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { ShoppingCart, Heart, User, Menu, X, LogOut, ChevronDown, Zap } from 'lucide-react';
|
|
7
|
+
import { useCartStore } from '@/store/useCartStore';
|
|
8
|
+
import { useWishlistStore } from '@/store/useWishlistStore';
|
|
9
|
+
import { useAuthStore } from '@/store/useAuthStore';
|
|
10
|
+
import { useRouter, usePathname } from 'next/navigation';
|
|
11
|
+
|
|
12
|
+
const NAV_LINKS = [
|
|
13
|
+
{ href: '/', label: 'Home' },
|
|
14
|
+
{ href: '/products', label: 'Products' },
|
|
15
|
+
{ href: '/about', label: 'About' },
|
|
16
|
+
{ href: '/contact', label: 'Contact' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export default function Header() {
|
|
20
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
21
|
+
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
22
|
+
const cartCount = useCartStore((s) => s.itemCount)();
|
|
23
|
+
const wishlistCount = useWishlistStore((s) => s.itemCount)();
|
|
24
|
+
const { user, isAuthenticated, logout } = useAuthStore();
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
|
|
28
|
+
const handleLogout = () => {
|
|
29
|
+
logout();
|
|
30
|
+
setUserMenuOpen(false);
|
|
31
|
+
router.push('/');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<header className="sticky top-0 z-50 bg-white/90 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
|
37
|
+
<div className="container-main">
|
|
38
|
+
<div className="flex items-center justify-between h-16">
|
|
39
|
+
{/* Logo */}
|
|
40
|
+
<Link href="/" className="flex items-center gap-2 group">
|
|
41
|
+
<div className="w-8 h-8 bg-gray-900 rounded-lg flex items-center justify-center group-hover:bg-gray-700 transition-colors">
|
|
42
|
+
<Zap className="w-4 h-4 text-white" />
|
|
43
|
+
</div>
|
|
44
|
+
<span className="text-xl font-black text-gray-900 tracking-tight">GenZ Shop</span>
|
|
45
|
+
</Link>
|
|
46
|
+
|
|
47
|
+
{/* Desktop Nav */}
|
|
48
|
+
<nav className="hidden md:flex items-center gap-1">
|
|
49
|
+
{NAV_LINKS.map(({ href, label }) => {
|
|
50
|
+
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
|
|
51
|
+
return (
|
|
52
|
+
<Link
|
|
53
|
+
key={href}
|
|
54
|
+
href={href}
|
|
55
|
+
className="relative px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
|
56
|
+
>
|
|
57
|
+
{label}
|
|
58
|
+
{isActive && (
|
|
59
|
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
|
|
60
|
+
)}
|
|
61
|
+
</Link>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</nav>
|
|
65
|
+
|
|
66
|
+
{/* Right Icons */}
|
|
67
|
+
<div className="flex items-center gap-1">
|
|
68
|
+
<Link href="/wishlist" className="relative p-2.5 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-xl transition-all" aria-label="Wishlist">
|
|
69
|
+
<Heart className="w-5 h-5" />
|
|
70
|
+
<AnimatePresence>
|
|
71
|
+
{wishlistCount > 0 && (
|
|
72
|
+
<motion.span key="wb" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="absolute -top-0.5 -right-0.5 badge">
|
|
73
|
+
{wishlistCount}
|
|
74
|
+
</motion.span>
|
|
75
|
+
)}
|
|
76
|
+
</AnimatePresence>
|
|
77
|
+
</Link>
|
|
78
|
+
|
|
79
|
+
<Link href="/cart" className="relative p-2.5 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-xl transition-all" aria-label="Cart">
|
|
80
|
+
<ShoppingCart className="w-5 h-5" />
|
|
81
|
+
<AnimatePresence>
|
|
82
|
+
{cartCount > 0 && (
|
|
83
|
+
<motion.span key="cb" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="absolute -top-0.5 -right-0.5 badge">
|
|
84
|
+
{cartCount}
|
|
85
|
+
</motion.span>
|
|
86
|
+
)}
|
|
87
|
+
</AnimatePresence>
|
|
88
|
+
</Link>
|
|
89
|
+
|
|
90
|
+
{isAuthenticated && user ? (
|
|
91
|
+
<div className="relative">
|
|
92
|
+
<button onClick={() => setUserMenuOpen(!userMenuOpen)} className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-xl transition-all">
|
|
93
|
+
<div className="w-7 h-7 bg-gray-900 rounded-full flex items-center justify-center">
|
|
94
|
+
<span className="text-white text-xs font-bold">{user.name.charAt(0).toUpperCase()}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<span className="hidden sm:block text-sm font-medium">{user.name.split(' ')[0]}</span>
|
|
97
|
+
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${userMenuOpen ? 'rotate-180' : ''}`} />
|
|
98
|
+
</button>
|
|
99
|
+
<AnimatePresence>
|
|
100
|
+
{userMenuOpen && (
|
|
101
|
+
<motion.div
|
|
102
|
+
initial={{ opacity: 0, y: -8, scale: 0.95 }}
|
|
103
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
104
|
+
exit={{ opacity: 0, y: -8, scale: 0.95 }}
|
|
105
|
+
transition={{ duration: 0.15 }}
|
|
106
|
+
className="absolute right-0 mt-1 w-48 bg-white border border-gray-100 rounded-2xl shadow-xl overflow-hidden z-50"
|
|
107
|
+
>
|
|
108
|
+
<div className="px-4 py-3 border-b border-gray-100">
|
|
109
|
+
<p className="text-sm font-semibold text-gray-900">{user.name}</p>
|
|
110
|
+
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
|
111
|
+
</div>
|
|
112
|
+
<Link href="/profile" onClick={() => setUserMenuOpen(false)} className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors">
|
|
113
|
+
<User className="w-4 h-4" /> My Profile
|
|
114
|
+
</Link>
|
|
115
|
+
<button onClick={handleLogout} className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 transition-colors">
|
|
116
|
+
<LogOut className="w-4 h-4" /> Logout
|
|
117
|
+
</button>
|
|
118
|
+
</motion.div>
|
|
119
|
+
)}
|
|
120
|
+
</AnimatePresence>
|
|
121
|
+
</div>
|
|
122
|
+
) : (
|
|
123
|
+
<Link href="/login" className="hidden sm:block btn-primary text-sm py-2">Login</Link>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<button className="md:hidden p-2.5 text-gray-500 hover:bg-gray-50 rounded-xl" onClick={() => setDrawerOpen(true)} aria-label="Open menu">
|
|
127
|
+
<Menu className="w-5 h-5" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
{/* Mobile Drawer */}
|
|
135
|
+
<AnimatePresence>
|
|
136
|
+
{drawerOpen && (
|
|
137
|
+
<>
|
|
138
|
+
<motion.div key="overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/40 z-50 md:hidden" onClick={() => setDrawerOpen(false)} />
|
|
139
|
+
<motion.div
|
|
140
|
+
key="drawer"
|
|
141
|
+
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
|
142
|
+
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
143
|
+
className="fixed right-0 top-0 h-full w-72 bg-white z-50 shadow-2xl flex flex-col md:hidden"
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
|
146
|
+
<div className="flex items-center gap-2">
|
|
147
|
+
<div className="w-7 h-7 bg-gray-900 rounded-lg flex items-center justify-center">
|
|
148
|
+
<Zap className="w-3.5 h-3.5 text-white" />
|
|
149
|
+
</div>
|
|
150
|
+
<span className="font-black text-gray-900">GenZ Shop</span>
|
|
151
|
+
</div>
|
|
152
|
+
<button onClick={() => setDrawerOpen(false)} className="p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-lg">
|
|
153
|
+
<X className="w-5 h-5" />
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
<nav className="flex flex-col p-5 gap-1 flex-1">
|
|
157
|
+
{NAV_LINKS.map(({ href, label }, i) => (
|
|
158
|
+
<motion.div key={href} initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: i * 0.05 }}>
|
|
159
|
+
<Link href={href} onClick={() => setDrawerOpen(false)} className={`block px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === href ? 'bg-gray-900 text-white' : 'text-gray-700 hover:bg-gray-50'}`}>
|
|
160
|
+
{label}
|
|
161
|
+
</Link>
|
|
162
|
+
</motion.div>
|
|
163
|
+
))}
|
|
164
|
+
</nav>
|
|
165
|
+
<div className="p-5 border-t border-gray-100">
|
|
166
|
+
{isAuthenticated && user ? (
|
|
167
|
+
<div className="space-y-2">
|
|
168
|
+
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 rounded-xl">
|
|
169
|
+
<div className="w-9 h-9 bg-gray-900 rounded-full flex items-center justify-center">
|
|
170
|
+
<span className="text-white text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div>
|
|
173
|
+
<p className="text-sm font-semibold text-gray-900">{user.name}</p>
|
|
174
|
+
<p className="text-xs text-gray-400">{user.email}</p>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
<Link href="/profile" onClick={() => setDrawerOpen(false)} className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
|
178
|
+
<User className="w-4 h-4" /> My Profile
|
|
179
|
+
</Link>
|
|
180
|
+
<button onClick={handleLogout} className="w-full flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm text-red-500 hover:bg-red-50 transition-colors">
|
|
181
|
+
<LogOut className="w-4 h-4" /> Logout
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
) : (
|
|
185
|
+
<Link href="/login" onClick={() => setDrawerOpen(false)} className="btn-primary w-full text-center block">Login</Link>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</motion.div>
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
</AnimatePresence>
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { motion } from 'framer-motion';
|
|
6
|
+
import { ShoppingCart, Heart, Star } from 'lucide-react';
|
|
7
|
+
import { useCartStore } from '@/store/useCartStore';
|
|
8
|
+
import { useWishlistStore } from '@/store/useWishlistStore';
|
|
9
|
+
import type { Product } from '@/types';
|
|
10
|
+
import { formatPrice, getDiscount } from '@/lib/utils';
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
|
|
13
|
+
interface ProductCardProps {
|
|
14
|
+
product: Product;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function ProductCard({ product }: ProductCardProps) {
|
|
18
|
+
const addItem = useCartStore((s) => s.addItem);
|
|
19
|
+
const { toggle, isWishlisted } = useWishlistStore();
|
|
20
|
+
const wishlisted = isWishlisted(product.id);
|
|
21
|
+
const [added, setAdded] = useState(false);
|
|
22
|
+
|
|
23
|
+
const handleAddToCart = (e: React.MouseEvent) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
if (!product.inStock) return;
|
|
26
|
+
addItem(product);
|
|
27
|
+
setAdded(true);
|
|
28
|
+
setTimeout(() => setAdded(false), 1500);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleWishlist = (e: React.MouseEvent) => {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
toggle(product);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const discount = product.originalPrice
|
|
37
|
+
? getDiscount(product.originalPrice, product.price)
|
|
38
|
+
: null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<motion.div
|
|
42
|
+
whileHover={{ y: -4 }}
|
|
43
|
+
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
44
|
+
>
|
|
45
|
+
<Link href={`/product/${product.id}`} className="group block">
|
|
46
|
+
<div className="card overflow-hidden">
|
|
47
|
+
{/* Image Container */}
|
|
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, (max-width: 1024px) 50vw, 25vw"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
{/* Hover overlay with quick-add */}
|
|
58
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300 flex items-end justify-center pb-4">
|
|
59
|
+
<motion.button
|
|
60
|
+
onClick={handleAddToCart}
|
|
61
|
+
disabled={!product.inStock}
|
|
62
|
+
initial={{ opacity: 0, y: 10 }}
|
|
63
|
+
whileHover={{ scale: 1.02 }}
|
|
64
|
+
className={`opacity-0 group-hover:opacity-100 transition-all duration-300 px-5 py-2 rounded-full text-sm font-semibold shadow-lg flex items-center gap-2
|
|
65
|
+
${added
|
|
66
|
+
? 'bg-green-500 text-white'
|
|
67
|
+
: product.inStock
|
|
68
|
+
? 'bg-white text-gray-900 hover:bg-gray-100'
|
|
69
|
+
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
|
70
|
+
}`}
|
|
71
|
+
>
|
|
72
|
+
<ShoppingCart className="w-4 h-4" />
|
|
73
|
+
{added ? 'Added!' : 'Quick Add'}
|
|
74
|
+
</motion.button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Discount badge */}
|
|
78
|
+
{discount && (
|
|
79
|
+
<div className="absolute top-3 left-3 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
|
80
|
+
-{discount}%
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{/* Out of stock */}
|
|
85
|
+
{!product.inStock && (
|
|
86
|
+
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
|
|
87
|
+
<span className="bg-gray-800 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
|
|
88
|
+
Out of Stock
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{/* Wishlist button */}
|
|
94
|
+
<motion.button
|
|
95
|
+
onClick={handleWishlist}
|
|
96
|
+
whileHover={{ scale: 1.15 }}
|
|
97
|
+
whileTap={{ scale: 0.9 }}
|
|
98
|
+
className="absolute top-3 right-3 w-9 h-9 bg-white rounded-full shadow-md flex items-center justify-center"
|
|
99
|
+
aria-label={wishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
|
|
100
|
+
>
|
|
101
|
+
<Heart
|
|
102
|
+
className={`w-4 h-4 transition-colors ${wishlisted ? 'text-red-500 fill-red-500' : 'text-gray-400'}`}
|
|
103
|
+
/>
|
|
104
|
+
</motion.button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Content */}
|
|
108
|
+
<div className="p-4">
|
|
109
|
+
<p className="text-xs text-gray-400 uppercase tracking-wider mb-1">{product.category}</p>
|
|
110
|
+
<h3 className="font-semibold text-gray-900 text-sm mb-2 line-clamp-2 group-hover:text-gray-700 transition-colors">
|
|
111
|
+
{product.name}
|
|
112
|
+
</h3>
|
|
113
|
+
|
|
114
|
+
{/* Rating */}
|
|
115
|
+
<div className="flex items-center gap-1 mb-3">
|
|
116
|
+
<div className="flex">
|
|
117
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
118
|
+
<Star
|
|
119
|
+
key={star}
|
|
120
|
+
className={`w-3 h-3 ${star <= Math.floor(product.rating) ? 'text-amber-400 fill-amber-400' : 'text-gray-200 fill-gray-200'}`}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
<span className="text-xs text-gray-400">({product.reviewCount})</span>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Price + Cart Button */}
|
|
128
|
+
<div className="flex items-center justify-between gap-2">
|
|
129
|
+
<div>
|
|
130
|
+
<span className="font-bold text-gray-900">{formatPrice(product.price)}</span>
|
|
131
|
+
{product.originalPrice && (
|
|
132
|
+
<span className="text-xs text-gray-400 line-through ml-1.5">
|
|
133
|
+
{formatPrice(product.originalPrice)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
<motion.button
|
|
138
|
+
onClick={handleAddToCart}
|
|
139
|
+
disabled={!product.inStock}
|
|
140
|
+
whileTap={{ scale: 0.92 }}
|
|
141
|
+
className={`flex-shrink-0 p-2 rounded-lg transition-colors duration-200
|
|
142
|
+
${added
|
|
143
|
+
? 'bg-green-500 text-white'
|
|
144
|
+
: product.inStock
|
|
145
|
+
? 'bg-gray-900 text-white hover:bg-gray-700'
|
|
146
|
+
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
147
|
+
}`}
|
|
148
|
+
aria-label="Add to cart"
|
|
149
|
+
>
|
|
150
|
+
<ShoppingCart className="w-4 h-4" />
|
|
151
|
+
</motion.button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</Link>
|
|
156
|
+
</motion.div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Search, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface SearchBarProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function SearchBar({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
placeholder = 'Search products...',
|
|
15
|
+
}: SearchBarProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="relative">
|
|
18
|
+
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
|
19
|
+
<Search className="w-4 h-4 text-gray-400" />
|
|
20
|
+
</div>
|
|
21
|
+
<input
|
|
22
|
+
type="text"
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(e) => onChange(e.target.value)}
|
|
25
|
+
placeholder={placeholder}
|
|
26
|
+
className="input pl-10 pr-10"
|
|
27
|
+
id="product-search"
|
|
28
|
+
aria-label="Search products"
|
|
29
|
+
/>
|
|
30
|
+
{value && (
|
|
31
|
+
<button
|
|
32
|
+
onClick={() => onChange('')}
|
|
33
|
+
className="absolute inset-y-0 right-3 flex items-center text-gray-400 hover:text-gray-700 transition-colors"
|
|
34
|
+
aria-label="Clear search"
|
|
35
|
+
>
|
|
36
|
+
<X className="w-4 h-4" />
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|