Initial commit
This commit is contained in:
57
client/src/components/CompareDrawer.jsx
Normal file
57
client/src/components/CompareDrawer.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X, Sliders, ArrowRight } from 'lucide-react';
|
||||
import { removeFromCompare, clearCompare } from '../store/slices/compareSlice';
|
||||
|
||||
const CompareDrawer = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { items } = useSelector((state) => state.compare);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 w-11/12 max-w-3xl bg-white/95 border border-slate-200/90 backdrop-blur-md rounded-2xl p-4 shadow-xl flex items-center justify-between gap-4 text-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-amber-50 border border-amber-200 text-amber-600 rounded-xl">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-bold text-slate-900">Compare Guitars ({items.length}/4)</div>
|
||||
<div className="text-[11px] text-slate-500 hidden sm:block">Side-by-side woods, pickups & price comparison</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{items.map((item) => (
|
||||
<div key={item._id} className="relative group flex-shrink-0">
|
||||
<img src={item.images?.[0]} alt="" className="w-10 h-10 object-cover rounded-lg border border-slate-200" />
|
||||
<button
|
||||
onClick={() => dispatch(removeFromCompare(item._id))}
|
||||
className="absolute -top-1.5 -right-1.5 p-0.5 bg-rose-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => dispatch(clearCompare())}
|
||||
className="text-xs text-slate-500 hover:text-slate-900 hidden sm:block"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<Link
|
||||
to="/compare"
|
||||
className="py-2 px-4 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
Compare Now <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareDrawer;
|
||||
155
client/src/components/Footer.jsx
Normal file
155
client/src/components/Footer.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Instagram, Facebook, Youtube, Twitter, Mail, Phone, MapPin, ShieldCheck, Truck, RotateCcw, Headphones } from 'lucide-react';
|
||||
import api from '../api/axios';
|
||||
|
||||
const Footer = () => {
|
||||
const [settings, setSettings] = useState({
|
||||
siteName: 'Antigravity Guitars India',
|
||||
tagline: 'Crafted Sound. Pure Perfection.',
|
||||
businessName: 'Antigravity Music Private Limited',
|
||||
gstin: '29ABCDE1234F1Z5',
|
||||
addressLine1: '100 Brigade Road, Ashok Nagar',
|
||||
addressLine2: 'Suite 400, Music Square',
|
||||
city: 'Bengaluru',
|
||||
state: 'Karnataka',
|
||||
pincode: '560025',
|
||||
country: 'India',
|
||||
phone: '+91 98765 43210',
|
||||
contactEmail: 'support@antigravityguitars.in'
|
||||
});
|
||||
const [socials, setSocials] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings').then(res => setSettings(res.data.data)).catch(() => {});
|
||||
api.get('/social-media').then(res => setSocials(res.data.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="bg-slate-900 text-slate-400 text-xs border-t border-slate-800">
|
||||
{/* Features Bar */}
|
||||
<div className="border-b border-slate-800/80 py-10 bg-slate-950/40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 text-amber-400 rounded-2xl border border-amber-500/20">
|
||||
<Truck className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Free Courier Delivery</div>
|
||||
<div className="text-xs text-slate-400">On all India orders over ₹5,000</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 text-amber-400 rounded-2xl border border-amber-500/20">
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">100% Authentic Guitars</div>
|
||||
<div className="text-xs text-slate-400">Official Indian brand warranty</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 text-amber-400 rounded-2xl border border-amber-500/20">
|
||||
<RotateCcw className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">10-Day Hassle Returns</div>
|
||||
<div className="text-xs text-slate-400">Safe studio trial period</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 text-amber-400 rounded-2xl border border-amber-500/20">
|
||||
<Headphones className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Luthier Support India</div>
|
||||
<div className="text-xs text-slate-400">Expert setup & advice</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Footer Links */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
|
||||
{/* Brand Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-9 h-9 bg-amber-500 rounded-xl flex items-center justify-center font-bold text-white text-lg">AG</div>
|
||||
<span className="text-lg font-serif font-bold text-white">{settings.siteName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
{settings.tagline}. India's premier music destination providing hand-crafted acoustic, electric, and custom shop guitars.
|
||||
</p>
|
||||
<div className="text-[11px] text-amber-400 font-semibold">
|
||||
GSTIN: {settings.gstin || '29ABCDE1234F1Z5'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-widest mb-4">Explore Instruments</h4>
|
||||
<ul className="space-y-2.5 text-xs">
|
||||
<li><Link to="/shop" className="hover:text-amber-400 transition-colors">All Guitars Catalog</Link></li>
|
||||
<li><Link to="/shop?category=electric-guitars" className="hover:text-amber-400 transition-colors">Electric Guitars</Link></li>
|
||||
<li><Link to="/shop?category=acoustic-guitars" className="hover:text-amber-400 transition-colors">Acoustic Guitars</Link></li>
|
||||
<li><Link to="/shop?category=bass-guitars" className="hover:text-amber-400 transition-colors">Bass Guitars</Link></li>
|
||||
<li><Link to="/shop?category=amplifiers" className="hover:text-amber-400 transition-colors">Amplifiers & Pedals</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Customer Care */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-widest mb-4">Customer Care</h4>
|
||||
<ul className="space-y-2.5 text-xs">
|
||||
<li><Link to="/contact" className="hover:text-amber-400 transition-colors">Contact Us</Link></li>
|
||||
<li><Link to="/about" className="hover:text-amber-400 transition-colors">About Antigravity India</Link></li>
|
||||
<li><Link to="/dashboard" className="hover:text-amber-400 transition-colors">Track Order Status</Link></li>
|
||||
<li><Link to="/privacy-policy" className="hover:text-amber-400 transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link to="/terms-conditions" className="hover:text-amber-400 transition-colors">Terms of Service</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Requirement 17: Indian Business Address & Contact Info (Replaces VIP Tone Club) */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-widest mb-2">Indian Headquarters & Address</h4>
|
||||
<div className="space-y-2 text-xs text-slate-300">
|
||||
<div className="font-bold text-white">{settings.businessName || settings.siteName}</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{settings.addressLine1 || '100 Brigade Road'}, {settings.addressLine2 || 'Ashok Nagar'},<br />
|
||||
{settings.city || 'Bengaluru'}, {settings.state || 'Karnataka'} – {settings.pincode || '560025'}, {settings.country || 'India'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
<span>{settings.phone || '+91 98765 43210'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
<span>{settings.contactEmail || 'support@antigravityguitars.in'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-xs font-bold text-white mb-2">Connect With Us</div>
|
||||
<div className="flex gap-2">
|
||||
<a href="https://instagram.com" target="_blank" rel="noreferrer" className="p-2 bg-slate-800 rounded-lg hover:text-amber-400"><Instagram className="w-4 h-4" /></a>
|
||||
<a href="https://facebook.com" target="_blank" rel="noreferrer" className="p-2 bg-slate-800 rounded-lg hover:text-amber-400"><Facebook className="w-4 h-4" /></a>
|
||||
<a href="https://youtube.com" target="_blank" rel="noreferrer" className="p-2 bg-slate-800 rounded-lg hover:text-amber-400"><Youtube className="w-4 h-4" /></a>
|
||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="p-2 bg-slate-800 rounded-lg hover:text-amber-400"><Twitter className="w-4 h-4" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-800/80 py-6 text-center text-xs text-slate-500">
|
||||
© {new Date().getFullYear()} {settings.businessName || settings.siteName}. All Rights Reserved. Built with MERN Stack Architecture.
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
193
client/src/components/GuitarFinderModal.jsx
Normal file
193
client/src/components/GuitarFinderModal.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { X, Sparkles, ChevronRight, RotateCcw, ShoppingBag } from 'lucide-react';
|
||||
import { closeGuitarFinder, showToast } from '../store/slices/uiSlice';
|
||||
import { addToCartAsync } from '../store/slices/cartSlice';
|
||||
import { formatINR } from '../utils/formatCurrency';
|
||||
import api from '../api/axios';
|
||||
|
||||
const GuitarFinderModal = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { guitarFinderOpen } = useSelector((state) => state.ui);
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [answers, setAnswers] = useState({
|
||||
skillLevel: 'Intermediate',
|
||||
acousticElectric: 'Electric',
|
||||
maxBudget: 100000, // ₹1,000,00
|
||||
handOrientation: 'Right-Handed'
|
||||
});
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!guitarFinderOpen) return null;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step < 3) {
|
||||
setStep(step + 1);
|
||||
} else {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/products/guitar-finder', answers);
|
||||
setRecommendations(res.data.data || []);
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: 'Failed to generate recommendations', type: 'error' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStep(1);
|
||||
setRecommendations([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => dispatch(closeGuitarFinder())} />
|
||||
|
||||
<div className="relative w-full max-w-2xl bg-white border border-slate-200 rounded-3xl p-6 md:p-8 shadow-2xl z-10 text-slate-900">
|
||||
<button
|
||||
onClick={() => dispatch(closeGuitarFinder())}
|
||||
className="absolute top-6 right-6 p-2 text-slate-400 hover:text-slate-900 rounded-full bg-slate-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 text-amber-600 rounded-2xl">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif">Tone Matcher & Guitar Finder India</h2>
|
||||
<p className="text-xs text-slate-500">Answer 3 quick questions to discover your ideal instrument</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6 animate-fadeIn text-left">
|
||||
<div className="text-sm font-bold text-slate-800">1. What type of instrument are you looking for?</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{['Electric', 'Acoustic', 'Bass', 'Classical'].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setAnswers({ ...answers, acousticElectric: type })}
|
||||
className={`p-4 rounded-2xl border text-left transition-all ${
|
||||
answers.acousticElectric === type
|
||||
? 'border-amber-500 bg-amber-50 text-slate-900 font-bold shadow-sm'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-base mb-1 font-bold">{type} Guitar</div>
|
||||
<div className="text-xs text-slate-500">Curated tone builds</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Budget (₹ INR) */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6 animate-fadeIn text-left">
|
||||
<div className="text-sm font-bold text-slate-800">2. What is your target budget range?</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'Under ₹30,000 (Player Standard)', value: 30000 },
|
||||
{ label: '₹30,000 - ₹1,00,000 (Custom Deluxe)', value: 100000 },
|
||||
{ label: '₹1,00,000+ (Master Custom Shop)', value: 300000 }
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.value}
|
||||
onClick={() => setAnswers({ ...answers, maxBudget: b.value })}
|
||||
className={`w-full p-4 rounded-2xl border text-left transition-all ${
|
||||
answers.maxBudget === b.value
|
||||
? 'border-amber-500 bg-amber-50 text-slate-900 font-bold shadow-sm'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-bold">{b.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Handedness */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6 animate-fadeIn text-left">
|
||||
<div className="text-sm font-bold text-slate-800">3. Select your playing hand orientation</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{['Right-Handed', 'Left-Handed'].map((orient) => (
|
||||
<button
|
||||
key={orient}
|
||||
onClick={() => setAnswers({ ...answers, handOrientation: orient })}
|
||||
className={`p-4 rounded-2xl border text-center transition-all ${
|
||||
answers.handOrientation === orient
|
||||
? 'border-amber-500 bg-amber-50 text-slate-900 font-bold shadow-sm'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-bold">{orient}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Results */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 text-left">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Found {recommendations.length} matching instruments</span>
|
||||
<button onClick={handleReset} className="flex items-center gap-1 text-amber-600 font-bold hover:underline">
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Retake Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recommendations.map((p) => (
|
||||
<div key={p._id} className="p-4 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between gap-4 shadow-sm">
|
||||
<img src={p.images?.[0]} alt="" className="w-16 h-16 object-cover rounded-xl border border-slate-200" />
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-amber-600">{p.brand?.name}</div>
|
||||
<h4 className="text-sm font-bold text-slate-900 line-clamp-1">{p.name}</h4>
|
||||
<div className="text-xs font-extrabold text-slate-900">{formatINR(p.salePrice || p.price)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
dispatch(addToCartAsync({ productId: p._id, quantity: 1 }));
|
||||
dispatch(showToast({ message: `${p.name} added to cart!`, type: 'success' }));
|
||||
dispatch(closeGuitarFinder());
|
||||
}}
|
||||
className="p-2.5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" /> Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer controls */}
|
||||
{step < 4 && (
|
||||
<div className="mt-8 flex items-center justify-between border-t border-slate-100 pt-6">
|
||||
<div className="text-xs text-slate-400 font-semibold">Step {step} of 3</div>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className="py-3 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
{loading ? 'Matching...' : step === 3 ? 'Generate Matches' : 'Next Step'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuitarFinderModal;
|
||||
82
client/src/components/MobileNav.jsx
Normal file
82
client/src/components/MobileNav.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X, Home, ShoppingBag, Heart, Compass, Sliders, User, Shield } from 'lucide-react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { openGuitarFinder } from '../store/slices/uiSlice';
|
||||
|
||||
const MobileNav = ({ isOpen, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
{/* Overlay */}
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed inset-y-0 left-0 w-4/5 max-w-xs bg-white border-r border-slate-200 p-6 flex flex-col justify-between overflow-y-auto text-slate-900 shadow-xl">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-6 border-b border-slate-100">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-xl flex items-center justify-center font-bold text-white">AG</div>
|
||||
<span className="font-serif font-bold text-lg text-slate-900">ANTIGRAVITY</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-900">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-6 space-y-4 font-semibold text-slate-700">
|
||||
<Link to="/" onClick={onClose} className="flex items-center gap-3 py-2 text-base hover:text-amber-600">
|
||||
<Home className="w-5 h-5 text-slate-500" /> Home
|
||||
</Link>
|
||||
<Link to="/shop" onClick={onClose} className="flex items-center gap-3 py-2 text-base hover:text-amber-600">
|
||||
<ShoppingBag className="w-5 h-5 text-slate-500" /> Shop All Guitars
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { onClose(); dispatch(openGuitarFinder()); }}
|
||||
className="w-full flex items-center gap-3 py-2 text-base text-amber-600 font-bold"
|
||||
>
|
||||
<Compass className="w-5 h-5 text-amber-600" /> Guitar Finder Tool
|
||||
</button>
|
||||
<Link to="/compare" onClick={onClose} className="flex items-center gap-3 py-2 text-base hover:text-amber-600">
|
||||
<Sliders className="w-5 h-5 text-slate-500" /> Product Comparison
|
||||
</Link>
|
||||
<Link to="/wishlist" onClick={onClose} className="flex items-center gap-3 py-2 text-base hover:text-amber-600">
|
||||
<Heart className="w-5 h-5 text-slate-500" /> Saved Wishlist
|
||||
</Link>
|
||||
{userInfo && userInfo.role === 'Admin' && (
|
||||
<Link to="/admin" onClick={onClose} className="flex items-center gap-3 py-2 text-base text-amber-600 font-bold">
|
||||
<Shield className="w-5 h-5" /> Admin Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-slate-100">
|
||||
{userInfo ? (
|
||||
<Link to="/dashboard" onClick={onClose} className="flex items-center gap-3 p-3 bg-slate-50 border border-slate-200 rounded-2xl">
|
||||
<img src={userInfo.avatar} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-900">{userInfo.name}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{userInfo.email}</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={onClose}
|
||||
className="block w-full py-3 text-center text-sm font-bold text-white bg-amber-500 rounded-2xl shadow-sm"
|
||||
>
|
||||
Sign In / Register
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNav;
|
||||
286
client/src/components/Navbar.jsx
Normal file
286
client/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Search, ShoppingBag, Heart, Sliders, User, Menu, Shield, LogOut, Compass, ChevronDown, Sparkles } from 'lucide-react';
|
||||
import { logout } from '../store/slices/authSlice';
|
||||
import { openGuitarFinder } from '../store/slices/uiSlice';
|
||||
import api from '../api/axios';
|
||||
|
||||
const Navbar = ({ onOpenMobileMenu }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
const { items: cartItems } = useSelector((state) => state.cart);
|
||||
const { products: wishlistProducts } = useSelector((state) => state.wishlist);
|
||||
const { items: compareItems } = useSelector((state) => state.compare);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories').then((res) => setCategories(res.data.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Click outside listener for profile dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setProfileDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = async (e) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
if (query.trim().length > 1) {
|
||||
try {
|
||||
const res = await api.get(`/products?search=${encodeURIComponent(query)}&limit=5`);
|
||||
setSuggestions(res.data.data.products || []);
|
||||
setIsSearchOpen(true);
|
||||
} catch (err) {
|
||||
setSuggestions([]);
|
||||
}
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
setIsSearchOpen(false);
|
||||
navigate(`/shop?search=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const totalCartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
const isActiveRoute = (path) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 bg-white/95 backdrop-blur-md border-b border-slate-200/80 shadow-sm">
|
||||
{/* Top Shipping Announcement Bar */}
|
||||
<div className="bg-slate-900 text-slate-100 text-xs font-semibold py-2 px-4 text-center tracking-wider flex items-center justify-center gap-2">
|
||||
<span>🇮🇳 FREE EXPRESS COURIER DELIVERY ACROSS INDIA ON ORDERS OVER ₹5,000</span>
|
||||
<span className="hidden md:inline text-amber-400">| CODE: <strong>WELCOME10</strong></span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
|
||||
{/* Logo & Mobile Menu Toggle */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onOpenMobileMenu}
|
||||
className="lg:hidden p-2 text-slate-600 hover:text-slate-900 rounded-lg hover:bg-slate-100"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<Link to="/" className="flex items-center space-x-3 group">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center font-bold text-white text-xl shadow-md shadow-amber-500/20 group-hover:scale-105 transition-transform">
|
||||
AG
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-extrabold tracking-tight text-slate-900 font-serif group-hover:text-amber-600 transition-colors">
|
||||
ANTIGRAVITY
|
||||
</span>
|
||||
<span className="block text-[10px] tracking-widest text-amber-600 font-bold uppercase">
|
||||
GUITARS INDIA
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation Links */}
|
||||
<nav className="hidden lg:flex items-center space-x-6 text-sm font-semibold text-slate-700">
|
||||
<Link
|
||||
to="/"
|
||||
className={`transition-colors hover:text-amber-600 ${isActiveRoute('/') ? 'text-amber-600 font-bold border-b-2 border-amber-500 pb-1' : ''}`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className={`transition-colors hover:text-amber-600 ${isActiveRoute('/shop') ? 'text-amber-600 font-bold border-b-2 border-amber-500 pb-1' : ''}`}
|
||||
>
|
||||
Shop All
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop?category=electric-guitars"
|
||||
className="hover:text-amber-600 transition-colors"
|
||||
>
|
||||
Guitars
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop?category=amplifiers"
|
||||
className="hover:text-amber-600 transition-colors"
|
||||
>
|
||||
Accessories
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop?isNewArrival=true"
|
||||
className="hover:text-amber-600 transition-colors text-amber-600 font-bold"
|
||||
>
|
||||
New Arrivals
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop?isBestSeller=true"
|
||||
className="hover:text-amber-600 transition-colors"
|
||||
>
|
||||
Deals
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => dispatch(openGuitarFinder())}
|
||||
className="flex items-center gap-1.5 text-amber-700 hover:text-amber-800 font-bold px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-full transition-all hover:bg-amber-100"
|
||||
>
|
||||
<Compass className="w-4 h-4 text-amber-600" />
|
||||
Guitar Finder
|
||||
</button>
|
||||
|
||||
<Link to="/about" className="hover:text-amber-600 transition-colors">About</Link>
|
||||
<Link to="/contact" className="hover:text-amber-600 transition-colors">Contact</Link>
|
||||
</nav>
|
||||
|
||||
{/* Actions: Search, Wishlist, Compare, Cart, Account */}
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative hidden md:block w-56 lg:w-64">
|
||||
<form onSubmit={handleSearchSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search guitars, brands..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full bg-slate-100 border border-slate-200 rounded-full py-2 pl-9 pr-4 text-xs text-slate-800 focus:outline-none focus:border-amber-500 focus:bg-white transition-all placeholder:text-slate-400"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||
</form>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{isSearchOpen && suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-slate-200 rounded-2xl shadow-xl overflow-hidden z-50">
|
||||
{suggestions.map((p) => (
|
||||
<Link
|
||||
key={p._id}
|
||||
to={`/product/${p.slug}`}
|
||||
onClick={() => setIsSearchOpen(false)}
|
||||
className="flex items-center gap-3 p-3 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<img src={p.images[0]} alt={p.name} className="w-10 h-10 object-cover rounded-lg" />
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-900 truncate max-w-[170px]">{p.name}</div>
|
||||
<div className="text-xs text-amber-600 font-extrabold">₹{(p.salePrice || p.price).toLocaleString('en-IN')}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compare Icon */}
|
||||
<Link to="/compare" className="relative p-2 text-slate-700 hover:text-amber-600 transition-colors" title="Compare Guitars">
|
||||
<Sliders className="w-5 h-5" />
|
||||
{compareItems.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white font-bold text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{compareItems.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Wishlist Icon */}
|
||||
<Link to="/wishlist" className="relative p-2 text-slate-700 hover:text-rose-600 transition-colors" title="Wishlist">
|
||||
<Heart className="w-5 h-5" />
|
||||
{wishlistProducts.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white font-bold text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistProducts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Cart Icon */}
|
||||
<Link to="/cart" className="relative p-2 text-slate-700 hover:text-amber-600 transition-colors" title="Shopping Cart">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
{totalCartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white font-bold text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-sm">
|
||||
{totalCartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Fixed User Profile Dropdown (Requirement 18) */}
|
||||
{userInfo ? (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setProfileDropdownOpen(!profileDropdownOpen)}
|
||||
className="flex items-center gap-2 p-1 bg-slate-100 border border-slate-200 rounded-full pr-3 hover:border-amber-500 transition-all"
|
||||
>
|
||||
<img src={userInfo.avatar} alt={userInfo.name} className="w-7 h-7 rounded-full object-cover" />
|
||||
<span className="text-xs font-bold text-slate-800 truncate max-w-[80px]">{userInfo.name}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
|
||||
</button>
|
||||
|
||||
{profileDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-52 bg-white border border-slate-200 rounded-2xl shadow-xl py-2 z-50 animate-fadeIn">
|
||||
<div className="px-4 py-2 border-b border-slate-100">
|
||||
<div className="text-xs font-bold text-slate-900">{userInfo.name}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{userInfo.email}</div>
|
||||
</div>
|
||||
{userInfo.role === 'Admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setProfileDropdownOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs font-bold text-amber-600 hover:bg-slate-50"
|
||||
>
|
||||
<Shield className="w-4 h-4" /> Admin Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => setProfileDropdownOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<User className="w-4 h-4" /> My Account & Orders
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileDropdownOpen(false);
|
||||
dispatch(logout());
|
||||
}}
|
||||
className="w-full text-left flex items-center gap-2 px-4 py-2 text-xs text-rose-600 hover:bg-slate-50 border-t border-slate-100 mt-1 font-semibold"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-xs font-bold text-white bg-amber-500 hover:bg-amber-600 px-4 py-2 rounded-full transition-all shadow-sm"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
175
client/src/components/ProductCard.jsx
Normal file
175
client/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Heart, Eye, Sliders, ShoppingBag, Star } from 'lucide-react';
|
||||
import { addToCartAsync } from '../store/slices/cartSlice';
|
||||
import { toggleWishlistAsync } from '../store/slices/wishlistSlice';
|
||||
import { addToCompare, removeFromCompare } from '../store/slices/compareSlice';
|
||||
import { openQuickView, showToast } from '../store/slices/uiSlice';
|
||||
import { formatINR } from '../utils/formatCurrency';
|
||||
|
||||
const ProductCard = ({ product }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { products: wishlistProducts } = useSelector((state) => state.wishlist);
|
||||
const { items: compareItems } = useSelector((state) => state.compare);
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const isWishlisted = wishlistProducts.some((p) => (p._id || p) === product._id);
|
||||
const isCompared = compareItems.some((p) => p._id === product._id);
|
||||
|
||||
const handleWishlist = (e) => {
|
||||
e.preventDefault();
|
||||
if (!userInfo) {
|
||||
dispatch(showToast({ message: 'Please login to save to wishlist', type: 'error' }));
|
||||
return;
|
||||
}
|
||||
dispatch(toggleWishlistAsync(product._id));
|
||||
dispatch(showToast({ message: isWishlisted ? 'Removed from wishlist' : 'Added to wishlist', type: 'success' }));
|
||||
};
|
||||
|
||||
const handleCompare = (e) => {
|
||||
e.preventDefault();
|
||||
if (isCompared) {
|
||||
dispatch(removeFromCompare(product._id));
|
||||
dispatch(showToast({ message: 'Removed from compare list', type: 'info' }));
|
||||
} else {
|
||||
if (compareItems.length >= 4) {
|
||||
dispatch(showToast({ message: 'Max 4 items allowed for comparison', type: 'error' }));
|
||||
return;
|
||||
}
|
||||
dispatch(addToCompare(product));
|
||||
dispatch(showToast({ message: 'Added to compare list', type: 'success' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = (e) => {
|
||||
e.preventDefault();
|
||||
if (product.stock <= 0) return;
|
||||
dispatch(addToCartAsync({ productId: product._id, quantity: 1 }));
|
||||
dispatch(showToast({ message: `${product.name} added to cart!`, type: 'success' }));
|
||||
};
|
||||
|
||||
const displayPrice = product.salePrice > 0 ? product.salePrice : product.price;
|
||||
const hasDiscount = product.salePrice > 0 && product.salePrice < product.price;
|
||||
const discountPercent = hasDiscount
|
||||
? Math.round(((product.price - product.salePrice) / product.price) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="group relative bg-white border border-slate-200/90 rounded-2xl overflow-hidden hover:border-amber-500/50 transition-all duration-300 flex flex-col justify-between hover:shadow-lg shadow-sm">
|
||||
{/* Product Image & Hover Overlay */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||
<Link to={`/product/${product.slug}`}>
|
||||
<img
|
||||
src={product.images?.[0] || 'https://images.unsplash.com/photo-1564186763535-ebb21ef5277f?w=600'}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
{product.images?.[1] && (
|
||||
<img
|
||||
src={product.images[1]}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Badges (Requirement 10) */}
|
||||
<div className="absolute top-3 left-3 flex flex-col gap-1.5 z-10">
|
||||
{hasDiscount && (
|
||||
<span className="bg-amber-600 text-white font-bold text-[10px] uppercase px-2.5 py-0.5 rounded-full shadow-sm">
|
||||
-{discountPercent}% OFF
|
||||
</span>
|
||||
)}
|
||||
{product.isNewArrival && (
|
||||
<span className="bg-slate-900 text-white font-bold text-[10px] uppercase px-2.5 py-0.5 rounded-full shadow-sm">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{product.isBestSeller && (
|
||||
<span className="bg-amber-500 text-white font-bold text-[10px] uppercase px-2.5 py-0.5 rounded-full shadow-sm">
|
||||
BEST SELLER
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Action Floating Buttons */}
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10">
|
||||
<button
|
||||
onClick={handleWishlist}
|
||||
className={`p-2 rounded-full backdrop-blur-md border shadow-sm transition-all ${
|
||||
isWishlisted
|
||||
? 'bg-rose-500 border-rose-500 text-white'
|
||||
: 'bg-white/90 border-slate-200 text-slate-600 hover:text-rose-600 hover:bg-white'
|
||||
}`}
|
||||
title="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4" fill={isWishlisted ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCompare}
|
||||
className={`p-2 rounded-full backdrop-blur-md border shadow-sm transition-all ${
|
||||
isCompared
|
||||
? 'bg-amber-500 border-amber-500 text-white'
|
||||
: 'bg-white/90 border-slate-200 text-slate-600 hover:text-amber-600 hover:bg-white'
|
||||
}`}
|
||||
title="Compare Specs"
|
||||
>
|
||||
<Sliders className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dispatch(openQuickView(product))}
|
||||
className="p-2 bg-white/90 border border-slate-200 text-slate-600 hover:text-slate-900 rounded-full backdrop-blur-md transition-all shadow-sm"
|
||||
title="Quick View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 flex-1 flex flex-col justify-between space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
|
||||
<span className="font-bold text-amber-600">{product.brand?.name || 'Custom Shop'}</span>
|
||||
<div className="flex items-center gap-1 text-amber-500 font-bold">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
<span>{product.rating || '4.9'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to={`/product/${product.slug}`}>
|
||||
<h3 className="text-sm font-bold text-slate-900 line-clamp-2 hover:text-amber-600 transition-colors leading-snug">
|
||||
{product.name}
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Requirement 19: ₹ INR Currency Format & Requirement 16: Hidden SKU */}
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
<span className="text-lg font-extrabold text-slate-900">{formatINR(displayPrice)}</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-xs text-slate-400 line-through">{formatINR(product.price)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock <= 0}
|
||||
className={`w-full py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm ${
|
||||
product.stock > 0
|
||||
? 'bg-slate-900 hover:bg-amber-600 text-white border border-slate-900'
|
||||
: 'bg-slate-100 text-slate-400 border border-slate-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
{product.stock > 0 ? 'Add To Cart' : 'Out Of Stock'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
110
client/src/components/ProductQuickView.jsx
Normal file
110
client/src/components/ProductQuickView.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { X, Star, ShoppingBag, Check } from 'lucide-react';
|
||||
import { closeQuickView, showToast } from '../store/slices/uiSlice';
|
||||
import { addToCartAsync } from '../store/slices/cartSlice';
|
||||
import { formatINR } from '../utils/formatCurrency';
|
||||
|
||||
const ProductQuickView = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { quickViewProduct } = useSelector((state) => state.ui);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!quickViewProduct) return null;
|
||||
|
||||
const handleAddToCart = () => {
|
||||
dispatch(addToCartAsync({ productId: quickViewProduct._id, quantity: 1 }));
|
||||
dispatch(showToast({ message: `${quickViewProduct.name} added to cart`, type: 'success' }));
|
||||
dispatch(closeQuickView());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => dispatch(closeQuickView())} />
|
||||
<div className="relative w-full max-w-4xl bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-2xl z-10 grid grid-cols-1 md:grid-cols-2 text-slate-900">
|
||||
<button
|
||||
onClick={() => dispatch(closeQuickView())}
|
||||
className="absolute top-4 right-4 z-20 p-2 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Gallery */}
|
||||
<div className="p-6 bg-slate-50 flex flex-col justify-between border-r border-slate-100">
|
||||
<div className="aspect-[4/3] rounded-2xl overflow-hidden mb-4 border border-slate-200 bg-white">
|
||||
<img
|
||||
src={quickViewProduct.images?.[selectedImage] || quickViewProduct.images?.[0]}
|
||||
alt={quickViewProduct.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{quickViewProduct.images?.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{quickViewProduct.images.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedImage(idx)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 ${
|
||||
selectedImage === idx ? 'border-amber-500 shadow-sm' : 'border-slate-200 opacity-70'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-6 flex flex-col justify-between text-left space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-amber-600 uppercase tracking-wider mb-1">
|
||||
{quickViewProduct.brand?.name}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif mb-2">{quickViewProduct.name}</h2>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex text-amber-400">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">({quickViewProduct.numReviews} Reviews)</span>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-extrabold text-slate-900 mb-4">
|
||||
{formatINR(quickViewProduct.salePrice || quickViewProduct.price)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-6">
|
||||
{quickViewProduct.description}
|
||||
</p>
|
||||
|
||||
{/* Quick Specs */}
|
||||
{quickViewProduct.specs && (
|
||||
<div className="bg-slate-50 p-4 rounded-2xl space-y-1.5 text-xs text-slate-600 border border-slate-200">
|
||||
<div><strong className="text-slate-900">Body Wood:</strong> {quickViewProduct.specs.bodyMaterial}</div>
|
||||
<div><strong className="text-slate-900">Neck:</strong> {quickViewProduct.specs.neckMaterial}</div>
|
||||
<div><strong className="text-slate-900">Pickups:</strong> {quickViewProduct.specs.pickupConfiguration}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="w-full py-3.5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" /> Add To Shopping Cart
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-emerald-700 font-semibold">
|
||||
<Check className="w-4 h-4" /> Ready to dispatch in 24 Hours
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductQuickView;
|
||||
15
client/src/components/ScrollToTop.jsx
Normal file
15
client/src/components/ScrollToTop.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
|
||||
21
client/src/components/SkeletonLoaders.jsx
Normal file
21
client/src/components/SkeletonLoaders.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export const ProductSkeleton = () => (
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-4 animate-pulse space-y-4 shadow-sm">
|
||||
<div className="aspect-[4/3] bg-slate-100 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-slate-100 rounded w-1/3" />
|
||||
<div className="h-4 bg-slate-100 rounded w-3/4" />
|
||||
<div className="h-5 bg-slate-100 rounded w-1/2" />
|
||||
</div>
|
||||
<div className="h-10 bg-slate-100 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProductGridSkeleton = ({ count = 4 }) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{[...Array(count)].map((_, i) => (
|
||||
<ProductSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
38
client/src/components/ToastNotification.jsx
Normal file
38
client/src/components/ToastNotification.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
|
||||
import { hideToast } from '../store/slices/uiSlice';
|
||||
|
||||
const ToastNotification = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { toast } = useSelector((state) => state.ui);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch(hideToast());
|
||||
}, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast, dispatch]);
|
||||
|
||||
if (!toast) return null;
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle2 className="w-5 h-5 text-emerald-600" />,
|
||||
error: <AlertCircle className="w-5 h-5 text-rose-600" />,
|
||||
info: <Info className="w-5 h-5 text-amber-600" />
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-24 right-4 z-50 flex items-center gap-3 bg-white border border-slate-200 p-4 rounded-2xl shadow-xl animate-bounce text-slate-900">
|
||||
{icons[toast.type || 'info']}
|
||||
<span className="text-xs font-bold text-slate-900">{toast.message}</span>
|
||||
<button onClick={() => dispatch(hideToast())} className="p-1 text-slate-400 hover:text-slate-900">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastNotification;
|
||||
Reference in New Issue
Block a user