Initial commit
This commit is contained in:
228
PROJECT_DOCUMENTATION.md
Normal file
228
PROJECT_DOCUMENTATION.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 🎸 MERN Stack Modern Guitar Ecommerce Application
|
||||
|
||||
## Complete Architecture, Workflow & Technical Documentation
|
||||
|
||||
This document provides a comprehensive technical guide on how the Modern Guitar Ecommerce Application works under the hood, detailing the MERN stack architecture, backend controllers, MongoDB database models, frontend state management, customer and admin workflows, and API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
|
||||
1. [Architecture Overview](#1-architecture-overview)
|
||||
2. [Database Schema & Models (MongoDB)](#2-database-schema--models-mongodb)
|
||||
3. [Backend API Architecture (Node & Express)](#3-backend-api-architecture-node--express)
|
||||
4. [Frontend Architecture (React, Vite & Redux Toolkit)](#4-frontend-architecture-react-vite--redux-toolkit)
|
||||
5. [End-to-End Application Workflows](#5-end-to-end-application-workflows)
|
||||
- [Customer Shopping Workflow](#customer-shopping-workflow)
|
||||
- [Interactive Guitar Finder Tool](#interactive-guitar-finder-tool)
|
||||
- [Product Comparison Matrix](#product-comparison-matrix)
|
||||
- [Admin Stock Audit & Inventory System](#admin-stock-audit--inventory-system)
|
||||
- [Order Fulfillment & Status Timeline](#order-fulfillment--status-timeline)
|
||||
6. [API Endpoints Reference](#6-api-endpoints-reference)
|
||||
7. [Installation & Execution Guide](#7-installation--execution-guide)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
The application follows a **Decoupled Architecture** built on the **MERN Stack**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ React 18 Customer & Admin Frontend │
|
||||
│ (Vite + Tailwind CSS + Framer Motion + Redux) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│ REST API Calls (Axios / JWT)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Node.js & Express.js Backend │
|
||||
│ Routes ──► Controllers ──► Services ──► Models │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│ Mongoose ODM
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ MongoDB Database │
|
||||
│ (16 Collections with Structured Schemas) │
|
||||
└────────────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Technologies Used:
|
||||
|
||||
- **Database**: MongoDB (Mongoose ODM with indexed text search & references)
|
||||
- **Backend**: Node.js, Express.js, JWT (`jsonwebtoken`), Password Hashing (`bcryptjs`), Multer (Media upload), Nodemailer (Email engine), Helmet (Security), CORS, Rate Limiter.
|
||||
- **Frontend**: React 18, Vite, Tailwind CSS, Framer Motion (Animations), Redux Toolkit (Global Store), TanStack Query (Data Fetching), Lucide Icons, React Hook Form.
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Schema & Models (MongoDB)
|
||||
|
||||
The backend features **16 MongoDB collections** located in `server/models/`:
|
||||
|
||||
| Collection / Model | Description | Key Fields |
|
||||
| :----------------- | :---------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `User` | Stores customer and admin accounts | `name`, `email`, `password` (hashed), `role` (`Customer`, `Admin`), `phone`, `addresses[]` |
|
||||
| `Product` | Core guitar catalog items with rich specs | `name`, `slug`, `sku`, `brand`, `category`, `price`, `salePrice`, `stock`, `specs` (body, neck, pickups, frets, bridge), `variants[]`, `images[]`, `rating` |
|
||||
| `Category` | Hierarchy for guitar types | `name`, `slug`, `description`, `image`, `displayOrder` |
|
||||
| `Brand` | Guitar manufacturers | `name`, `slug`, `logo`, `description`, `website` |
|
||||
| `Cart` | Shopping carts for users & guests | `user`, `sessionId`, `items[]` (`product`, `quantity`, `variantColor`, `price`), `couponCode` |
|
||||
| `Wishlist` | User saved instruments | `user`, `products[]` |
|
||||
| `Order` | Customer purchase orders | `orderNumber`, `user`, `orderItems[]`, `shippingAddress`, `paymentMethod`, `orderStatus`, `statusHistory[]`, `trackingNumber` |
|
||||
| `InventoryHistory` | Audit trail for stock changes | `product`, `sku`, `changeType` (`Order Placed`, `Restock`, `Manual`), `quantityChanged`, `previousStock`, `newStock`, `reason` |
|
||||
| `Review` | Customer product reviews | `user`, `product`, `rating`, `title`, `comment`, `isApproved` |
|
||||
| `Coupon` | Promo discount codes | `code`, `discountType`, `discountValue`, `minPurchaseAmount`, `endDate` |
|
||||
| `Banner` | Homepage hero sliders | `title`, `subtitle`, `desktopImage`, `mobileImage`, `buttonText`, `buttonLink` |
|
||||
| `WebsiteSettings` | Global store branding | `siteName`, `tagline`, `contactEmail`, `phone`, `address`, `currencySymbol`, `logos` |
|
||||
| `SocialMedia` | Dynamic social links | `platform`, `url`, `isEnabled` |
|
||||
| `EmailTemplate` | Dynamic HTML emails | `name`, `subject`, `bodyHtml`, `placeholders[]` |
|
||||
| `ContactMessage` | Customer contact submissions | `name`, `email`, `phone`, `subject`, `message`, `status` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend API Architecture (Node & Express)
|
||||
|
||||
The backend follows the clean **Routes → Controllers → Services → Models** pattern:
|
||||
|
||||
1. **Routes (`server/routes/`)**: Map incoming HTTP endpoints (e.g. `POST /api/products`, `PUT /api/orders/:id/status`) to controller functions, applying authentication (`protect`) and authorization (`authorize('Admin')`) middlewares.
|
||||
2. **Controllers (`server/controllers/`)**: Handle request data validation, invoke database queries through Mongoose models, format responses using standardized helper `apiResponse.js`, and trigger email notifications.
|
||||
3. **Middlewares (`server/middleware/`)**:
|
||||
- `authMiddleware.js`: Verifies JWT tokens in `Authorization: Bearer <token>` headers and enforces role restrictions.
|
||||
- `errorHandler.js`: Intercepts Mongoose validation errors, duplicate keys, and invalid ObjectIds, returning clean JSON error payloads.
|
||||
- `upload.js`: Configures Multer for local disk storage of product images and banner media in `server/uploads/`.
|
||||
4. **Email Sender (`server/utils/emailSender.js`)**: Loads dynamic `EmailTemplate` documents from MongoDB and performs placeholder replacement (e.g. `{{customerName}}`, `{{orderId}}`) before sending via Nodemailer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Architecture (React, Vite & Redux Toolkit)
|
||||
|
||||
The frontend is structured in `client/src/`:
|
||||
|
||||
```
|
||||
client/src/
|
||||
├── api/ # Axios HTTP client with JWT interceptor
|
||||
├── components/ # Reusable UI components (Navbar, Footer, ProductCard, Modals, Drawers)
|
||||
├── pages/ # Customer & Admin pages
|
||||
│ ├── customer/ # HomePage, ShopPage, ProductDetailPage, CartPage, CheckoutPage, etc.
|
||||
│ └── admin/ # AdminDashboard, AdminProducts, AdminInventory, AdminOrders, etc.
|
||||
├── store/ # Redux Toolkit Slices (auth, cart, wishlist, compare, ui)
|
||||
└── index.css # Tailwind CSS & custom scrollbars
|
||||
```
|
||||
|
||||
### Global Redux State Slices:
|
||||
|
||||
- `auth`: Manages logged-in user profile, JWT token, and authentication state (`login`, `register`, `logout`).
|
||||
- `cart`: Manages shopping cart items, quantities, and coupon applications with persistent backend syncing.
|
||||
- `wishlist`: Synchronizes user saved guitars.
|
||||
- `compare`: Stores up to 4 instruments for side-by-side spec comparison.
|
||||
- `ui`: Controls floating modals (`GuitarFinderModal`, `ProductQuickView`), drawers, and toast notifications.
|
||||
|
||||
---
|
||||
|
||||
## 5. End-to-End Application Workflows
|
||||
|
||||
### Customer Shopping Workflow
|
||||
|
||||
1. **Browse & Search**: Customer lands on `HomePage` -> views Hero Banners -> filters products on `ShopPage` by Category, Brand, Guitar Type (Electric, Acoustic, Bass), Handedness, and Price range.
|
||||
2. **Product Details & Spec Inspection**: Clicking an item navigates to `ProductDetailPage` -> views high-res thumbnail gallery -> inspects luthier specs table (Body wood, Pickups, Scale length, Frets) -> selects Color/Finish variant.
|
||||
3. **Cart & Coupons**: Customer adds product to cart -> navigates to `CartPage` -> views Free Shipping Progress Bar -> enters promo code `WELCOME10` for a 10% discount.
|
||||
4. **Checkout & Order Placement**: Proceeds to `CheckoutPage` -> enters shipping address -> selects payment method (`Stripe Encrypted` or `Cash on Delivery`) -> clicks **Authorize & Place Order**.
|
||||
5. **Stock Deduction & Confirmation**: Backend creates order document -> deducts inventory stock -> creates `InventoryHistory` audit entry -> sends Order Confirmation Email -> clears user cart -> redirects customer to `OrderConfirmationPage`.
|
||||
|
||||
### Interactive Guitar Finder Tool
|
||||
|
||||
- Triggered from Navbar or Home banner (`GuitarFinderModal.jsx`).
|
||||
- Asks 3 interactive questions:
|
||||
1. Instrument type (Electric, Acoustic, Bass, Classical)
|
||||
2. Target budget range ($800, $1800, $4000+)
|
||||
3. Hand orientation (Right-Handed, Left-Handed)
|
||||
- Calls `POST /api/products/guitar-finder` -> backend runs rule-based filtering on specs and ratings -> returns matching recommendations directly in the modal for instant cart addition.
|
||||
|
||||
### Product Comparison Matrix
|
||||
|
||||
- Customers click the compare button on up to 4 products -> floating `CompareDrawer.jsx` appears.
|
||||
- Navigating to `/compare` renders `ComparePage.jsx` showing a side-by-side spec table comparing body woods, pickups, fingerboards, scale lengths, and prices.
|
||||
|
||||
### Admin Stock Audit & Inventory System
|
||||
|
||||
- Admins access `/admin/inventory` -> view low-stock and out-of-stock alerts.
|
||||
- Admin selects a guitar, inputs new stock number, selects change type (`Restock`, `Manual Adjustment`, `Order Cancelled`, `Returned`), and adds a luthier note.
|
||||
- Backend updates product stock level AND logs an immutable audit document in `InventoryHistory`.
|
||||
|
||||
### Order Fulfillment & Status Timeline
|
||||
|
||||
- Admin views `/admin/orders` -> selects order -> updates status to `Shipped` -> enters courier tracking code (e.g. `FDX-990842-US`).
|
||||
- Backend saves status history timestamp -> triggers "Order Shipped" email to customer.
|
||||
- Customer views `/track-order/:id` -> sees visual horizontal progress timeline highlighting exact current fulfillment status.
|
||||
|
||||
---
|
||||
|
||||
## 6. API Endpoints Reference
|
||||
|
||||
### Authentication (`/api/auth`)
|
||||
|
||||
- `POST /api/auth/register` - Register customer account
|
||||
- `POST /api/auth/login` - Login user & receive JWT token
|
||||
- `GET /api/auth/profile` - Fetch profile (Protected)
|
||||
- `PUT /api/auth/profile` - Update profile & address book (Protected)
|
||||
|
||||
### Products (`/api/products`)
|
||||
|
||||
- `GET /api/products` - List products (filters: category, brand, guitarType, minPrice, maxPrice, search, sort, page)
|
||||
- `POST /api/products/guitar-finder` - Interactive recommendation engine
|
||||
- `POST /api/products/compare` - Compare multiple product IDs
|
||||
- `GET /api/products/:slug` - Detailed product specs by slug
|
||||
- `POST /api/products` - Create guitar (Admin)
|
||||
- `PUT /api/products/:id` - Update guitar specs & variants (Admin)
|
||||
- `DELETE /api/products/:id` - Delete guitar (Admin)
|
||||
|
||||
### Cart & Wishlist (`/api/cart`, `/api/wishlist`)
|
||||
|
||||
- `GET /api/cart` - Retrieve active session/user cart
|
||||
- `POST /api/cart/add` - Add item to cart (with stock validation)
|
||||
- `PUT /api/cart/update` - Change item quantity
|
||||
- `DELETE /api/cart/item/:itemId` - Remove item
|
||||
- `POST /api/cart/coupon` - Validate & apply coupon code
|
||||
- `GET /api/wishlist` & `POST /api/wishlist/toggle` - Manage saved items
|
||||
|
||||
### Orders & Inventory (`/api/orders`, `/api/inventory`)
|
||||
|
||||
- `POST /api/orders` - Place order & deduct stock
|
||||
- `GET /api/orders/my-orders` - List customer order history
|
||||
- `GET /api/orders/:id` - Get order receipt & status history
|
||||
- `GET /api/orders/admin/all` - List all store orders (Admin)
|
||||
- `PUT /api/orders/:id/status` - Update status & tracking number (Admin)
|
||||
- `GET /api/inventory/overview` - Stock overview & alerts (Admin)
|
||||
- `POST /api/inventory/adjust` - Adjust stock & log audit history (Admin)
|
||||
- `GET /api/inventory/history` - View stock audit trail log (Admin)
|
||||
|
||||
---
|
||||
|
||||
## 7. Installation & Execution Guide
|
||||
|
||||
### 1. Install All Dependencies
|
||||
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### 2. Seed Database
|
||||
|
||||
Runs `server/seed/seeder.js` to clear old collections and populate demo data:
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
### 3. Run Development Servers
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend Server (Port 5000)
|
||||
npm run server
|
||||
|
||||
# Terminal 2: Frontend Client (Port 5173)
|
||||
npm run client
|
||||
```
|
||||
|
||||
### 4. Admin Credentials
|
||||
|
||||
- **Admin**: `admin@guitars.com` | Password: `Admin@123`
|
||||
- **Customer**: `john@example.com` | Password: `Customer@123`
|
||||
8
client/.gitignore
vendored
Normal file
8
client/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
17
client/index.html
Normal file
17
client/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Antigravity Guitars India | Crafted Sound. Pure Perfection.</title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900 font-sans antialiased selection:bg-amber-500 selection:text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3239
client/package-lock.json
generated
Normal file
3239
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
client/package.json
Normal file
38
client/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "guitar-ecommerce-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@tanstack/react-query": "^5.29.2",
|
||||
"axios": "^1.6.8",
|
||||
"clsx": "^2.1.0",
|
||||
"framer-motion": "^11.1.7",
|
||||
"lucide-react": "^0.368.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^5.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
7
client/postcss.config.js
Normal file
7
client/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
120
client/src/App.jsx
Normal file
120
client/src/App.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// Scroll to top on page change
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
|
||||
// Layouts & Global Components
|
||||
import Navbar from './components/Navbar';
|
||||
import MobileNav from './components/MobileNav';
|
||||
import Footer from './components/Footer';
|
||||
import ProductQuickView from './components/ProductQuickView';
|
||||
import GuitarFinderModal from './components/GuitarFinderModal';
|
||||
import CompareDrawer from './components/CompareDrawer';
|
||||
import ToastNotification from './components/ToastNotification';
|
||||
|
||||
// Customer Pages
|
||||
import HomePage from './pages/customer/HomePage';
|
||||
import ShopPage from './pages/customer/ShopPage';
|
||||
import ProductDetailPage from './pages/customer/ProductDetailPage';
|
||||
import ComparePage from './pages/customer/ComparePage';
|
||||
import WishlistPage from './pages/customer/WishlistPage';
|
||||
import CartPage from './pages/customer/CartPage';
|
||||
import CheckoutPage from './pages/customer/CheckoutPage';
|
||||
import OrderConfirmationPage from './pages/customer/OrderConfirmationPage';
|
||||
import OrderTrackingPage from './pages/customer/OrderTrackingPage';
|
||||
import UserDashboardPage from './pages/customer/UserDashboardPage';
|
||||
import ContactPage from './pages/customer/ContactPage';
|
||||
import { AboutUsPage, PolicyPage } from './pages/customer/CMSPages';
|
||||
import LoginPage from './pages/customer/LoginPage';
|
||||
|
||||
// Admin Pages & Dedicated Admin Login
|
||||
import AdminLoginPage from './pages/admin/AdminLoginPage';
|
||||
import AdminLayout from './pages/admin/AdminLayout';
|
||||
import AdminDashboard from './pages/admin/AdminDashboard';
|
||||
import AdminProducts from './pages/admin/AdminProducts';
|
||||
import AdminInventory from './pages/admin/AdminInventory';
|
||||
import AdminOrders from './pages/admin/AdminOrders';
|
||||
import AdminCategories from './pages/admin/AdminCategories';
|
||||
import AdminBrands from './pages/admin/AdminBrands';
|
||||
import AdminCoupons from './pages/admin/AdminCoupons';
|
||||
import AdminBanners from './pages/admin/AdminBanners';
|
||||
import AdminSettings from './pages/admin/AdminSettings';
|
||||
import AdminMessages from './pages/admin/AdminMessages';
|
||||
import AdminEmailTemplates from './pages/admin/AdminEmailTemplates';
|
||||
|
||||
// Protected Admin Route guard (redirects to /admin/login if not admin)
|
||||
const ProtectedAdminRoute = ({ children }) => {
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
if (!userInfo || userInfo.role !== 'Admin') {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const CustomerLayoutWrapper = ({ children }) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 font-sans">
|
||||
<Navbar onOpenMobileMenu={() => setMobileMenuOpen(true)} />
|
||||
<MobileNav isOpen={mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
<ProductQuickView />
|
||||
<GuitarFinderModal />
|
||||
<CompareDrawer />
|
||||
<ToastNotification />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
{/* Customer Website Routes */}
|
||||
<Route path="/" element={<CustomerLayoutWrapper><HomePage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/shop" element={<CustomerLayoutWrapper><ShopPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/product/:slug" element={<CustomerLayoutWrapper><ProductDetailPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/compare" element={<CustomerLayoutWrapper><ComparePage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/wishlist" element={<CustomerLayoutWrapper><WishlistPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/cart" element={<CustomerLayoutWrapper><CartPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/checkout" element={<CustomerLayoutWrapper><CheckoutPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/order-confirmation/:id" element={<CustomerLayoutWrapper><OrderConfirmationPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/track-order/:id" element={<CustomerLayoutWrapper><OrderTrackingPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/dashboard" element={<CustomerLayoutWrapper><UserDashboardPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/contact" element={<CustomerLayoutWrapper><ContactPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/about" element={<CustomerLayoutWrapper><AboutUsPage /></CustomerLayoutWrapper>} />
|
||||
<Route path="/privacy-policy" element={<CustomerLayoutWrapper><PolicyPage title="Privacy Policy" /></CustomerLayoutWrapper>} />
|
||||
<Route path="/terms-conditions" element={<CustomerLayoutWrapper><PolicyPage title="Terms & Conditions" /></CustomerLayoutWrapper>} />
|
||||
<Route path="/login" element={<CustomerLayoutWrapper><LoginPage /></CustomerLayoutWrapper>} />
|
||||
|
||||
{/* Dedicated Admin Login Route (Requirement 3) */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
|
||||
{/* Protected Admin Portal Routes */}
|
||||
<Route path="/admin" element={<ProtectedAdminRoute><AdminLayout /></ProtectedAdminRoute>}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="products" element={<AdminProducts />} />
|
||||
<Route path="inventory" element={<AdminInventory />} />
|
||||
<Route path="orders" element={<AdminOrders />} />
|
||||
<Route path="categories" element={<AdminCategories />} />
|
||||
<Route path="brands" element={<AdminBrands />} />
|
||||
<Route path="coupons" element={<AdminCoupons />} />
|
||||
<Route path="banners" element={<AdminBanners />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="messages" element={<AdminMessages />} />
|
||||
<Route path="email-templates" element={<AdminEmailTemplates />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
30
client/src/api/axios.js
Normal file
30
client/src/api/axios.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Request interceptor to attach JWT token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const user = JSON.parse(localStorage.getItem('userInfo'));
|
||||
if (user && user.token) {
|
||||
config.headers.Authorization = `Bearer ${user.token}`;
|
||||
}
|
||||
// Guest Session ID for cart
|
||||
let sessionId = localStorage.getItem('guestSessionId');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Math.random().toString(36).substring(2, 9);
|
||||
localStorage.setItem('guestSessionId', sessionId);
|
||||
}
|
||||
config.headers['x-session-id'] = sessionId;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
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;
|
||||
25
client/src/index.css
Normal file
25
client/src/index.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 overflow-x-hidden antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light Mode Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
23
client/src/main.jsx
Normal file
23
client/src/main.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { store } from './store';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
76
client/src/pages/admin/AdminBanners.jsx
Normal file
76
client/src/pages/admin/AdminBanners.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Image, Trash2 } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminBanners = () => {
|
||||
const [banners, setBanners] = useState([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
const [desktopImage, setDesktopImage] = useState('https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=1600');
|
||||
|
||||
const fetchBanners = () => api.get('/banners').then(res => setBanners(res.data.data));
|
||||
|
||||
useEffect(() => { fetchBanners(); }, []);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.post('/banners', { title, subtitle, desktopImage, buttonText: 'Shop Guitars', buttonLink: '/shop' });
|
||||
setTitle(''); setSubtitle('');
|
||||
fetchBanners();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await api.delete(`/banners/${id}`);
|
||||
fetchBanners();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Hero Slider Banners</h1>
|
||||
|
||||
<form onSubmit={handleAdd} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-lg text-xs">
|
||||
<h3 className="font-bold text-slate-800 uppercase tracking-wider">Add Hero Banner</h3>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Banner Headline Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Subtitle"
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Desktop Image URL"
|
||||
value={desktopImage}
|
||||
onChange={(e) => setDesktopImage(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<button type="submit" className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-md shadow-amber-500/20">
|
||||
Save Hero Banner
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{banners.map((b) => (
|
||||
<div key={b._id} className="p-4 bg-white border border-slate-200 shadow-sm rounded-2xl space-y-2 relative">
|
||||
<img src={b.desktopImage} alt="" className="w-full h-32 object-cover rounded-xl" />
|
||||
<h4 className="font-bold text-slate-900 text-sm">{b.title}</h4>
|
||||
<p className="text-xs text-slate-500">{b.subtitle}</p>
|
||||
<button onClick={() => handleDelete(b._id)} className="absolute top-6 right-6 p-2 bg-rose-500 hover:bg-rose-600 text-white rounded-lg transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBanners;
|
||||
58
client/src/pages/admin/AdminBrands.jsx
Normal file
58
client/src/pages/admin/AdminBrands.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminBrands = () => {
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const fetchBrands = () => api.get('/brands').then(res => setBrands(res.data.data));
|
||||
|
||||
useEffect(() => { fetchBrands(); }, []);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.post('/brands', { name, slug: name.toLowerCase().replace(/\s+/g, '-') });
|
||||
setName('');
|
||||
fetchBrands();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await api.delete(`/brands/${id}`);
|
||||
fetchBrands();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Guitar Brands Management</h1>
|
||||
|
||||
<form onSubmit={handleAdd} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-md text-xs">
|
||||
<h3 className="font-bold text-slate-800 uppercase tracking-wider">Add New Manufacturer</h3>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Brand Name (e.g. Suhr Guitars)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<button type="submit" className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-md shadow-amber-500/20">
|
||||
Add Brand
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{brands.map((b) => (
|
||||
<div key={b._id} className="p-4 bg-white border border-slate-200 shadow-sm rounded-2xl flex items-center justify-between">
|
||||
<span className="font-bold text-slate-900 text-sm">{b.name}</span>
|
||||
<button onClick={() => handleDelete(b._id)} className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBrands;
|
||||
143
client/src/pages/admin/AdminCategories.jsx
Normal file
143
client/src/pages/admin/AdminCategories.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminCategories = () => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [image, setImage] = useState('');
|
||||
const [homeImage, setHomeImage] = useState('');
|
||||
const [displayOrder, setDisplayOrder] = useState(1);
|
||||
const [isFeaturedHome, setIsFeaturedHome] = useState(true);
|
||||
|
||||
const fetchCats = () => api.get('/categories').then(res => setCategories(res.data.data));
|
||||
|
||||
useEffect(() => { fetchCats(); }, []);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.post('/categories', {
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
description,
|
||||
image,
|
||||
homeImage,
|
||||
displayOrder: Number(displayOrder),
|
||||
isFeaturedHome
|
||||
});
|
||||
setName(''); setDescription(''); setImage(''); setHomeImage('');
|
||||
fetchCats();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm('Delete category?')) {
|
||||
await api.delete(`/categories/${id}`);
|
||||
fetchCats();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-slate-900">
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Category Management (Homepage 4 Categories)</h1>
|
||||
|
||||
<form onSubmit={handleAdd} className="p-6 bg-white border border-slate-200 rounded-3xl space-y-4 max-w-xl text-xs shadow-sm">
|
||||
<h3 className="font-bold text-slate-900 uppercase">Add New Category</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Category Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Electric Guitars"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Display Order Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
value={displayOrder}
|
||||
onChange={(e) => setDisplayOrder(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Short description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Category Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Homepage Background Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
value={homeImage}
|
||||
onChange={(e) => setHomeImage(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 font-semibold cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFeaturedHome}
|
||||
onChange={(e) => setIsFeaturedHome(e.target.checked)}
|
||||
className="rounded text-amber-500"
|
||||
/>
|
||||
Show as Featured Category on Home Page (Top 4)
|
||||
</label>
|
||||
|
||||
<button type="submit" className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-sm">
|
||||
Add Category
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{categories.map((c) => (
|
||||
<div key={c._id} className="p-4 bg-white border border-slate-200 rounded-2xl flex flex-col justify-between space-y-3 shadow-sm">
|
||||
<img src={c.homeImage || c.image} alt="" className="w-full h-24 object-cover rounded-xl border border-slate-200" />
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm flex items-center justify-between">
|
||||
<span>{c.name}</span>
|
||||
<span className="text-[10px] text-amber-700 bg-amber-50 px-2 py-0.5 rounded font-mono">Order: {c.displayOrder}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 line-clamp-1 mt-0.5">{c.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<span className="text-[10px] text-slate-400 font-semibold">{c.isFeaturedHome ? 'Featured on Home' : 'Standard'}</span>
|
||||
<button onClick={() => handleDelete(c._id)} className="p-1 text-rose-600 hover:text-rose-700">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCategories;
|
||||
75
client/src/pages/admin/AdminCoupons.jsx
Normal file
75
client/src/pages/admin/AdminCoupons.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tag, Trash2 } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminCoupons = () => {
|
||||
const [coupons, setCoupons] = useState([]);
|
||||
const [code, setCode] = useState('');
|
||||
const [discountValue, setDiscountValue] = useState(10);
|
||||
|
||||
const fetchCoupons = () => api.get('/coupons').then(res => setCoupons(res.data.data));
|
||||
|
||||
useEffect(() => { fetchCoupons(); }, []);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.post('/coupons', {
|
||||
code: code.toUpperCase(),
|
||||
discountType: 'percentage',
|
||||
discountValue: Number(discountValue),
|
||||
endDate: new Date('2028-12-31')
|
||||
});
|
||||
setCode('');
|
||||
fetchCoupons();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await api.delete(`/coupons/${id}`);
|
||||
fetchCoupons();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Coupons & Promo Codes</h1>
|
||||
|
||||
<form onSubmit={handleAdd} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-md text-xs">
|
||||
<h3 className="font-bold text-slate-800 uppercase tracking-wider">Create Promo Code</h3>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Code (e.g. TONE2026)"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 uppercase font-mono focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
placeholder="Discount Percentage (%)"
|
||||
value={discountValue}
|
||||
onChange={(e) => setDiscountValue(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<button type="submit" className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-md shadow-amber-500/20">
|
||||
Create Coupon
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
|
||||
{coupons.map((c) => (
|
||||
<div key={c._id} className="p-4 bg-white border border-slate-200 shadow-sm rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-amber-700 font-mono text-sm">{c.code}</div>
|
||||
<div className="text-slate-500">{c.discountValue}% OFF Discount</div>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(c._id)} className="p-2 text-rose-500 hover:bg-rose-50 rounded-lg transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCoupons;
|
||||
157
client/src/pages/admin/AdminDashboard.jsx
Normal file
157
client/src/pages/admin/AdminDashboard.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IndianRupee, ShoppingBag, Users, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalSales: 0,
|
||||
totalOrders: 0,
|
||||
totalProducts: 0,
|
||||
lowStockCount: 0,
|
||||
recentOrders: [],
|
||||
lowStockItems: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [ordersRes, overviewRes] = await Promise.all([
|
||||
api.get('/orders/admin/all'),
|
||||
api.get('/inventory/overview')
|
||||
]);
|
||||
|
||||
const allOrders = ordersRes.data.data || [];
|
||||
const overview = overviewRes.data.data || {};
|
||||
|
||||
const totalRev = allOrders.reduce((acc, ord) => acc + (ord.totalAmount || 0), 0);
|
||||
|
||||
setStats({
|
||||
totalSales: totalRev,
|
||||
totalOrders: allOrders.length,
|
||||
totalProducts: overview.totalProducts || 0,
|
||||
lowStockCount: overview.lowStockCount || 0,
|
||||
recentOrders: allOrders.slice(0, 5),
|
||||
lowStockItems: overview.lowStock || []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch admin stats', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardStats();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-slate-500 text-xs">Loading Store Dashboard...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 text-slate-900">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Executive Store Overview (India)</h1>
|
||||
<p className="text-xs text-slate-500">Live sales metrics, inventory stock status, and fulfillment updates.</p>
|
||||
</div>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-semibold">
|
||||
<span>Total Sales Revenue</span>
|
||||
<IndianRupee className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-slate-900">{formatINR(stats.totalSales)}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-semibold">
|
||||
<span>Total Orders Placed</span>
|
||||
<ShoppingBag className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-slate-900">{stats.totalOrders}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-semibold">
|
||||
<span>Catalog Guitars</span>
|
||||
<Users className="w-5 h-5 text-sky-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-slate-900">{stats.totalProducts}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-semibold">
|
||||
<span>Low Stock Alerts</span>
|
||||
<AlertTriangle className="w-5 h-5 text-rose-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-rose-600">{stats.lowStockCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* Recent Orders Table */}
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-slate-900 font-serif">Recent Store Orders</h3>
|
||||
<Link to="/admin/orders" className="text-xs text-amber-600 font-bold hover:underline flex items-center gap-1">
|
||||
View All <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
{stats.recentOrders.map((ord) => (
|
||||
<div key={ord._id} className="p-3.5 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-amber-700 font-mono">{ord.orderNumber}</div>
|
||||
<div className="text-slate-600">{ord.user?.name || 'Customer'}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-slate-900">{formatINR(ord.totalAmount)}</div>
|
||||
<span className="text-[10px] text-emerald-700 font-bold">{ord.orderStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alerts */}
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-slate-900 font-serif">Low Stock Inventory Alerts</h3>
|
||||
<Link to="/admin/inventory" className="text-xs text-amber-600 font-bold hover:underline flex items-center gap-1">
|
||||
Manage Stock <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
{stats.lowStockItems.length === 0 ? (
|
||||
<div className="text-slate-400 py-4 text-center">All guitar stock levels are optimal.</div>
|
||||
) : (
|
||||
stats.lowStockItems.map((item) => (
|
||||
<div key={item._id} className="p-3 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={item.images?.[0]} alt="" className="w-10 h-10 object-cover rounded-lg border border-slate-200" />
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 line-clamp-1">{item.name}</div>
|
||||
<div className="text-slate-400 font-mono text-[11px]">SKU: {item.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-rose-700 font-bold px-3 py-1 bg-rose-50 border border-rose-200 rounded-full">
|
||||
{item.stock} Units
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
35
client/src/pages/admin/AdminEmailTemplates.jsx
Normal file
35
client/src/pages/admin/AdminEmailTemplates.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mail } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminEmailTemplates = () => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/email-templates').then(res => setTemplates(res.data.data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Transactional Email Templates</h1>
|
||||
<p className="text-xs text-slate-500">Preview transactional order confirmation and invoice email formats.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{templates.map((tpl) => (
|
||||
<div key={tpl._id} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-2 text-xs">
|
||||
<h3 className="font-bold text-amber-700 text-sm">{tpl.name}</h3>
|
||||
<div className="text-slate-700"><strong>Subject:</strong> {tpl.subject}</div>
|
||||
<div className="text-slate-500 font-mono text-[11px]">Placeholders: {tpl.placeholders?.join(', ')}</div>
|
||||
<div className="p-3.5 bg-slate-50 rounded-xl border border-slate-200 text-slate-600 font-mono line-clamp-3">
|
||||
{tpl.bodyHtml}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEmailTemplates;
|
||||
161
client/src/pages/admin/AdminInventory.jsx
Normal file
161
client/src/pages/admin/AdminInventory.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminInventory = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [overview, setOverview] = useState({ products: [], lowStock: [], outOfStock: [] });
|
||||
const [history, setHistory] = useState([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState('');
|
||||
const [newStock, setNewStock] = useState(10);
|
||||
const [changeType, setChangeType] = useState('Restock');
|
||||
const [reason, setReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [ovRes, histRes] = await Promise.all([
|
||||
api.get('/inventory/overview'),
|
||||
api.get('/inventory/history')
|
||||
]);
|
||||
setOverview(ovRes.data.data);
|
||||
setHistory(histRes.data.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleAdjustStock = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedProduct) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await api.post('/inventory/adjust', {
|
||||
productId: selectedProduct,
|
||||
newStock: Number(newStock),
|
||||
changeType,
|
||||
reason
|
||||
});
|
||||
dispatch(showToast({ message: 'Stock level updated and audit logged!', type: 'success' }));
|
||||
setReason('');
|
||||
loadData();
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: 'Failed to update stock', type: 'error' }));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Stock Audit & Inventory History</h1>
|
||||
<p className="text-xs text-slate-500">Track all stock changes, perform manual restocks, and inspect change reasons.</p>
|
||||
</div>
|
||||
|
||||
{/* Stock Adjustment Form */}
|
||||
<form onSubmit={handleAdjustStock} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-2xl">
|
||||
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">Manual Stock Level Adjustment</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Select Product</label>
|
||||
<select
|
||||
required
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="">Select Guitar</option>
|
||||
{overview.products?.map((p) => (
|
||||
<option key={p._id} value={p._id}>{p.name} (Current: {p.stock})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">New Stock Level</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={newStock}
|
||||
onChange={(e) => setNewStock(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Change Type</label>
|
||||
<select
|
||||
value={changeType}
|
||||
onChange={(e) => setChangeType(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="Restock">Restock</option>
|
||||
<option value="Manual Adjustment">Manual Adjustment</option>
|
||||
<option value="Order Cancelled">Order Cancelled</option>
|
||||
<option value="Returned Product">Returned Product</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Adjustment Reason / Luthier Note</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Batch #409 Restocked from Custom Shop Factory"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-md shadow-amber-500/20"
|
||||
>
|
||||
Submit Stock Audit Change
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* History Audit Log */}
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-3xl p-6 space-y-4">
|
||||
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">Inventory Change History Audit Log</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500 font-semibold">
|
||||
<tr>
|
||||
<th className="p-3">Product</th>
|
||||
<th className="p-3">Change Type</th>
|
||||
<th className="p-3">Qty Changed</th>
|
||||
<th className="p-3">Prev / New</th>
|
||||
<th className="p-3">Reason</th>
|
||||
<th className="p-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{history.map((log) => (
|
||||
<tr key={log._id}>
|
||||
<td className="p-3 font-bold text-slate-900">{log.product?.name || log.sku}</td>
|
||||
<td className="p-3"><span className="px-2 py-0.5 bg-amber-50 border border-amber-200 text-amber-700 font-semibold rounded">{log.changeType}</span></td>
|
||||
<td className="p-3 font-bold text-slate-900">{log.quantityChanged > 0 ? `+${log.quantityChanged}` : log.quantityChanged}</td>
|
||||
<td className="p-3 text-slate-500">{log.previousStock} → {log.newStock}</td>
|
||||
<td className="p-3 text-slate-600">{log.reason}</td>
|
||||
<td className="p-3 text-slate-400">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInventory;
|
||||
129
client/src/pages/admin/AdminLayout.jsx
Normal file
129
client/src/pages/admin/AdminLayout.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { LayoutDashboard, ShoppingBag, Layers, Tag, Image, Settings, MessageSquare, Mail, Shield, ExternalLink, Menu, X, LogOut } from 'lucide-react';
|
||||
import { logout } from '../../store/slices/authSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
|
||||
const AdminLayout = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const handleAdminLogout = () => {
|
||||
dispatch(logout());
|
||||
dispatch(showToast({ message: 'Admin logged out', type: 'info' }));
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Dashboard', path: '/admin', icon: LayoutDashboard },
|
||||
{ name: 'Products Catalog', path: '/admin/products', icon: ShoppingBag },
|
||||
{ name: 'Inventory & Audit', path: '/admin/inventory', icon: Layers },
|
||||
{ name: 'Orders Fulfillment', path: '/admin/orders', icon: Tag },
|
||||
{ name: 'Categories', path: '/admin/categories', icon: Layers },
|
||||
{ name: 'Guitar Brands', path: '/admin/brands', icon: Shield },
|
||||
{ name: 'Coupons & Promos', path: '/admin/coupons', icon: Tag },
|
||||
{ name: 'Hero Banners', path: '/admin/banners', icon: Image },
|
||||
{ name: 'Contact Inquiries', path: '/admin/messages', icon: MessageSquare },
|
||||
{ name: 'Email Templates', path: '/admin/email-templates', icon: Mail },
|
||||
{ name: 'Website Settings', path: '/admin/settings', icon: Settings }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 text-slate-900 flex font-sans">
|
||||
|
||||
{/* Sidebar (Light Theme) */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-slate-200 p-4 flex flex-col justify-between shadow-sm transition-transform duration-300 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="w-9 h-9 bg-amber-500 rounded-xl flex items-center justify-center font-bold text-white shadow-sm">AG</div>
|
||||
<div>
|
||||
<span className="font-serif font-bold text-sm text-slate-900 block">ADMIN PORTAL</span>
|
||||
<span className="text-[10px] text-amber-600 font-bold uppercase">ANTIGRAVITY INDIA</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setSidebarOpen(false)} className="lg:hidden p-1 text-slate-400">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = location.pathname === link.path;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-semibold transition-all ${
|
||||
isActive
|
||||
? 'bg-amber-500 text-white font-bold shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Requirement 14: Clear Admin Logout & Preview Storefront */}
|
||||
<div className="pt-4 border-t border-slate-100 space-y-2">
|
||||
<Link
|
||||
to="/"
|
||||
target="_blank"
|
||||
className="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold text-amber-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span>Preview Storefront</span>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleAdminLogout}
|
||||
className="w-full flex items-center justify-between p-2.5 bg-rose-50 border border-rose-100 rounded-xl text-xs font-bold text-rose-600 hover:bg-rose-100 transition-colors"
|
||||
>
|
||||
<span>Sign Out Admin</span>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 lg:ml-64 flex flex-col min-w-0">
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="h-16 border-b border-slate-200 bg-white/80 backdrop-blur-md px-6 flex items-center justify-between shadow-sm">
|
||||
<button onClick={() => setSidebarOpen(true)} className="lg:hidden p-2 text-slate-600">
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="text-xs font-bold text-slate-700">Antigravity Store Administration (India)</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-slate-800">{userInfo?.name || 'Admin'}</span>
|
||||
<button
|
||||
onClick={handleAdminLogout}
|
||||
className="text-xs font-bold text-rose-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-6 md:p-8 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
88
client/src/pages/admin/AdminLoginPage.jsx
Normal file
88
client/src/pages/admin/AdminLoginPage.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Shield, Lock } from 'lucide-react';
|
||||
import { login } from '../../store/slices/authSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
|
||||
const AdminLoginPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { loading } = useSelector((state) => state.auth);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(login({ email, password }))
|
||||
.unwrap()
|
||||
.then((user) => {
|
||||
if (user.role === 'Admin') {
|
||||
dispatch(showToast({ message: 'Welcome to Admin Portal', type: 'success' }));
|
||||
navigate('/admin');
|
||||
} else {
|
||||
dispatch(showToast({ message: 'Access Denied: Admin privileges required', type: 'error' }));
|
||||
}
|
||||
})
|
||||
.catch((err) => dispatch(showToast({ message: err || 'Invalid admin credentials', type: 'error' })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-white border border-slate-200 rounded-3xl p-8 shadow-xl space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-14 h-14 bg-amber-500 rounded-2xl flex items-center justify-center font-bold text-white text-2xl mx-auto shadow-md shadow-amber-500/20">
|
||||
<Shield className="w-8 h-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Admin Portal Authentication</h1>
|
||||
<p className="text-xs text-slate-500">Antigravity Guitars Store Administration</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Admin Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@guitars.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900 focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Security Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900 focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-md transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
{loading ? 'Authenticating Admin...' : 'Sign In To Admin Portal'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] text-slate-600 space-y-1">
|
||||
<div className="font-bold text-amber-700">Seeded Admin Quick Credentials:</div>
|
||||
<div>Email: <code className="text-slate-900 font-bold">admin@guitars.com</code></div>
|
||||
<div>Password: <code className="text-slate-900 font-bold">Admin@123</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
|
||||
51
client/src/pages/admin/AdminMessages.jsx
Normal file
51
client/src/pages/admin/AdminMessages.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MessageSquare, CheckCircle } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminMessages = () => {
|
||||
const [messages, setMessages] = useState([]);
|
||||
|
||||
const fetchMsgs = () => api.get('/contact').then(res => setMessages(res.data.data));
|
||||
|
||||
useEffect(() => { fetchMsgs(); }, []);
|
||||
|
||||
const markResolved = async (id) => {
|
||||
await api.put(`/contact/${id}/status`, { status: 'Resolved' });
|
||||
fetchMsgs();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Customer Contact Submissions</h1>
|
||||
<p className="text-xs text-slate-500">Review and resolve customer inquiries sent through the store contact form.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="p-8 text-center bg-white border border-slate-200 shadow-sm rounded-3xl text-xs text-slate-500">
|
||||
No contact submissions found.
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div key={m._id} className="p-5 bg-white border border-slate-200 shadow-sm rounded-2xl flex flex-col sm:flex-row items-start justify-between gap-4 text-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-slate-900 text-sm">{m.name} ({m.email})</div>
|
||||
<div className="text-amber-700 font-semibold">{m.subject}</div>
|
||||
<p className="text-slate-600 mt-1">{m.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => markResolved(m._id)}
|
||||
className="py-1.5 px-3 bg-slate-100 hover:bg-emerald-50 hover:text-emerald-700 text-slate-700 font-bold rounded-lg border border-slate-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Mark Resolved
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMessages;
|
||||
165
client/src/pages/admin/AdminOrders.jsx
Normal file
165
client/src/pages/admin/AdminOrders.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Edit3, CheckCircle2, Truck, Package } from 'lucide-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import api from '../../api/axios';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
|
||||
const AdminOrders = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
const [trackingNumber, setTrackingNumber] = useState('');
|
||||
const [courierProvider, setCourierProvider] = useState('BlueDart Express');
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get('/orders/admin/all');
|
||||
setOrders(res.data.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, []);
|
||||
|
||||
const handleUpdateStatus = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrder) return;
|
||||
try {
|
||||
await api.put(`/orders/${selectedOrder._id}/status`, {
|
||||
orderStatus: newStatus,
|
||||
trackingNumber,
|
||||
courierProvider,
|
||||
shippingProvider: courierProvider
|
||||
});
|
||||
dispatch(showToast({ message: 'Order status & courier tracking updated!', type: 'success' }));
|
||||
setSelectedOrder(null);
|
||||
fetchOrders();
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: 'Failed to update order', type: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Customer Order Management</h1>
|
||||
<p className="text-xs text-slate-500">Update order fulfillment status, assign Indian courier tracking codes, and update tracking info.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-3xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500 font-semibold uppercase">
|
||||
<tr>
|
||||
<th className="p-4">Order ID</th>
|
||||
<th className="p-4">Customer</th>
|
||||
<th className="p-4">Total</th>
|
||||
<th className="p-4">Payment</th>
|
||||
<th className="p-4">Fulfillment Status</th>
|
||||
<th className="p-4 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{orders.map((ord) => (
|
||||
<tr key={ord._id} className="hover:bg-slate-50/80 transition-colors">
|
||||
<td className="p-4 font-bold text-amber-700 font-mono">{ord.orderNumber}</td>
|
||||
<td className="p-4 text-slate-900 font-semibold">{ord.user?.name || ord.shippingAddress?.fullName || 'Guest'}</td>
|
||||
<td className="p-4 font-bold text-slate-900">{formatINR(ord.totalAmount)}</td>
|
||||
<td className="p-4 text-slate-500">
|
||||
<span className="px-2 py-0.5 bg-slate-100 border border-slate-200 rounded font-semibold text-slate-700">{ord.paymentMethod}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="px-3 py-1 bg-amber-50 border border-amber-200 text-amber-700 rounded-full font-bold">
|
||||
{ord.orderStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedOrder(ord);
|
||||
setNewStatus(ord.orderStatus);
|
||||
setTrackingNumber(ord.trackingNumber || '');
|
||||
setCourierProvider(ord.courierProvider || ord.shippingProvider || 'BlueDart Express');
|
||||
}}
|
||||
className="py-1.5 px-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-lg text-xs"
|
||||
>
|
||||
Update Status
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Status Form */}
|
||||
{selectedOrder && (
|
||||
<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={() => setSelectedOrder(null)} />
|
||||
<form onSubmit={handleUpdateStatus} className="relative w-full max-w-md bg-white border border-slate-200 shadow-xl rounded-3xl p-6 space-y-4 z-10 text-xs">
|
||||
<h3 className="text-base font-bold text-slate-900 font-serif">Fulfill Order #{selectedOrder.orderNumber}</h3>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">New Order Status</label>
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['Pending', 'Payment Confirmed', 'Processing', 'Packed', 'Shipped', 'Out for Delivery', 'Delivered', 'Cancelled'].map((st) => (
|
||||
<option key={st} value={st}>{st}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Courier / Delivery Partner</label>
|
||||
<select
|
||||
value={courierProvider}
|
||||
onChange={(e) => setCourierProvider(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['BlueDart Express', 'Delhivery', 'DTDC Express', 'India Post Speed Post', 'Xpressbees', 'Shadowfax', 'FedEx India'].map((cp) => (
|
||||
<option key={cp} value={cp}>{cp}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Tracking / AWB Number</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. BLUEDART-8823-IN"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 font-mono focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={() => setSelectedOrder(null)} className="py-2 px-4 bg-slate-100 text-slate-700 rounded-xl font-semibold border border-slate-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="py-2 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-md shadow-amber-500/20">
|
||||
Save & Notify Customer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminOrders;
|
||||
165
client/src/pages/admin/AdminProducts.jsx
Normal file
165
client/src/pages/admin/AdminProducts.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Edit3, Trash2 } from 'lucide-react';
|
||||
import ProductFormModal from './ProductFormModal';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminProducts = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get('/products?limit=50');
|
||||
setProducts(res.data.data.products || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const handleSaveProduct = async (formData) => {
|
||||
try {
|
||||
if (editingProduct) {
|
||||
await api.put(`/products/${editingProduct._id}`, formData);
|
||||
dispatch(showToast({ message: 'Product updated successfully', type: 'success' }));
|
||||
} else {
|
||||
await api.post('/products', formData);
|
||||
dispatch(showToast({ message: 'Product created successfully', type: 'success' }));
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: err.response?.data?.message || 'Error saving product', type: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (id) => {
|
||||
if (window.confirm('Are you sure you want to delete this guitar product?')) {
|
||||
try {
|
||||
await api.delete(`/products/${id}`);
|
||||
dispatch(showToast({ message: 'Product deleted', type: 'info' }));
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: 'Delete failed', type: 'error' }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-slate-900">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Guitar & Gear Catalog Management</h1>
|
||||
<p className="text-xs text-slate-500">Add, update, or edit guitar specifications, pricing, and inventory stock.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setEditingProduct(null); setIsModalOpen(true); }}
|
||||
className="py-2.5 px-5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add New Guitar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, SKU..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl py-2.5 pl-10 pr-4 text-xs text-slate-900 shadow-sm"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500 font-semibold uppercase">
|
||||
<tr>
|
||||
<th className="p-4">Instrument</th>
|
||||
<th className="p-4">Brand</th>
|
||||
<th className="p-4">Category</th>
|
||||
<th className="p-4">Regular / Sale Price</th>
|
||||
<th className="p-4">Stock</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredProducts.map((p) => (
|
||||
<tr key={p._id} className="hover:bg-slate-50">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={p.images?.[0]} alt="" className="w-10 h-10 object-cover rounded-lg border border-slate-200" />
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 max-w-[200px] truncate">{p.name}</div>
|
||||
<div className="text-slate-400 font-mono text-[11px]">SKU: {p.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-slate-700 font-semibold">{p.brand?.name || 'N/A'}</td>
|
||||
<td className="p-4 text-slate-700">{p.category?.name || 'N/A'}</td>
|
||||
<td className="p-4 font-bold text-slate-900">
|
||||
{formatINR(p.salePrice || p.price)}
|
||||
{p.salePrice > 0 && p.salePrice < p.price && (
|
||||
<span className="block text-[10px] text-slate-400 line-through font-normal">{formatINR(p.price)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold ${
|
||||
p.stock > 3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'
|
||||
}`}>
|
||||
{p.stock} units
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setEditingProduct(p); setIsModalOpen(true); }}
|
||||
className="p-1.5 bg-slate-100 hover:bg-slate-200 text-amber-700 rounded-lg"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(p._id)}
|
||||
className="p-1.5 bg-slate-100 hover:bg-slate-200 text-rose-600 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFormModal
|
||||
product={editingProduct}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveProduct}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProducts;
|
||||
152
client/src/pages/admin/AdminSettings.jsx
Normal file
152
client/src/pages/admin/AdminSettings.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const AdminSettings = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
businessName: 'Antigravity Guitars India',
|
||||
siteName: 'Antigravity Guitars',
|
||||
tagline: 'Premium Electric, Acoustic & Custom Guitars in India',
|
||||
contactEmail: 'support@antigravityguitars.in',
|
||||
phone: '+91 98765 43210',
|
||||
address: '#102 MG Road, Indiranagar, Bengaluru, Karnataka 560038, India',
|
||||
gstin: '29ABCDE1234F1Z5',
|
||||
currencySymbol: '₹',
|
||||
codEnabled: true,
|
||||
maxCodAmount: 50000,
|
||||
freeShippingThreshold: 5000
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings').then(res => setSettings(prev => ({ ...prev, ...res.data.data })));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.put('/settings', settings);
|
||||
dispatch(showToast({ message: 'Store settings updated successfully!', type: 'success' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-serif">Global Storefront & Indian Business Settings</h1>
|
||||
<p className="text-xs text-slate-500">Configure Indian GST, business address, currency, COD limits, and store contact info.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-2xl text-xs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Registered Business Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.businessName || ''}
|
||||
onChange={(e) => setSettings({ ...settings, businessName: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">GSTIN Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gstin || ''}
|
||||
onChange={(e) => setSettings({ ...settings, gstin: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 font-mono uppercase focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Store Front Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.siteName || ''}
|
||||
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Store Tagline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.tagline || ''}
|
||||
onChange={(e) => setSettings({ ...settings, tagline: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Support Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.contactEmail || ''}
|
||||
onChange={(e) => setSettings({ ...settings, contactEmail: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Support Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.phone || ''}
|
||||
onChange={(e) => setSettings({ ...settings, phone: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Full Registered Business Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.address || ''}
|
||||
onChange={(e) => setSettings({ ...settings, address: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-2 border-t border-slate-200">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Currency Symbol</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.currencySymbol || '₹'}
|
||||
onChange={(e) => setSettings({ ...settings, currencySymbol: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 font-bold focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Free Delivery Min (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.freeShippingThreshold || 5000}
|
||||
onChange={(e) => setSettings({ ...settings, freeShippingThreshold: Number(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Max COD Limit (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxCodAmount || 50000}
|
||||
onChange={(e) => setSettings({ ...settings, maxCodAmount: Number(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-md shadow-amber-500/20">
|
||||
Save Business & Store Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
||||
529
client/src/pages/admin/ProductFormModal.jsx
Normal file
529
client/src/pages/admin/ProductFormModal.jsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Trash2, Upload, Check, Star } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const defaultSpecs = {
|
||||
guitarType: 'Electric',
|
||||
bodyShape: 'Stratocaster',
|
||||
bodyMaterial: 'Alder',
|
||||
topMaterial: '',
|
||||
backMaterial: '',
|
||||
sideMaterial: '',
|
||||
neckMaterial: 'Maple',
|
||||
fingerboardMaterial: 'Rosewood',
|
||||
scaleLength: '25.5"',
|
||||
numberOfStrings: 6,
|
||||
frets: 22,
|
||||
pickupConfiguration: 'SSS',
|
||||
pickupType: 'Alnico Single-Coil',
|
||||
bridgeType: '2-Point Tremolo',
|
||||
tuningMachines: 'Standard Cast/Sealed',
|
||||
handOrientation: 'Right-Handed',
|
||||
color: '3-Color Sunburst',
|
||||
finish: 'Gloss Polyurethane',
|
||||
includesCase: false
|
||||
};
|
||||
|
||||
const ProductFormModal = ({ product, isOpen, onClose, onSave }) => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Requirement 13: Reset state cleanly when adding new product
|
||||
const getInitialState = () => {
|
||||
const randomSKU = `GTR-2026-${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
if (product) {
|
||||
return {
|
||||
...product,
|
||||
brand: product.brand?._id || product.brand || '',
|
||||
category: product.category?._id || product.category || '',
|
||||
price: product.price || 0,
|
||||
salePrice: product.salePrice || 0,
|
||||
discountType: product.discountType || 'percentage',
|
||||
discountValue: product.discountValue || 0,
|
||||
stock: product.stock !== undefined ? product.stock : 10,
|
||||
images: product.images || [],
|
||||
primaryImage: product.primaryImage || (product.images?.[0] || ''),
|
||||
specs: { ...defaultSpecs, ...(product.specs || {}) },
|
||||
variants: product.variants || [],
|
||||
isFeatured: Boolean(product.isFeatured),
|
||||
isNewArrival: Boolean(product.isNewArrival),
|
||||
isBestSeller: Boolean(product.isBestSeller)
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
sku: randomSKU, // Requirement 15: Auto-generated SKU
|
||||
brand: '',
|
||||
category: '',
|
||||
price: 0,
|
||||
salePrice: 0,
|
||||
discountType: 'percentage',
|
||||
discountValue: 0,
|
||||
stock: 10,
|
||||
description: '',
|
||||
images: [],
|
||||
primaryImage: '',
|
||||
videoUrl: '',
|
||||
specs: { ...defaultSpecs },
|
||||
variants: [],
|
||||
isFeatured: false,
|
||||
isNewArrival: false,
|
||||
isBestSeller: false
|
||||
};
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(getInitialState);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories').then(res => setCategories(res.data.data)).catch(() => {});
|
||||
api.get('/brands').then(res => setBrands(res.data.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(getInitialState());
|
||||
}, [product, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Requirement 11: Auto calculate sale price from discount value & type
|
||||
const handleDiscountChange = (type, val) => {
|
||||
const regPrice = Number(formData.price) || 0;
|
||||
let computedSalePrice = regPrice;
|
||||
|
||||
if (type === 'percentage') {
|
||||
computedSalePrice = regPrice - (regPrice * (Number(val) || 0)) / 100;
|
||||
} else if (type === 'fixed') {
|
||||
computedSalePrice = regPrice - (Number(val) || 0);
|
||||
}
|
||||
computedSalePrice = Math.max(0, computedSalePrice);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
discountType: type,
|
||||
discountValue: Number(val),
|
||||
salePrice: Math.round(computedSalePrice)
|
||||
});
|
||||
};
|
||||
|
||||
const handlePriceChange = (regPrice) => {
|
||||
const p = Number(regPrice) || 0;
|
||||
let computedSalePrice = p;
|
||||
|
||||
if (formData.discountType === 'percentage') {
|
||||
computedSalePrice = p - (p * (formData.discountValue || 0)) / 100;
|
||||
} else {
|
||||
computedSalePrice = p - (formData.discountValue || 0);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
price: p,
|
||||
salePrice: Math.max(0, Math.round(computedSalePrice))
|
||||
});
|
||||
};
|
||||
|
||||
// Requirement 4: Complete Image Upload Handling
|
||||
const handleImageUpload = async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const data = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
data.append('images', files[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const res = await api.post('/upload/multiple', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
const uploadedUrls = res.data.data.urls || [];
|
||||
const updatedImages = [...formData.images, ...uploadedUrls];
|
||||
setFormData({
|
||||
...formData,
|
||||
images: updatedImages,
|
||||
primaryImage: formData.primaryImage || updatedImages[0]
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.message || 'Failed to upload images');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index) => {
|
||||
const newImgs = [...formData.images];
|
||||
const removed = newImgs.splice(index, 1)[0];
|
||||
let newPrimary = formData.primaryImage;
|
||||
if (newPrimary === removed) {
|
||||
newPrimary = newImgs[0] || '';
|
||||
}
|
||||
setFormData({ ...formData, images: newImgs, primaryImage: newPrimary });
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (formData.price < 0 || formData.salePrice > formData.price) {
|
||||
alert('Invalid price configuration. Sale price cannot exceed regular price.');
|
||||
return;
|
||||
}
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto bg-slate-900/60 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-4xl bg-white border border-slate-200 rounded-3xl p-6 md:p-8 shadow-2xl z-10 my-8 max-h-[90vh] overflow-y-auto text-slate-900">
|
||||
|
||||
<button onClick={onClose} 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>
|
||||
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif mb-6">
|
||||
{product ? 'Edit Guitar Specifications' : 'Add New Instrument To Catalog'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 text-xs">
|
||||
|
||||
{/* Main Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Guitar Product Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Fender Player II Stratocaster"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value, slug: e.target.value.toLowerCase().replace(/\s+/g, '-') })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
{/* Requirement 15: Auto-generated SKU */}
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Auto-Generated SKU</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
readOnly
|
||||
value={formData.sku}
|
||||
className="w-full bg-slate-100 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-700 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Category</label>
|
||||
<select
|
||||
required
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-slate-900"
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c._id} value={c._id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Brand</label>
|
||||
<select
|
||||
required
|
||||
value={formData.brand}
|
||||
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-slate-900"
|
||||
>
|
||||
<option value="">Select Brand</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b._id} value={b._id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Stock Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
value={formData.stock}
|
||||
onChange={(e) => setFormData({ ...formData, stock: Number(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Video Demo URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value={formData.videoUrl}
|
||||
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement 11: Pricing & Discount Section */}
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl space-y-3">
|
||||
<div className="font-bold text-amber-700 uppercase tracking-wider text-[11px]">Pricing & Discount Configuration (₹ INR)</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Regular Price (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
value={formData.price}
|
||||
onChange={(e) => handlePriceChange(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-2.5 text-slate-900 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Discount Type</label>
|
||||
<select
|
||||
value={formData.discountType}
|
||||
onChange={(e) => handleDiscountChange(e.target.value, formData.discountValue)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
>
|
||||
<option value="percentage">Percentage (%)</option>
|
||||
<option value="fixed">Fixed Amount (₹)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Discount Value</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={formData.discountValue}
|
||||
onChange={(e) => handleDiscountChange(formData.discountType, e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Calculated Sale Price (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
readOnly
|
||||
value={formData.salePrice}
|
||||
className="w-full bg-slate-100 border border-slate-200 rounded-xl p-2.5 text-amber-700 font-extrabold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement 4: Product Image Upload Section */}
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Product Gallery Images</label>
|
||||
<label 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 cursor-pointer shadow-sm">
|
||||
<Upload className="w-4 h-4" /> {uploading ? 'Uploading...' : 'Upload Images'}
|
||||
<input type="file" multiple accept="image/*" onChange={handleImageUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.images.length > 0 ? (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3">
|
||||
{formData.images.map((img, idx) => (
|
||||
<div key={idx} className="relative group aspect-square rounded-xl overflow-hidden border border-slate-200 bg-white">
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(idx)}
|
||||
className="absolute top-1 right-1 p-1 bg-rose-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{formData.primaryImage === img && (
|
||||
<span className="absolute bottom-1 left-1 bg-amber-500 text-white font-bold text-[9px] px-1.5 py-0.5 rounded">Primary</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-slate-400 text-xs border border-dashed border-slate-200 rounded-xl">
|
||||
No images uploaded yet. Upload images above.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Requirement 10: Badges & Flags */}
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl space-y-2">
|
||||
<label className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Promotional Badges</label>
|
||||
<div className="flex flex-wrap gap-6 text-xs font-semibold">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isNewArrival}
|
||||
onChange={(e) => setFormData({ ...formData, isNewArrival: e.target.checked })}
|
||||
className="rounded text-amber-500"
|
||||
/>
|
||||
Mark as New Arrival (NEW)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isBestSeller}
|
||||
onChange={(e) => setFormData({ ...formData, isBestSeller: e.target.checked })}
|
||||
className="rounded text-amber-500"
|
||||
/>
|
||||
Mark as Best Seller
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isFeatured}
|
||||
onChange={(e) => setFormData({ ...formData, isFeatured: e.target.checked })}
|
||||
className="rounded text-amber-500"
|
||||
/>
|
||||
Mark as Featured Custom
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirement 9: Full Technical Specifications Fields */}
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl space-y-4">
|
||||
<div className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">All Guitar Technical Specifications</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-600">Instrument Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.guitarType}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, guitarType: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Body Shape</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.bodyShape}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, bodyShape: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Body Material</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.bodyMaterial}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, bodyMaterial: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Top Material</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.topMaterial}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, topMaterial: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Neck Material</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.neckMaterial}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, neckMaterial: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Fingerboard</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.fingerboardMaterial}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, fingerboardMaterial: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Scale Length</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.scaleLength}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, scaleLength: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Frets Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.specs.frets}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, frets: Number(e.target.value) } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Pickup Configuration</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.pickupConfiguration}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, pickupConfiguration: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Bridge Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.bridgeType}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, bridgeType: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Hand Orientation</label>
|
||||
<select
|
||||
value={formData.specs.handOrientation}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, handOrientation: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
>
|
||||
<option value="Right-Handed">Right-Handed</option>
|
||||
<option value="Left-Handed">Left-Handed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600">Finish</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.specs.finish}
|
||||
onChange={(e) => setFormData({ ...formData, specs: { ...formData.specs, finish: e.target.value } })}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Description & Notes</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<button type="button" onClick={onClose} className="py-2.5 px-6 bg-slate-200 text-slate-800 font-bold rounded-xl">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="py-2.5 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl shadow-sm">
|
||||
Save Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductFormModal;
|
||||
24
client/src/pages/customer/CMSPages.jsx
Normal file
24
client/src/pages/customer/CMSPages.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export const AboutUsPage = () => (
|
||||
<div className="max-w-4xl mx-auto px-4 py-16 space-y-6">
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest bg-amber-50 px-3 py-1 rounded-full border border-amber-200">Our Heritage</span>
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">About Antigravity Guitars India</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-600 leading-relaxed">
|
||||
Antigravity Guitars is India's premier destination for guitarists, tone purists, studio musicians, and live stage artists. We curate world-class acoustic, electric, and bass guitars alongside genuine accessories from top global manufacturers.
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-slate-600 leading-relaxed">
|
||||
Every instrument sold through our store undergoes a rigorous 55-point luthier setup inspection before dispatch across India. We partner directly with iconic guitar makers including Fender, Gibson, Martin, Taylor, PRS, Ibanez, and Yamaha.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PolicyPage = ({ title }) => (
|
||||
<div className="max-w-4xl mx-auto px-4 py-16 space-y-6">
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">{title}</h1>
|
||||
<div className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl text-xs text-slate-600 space-y-4">
|
||||
<p>All sales and transactions conducted through Antigravity Guitars India are protected under standard warranty terms and full buyer protection guaranteed.</p>
|
||||
<p>Items may be returned within 30 days of arrival in original factory condition for a full refund or exchange.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
194
client/src/pages/customer/CartPage.jsx
Normal file
194
client/src/pages/customer/CartPage.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShieldCheck, ShoppingBag, Truck } from 'lucide-react';
|
||||
import { fetchCart, updateCartItemAsync, removeCartItemAsync, applyCouponAsync } from '../../store/slices/cartSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
|
||||
const CartPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { items, coupon } = useSelector((state) => state.cart);
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCart());
|
||||
}, [dispatch]);
|
||||
|
||||
const subtotal = items.reduce((acc, item) => {
|
||||
const itemPrice = item.product?.salePrice || item.product?.price || item.price;
|
||||
return acc + itemPrice * item.quantity;
|
||||
}, 0);
|
||||
|
||||
const discountAmount = coupon ? (coupon.discountType === 'percentage' ? (subtotal * coupon.discountValue) / 100 : coupon.discountValue) : 0;
|
||||
const freeShippingThreshold = 5000; // ₹5,000 threshold for India
|
||||
const shippingFee = subtotal >= freeShippingThreshold || subtotal === 0 ? 0 : 499;
|
||||
const totalAmount = Math.max(0, subtotal - discountAmount + shippingFee);
|
||||
|
||||
const freeShippingProgress = Math.min(100, (subtotal / freeShippingThreshold) * 100);
|
||||
|
||||
const handleApplyCoupon = (e) => {
|
||||
e.preventDefault();
|
||||
if (couponCode.trim()) {
|
||||
dispatch(applyCouponAsync(couponCode.trim()))
|
||||
.unwrap()
|
||||
.then(() => dispatch(showToast({ message: 'Coupon applied successfully!', type: 'success' })))
|
||||
.catch((err) => dispatch(showToast({ message: err || 'Invalid coupon', type: 'error' })));
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-20 text-center space-y-4 bg-slate-50">
|
||||
<div className="w-16 h-16 bg-white border border-slate-200 rounded-full flex items-center justify-center mx-auto text-slate-400 shadow-sm">
|
||||
<ShoppingBag className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif">Your Shopping Cart is Empty</h2>
|
||||
<p className="text-xs text-slate-500">Discover hand-crafted custom shop electric, acoustic, and bass guitars.</p>
|
||||
<Link to="/shop" className="inline-block py-3 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-2xl text-xs shadow-sm">
|
||||
Explore Guitar Catalog
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 bg-slate-50 text-slate-900">
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Your Shopping Cart ({items.length} Items)</h1>
|
||||
|
||||
{/* Free Shipping Progress Bar */}
|
||||
<div className="p-4 bg-white border border-slate-200 rounded-2xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs font-semibold">
|
||||
<span className="text-slate-700 flex items-center gap-2">
|
||||
<Truck className="w-4 h-4 text-amber-600" />
|
||||
{subtotal >= freeShippingThreshold
|
||||
? '🎉 You qualify for FREE Express Delivery in India!'
|
||||
: `Add ${formatINR(freeShippingThreshold - subtotal)} more to unlock FREE Delivery`}
|
||||
</span>
|
||||
<span className="text-amber-600 font-bold">{Math.round(freeShippingProgress)}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${freeShippingProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Cart Items Table */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{items.map((item) => {
|
||||
const product = item.product || {};
|
||||
const itemPrice = product.salePrice > 0 ? product.salePrice : (product.price || item.price);
|
||||
|
||||
return (
|
||||
<div key={item._id} className="p-4 bg-white border border-slate-200 rounded-2xl flex flex-col sm:flex-row items-center justify-between gap-4 shadow-sm">
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto">
|
||||
<img
|
||||
src={product.images?.[0] || 'https://images.unsplash.com/photo-1564186763535-ebb21ef5277f?w=300'}
|
||||
alt={product.name}
|
||||
className="w-20 h-20 object-cover rounded-xl border border-slate-200"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-amber-600 uppercase">{product.brand?.name}</span>
|
||||
<h3 className="text-sm font-bold text-slate-900 line-clamp-1">{product.name}</h3>
|
||||
{item.variantColor && (
|
||||
<div className="text-xs text-slate-500">Variant: {item.variantColor} ({item.variantFinish})</div>
|
||||
)}
|
||||
<div className="text-xs font-bold text-slate-900 mt-1">{formatINR(itemPrice)} each</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end gap-6 w-full sm:w-auto">
|
||||
{/* Quantity selector */}
|
||||
<div className="flex items-center bg-slate-100 border border-slate-200 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => dispatch(updateCartItemAsync({ itemId: item._id, quantity: item.quantity - 1 }))}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="w-8 text-center text-xs font-bold text-slate-900">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => dispatch(updateCartItemAsync({ itemId: item._id, quantity: item.quantity + 1 }))}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-extrabold text-amber-700">{formatINR(itemPrice * item.quantity)}</div>
|
||||
|
||||
<button
|
||||
onClick={() => dispatch(removeCartItemAsync(item._id))}
|
||||
className="p-2 text-slate-400 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Order Summary Column */}
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-slate-900 font-serif">Order Summary</h3>
|
||||
|
||||
<div className="space-y-3 text-xs border-b border-slate-100 pb-4">
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-bold text-slate-900">{formatINR(subtotal)}</span>
|
||||
</div>
|
||||
{coupon && (
|
||||
<div className="flex justify-between text-emerald-700">
|
||||
<span>Discount ({coupon.code})</span>
|
||||
<span className="font-bold">-{formatINR(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Estimated Shipping</span>
|
||||
<span className="font-bold text-slate-900">{shippingFee === 0 ? 'FREE' : formatINR(shippingFee)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coupon Code Input */}
|
||||
<form onSubmit={handleApplyCoupon} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Coupon (e.g. WELCOME10)"
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 uppercase focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<button type="submit" className="py-2 px-4 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-xl text-xs">
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-between items-baseline pt-2">
|
||||
<span className="text-sm font-bold text-slate-900">Total Payable</span>
|
||||
<span className="text-2xl font-extrabold text-amber-600">{formatINR(totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/checkout')}
|
||||
className="w-full py-4 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-2xl text-xs sm:text-sm flex items-center justify-center gap-2 shadow-sm transition-all"
|
||||
>
|
||||
Proceed to Secure Checkout <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500 pt-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-600" /> 256-Bit Encrypted Payment
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
306
client/src/pages/customer/CheckoutPage.jsx
Normal file
306
client/src/pages/customer/CheckoutPage.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ShieldCheck, CheckCircle2, CreditCard, Truck, MapPin, ArrowRight, Smartphone, Building } from 'lucide-react';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import { clearCart } from '../../store/slices/cartSlice';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const CheckoutPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { items, coupon } = useSelector((state) => state.cart);
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [shippingAddress, setShippingAddress] = useState({
|
||||
fullName: userInfo?.name || 'Rahul Sharma',
|
||||
mobileNumber: userInfo?.phone || '9812345678',
|
||||
addressLine1: userInfo?.addresses?.[0]?.addressLine1 || 'Flat 402, Green Glen Layout',
|
||||
addressLine2: userInfo?.addresses?.[0]?.addressLine2 || 'Outer Ring Road, Bellandur',
|
||||
landmark: userInfo?.addresses?.[0]?.landmark || 'Near Sakra Hospital',
|
||||
city: userInfo?.addresses?.[0]?.city || 'Bengaluru',
|
||||
district: userInfo?.addresses?.[0]?.district || 'Bengaluru Urban',
|
||||
state: userInfo?.addresses?.[0]?.state || 'Karnataka',
|
||||
postalCode: userInfo?.addresses?.[0]?.postalCode || '560103',
|
||||
country: 'India'
|
||||
});
|
||||
|
||||
const [deliveryMethod, setDeliveryMethod] = useState('express');
|
||||
const [paymentMethod, setPaymentMethod] = useState('Razorpay'); // Razorpay, UPI, Credit/Debit Card, Cash on Delivery
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const subtotal = items.reduce((acc, item) => {
|
||||
const price = item.product?.salePrice || item.product?.price || item.price;
|
||||
return acc + price * item.quantity;
|
||||
}, 0);
|
||||
|
||||
const discountAmount = coupon ? (coupon.discountType === 'percentage' ? (subtotal * coupon.discountValue) / 100 : coupon.discountValue) : 0;
|
||||
const shippingPrice = subtotal >= 5000 ? 0 : 499;
|
||||
const gstRate = 0.18; // 18% GST for musical instruments in India
|
||||
const taxPrice = Number((subtotal * gstRate).toFixed(2));
|
||||
const totalAmount = Math.max(0, subtotal - discountAmount + shippingPrice + taxPrice);
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!userInfo) {
|
||||
dispatch(showToast({ message: 'Please sign in to place an order', type: 'error' }));
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const orderItems = items.map((item) => ({
|
||||
product: item.product?._id || item.product,
|
||||
name: item.product?.name || 'Guitar Product',
|
||||
image: item.product?.images?.[0] || '',
|
||||
price: item.product?.salePrice || item.product?.price || item.price,
|
||||
quantity: item.quantity,
|
||||
variantColor: item.variantColor || '',
|
||||
variantFinish: item.variantFinish || '',
|
||||
sku: item.product?.sku || ''
|
||||
}));
|
||||
|
||||
const res = await api.post('/orders', {
|
||||
orderItems,
|
||||
shippingAddress,
|
||||
paymentMethod,
|
||||
itemsPrice: subtotal,
|
||||
gstPercentage: 18,
|
||||
cgst: Number((taxPrice / 2).toFixed(2)),
|
||||
sgst: Number((taxPrice / 2).toFixed(2)),
|
||||
taxPrice,
|
||||
shippingPrice,
|
||||
discountAmount,
|
||||
totalAmount
|
||||
});
|
||||
|
||||
dispatch(clearCart());
|
||||
dispatch(showToast({ message: 'Order placed successfully!', type: 'success' }));
|
||||
navigate(`/order-confirmation/${res.data.data._id}`);
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: err.response?.data?.message || 'Failed to place order', type: 'error' }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 bg-slate-50 text-slate-900">
|
||||
|
||||
{/* Step Tracker */}
|
||||
<div className="flex items-center justify-between max-w-2xl mx-auto border-b border-slate-200 pb-6 text-xs font-bold">
|
||||
<div className={`flex items-center gap-2 ${step >= 1 ? 'text-amber-600' : 'text-slate-400'}`}>
|
||||
<MapPin className="w-4 h-4" /> 1. Shipping Address (India)
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${step >= 2 ? 'text-amber-600' : 'text-slate-400'}`}>
|
||||
<CreditCard className="w-4 h-4" /> 2. Payment Architecture
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${step >= 3 ? 'text-amber-600' : 'text-slate-400'}`}>
|
||||
<CheckCircle2 className="w-4 h-4" /> 3. Review & Order
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
{/* Step 1: Indian Shipping Address Form */}
|
||||
{step === 1 && (
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-4 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-slate-900 font-serif">Indian Delivery Address</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.fullName}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, fullName: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">10-Digit Mobile Number (+91)</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="9812345678"
|
||||
value={shippingAddress.mobileNumber}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, mobileNumber: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Flat, House No., Building</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.addressLine1}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, addressLine1: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Area, Street, Sector</label>
|
||||
<input
|
||||
type="text"
|
||||
value={shippingAddress.addressLine2}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, addressLine2: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Landmark (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={shippingAddress.landmark}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, landmark: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">City / Town</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.city}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, city: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">State</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingAddress.state}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, state: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">6-Digit PIN Code</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
value={shippingAddress.postalCode}
|
||||
onChange={(e) => setShippingAddress({ ...shippingAddress, postalCode: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-slate-900 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="py-3.5 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
Continue to Payment Method <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Payment Method Selection */}
|
||||
{step === 2 && (
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-slate-900 font-serif">Indian Payment Options</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('Razorpay')}
|
||||
className={`w-full p-4 rounded-2xl border text-left flex items-center justify-between transition-all ${
|
||||
paymentMethod === 'Razorpay'
|
||||
? 'border-amber-500 bg-amber-50 text-slate-900 font-bold'
|
||||
: 'border-slate-200 bg-white text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Smartphone className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<div className="text-sm font-bold">UPI / GPay / PhonePe / Paytm / Cards (Razorpay)</div>
|
||||
<div className="text-xs text-slate-500">Instant verification & free courier priority</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPaymentMethod('Cash on Delivery')}
|
||||
className={`w-full p-4 rounded-2xl border text-left flex items-center justify-between transition-all ${
|
||||
paymentMethod === 'Cash on Delivery'
|
||||
? 'border-amber-500 bg-amber-50 text-slate-900 font-bold'
|
||||
: 'border-slate-200 bg-white text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<div className="text-sm font-bold">Cash on Delivery (COD)</div>
|
||||
<div className="text-xs text-slate-500">Pay upon physical courier inspection in India</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button onClick={() => setStep(1)} className="py-3 px-6 bg-slate-200 text-slate-800 font-bold rounded-xl text-xs">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
className="py-3.5 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-2"
|
||||
>
|
||||
Review Order <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review Order */}
|
||||
{step === 3 && (
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-slate-900 font-serif">Review & Confirm Purchase</h3>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-200 text-xs space-y-2">
|
||||
<div className="font-bold text-amber-700">Delivery Destination:</div>
|
||||
<div className="text-slate-800 font-semibold">{shippingAddress.fullName} (+91 {shippingAddress.mobileNumber})</div>
|
||||
<div className="text-slate-600">{shippingAddress.addressLine1}, {shippingAddress.addressLine2}, {shippingAddress.city}, {shippingAddress.state} – {shippingAddress.postalCode}</div>
|
||||
<div className="font-bold text-amber-700 pt-2">Selected Payment:</div>
|
||||
<div className="text-slate-800 font-semibold">{paymentMethod}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-4 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-2xl text-sm flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
{isSubmitting ? 'Authorizing Purchase...' : 'Place Order & Pay'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Order Summary Column */}
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl space-y-4 h-fit shadow-sm">
|
||||
<h3 className="text-base font-bold text-slate-900 font-serif">Price Breakdown (₹ INR)</h3>
|
||||
<div className="space-y-3 text-xs border-b border-slate-100 pb-4">
|
||||
<div className="flex justify-between text-slate-600"><span>Items ({items.length})</span><span>{formatINR(subtotal)}</span></div>
|
||||
<div className="flex justify-between text-slate-600"><span>GST (18%)</span><span>{formatINR(taxPrice)}</span></div>
|
||||
<div className="flex justify-between text-slate-600"><span>Shipping</span><span>{shippingPrice === 0 ? 'FREE' : formatINR(shippingPrice)}</span></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-extrabold text-slate-900">
|
||||
<span>Total Payable</span>
|
||||
<span className="text-amber-600">{formatINR(totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutPage;
|
||||
116
client/src/pages/customer/ComparePage.jsx
Normal file
116
client/src/pages/customer/ComparePage.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X, ShoppingBag, ArrowLeft, Check, Minus } from 'lucide-react';
|
||||
import { removeFromCompare, clearCompare } from '../../store/slices/compareSlice';
|
||||
import { addToCartAsync } from '../../store/slices/cartSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
|
||||
const ComparePage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { items } = useSelector((state) => state.compare);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-20 text-center space-y-4 bg-slate-50">
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif">No Guitars Selected for Comparison</h2>
|
||||
<p className="text-xs text-slate-500">Click the compare icon on any product card to add up to 4 instruments.</p>
|
||||
<Link to="/shop" className="inline-block py-3 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-sm">
|
||||
Browse Shop
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const specKeys = [
|
||||
{ label: 'Brand', key: 'brand.name' },
|
||||
{ label: 'Guitar Type', key: 'specs.guitarType' },
|
||||
{ label: 'Body Shape', key: 'specs.bodyShape' },
|
||||
{ label: 'Body Wood', key: 'specs.bodyMaterial' },
|
||||
{ label: 'Top Wood', key: 'specs.topMaterial' },
|
||||
{ label: 'Neck Wood', key: 'specs.neckMaterial' },
|
||||
{ label: 'Fingerboard', key: 'specs.fingerboardMaterial' },
|
||||
{ label: 'Scale Length', key: 'specs.scaleLength' },
|
||||
{ label: 'Pickups', key: 'specs.pickupConfiguration' },
|
||||
{ label: 'Bridge Type', key: 'specs.bridgeType' },
|
||||
{ label: 'Handedness', key: 'specs.handOrientation' },
|
||||
{ label: 'Frets', key: 'specs.frets' },
|
||||
{ label: 'Case Included', key: 'specs.includesCase', isBool: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 bg-slate-50 text-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||
<div>
|
||||
<Link to="/shop" className="text-xs font-semibold text-amber-600 hover:underline flex items-center gap-1 mb-1">
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Back to Shop
|
||||
</Link>
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Guitar Specification Comparison Matrix</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatch(clearCompare())}
|
||||
className="text-xs font-semibold text-rose-600 hover:underline"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="overflow-x-auto bg-white border border-slate-200 rounded-3xl shadow-sm">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 bg-slate-100 border-b border-r border-slate-200 text-slate-700 font-bold w-48">Specification</th>
|
||||
{items.map((p) => (
|
||||
<th key={p._id} className="p-4 bg-white border-b border-r border-slate-200 min-w-[220px]">
|
||||
<div className="space-y-3 relative text-center">
|
||||
<button
|
||||
onClick={() => dispatch(removeFromCompare(p._id))}
|
||||
className="absolute top-0 right-0 p-1 text-slate-400 hover:text-rose-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<img src={p.images?.[0]} alt="" className="w-28 h-24 object-cover rounded-xl mx-auto border border-slate-200" />
|
||||
<h3 className="font-bold text-slate-900 text-xs line-clamp-2">{p.name}</h3>
|
||||
<div className="text-amber-700 font-extrabold text-sm">{formatINR(p.salePrice || p.price)}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
dispatch(addToCartAsync({ productId: p._id, quantity: 1 }));
|
||||
dispatch(showToast({ message: `${p.name} added to cart`, type: 'success' }));
|
||||
}}
|
||||
className="w-full py-2 bg-slate-900 hover:bg-amber-600 text-white font-bold rounded-xl flex items-center justify-center gap-1 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" /> Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{specKeys.map((row) => (
|
||||
<tr key={row.label} className="hover:bg-slate-50">
|
||||
<td className="p-4 bg-slate-50 border-r border-slate-200 font-bold text-slate-700">{row.label}</td>
|
||||
{items.map((p) => {
|
||||
const val = row.key.split('.').reduce((o, i) => (o ? o[i] : undefined), p);
|
||||
return (
|
||||
<td key={p._id} className="p-4 border-r border-slate-200 text-slate-700 font-medium">
|
||||
{row.isBool ? (
|
||||
val ? <Check className="w-4 h-4 text-emerald-600" /> : <Minus className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
val || 'N/A'
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComparePage;
|
||||
140
client/src/pages/customer/ContactPage.jsx
Normal file
140
client/src/pages/customer/ContactPage.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Phone, MapPin, Send, MessageSquare } from 'lucide-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const ContactPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: 'General Inquiry',
|
||||
message: ''
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await api.post('/contact', formData);
|
||||
dispatch(showToast({ message: 'Thank you! Your message has been sent to our tone specialists.', type: 'success' }));
|
||||
setFormData({ name: '', email: '', phone: '', subject: 'General Inquiry', message: '' });
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: 'Failed to submit contact form', type: 'error' }));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 space-y-12">
|
||||
<div className="text-center max-w-2xl mx-auto space-y-3">
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest bg-amber-50 px-3 py-1 rounded-full border border-amber-200">Connect With Antigravity</span>
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-900 font-serif">Speak With A Guitar Specialist</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-600">
|
||||
Have questions about neck profile specs, pickup wiring, or custom luthier setups? Our guitar support team is ready to assist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Info */}
|
||||
<div className="p-8 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-8">
|
||||
<h3 className="text-xl font-bold text-slate-900 font-serif">India Experience Center & Store</h3>
|
||||
<div className="space-y-6 text-xs text-slate-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 text-amber-600 rounded-2xl">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm">Bengaluru Experience Store</div>
|
||||
<div className="text-slate-600">#102 MG Road, Indiranagar</div>
|
||||
<div className="text-slate-600">Bengaluru, Karnataka 560038, India</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 text-amber-600 rounded-2xl">
|
||||
<Phone className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm">Customer Helpline (India)</div>
|
||||
<div className="text-slate-600">+91 98765 43210 / 1800 123 4567 (Toll Free)</div>
|
||||
<div className="text-slate-400">Mon-Sat 10:00 AM - 7:00 PM IST</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 text-amber-600 rounded-2xl">
|
||||
<Mail className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm">Email Inquiries</div>
|
||||
<div className="text-slate-600">support@antigravityguitars.in</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<form onSubmit={handleSubmit} className="p-8 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4">
|
||||
<h3 className="text-xl font-bold text-slate-900 font-serif mb-4">Send Us A Message</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Your Message</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl p-4 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="py-3 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md shadow-amber-500/20"
|
||||
>
|
||||
<Send className="w-4 h-4" /> {submitting ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactPage;
|
||||
250
client/src/pages/customer/HomePage.jsx
Normal file
250
client/src/pages/customer/HomePage.jsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Compass, Sparkles, ArrowRight, ChevronRight, Zap } from 'lucide-react';
|
||||
import { openGuitarFinder } from '../../store/slices/uiSlice';
|
||||
import ProductCard from '../../components/ProductCard';
|
||||
import { ProductGridSkeleton } from '../../components/SkeletonLoaders';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const HomePage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [banners, setBanners] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [featuredProducts, setFeaturedProducts] = useState([]);
|
||||
const [newArrivals, setNewArrivals] = useState([]);
|
||||
const [bestSellers, setBestSellers] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('featured');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadHomeData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [bannerRes, catRes, brandRes, featRes, newRes, bestRes] = await Promise.all([
|
||||
api.get('/banners'),
|
||||
api.get('/categories'),
|
||||
api.get('/brands'),
|
||||
api.get('/products?isFeatured=true&limit=4'), // Requirement 7: Exactly 4 products
|
||||
api.get('/products?isNewArrival=true&limit=4'),
|
||||
api.get('/products?isBestSeller=true&limit=4')
|
||||
]);
|
||||
|
||||
setBanners(bannerRes.data.data || []);
|
||||
// Requirement 6: Display top 4 active featured categories
|
||||
const allCats = catRes.data.data || [];
|
||||
setCategories(allCats.slice(0, 4));
|
||||
|
||||
setBrands(brandRes.data.data || []);
|
||||
setFeaturedProducts(featRes.data.data.products || []);
|
||||
setNewArrivals(newRes.data.data.products || []);
|
||||
setBestSellers(bestRes.data.data.products || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load home page data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHomeData();
|
||||
}, []);
|
||||
|
||||
const displayedProducts =
|
||||
activeTab === 'featured' ? featuredProducts :
|
||||
activeTab === 'new' ? newArrivals : bestSellers;
|
||||
|
||||
return (
|
||||
<div className="space-y-16 pb-20 bg-slate-50 text-slate-900">
|
||||
|
||||
{/* Requirement 5: Hero Banner Section with Left-Aligned Title, Subtitle, and Buttons */}
|
||||
<section className="relative overflow-hidden bg-slate-900 border-b border-slate-200">
|
||||
{banners.length > 0 ? (
|
||||
<div className="relative min-h-[500px] md:min-h-[600px] flex items-center">
|
||||
<img
|
||||
src={banners[0].desktopImage}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-50"
|
||||
/>
|
||||
{/* Left-aligned gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-transparent" />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 sm:px-8 lg:px-12 w-full py-16">
|
||||
{/* Left Aligned Content */}
|
||||
<div className="max-w-2xl text-left space-y-5 animate-fadeIn">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-amber-500/20 border border-amber-500/40 text-amber-400 text-xs font-bold rounded-full uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5" /> 2026 Guitar Heritage Collection
|
||||
</span>
|
||||
<h1 className="text-4xl sm:text-6xl font-extrabold text-white font-serif tracking-tight leading-tight text-left">
|
||||
{banners[0].title}
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-slate-300 font-normal leading-relaxed text-left max-w-xl">
|
||||
{banners[0].subtitle}
|
||||
</p>
|
||||
<div className="pt-4 flex flex-wrap gap-4 justify-start">
|
||||
<Link
|
||||
to={banners[0].buttonLink || '/shop'}
|
||||
className="py-3.5 px-7 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-sm shadow-md transition-all flex items-center gap-2"
|
||||
>
|
||||
{banners[0].buttonText || 'Explore Shop'} <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => dispatch(openGuitarFinder())}
|
||||
className="py-3.5 px-7 bg-white/10 hover:bg-white/20 border border-white/30 text-white font-bold rounded-xl text-sm backdrop-blur-sm transition-all flex items-center gap-2"
|
||||
>
|
||||
<Compass className="w-4 h-4 text-amber-400" /> Interactive Tone Finder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-24 text-center text-slate-400">Loading Banners...</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Requirement 6: Featured Categories Grid – Display ONLY 4 Cards */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest">Top Instrument Categories</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-slate-900 font-serif">Featured Collections</h2>
|
||||
</div>
|
||||
<Link to="/shop" className="text-xs font-bold text-amber-600 hover:underline flex items-center gap-1">
|
||||
View All Categories <ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat._id}
|
||||
to={`/shop?category=${cat.slug}`}
|
||||
className="group relative h-56 rounded-2xl overflow-hidden border border-slate-200 bg-white p-6 flex flex-col justify-end transition-all hover:border-amber-500 hover:shadow-md"
|
||||
>
|
||||
<img
|
||||
src={cat.homeImage || cat.image || cat.fallbackImage}
|
||||
alt={cat.name}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-900/40 to-transparent" />
|
||||
<div className="relative z-10 text-left">
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-amber-400 transition-colors">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-300 line-clamp-1 mt-0.5">{cat.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Guitar Finder Banner */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative rounded-3xl bg-white border border-slate-200 p-8 sm:p-12 overflow-hidden shadow-sm flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="space-y-3 max-w-xl text-left">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-full border border-amber-200">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-600" /> TONE RECOMMENDATION TOOL
|
||||
</span>
|
||||
<h3 className="text-2xl sm:text-3xl font-extrabold text-slate-900 font-serif">
|
||||
Need Help Choosing The Perfect Guitar?
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-slate-600 leading-relaxed">
|
||||
Use our interactive 30-second Tone Finder wizard. Select your playing style, neck preferences, and budget to get instant tailored recommendations.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatch(openGuitarFinder())}
|
||||
className="py-4 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-sm whitespace-nowrap shadow-sm"
|
||||
>
|
||||
Launch Guitar Finder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirement 7: Home Page Product Showcase Sections – Display ONLY 4 Products Per Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest">Hand-Selected Guitars</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-slate-900 font-serif">Curated Master Collections</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-slate-200/80 p-1 rounded-xl text-xs font-bold">
|
||||
<button
|
||||
onClick={() => setActiveTab('featured')}
|
||||
className={`px-4 py-2 rounded-lg transition-all ${
|
||||
activeTab === 'featured' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Featured
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('new')}
|
||||
className={`px-4 py-2 rounded-lg transition-all ${
|
||||
activeTab === 'new' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
New Arrivals
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bestsellers')}
|
||||
className={`px-4 py-2 rounded-lg transition-all ${
|
||||
activeTab === 'bestsellers' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Best Sellers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/shop" className="text-xs font-bold text-amber-600 hover:underline hidden sm:flex items-center gap-1">
|
||||
View All <ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<ProductGridSkeleton count={4} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayedProducts.slice(0, 4).map((product) => (
|
||||
<ProductCard key={product._id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center pt-4 sm:hidden">
|
||||
<Link to="/shop" className="inline-block py-3 px-6 bg-slate-900 text-white font-bold rounded-xl text-xs">
|
||||
View All Catalog Products
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Premier Brands */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest">Authorized Dealer</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-slate-900 font-serif">Premier Guitar Manufacturers</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
{brands.map((brand) => (
|
||||
<Link
|
||||
key={brand._id}
|
||||
to={`/shop?brand=${brand.slug}`}
|
||||
className="p-5 bg-white border border-slate-200 hover:border-amber-500 rounded-2xl flex flex-col items-center justify-center text-center transition-all hover:shadow-sm"
|
||||
>
|
||||
<span className="font-bold text-xs text-slate-800 hover:text-amber-600">{brand.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
127
client/src/pages/customer/LoginPage.jsx
Normal file
127
client/src/pages/customer/LoginPage.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login, register } from '../../store/slices/authSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
|
||||
const LoginPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { loading } = useSelector((state) => state.auth);
|
||||
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (isRegister) {
|
||||
dispatch(register({ name, email, password, phone }))
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
dispatch(showToast({ message: 'Account created successfully!', type: 'success' }));
|
||||
navigate('/');
|
||||
})
|
||||
.catch((err) => dispatch(showToast({ message: err || 'Registration failed', type: 'error' })));
|
||||
} else {
|
||||
dispatch(login({ email, password }))
|
||||
.unwrap()
|
||||
.then((user) => {
|
||||
dispatch(showToast({ message: `Welcome back, ${user.name}!`, type: 'success' }));
|
||||
navigate('/');
|
||||
})
|
||||
.catch((err) => dispatch(showToast({ message: err || 'Invalid email or password', type: 'error' })));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-20 bg-slate-50">
|
||||
<div className="p-8 bg-white border border-slate-200 rounded-3xl space-y-6 shadow-sm">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-12 h-12 bg-amber-500 rounded-2xl flex items-center justify-center font-bold text-white mx-auto text-xl shadow-sm">AG</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif">{isRegister ? 'Create Customer Account' : 'Customer Sign In'}</h2>
|
||||
<p className="text-xs text-slate-500">Access orders, saved guitars & tone recommendations</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{isRegister && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Rahul Sharma"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Mobile Number (+91)</label>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="9812345678"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 font-semibold mb-1 block">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-sm transition-all"
|
||||
>
|
||||
{loading ? 'Authenticating...' : isRegister ? 'Register Account' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] text-slate-600 space-y-1">
|
||||
<div className="font-bold text-amber-700">Customer Demo Credentials:</div>
|
||||
<div>Email: <code className="text-slate-900 font-bold">john@example.com</code></div>
|
||||
<div>Password: <code className="text-slate-900 font-bold">Customer@123</code></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs">
|
||||
<button
|
||||
onClick={() => setIsRegister(!isRegister)}
|
||||
className="text-amber-600 font-semibold hover:underline"
|
||||
>
|
||||
{isRegister ? 'Already registered? Sign In' : "New customer? Create an account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
77
client/src/pages/customer/OrderConfirmationPage.jsx
Normal file
77
client/src/pages/customer/OrderConfirmationPage.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const OrderConfirmationPage = () => {
|
||||
const { id } = useParams();
|
||||
const [order, setOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/orders/${id}`)
|
||||
.then((res) => setOrder(res.data.data))
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading || !order) {
|
||||
return <div className="max-w-7xl mx-auto px-4 py-20 text-center text-slate-500">Retrieving Order Receipt...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-16 text-center space-y-8 bg-slate-50 text-slate-900">
|
||||
<div className="w-20 h-20 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto border border-emerald-200 shadow-sm">
|
||||
<CheckCircle2 className="w-10 h-10" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest">Order Confirmed & Placed in India</span>
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Thank You for Your Order!</h1>
|
||||
<p className="text-xs text-slate-500">Order Number: <strong className="text-slate-900 font-mono">{order.orderNumber}</strong></p>
|
||||
</div>
|
||||
|
||||
{/* Summary Box */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 text-left space-y-4 shadow-sm">
|
||||
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wider border-b border-slate-100 pb-3">Purchased Items</h3>
|
||||
<div className="space-y-3">
|
||||
{order.orderItems.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={item.image} alt="" className="w-12 h-12 object-cover rounded-lg border border-slate-200" />
|
||||
<div>
|
||||
<div className="font-bold text-slate-900">{item.name}</div>
|
||||
<div className="text-slate-500">Qty: {item.quantity} {item.variantColor && `| ${item.variantColor}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-bold text-amber-700">{formatINR(item.price * item.quantity)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 pt-4 flex justify-between text-sm font-bold text-slate-900">
|
||||
<span>Total Paid</span>
|
||||
<span className="text-amber-700">{formatINR(order.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link
|
||||
to={`/track-order/${order._id}`}
|
||||
className="py-3.5 px-8 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-2xl text-xs flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
Track Live Delivery Timeline <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="py-3.5 px-8 bg-white hover:bg-slate-50 text-slate-800 font-bold rounded-2xl text-xs border border-slate-200 shadow-sm"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderConfirmationPage;
|
||||
95
client/src/pages/customer/OrderTrackingPage.jsx
Normal file
95
client/src/pages/customer/OrderTrackingPage.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Clock, CheckCircle2, Truck, PackageCheck, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import api from '../../api/axios';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
|
||||
const OrderTrackingPage = () => {
|
||||
const { id } = useParams();
|
||||
const [order, setOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/orders/${id}`)
|
||||
.then((res) => setOrder(res.data.data))
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading || !order) {
|
||||
return <div className="max-w-7xl mx-auto px-4 py-20 text-center text-slate-500">Loading Order Tracking...</div>;
|
||||
}
|
||||
|
||||
const statuses = ['Pending', 'Payment Confirmed', 'Processing', 'Packed', 'Shipped', 'Out for Delivery', 'Delivered'];
|
||||
const currentStep = statuses.indexOf(order.orderStatus);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8">
|
||||
<div>
|
||||
<Link to="/dashboard" className="text-xs font-semibold text-amber-600 hover:text-amber-700 hover:underline flex items-center gap-1 mb-2">
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Back to My Orders
|
||||
</Link>
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Order Tracking & Status Timeline</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">Order ID: <strong className="text-slate-800 font-mono">{order.orderNumber}</strong></p>
|
||||
</div>
|
||||
|
||||
{/* Visual Timeline Progress */}
|
||||
<div className="p-8 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs font-bold border-b border-slate-100 pb-4 gap-2">
|
||||
<div>Status: <span className="text-amber-600 bg-amber-50 px-2.5 py-1 rounded-full border border-amber-200">{order.orderStatus}</span></div>
|
||||
{order.trackingNumber && (
|
||||
<div className="text-slate-600">Tracking #: <span className="text-slate-900 font-mono">{order.trackingNumber}</span> ({order.courierProvider || order.shippingProvider || 'BlueDart Express'})</div>
|
||||
)}
|
||||
<div className="text-slate-900">Total: {formatINR(order.totalAmount)}</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Timeline */}
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-slate-100 z-0" />
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-amber-500 transition-all duration-500 z-0"
|
||||
style={{ width: `${(Math.max(0, currentStep) / (statuses.length - 1)) * 100}%` }}
|
||||
/>
|
||||
|
||||
{statuses.map((st, idx) => {
|
||||
const isCompleted = idx <= currentStep;
|
||||
return (
|
||||
<div key={st} className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all ${
|
||||
isCompleted
|
||||
? 'bg-amber-500 text-white shadow-md shadow-amber-500/30'
|
||||
: 'bg-white border border-slate-300 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle2 className="w-4 h-4" /> : idx + 1}
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold hidden md:block ${isCompleted ? 'text-slate-900 font-bold' : 'text-slate-400'}`}>
|
||||
{st}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status History Logs */}
|
||||
<div className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4">
|
||||
<h3 className="text-xs font-bold text-slate-800 uppercase tracking-wider">Order Status Log</h3>
|
||||
<div className="space-y-3 text-xs">
|
||||
{order.statusHistory?.map((hist, idx) => (
|
||||
<div key={idx} className="p-3.5 bg-slate-50 border border-slate-200 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-bold text-amber-700">{hist.status}</span>
|
||||
<p className="text-slate-600 mt-0.5">{hist.note}</p>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium">{new Date(hist.updatedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderTrackingPage;
|
||||
402
client/src/pages/customer/ProductDetailPage.jsx
Normal file
402
client/src/pages/customer/ProductDetailPage.jsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Star, ShoppingBag, Heart, Sliders, Truck, ShieldCheck, RotateCcw, Check, Plus, Minus, Video, MessageSquare } from 'lucide-react';
|
||||
import { addToCartAsync } from '../../store/slices/cartSlice';
|
||||
import { toggleWishlistAsync } from '../../store/slices/wishlistSlice';
|
||||
import { addToCompare } from '../../store/slices/compareSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import ProductCard from '../../components/ProductCard';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const ProductDetailPage = () => {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
const { products: wishlistProducts } = useSelector((state) => state.wishlist);
|
||||
|
||||
const [product, setProduct] = useState(null);
|
||||
const [relatedProducts, setRelatedProducts] = useState([]);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [selectedVariant, setSelectedVariant] = useState(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Review Form state
|
||||
const [reviewRating, setReviewRating] = useState(5);
|
||||
const [reviewTitle, setReviewTitle] = useState('');
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get(`/products/${slug}`);
|
||||
const p = res.data.data.product;
|
||||
setProduct(p);
|
||||
setRelatedProducts(res.data.data.relatedProducts || []);
|
||||
if (p.variants && p.variants.length > 0) {
|
||||
setSelectedVariant(p.variants[0]);
|
||||
}
|
||||
|
||||
const revRes = await api.get(`/reviews/product/${p._id}`);
|
||||
setReviews(revRes.data.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load product detail', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProduct();
|
||||
}, [slug]);
|
||||
|
||||
if (loading || !product) {
|
||||
return <div className="max-w-7xl mx-auto px-4 py-20 text-center text-slate-500">Loading Instrument Details...</div>;
|
||||
}
|
||||
|
||||
const isWishlisted = wishlistProducts.some((p) => (p._id || p) === product._id);
|
||||
const priceToDisplay = selectedVariant?.price || (product.salePrice > 0 ? product.salePrice : product.price);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (product.stock <= 0) return;
|
||||
dispatch(
|
||||
addToCartAsync({
|
||||
productId: product._id,
|
||||
quantity,
|
||||
variantColor: selectedVariant?.color || '',
|
||||
variantFinish: selectedVariant?.finish || ''
|
||||
})
|
||||
);
|
||||
dispatch(showToast({ message: `${product.name} added to cart`, type: 'success' }));
|
||||
};
|
||||
|
||||
const handleBuyNow = () => {
|
||||
handleAddToCart();
|
||||
navigate('/cart');
|
||||
};
|
||||
|
||||
const handleReviewSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!userInfo) {
|
||||
dispatch(showToast({ message: 'Please sign in to write a review', type: 'error' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmittingReview(true);
|
||||
const res = await api.post('/reviews', {
|
||||
productId: product._id,
|
||||
rating: reviewRating,
|
||||
title: reviewTitle,
|
||||
comment: reviewComment
|
||||
});
|
||||
setReviews([res.data.data, ...reviews]);
|
||||
setReviewTitle('');
|
||||
setReviewComment('');
|
||||
dispatch(showToast({ message: 'Review submitted successfully!', type: 'success' }));
|
||||
} catch (err) {
|
||||
dispatch(showToast({ message: err.response?.data?.message || 'Failed to submit review', type: 'error' }));
|
||||
} finally {
|
||||
setSubmittingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-16 bg-slate-50 text-slate-900">
|
||||
|
||||
{/* Product Detail Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
|
||||
{/* Gallery Column */}
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-[4/3] bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-sm relative">
|
||||
<img
|
||||
src={product.images?.[selectedImage] || product.images?.[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{product.videoUrl && (
|
||||
<a
|
||||
href={product.videoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute bottom-4 right-4 py-2 px-4 bg-white/90 border border-slate-200 backdrop-blur-md rounded-xl text-xs font-bold text-amber-700 flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Video className="w-4 h-4 text-amber-600" /> Watch Audio Demo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{product.images?.length > 1 && (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{product.images.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedImage(idx)}
|
||||
className={`w-20 h-20 rounded-2xl overflow-hidden border-2 flex-shrink-0 transition-all ${
|
||||
selectedImage === idx ? 'border-amber-500 scale-105 shadow-sm' : 'border-slate-200 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Column */}
|
||||
<div className="space-y-6 text-left">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-amber-600 uppercase tracking-widest">{product.brand?.name}</span>
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-slate-900 font-serif mt-1">{product.name}</h1>
|
||||
|
||||
{/* Requirement 16: Hidden internal SKU from customer view */}
|
||||
<div className="flex items-center gap-4 mt-3 text-xs">
|
||||
<div className="flex items-center gap-1 text-amber-500 font-bold">
|
||||
<Star className="w-4 h-4 fill-amber-400" /> {product.rating || '4.9'}
|
||||
</div>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="text-slate-600">{reviews.length} Verified Customer Reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price & Stock */}
|
||||
<div className="p-5 bg-white border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<div className="text-3xl font-extrabold text-slate-900">{formatINR(priceToDisplay)}</div>
|
||||
{product.salePrice > 0 && product.salePrice < product.price && (
|
||||
<div className="text-xs text-slate-400 line-through mt-0.5">{formatINR(product.price)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
{product.stock > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold rounded-full">
|
||||
<Check className="w-3.5 h-3.5" /> In Stock ({product.stock} left)
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 bg-rose-50 border border-rose-200 text-rose-700 text-xs font-bold rounded-full">
|
||||
Out of Stock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs sm:text-sm text-slate-600 leading-relaxed">{product.description}</p>
|
||||
|
||||
{/* Variants Selector */}
|
||||
{product.variants && product.variants.length > 0 && (
|
||||
<div className="space-y-3 pt-4 border-t border-slate-200">
|
||||
<label className="text-xs font-bold text-slate-800">Select Finish & Color Variant:</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedVariant(v)}
|
||||
className={`py-2 px-4 rounded-xl text-xs font-bold border flex items-center gap-2 transition-all ${
|
||||
selectedVariant?.color === v.color
|
||||
? 'border-amber-500 bg-amber-50 text-amber-700 shadow-sm'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full border border-slate-300" style={{ backgroundColor: v.colorHex }} />
|
||||
{v.color} ({v.finish})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity & Actions */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center bg-white border border-slate-200 rounded-xl p-1 shadow-sm">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="p-2 text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-bold text-slate-900">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="p-2 text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock <= 0}
|
||||
className="flex-1 py-3.5 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-2xl text-xs sm:text-sm flex items-center justify-center gap-2 shadow-sm transition-all"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" /> Add To Shopping Cart
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBuyNow}
|
||||
disabled={product.stock <= 0}
|
||||
className="py-3.5 px-6 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-2xl text-xs sm:text-sm border border-slate-900 whitespace-nowrap"
|
||||
>
|
||||
Buy Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-2">
|
||||
<button
|
||||
onClick={() => dispatch(toggleWishlistAsync(product._id))}
|
||||
className="flex-1 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-semibold text-slate-700 hover:text-rose-600 flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<Heart className="w-4 h-4" fill={isWishlisted ? 'currentColor' : 'none'} /> {isWishlisted ? 'Wishlisted' : 'Save to Wishlist'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dispatch(addToCompare(product))}
|
||||
className="flex-1 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-semibold text-slate-700 hover:text-amber-600 flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<Sliders className="w-4 h-4" /> Compare Specs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-slate-200 text-center text-[11px] text-slate-500 font-medium">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Truck className="w-4 h-4 text-amber-600" /> Free India Shipping
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ShieldCheck className="w-4 h-4 text-amber-600" /> 2-Year Warranty
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<RotateCcw className="w-4 h-4 text-amber-600" /> 10-Day Returns
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Requirement 9: Specifications Table (Displaying only non-empty specs) */}
|
||||
{product.specs && (
|
||||
<section className="bg-white border border-slate-200 rounded-3xl p-8 space-y-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif">Guitar Technical Specifications</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-xs">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-200 space-y-2">
|
||||
<div className="text-slate-400 font-bold uppercase tracking-wider text-[11px]">Body & Construction</div>
|
||||
{product.specs.guitarType && <div><span className="text-slate-500">Instrument Type:</span> <strong className="text-slate-900">{product.specs.guitarType}</strong></div>}
|
||||
{product.specs.bodyShape && <div><span className="text-slate-500">Body Shape:</span> <strong className="text-slate-900">{product.specs.bodyShape}</strong></div>}
|
||||
{product.specs.bodyMaterial && <div><span className="text-slate-500">Body Material:</span> <strong className="text-slate-900">{product.specs.bodyMaterial}</strong></div>}
|
||||
{product.specs.topMaterial && <div><span className="text-slate-500">Top Wood:</span> <strong className="text-slate-900">{product.specs.topMaterial}</strong></div>}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-200 space-y-2">
|
||||
<div className="text-slate-400 font-bold uppercase tracking-wider text-[11px]">Neck & Fretboard</div>
|
||||
{product.specs.neckMaterial && <div><span className="text-slate-500">Neck Material:</span> <strong className="text-slate-900">{product.specs.neckMaterial}</strong></div>}
|
||||
{product.specs.fingerboardMaterial && <div><span className="text-slate-500">Fingerboard:</span> <strong className="text-slate-900">{product.specs.fingerboardMaterial}</strong></div>}
|
||||
{product.specs.scaleLength && <div><span className="text-slate-500">Scale Length:</span> <strong className="text-slate-900">{product.specs.scaleLength}</strong></div>}
|
||||
{product.specs.frets && <div><span className="text-slate-500">Frets Count:</span> <strong className="text-slate-900">{product.specs.frets} Frets</strong></div>}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-200 space-y-2">
|
||||
<div className="text-slate-400 font-bold uppercase tracking-wider text-[11px]">Electronics & Hardware</div>
|
||||
{product.specs.pickupConfiguration && <div><span className="text-slate-500">Pickups:</span> <strong className="text-slate-900">{product.specs.pickupConfiguration}</strong></div>}
|
||||
{product.specs.bridgeType && <div><span className="text-slate-500">Bridge System:</span> <strong className="text-slate-900">{product.specs.bridgeType}</strong></div>}
|
||||
{product.specs.handOrientation && <div><span className="text-slate-500">Handedness:</span> <strong className="text-slate-900">{product.specs.handOrientation}</strong></div>}
|
||||
<div><span className="text-slate-500">Case Included:</span> <strong className="text-slate-900">{product.specs.includesCase ? 'Yes' : 'No'}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Customer Reviews Section */}
|
||||
<section className="space-y-8">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif flex items-center gap-2">
|
||||
<MessageSquare className="w-6 h-6 text-amber-600" /> Customer Reviews ({reviews.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Submit Review Form */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 space-y-4 shadow-sm">
|
||||
<h3 className="text-sm font-bold text-slate-900">Write a Review for {product.name}</h3>
|
||||
<form onSubmit={handleReviewSubmit} className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-xs text-slate-600 font-semibold">Rating:</label>
|
||||
<select
|
||||
value={reviewRating}
|
||||
onChange={(e) => setReviewRating(Number(e.target.value))}
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-900 font-semibold"
|
||||
>
|
||||
<option value={5}>5 Stars - Outstanding Tone</option>
|
||||
<option value={4}>4 Stars - Great Quality</option>
|
||||
<option value={3}>3 Stars - Average</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Review Title"
|
||||
value={reviewTitle}
|
||||
onChange={(e) => setReviewTitle(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs text-slate-900"
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Share your detailed studio or live stage review..."
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-4 text-xs text-slate-900"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submittingReview}
|
||||
className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs"
|
||||
>
|
||||
Submit Review
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Reviews List */}
|
||||
<div className="space-y-4">
|
||||
{reviews.map((rev) => (
|
||||
<div key={rev._id} className="p-6 bg-white border border-slate-200 rounded-2xl space-y-2 shadow-sm">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="font-bold text-slate-900">{rev.user?.name || 'Verified Player'}</div>
|
||||
<div className="flex text-amber-400">
|
||||
{[...Array(rev.rating)].map((_, i) => (
|
||||
<Star key={i} className="w-3.5 h-3.5 fill-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-sm font-bold text-amber-700">{rev.title}</h4>
|
||||
<p className="text-xs text-slate-600 leading-relaxed">{rev.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Related Products */}
|
||||
{relatedProducts.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif">Similar Guitars You Might Like</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{relatedProducts.map((p) => (
|
||||
<ProductCard key={p._id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
323
client/src/pages/customer/ShopPage.jsx
Normal file
323
client/src/pages/customer/ShopPage.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SlidersHorizontal, X, ChevronLeft, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import ProductCard from '../../components/ProductCard';
|
||||
import { ProductGridSkeleton } from '../../components/SkeletonLoaders';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const ShopPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
|
||||
// Filters state from URL query parameters
|
||||
const selectedCategory = searchParams.get('category') || '';
|
||||
const selectedBrand = searchParams.get('brand') || '';
|
||||
const selectedSearch = searchParams.get('search') || '';
|
||||
const selectedGuitarType = searchParams.get('guitarType') || '';
|
||||
const selectedSort = searchParams.get('sort') || 'newest';
|
||||
const currentPage = parseInt(searchParams.get('page')) || 1;
|
||||
const minPrice = searchParams.get('minPrice') || '';
|
||||
const maxPrice = searchParams.get('maxPrice') || '';
|
||||
|
||||
// Local price input state
|
||||
const [priceRange, setPriceRange] = useState({
|
||||
min: minPrice,
|
||||
max: maxPrice
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories').then((res) => setCategories(res.data.data)).catch(() => {});
|
||||
api.get('/brands').then((res) => setBrands(res.data.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const queryParams = new URLSearchParams(searchParams);
|
||||
if (!queryParams.get('page')) queryParams.set('page', '1');
|
||||
|
||||
const res = await api.get(`/products?${queryParams.toString()}`);
|
||||
setProducts(res.data.data.products || []);
|
||||
setPagination(res.data.data.pagination || { page: 1, pages: 1, total: 0 });
|
||||
} catch (err) {
|
||||
console.error('Error fetching shop products', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, [searchParams]);
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handlePriceApply = (e) => {
|
||||
e.preventDefault();
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (priceRange.min) newParams.set('minPrice', priceRange.min);
|
||||
else newParams.delete('minPrice');
|
||||
|
||||
if (priceRange.max) newParams.set('maxPrice', priceRange.max);
|
||||
else newParams.delete('maxPrice');
|
||||
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setPriceRange({ min: '', max: '' });
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
selectedCategory || selectedBrand || selectedSearch || selectedGuitarType || minPrice || maxPrice
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 bg-slate-50 text-slate-900">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-6 border-b border-slate-200">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Guitar & Gear Catalog</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">Showing {pagination.total} high-grade instruments</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
className="md:hidden py-2 px-4 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-800 flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4 text-amber-600" /> Filters
|
||||
</button>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500 font-semibold hidden sm:inline">Sort By:</span>
|
||||
<select
|
||||
value={selectedSort}
|
||||
onChange={(e) => updateFilter('sort', e.target.value)}
|
||||
className="bg-white border border-slate-200 rounded-xl py-2 px-3 text-xs text-slate-800 focus:outline-none focus:border-amber-500 font-semibold shadow-sm"
|
||||
>
|
||||
<option value="newest">Newest Arrivals</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="rating">Highest Rated</option>
|
||||
<option value="bestselling">Best Selling</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applied Filters Feedback Bar */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2 p-3 bg-amber-50 border border-amber-200 rounded-2xl text-xs">
|
||||
<span className="font-bold text-amber-800">Active Filters:</span>
|
||||
{selectedCategory && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-amber-300 rounded-full text-slate-800 font-semibold">
|
||||
Category: {selectedCategory}
|
||||
<X className="w-3 h-3 cursor-pointer text-slate-400 hover:text-slate-900" onClick={() => updateFilter('category', '')} />
|
||||
</span>
|
||||
)}
|
||||
{selectedBrand && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-amber-300 rounded-full text-slate-800 font-semibold">
|
||||
Brand: {selectedBrand}
|
||||
<X className="w-3 h-3 cursor-pointer text-slate-400 hover:text-slate-900" onClick={() => updateFilter('brand', '')} />
|
||||
</span>
|
||||
)}
|
||||
{minPrice && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-amber-300 rounded-full text-slate-800 font-semibold">
|
||||
Min: {formatINR(minPrice)}
|
||||
<X className="w-3 h-3 cursor-pointer text-slate-400 hover:text-slate-900" onClick={() => updateFilter('minPrice', '')} />
|
||||
</span>
|
||||
)}
|
||||
{maxPrice && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-amber-300 rounded-full text-slate-800 font-semibold">
|
||||
Max: {formatINR(maxPrice)}
|
||||
<X className="w-3 h-3 cursor-pointer text-slate-400 hover:text-slate-900" onClick={() => updateFilter('maxPrice', '')} />
|
||||
</span>
|
||||
)}
|
||||
<button onClick={clearAllFilters} className="text-amber-700 font-bold hover:underline ml-auto flex items-center gap-1">
|
||||
<RotateCcw className="w-3 h-3" /> Clear All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
|
||||
{/* Sidebar Filters */}
|
||||
<aside className={`md:block space-y-6 ${showMobileFilters ? 'block' : 'hidden md:block'}`}>
|
||||
<div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
|
||||
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wider flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-amber-600" /> Filter Options
|
||||
</h3>
|
||||
<button onClick={clearAllFilters} className="text-xs text-amber-600 hover:underline">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Requirement 8: Category Filter Select Dropdown */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700">Filter By Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => updateFilter('category', e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-xs text-slate-800 font-semibold"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c._id} value={c.slug}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Requirement 8: Brand Filter Select Dropdown */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-100">
|
||||
<label className="text-xs font-bold text-slate-700">Filter By Brand</label>
|
||||
<select
|
||||
value={selectedBrand}
|
||||
onChange={(e) => updateFilter('brand', e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-xs text-slate-800 font-semibold"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b._id} value={b.slug}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Requirement 8: Price Filter (Min & Max Price) */}
|
||||
<div className="space-y-3 pt-4 border-t border-slate-100">
|
||||
<label className="text-xs font-bold text-slate-700">Price Range (₹ INR)</label>
|
||||
<form onSubmit={handlePriceApply} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min ₹"
|
||||
value={priceRange.min}
|
||||
onChange={(e) => setPriceRange({ ...priceRange, min: e.target.value })}
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl p-2 text-xs text-slate-800"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max ₹"
|
||||
value={priceRange.max}
|
||||
onChange={(e) => setPriceRange({ ...priceRange, max: e.target.value })}
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl p-2 text-xs text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2 bg-slate-900 hover:bg-amber-600 text-white font-bold rounded-xl text-xs transition-colors"
|
||||
>
|
||||
Apply Price Filter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Guitar Type */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-100">
|
||||
<label className="text-xs font-bold text-slate-700">Instrument Type</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Electric', 'Acoustic', 'Bass', 'Classical'].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => updateFilter('guitarType', selectedGuitarType === type ? '' : type)}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-semibold border text-center transition-all ${
|
||||
selectedGuitarType === type
|
||||
? 'border-amber-500 bg-amber-50 text-amber-700 font-bold'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Product Grid */}
|
||||
<main className="md:col-span-3 space-y-8">
|
||||
{loading ? (
|
||||
<ProductGridSkeleton count={9} />
|
||||
) : products.length === 0 ? (
|
||||
<div className="p-12 text-center bg-white border border-slate-200 rounded-3xl space-y-4 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-slate-900">No guitars matched your search</h3>
|
||||
<p className="text-xs text-slate-500">Try adjusting your filters or price parameters.</p>
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="py-2.5 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product._id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-6">
|
||||
<button
|
||||
onClick={() => updateFilter('page', currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{[...Array(pagination.pages)].map((_, i) => (
|
||||
<button
|
||||
key={i + 1}
|
||||
onClick={() => updateFilter('page', i + 1)}
|
||||
className={`w-10 h-10 rounded-xl text-xs font-bold transition-all ${
|
||||
currentPage === i + 1
|
||||
? 'bg-amber-500 text-white shadow-sm'
|
||||
: 'bg-white border border-slate-200 text-slate-700 hover:border-amber-500'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => updateFilter('page', currentPage + 1)}
|
||||
disabled={currentPage === pagination.pages}
|
||||
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopPage;
|
||||
252
client/src/pages/customer/UserDashboardPage.jsx
Normal file
252
client/src/pages/customer/UserDashboardPage.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { User, Package, MapPin, Key, Shield, Eye, LogOut } from 'lucide-react';
|
||||
import { updateProfile, logout } from '../../store/slices/authSlice';
|
||||
import { showToast } from '../../store/slices/uiSlice';
|
||||
import api from '../../api/axios';
|
||||
import { formatINR } from '../../utils/formatCurrency';
|
||||
|
||||
const UserDashboardPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('orders'); // orders, profile
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(true);
|
||||
|
||||
// Indian Form states
|
||||
const [name, setName] = useState(userInfo?.name || '');
|
||||
const [phone, setPhone] = useState(userInfo?.phone || '');
|
||||
const [addressLine1, setAddressLine1] = useState(userInfo?.addresses?.[0]?.addressLine1 || userInfo?.addresses?.[0]?.street || '');
|
||||
const [addressLine2, setAddressLine2] = useState(userInfo?.addresses?.[0]?.addressLine2 || '');
|
||||
const [city, setCity] = useState(userInfo?.addresses?.[0]?.city || '');
|
||||
const [district, setDistrict] = useState(userInfo?.addresses?.[0]?.district || '');
|
||||
const [state, setState] = useState(userInfo?.addresses?.[0]?.state || '');
|
||||
const [postalCode, setPostalCode] = useState(userInfo?.addresses?.[0]?.postalCode || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (userInfo) {
|
||||
api.get('/orders/my-orders')
|
||||
.then((res) => setOrders(res.data.data || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingOrders(false));
|
||||
}
|
||||
}, [userInfo]);
|
||||
|
||||
const handleProfileSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
updateProfile({
|
||||
name,
|
||||
phone,
|
||||
addresses: [{
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
street: addressLine1,
|
||||
city,
|
||||
district,
|
||||
state,
|
||||
postalCode,
|
||||
country: 'India',
|
||||
isDefault: true
|
||||
}]
|
||||
})
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => dispatch(showToast({ message: 'Profile saved successfully!', type: 'success' })))
|
||||
.catch((err) => dispatch(showToast({ message: err || 'Update failed', type: 'error' })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8">
|
||||
|
||||
{/* User Header */}
|
||||
<div className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={userInfo?.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150'} alt="" className="w-16 h-16 rounded-full object-cover border-2 border-amber-500" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 font-serif">{userInfo?.name}</h1>
|
||||
<div className="text-xs text-slate-500">{userInfo?.email} | Role: <span className="text-amber-700 font-bold">{userInfo?.role}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{userInfo?.role === 'Admin' && (
|
||||
<Link to="/admin" className="py-2.5 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">
|
||||
<Shield className="w-4 h-4" /> Admin Portal
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dispatch(logout())}
|
||||
className="py-2.5 px-4 bg-slate-100 hover:bg-slate-200 text-slate-700 font-bold rounded-xl text-xs flex items-center gap-1.5 border border-slate-200 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-rose-500" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<aside className="space-y-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`w-full p-3.5 rounded-2xl text-xs font-bold flex items-center gap-3 text-left transition-all ${
|
||||
activeTab === 'orders' ? 'bg-amber-500 text-white shadow-md shadow-amber-500/20' : 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Package className="w-4 h-4" /> Order History ({orders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`w-full p-3.5 rounded-2xl text-xs font-bold flex items-center gap-3 text-left transition-all ${
|
||||
activeTab === 'profile' ? 'bg-amber-500 text-white shadow-md shadow-amber-500/20' : 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" /> Personal Profile & Address
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="md:col-span-3">
|
||||
|
||||
{/* Order History */}
|
||||
{activeTab === 'orders' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif">Order History</h2>
|
||||
{loadingOrders ? (
|
||||
<div className="text-xs text-slate-500">Loading orders...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="p-8 text-center bg-white border border-slate-200 rounded-2xl text-xs text-slate-500">
|
||||
No orders placed yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map((ord) => (
|
||||
<div key={ord._id} className="p-5 bg-white border border-slate-200 shadow-sm rounded-2xl flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-amber-700 font-mono">{ord.orderNumber}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Date: {new Date(ord.createdAt).toLocaleDateString()} | Total: <strong className="text-slate-900 font-bold">{formatINR(ord.totalAmount)}</strong></div>
|
||||
<div className="text-xs font-bold text-emerald-600 mt-1">Status: {ord.orderStatus}</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/track-order/${ord._id}`}
|
||||
className="py-2 px-4 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-xl text-xs flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Eye className="w-4 h-4" /> Track Order
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Form */}
|
||||
{activeTab === 'profile' && (
|
||||
<form onSubmit={handleProfileSubmit} className="p-6 bg-white border border-slate-200 shadow-sm rounded-3xl space-y-4 max-w-xl">
|
||||
<h2 className="text-xl font-bold text-slate-900 font-serif mb-4">Edit Profile & Delivery Address (India)</h2>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Mobile Number (+91)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="10-digit Mobile Number"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-slate-200 font-bold text-slate-900">Default Shipping Address (India)</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Flat / House No. / Building / Street</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addressLine1}
|
||||
onChange={(e) => setAddressLine1(e.target.value)}
|
||||
placeholder="Address Line 1"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">Area / Colony / Sector / Landmark</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addressLine2}
|
||||
onChange={(e) => setAddressLine2(e.target.value)}
|
||||
placeholder="Address Line 2"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">City / Town</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="City"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">District</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="District"
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">State</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="State"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-600 font-semibold mb-1 block">PIN Code (6 digits)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6-digit PIN Code"
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-slate-900 focus:outline-none focus:border-amber-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="py-3 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-md shadow-amber-500/20"
|
||||
>
|
||||
Save Profile & Address
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDashboardPage;
|
||||
41
client/src/pages/customer/WishlistPage.jsx
Normal file
41
client/src/pages/customer/WishlistPage.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { fetchWishlist } from '../../store/slices/wishlistSlice';
|
||||
import ProductCard from '../../components/ProductCard';
|
||||
|
||||
const WishlistPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { products } = useSelector((state) => state.wishlist);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchWishlist());
|
||||
}, [dispatch]);
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-20 text-center space-y-4 bg-slate-50 text-slate-900">
|
||||
<Heart className="w-12 h-12 text-slate-300 mx-auto" />
|
||||
<h2 className="text-2xl font-bold text-slate-900 font-serif">Your Wishlist is Empty</h2>
|
||||
<p className="text-xs text-slate-500">Save your favorite guitars and custom shop models here.</p>
|
||||
<Link to="/shop" className="inline-block py-3 px-6 bg-amber-500 hover:bg-amber-600 text-white font-bold rounded-xl text-xs shadow-sm">
|
||||
Browse Shop
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 bg-slate-50 text-slate-900">
|
||||
<h1 className="text-3xl font-extrabold text-slate-900 font-serif">Your Saved Wishlist ({products.length})</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p._id || p} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WishlistPage;
|
||||
17
client/src/store/index.js
Normal file
17
client/src/store/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from './slices/authSlice';
|
||||
import cartReducer from './slices/cartSlice';
|
||||
import wishlistReducer from './slices/wishlistSlice';
|
||||
import compareReducer from './slices/compareSlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
cart: cartReducer,
|
||||
wishlist: wishlistReducer,
|
||||
compare: compareReducer,
|
||||
ui: uiReducer
|
||||
}
|
||||
});
|
||||
|
||||
73
client/src/store/slices/authSlice.js
Normal file
73
client/src/store/slices/authSlice.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import api from '../../api/axios';
|
||||
|
||||
const userInfoFromStorage = localStorage.getItem('userInfo')
|
||||
? JSON.parse(localStorage.getItem('userInfo'))
|
||||
: null;
|
||||
|
||||
export const login = createAsyncThunk('auth/login', async ({ email, password }, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data.data));
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Login failed');
|
||||
}
|
||||
});
|
||||
|
||||
export const register = createAsyncThunk('auth/register', async (userData, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.post('/auth/register', userData);
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data.data));
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Registration failed');
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProfile = createAsyncThunk('auth/updateProfile', async (userData, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.put('/auth/profile', userData);
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data.data));
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Profile update failed');
|
||||
}
|
||||
});
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: {
|
||||
userInfo: userInfoFromStorage,
|
||||
loading: false,
|
||||
error: null
|
||||
},
|
||||
reducers: {
|
||||
logout: (state) => {
|
||||
localStorage.removeItem('userInfo');
|
||||
state.userInfo = null;
|
||||
state.error = null;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(login.pending, (state) => { state.loading = true; state.error = null; })
|
||||
.addCase(login.fulfilled, (state, action) => { state.loading = false; state.userInfo = action.payload; })
|
||||
.addCase(login.rejected, (state, action) => { state.loading = false; state.error = action.payload; })
|
||||
|
||||
.addCase(register.pending, (state) => { state.loading = true; state.error = null; })
|
||||
.addCase(register.fulfilled, (state, action) => { state.loading = false; state.userInfo = action.payload; })
|
||||
.addCase(register.rejected, (state, action) => { state.loading = false; state.error = action.payload; })
|
||||
|
||||
.addCase(updateProfile.pending, (state) => { state.loading = true; })
|
||||
.addCase(updateProfile.fulfilled, (state, action) => { state.loading = false; state.userInfo = action.payload; })
|
||||
.addCase(updateProfile.rejected, (state, action) => { state.loading = false; state.error = action.payload; });
|
||||
}
|
||||
});
|
||||
|
||||
export const { logout, clearError } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
||||
85
client/src/store/slices/cartSlice.js
Normal file
85
client/src/store/slices/cartSlice.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import api from '../../api/axios';
|
||||
|
||||
export const fetchCart = createAsyncThunk('cart/fetchCart', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.get('/cart');
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to fetch cart');
|
||||
}
|
||||
});
|
||||
|
||||
export const addToCartAsync = createAsyncThunk('cart/addToCartAsync', async (itemData, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.post('/cart/add', itemData);
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to add item');
|
||||
}
|
||||
});
|
||||
|
||||
export const updateCartItemAsync = createAsyncThunk('cart/updateCartItemAsync', async ({ itemId, quantity }, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.put('/cart/update', { itemId, quantity });
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to update item');
|
||||
}
|
||||
});
|
||||
|
||||
export const removeCartItemAsync = createAsyncThunk('cart/removeCartItemAsync', async (itemId, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.delete(`/cart/item/${itemId}`);
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to remove item');
|
||||
}
|
||||
});
|
||||
|
||||
export const applyCouponAsync = createAsyncThunk('cart/applyCouponAsync', async (code, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.post('/cart/coupon', { code });
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Invalid coupon');
|
||||
}
|
||||
});
|
||||
|
||||
const cartSlice = createSlice({
|
||||
name: 'cart',
|
||||
initialState: {
|
||||
items: [],
|
||||
coupon: null,
|
||||
loading: false,
|
||||
error: null
|
||||
},
|
||||
reducers: {
|
||||
clearCart: (state) => {
|
||||
state.items = [];
|
||||
state.coupon = null;
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchCart.fulfilled, (state, action) => {
|
||||
state.items = action.payload.items || [];
|
||||
})
|
||||
.addCase(addToCartAsync.fulfilled, (state, action) => {
|
||||
state.items = action.payload.items || [];
|
||||
})
|
||||
.addCase(updateCartItemAsync.fulfilled, (state, action) => {
|
||||
state.items = action.payload.items || [];
|
||||
})
|
||||
.addCase(removeCartItemAsync.fulfilled, (state, action) => {
|
||||
state.items = action.payload.items || [];
|
||||
})
|
||||
.addCase(applyCouponAsync.fulfilled, (state, action) => {
|
||||
state.coupon = action.payload;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const { clearCart } = cartSlice.actions;
|
||||
export default cartSlice.reducer;
|
||||
|
||||
33
client/src/store/slices/compareSlice.js
Normal file
33
client/src/store/slices/compareSlice.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialItems = localStorage.getItem('compareProducts')
|
||||
? JSON.parse(localStorage.getItem('compareProducts'))
|
||||
: [];
|
||||
|
||||
const compareSlice = createSlice({
|
||||
name: 'compare',
|
||||
initialState: {
|
||||
items: initialItems
|
||||
},
|
||||
reducers: {
|
||||
addToCompare: (state, action) => {
|
||||
const exists = state.items.some(item => item._id === action.payload._id);
|
||||
if (!exists && state.items.length < 4) {
|
||||
state.items.push(action.payload);
|
||||
localStorage.setItem('compareProducts', JSON.stringify(state.items));
|
||||
}
|
||||
},
|
||||
removeFromCompare: (state, action) => {
|
||||
state.items = state.items.filter(item => item._id !== action.payload);
|
||||
localStorage.setItem('compareProducts', JSON.stringify(state.items));
|
||||
},
|
||||
clearCompare: (state) => {
|
||||
state.items = [];
|
||||
localStorage.removeItem('compareProducts');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { addToCompare, removeFromCompare, clearCompare } = compareSlice.actions;
|
||||
export default compareSlice.reducer;
|
||||
|
||||
30
client/src/store/slices/uiSlice.js
Normal file
30
client/src/store/slices/uiSlice.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState: {
|
||||
guitarFinderOpen: false,
|
||||
quickViewProduct: null,
|
||||
toast: null // { message, type: 'success' | 'error' | 'info' }
|
||||
},
|
||||
reducers: {
|
||||
openGuitarFinder: (state) => { state.guitarFinderOpen = true; },
|
||||
closeGuitarFinder: (state) => { state.guitarFinderOpen = false; },
|
||||
openQuickView: (state, action) => { state.quickViewProduct = action.payload; },
|
||||
closeQuickView: (state) => { state.quickViewProduct = null; },
|
||||
showToast: (state, action) => { state.toast = action.payload; },
|
||||
hideToast: (state) => { state.toast = null; }
|
||||
}
|
||||
});
|
||||
|
||||
export const {
|
||||
openGuitarFinder,
|
||||
closeGuitarFinder,
|
||||
openQuickView,
|
||||
closeQuickView,
|
||||
showToast,
|
||||
hideToast
|
||||
} = uiSlice.actions;
|
||||
|
||||
export default uiSlice.reducer;
|
||||
|
||||
41
client/src/store/slices/wishlistSlice.js
Normal file
41
client/src/store/slices/wishlistSlice.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import api from '../../api/axios';
|
||||
|
||||
export const fetchWishlist = createAsyncThunk('wishlist/fetchWishlist', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.get('/wishlist');
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to fetch wishlist');
|
||||
}
|
||||
});
|
||||
|
||||
export const toggleWishlistAsync = createAsyncThunk('wishlist/toggleWishlistAsync', async (productId, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await api.post('/wishlist/toggle', { productId });
|
||||
return res.data.data;
|
||||
} catch (err) {
|
||||
return rejectWithValue(err.response?.data?.message || 'Failed to update wishlist');
|
||||
}
|
||||
});
|
||||
|
||||
const wishlistSlice = createSlice({
|
||||
name: 'wishlist',
|
||||
initialState: {
|
||||
products: [],
|
||||
loading: false
|
||||
},
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchWishlist.fulfilled, (state, action) => {
|
||||
state.products = action.payload.products || [];
|
||||
})
|
||||
.addCase(toggleWishlistAsync.fulfilled, (state, action) => {
|
||||
state.products = action.payload.products || [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default wishlistSlice.reducer;
|
||||
|
||||
15
client/src/utils/formatCurrency.js
Normal file
15
client/src/utils/formatCurrency.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Indian Rupee (₹ INR) Currency Formatter Helper
|
||||
* Formats numbers in Indian format (e.g., 49999 -> ₹49,999)
|
||||
*/
|
||||
export const formatINR = (amount = 0) => {
|
||||
const num = Number(amount) || 0;
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
export default formatINR;
|
||||
|
||||
25
client/tailwind.config.js
Normal file
25
client/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
amber: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
500: '#d97706', // Primary amber/gold accent
|
||||
600: '#b45309',
|
||||
700: '#92400e'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'Inter', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'serif']
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
20
client/vite.config.js
Normal file
20
client/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
13
server/.env.example
Normal file
13
server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/guitar_ecommerce
|
||||
JWT_SECRET=guitar_ecommerce_super_secret_jwt_key_2026
|
||||
JWT_EXPIRE=30d
|
||||
STRIPE_SECRET_KEY=sk_test_mock_stripe_key_guitar_ecommerce
|
||||
CLIENT_URL=http://localhost:5173
|
||||
EMAIL_HOST=smtp.mailtrap.io
|
||||
EMAIL_PORT=2525
|
||||
EMAIL_USER=mock_user
|
||||
EMAIL_PASS=mock_pass
|
||||
EMAIL_FROM=noreply@guitars.com
|
||||
|
||||
8
server/.gitignore
vendored
Normal file
8
server/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
77
server/app.js
Normal file
77
server/app.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
|
||||
// Route Imports
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const productRoutes = require('./routes/productRoutes');
|
||||
const categoryRoutes = require('./routes/categoryRoutes');
|
||||
const brandRoutes = require('./routes/brandRoutes');
|
||||
const cartRoutes = require('./routes/cartRoutes');
|
||||
const wishlistRoutes = require('./routes/wishlistRoutes');
|
||||
const orderRoutes = require('./routes/orderRoutes');
|
||||
const inventoryRoutes = require('./routes/inventoryRoutes');
|
||||
const reviewRoutes = require('./routes/reviewRoutes');
|
||||
const couponRoutes = require('./routes/couponRoutes');
|
||||
const bannerRoutes = require('./routes/bannerRoutes');
|
||||
const settingsRoutes = require('./routes/settingsRoutes');
|
||||
const socialMediaRoutes = require('./routes/socialMediaRoutes');
|
||||
const emailTemplateRoutes = require('./routes/emailTemplateRoutes');
|
||||
const contactRoutes = require('./routes/contactRoutes');
|
||||
const uploadRoutes = require('./routes/uploadRoutes');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security Middlewares
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
app.use(cors({
|
||||
origin: process.env.CLIENT_URL || '*',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate Limiter
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 300,
|
||||
message: { success: false, message: 'Too many requests from this IP, please try again later.' }
|
||||
});
|
||||
app.use('/api', limiter);
|
||||
|
||||
// Body Parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Static Uploads Folder
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// Root test route
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ success: true, message: 'Antigravity Guitars API is healthy', timestamp: new Date() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/products', productRoutes);
|
||||
app.use('/api/categories', categoryRoutes);
|
||||
app.use('/api/brands', brandRoutes);
|
||||
app.use('/api/cart', cartRoutes);
|
||||
app.use('/api/wishlist', wishlistRoutes);
|
||||
app.use('/api/orders', orderRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/reviews', reviewRoutes);
|
||||
app.use('/api/coupons', couponRoutes);
|
||||
app.use('/api/banners', bannerRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/social-media', socialMediaRoutes);
|
||||
app.use('/api/email-templates', emailTemplateRoutes);
|
||||
app.use('/api/contact', contactRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
|
||||
// Centralized Error Handling Middleware
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
15
server/config/db.js
Normal file
15
server/config/db.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const conn = await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/guitar_ecommerce');
|
||||
console.log(`[MongoDB Connected]: ${conn.connection.host}`);
|
||||
} catch (error) {
|
||||
console.error(`[MongoDB Connection Error]: ${error.message}`);
|
||||
// Process can continue for seed script or fallback handling
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = connectDB;
|
||||
|
||||
128
server/controllers/authController.js
Normal file
128
server/controllers/authController.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const User = require('../models/User');
|
||||
const { generateToken } = require('../utils/jwt');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
// @desc Register new user
|
||||
// @route POST /api/auth/register
|
||||
// @access Public
|
||||
const registerUser = async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, password, phone } = req.body;
|
||||
|
||||
const userExists = await User.findOne({ email: email.toLowerCase() });
|
||||
if (userExists) {
|
||||
return errorResponse(res, 'User already exists with this email', [], 400);
|
||||
}
|
||||
|
||||
const user = await User.create({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
phone: phone || ''
|
||||
});
|
||||
|
||||
const token = generateToken(user._id, user.role);
|
||||
|
||||
return successResponse(res, 'Registration successful', {
|
||||
_id: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token
|
||||
}, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Login user & get token
|
||||
// @route POST /api/auth/login
|
||||
// @access Public
|
||||
const loginUser = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return errorResponse(res, 'Please provide email and password', [], 400);
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email: email.toLowerCase() }).select('+password');
|
||||
if (!user || !(await user.matchPassword(password))) {
|
||||
return errorResponse(res, 'Invalid credentials', [], 401);
|
||||
}
|
||||
|
||||
const token = generateToken(user._id, user.role);
|
||||
|
||||
return successResponse(res, 'Login successful', {
|
||||
_id: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
addresses: user.addresses,
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user profile
|
||||
// @route GET /api/auth/profile
|
||||
// @access Private
|
||||
const getUserProfile = async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
if (!user) {
|
||||
return errorResponse(res, 'User not found', [], 404);
|
||||
}
|
||||
return successResponse(res, 'User profile retrieved', user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update user profile & addresses
|
||||
// @route PUT /api/auth/profile
|
||||
// @access Private
|
||||
const updateUserProfile = async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
if (!user) {
|
||||
return errorResponse(res, 'User not found', [], 404);
|
||||
}
|
||||
|
||||
user.name = req.body.name || user.name;
|
||||
user.phone = req.body.phone !== undefined ? req.body.phone : user.phone;
|
||||
if (req.body.addresses) {
|
||||
user.addresses = req.body.addresses;
|
||||
}
|
||||
|
||||
if (req.body.password) {
|
||||
user.password = req.body.password;
|
||||
}
|
||||
|
||||
const updatedUser = await user.save();
|
||||
const token = generateToken(updatedUser._id, updatedUser.role);
|
||||
|
||||
return successResponse(res, 'Profile updated successfully', {
|
||||
_id: updatedUser._id,
|
||||
name: updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
role: updatedUser.role,
|
||||
phone: updatedUser.phone,
|
||||
addresses: updatedUser.addresses,
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile
|
||||
};
|
||||
|
||||
44
server/controllers/bannerController.js
Normal file
44
server/controllers/bannerController.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const Banner = require('../models/Banner');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getBanners = async (req, res, next) => {
|
||||
try {
|
||||
const banners = await Banner.find({ isActive: true }).sort({ displayOrder: 1 });
|
||||
return successResponse(res, 'Hero banners retrieved', banners);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createBanner = async (req, res, next) => {
|
||||
try {
|
||||
const banner = new Banner(req.body);
|
||||
const saved = await banner.save();
|
||||
return successResponse(res, 'Banner created', saved, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBanner = async (req, res, next) => {
|
||||
try {
|
||||
const banner = await Banner.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
||||
if (!banner) return errorResponse(res, 'Banner not found', [], 404);
|
||||
return successResponse(res, 'Banner updated', banner);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBanner = async (req, res, next) => {
|
||||
try {
|
||||
const banner = await Banner.findByIdAndDelete(req.params.id);
|
||||
if (!banner) return errorResponse(res, 'Banner not found', [], 404);
|
||||
return successResponse(res, 'Banner deleted');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBanners, createBanner, updateBanner, deleteBanner };
|
||||
|
||||
44
server/controllers/brandController.js
Normal file
44
server/controllers/brandController.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const Brand = require('../models/Brand');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getBrands = async (req, res, next) => {
|
||||
try {
|
||||
const brands = await Brand.find({ isActive: true }).sort({ name: 1 });
|
||||
return successResponse(res, 'Brands retrieved', brands);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createBrand = async (req, res, next) => {
|
||||
try {
|
||||
const brand = new Brand(req.body);
|
||||
const saved = await brand.save();
|
||||
return successResponse(res, 'Brand created', saved, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBrand = async (req, res, next) => {
|
||||
try {
|
||||
const brand = await Brand.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
||||
if (!brand) return errorResponse(res, 'Brand not found', [], 404);
|
||||
return successResponse(res, 'Brand updated', brand);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBrand = async (req, res, next) => {
|
||||
try {
|
||||
const brand = await Brand.findByIdAndDelete(req.params.id);
|
||||
if (!brand) return errorResponse(res, 'Brand not found', [], 404);
|
||||
return successResponse(res, 'Brand deleted');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBrands, createBrand, updateBrand, deleteBrand };
|
||||
|
||||
150
server/controllers/cartController.js
Normal file
150
server/controllers/cartController.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const Cart = require('../models/Cart');
|
||||
const Product = require('../models/Product');
|
||||
const Coupon = require('../models/Coupon');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getCart = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user ? req.user._id : null;
|
||||
const sessionId = req.headers['x-session-id'] || null;
|
||||
|
||||
let cart = null;
|
||||
if (userId) {
|
||||
cart = await Cart.findOne({ user: userId }).populate('items.product');
|
||||
} else if (sessionId) {
|
||||
cart = await Cart.findOne({ sessionId }).populate('items.product');
|
||||
}
|
||||
|
||||
if (!cart) {
|
||||
cart = { items: [], couponCode: '', discountAmount: 0 };
|
||||
}
|
||||
|
||||
return successResponse(res, 'Cart retrieved', cart);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const addToCart = async (req, res, next) => {
|
||||
try {
|
||||
const { productId, quantity = 1, variantColor = '', variantFinish = '' } = req.body;
|
||||
const userId = req.user ? req.user._id : null;
|
||||
const sessionId = req.headers['x-session-id'] || 'guest-' + Date.now();
|
||||
|
||||
const product = await Product.findById(productId);
|
||||
if (!product) {
|
||||
return errorResponse(res, 'Product not found', [], 404);
|
||||
}
|
||||
|
||||
// Inventory check
|
||||
if (product.stock < quantity) {
|
||||
return errorResponse(res, `Only ${product.stock} items remaining in stock`, [], 400);
|
||||
}
|
||||
|
||||
let cart;
|
||||
if (userId) {
|
||||
cart = await Cart.findOne({ user: userId });
|
||||
} else {
|
||||
cart = await Cart.findOne({ sessionId });
|
||||
}
|
||||
|
||||
const price = product.salePrice > 0 ? product.salePrice : product.price;
|
||||
|
||||
if (!cart) {
|
||||
cart = new Cart({
|
||||
user: userId,
|
||||
sessionId,
|
||||
items: [{ product: productId, quantity, variantColor, variantFinish, price }]
|
||||
});
|
||||
} else {
|
||||
const existingItemIndex = cart.items.findIndex(
|
||||
item => item.product.toString() === productId && item.variantColor === variantColor
|
||||
);
|
||||
|
||||
if (existingItemIndex > -1) {
|
||||
cart.items[existingItemIndex].quantity += Number(quantity);
|
||||
} else {
|
||||
cart.items.push({ product: productId, quantity, variantColor, variantFinish, price });
|
||||
}
|
||||
}
|
||||
|
||||
await cart.save();
|
||||
const updatedCart = await Cart.findById(cart._id).populate('items.product');
|
||||
return successResponse(res, 'Product added to cart', updatedCart);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCartItem = async (req, res, next) => {
|
||||
try {
|
||||
const { itemId, quantity } = req.body;
|
||||
const userId = req.user ? req.user._id : null;
|
||||
const sessionId = req.headers['x-session-id'];
|
||||
|
||||
const cart = userId
|
||||
? await Cart.findOne({ user: userId })
|
||||
: await Cart.findOne({ sessionId });
|
||||
|
||||
if (!cart) return errorResponse(res, 'Cart not found', [], 404);
|
||||
|
||||
const item = cart.items.id(itemId);
|
||||
if (!item) return errorResponse(res, 'Item not found in cart', [], 404);
|
||||
|
||||
if (quantity <= 0) {
|
||||
cart.items.pull(itemId);
|
||||
} else {
|
||||
item.quantity = quantity;
|
||||
}
|
||||
|
||||
await cart.save();
|
||||
const updatedCart = await Cart.findById(cart._id).populate('items.product');
|
||||
return successResponse(res, 'Cart updated', updatedCart);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCartItem = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user ? req.user._id : null;
|
||||
const sessionId = req.headers['x-session-id'];
|
||||
|
||||
const cart = userId
|
||||
? await Cart.findOne({ user: userId })
|
||||
: await Cart.findOne({ sessionId });
|
||||
|
||||
if (cart) {
|
||||
cart.items = cart.items.filter(item => item._id.toString() !== req.params.itemId);
|
||||
await cart.save();
|
||||
}
|
||||
|
||||
const updatedCart = cart ? await Cart.findById(cart._id).populate('items.product') : { items: [] };
|
||||
return successResponse(res, 'Item removed from cart', updatedCart);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCoupon = async (req, res, next) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
const coupon = await Coupon.findOne({ code: code.toUpperCase(), isActive: true });
|
||||
|
||||
if (!coupon || coupon.endDate < new Date()) {
|
||||
return errorResponse(res, 'Invalid or expired coupon code', [], 400);
|
||||
}
|
||||
|
||||
return successResponse(res, 'Coupon applied successfully', {
|
||||
code: coupon.code,
|
||||
discountType: coupon.discountType,
|
||||
discountValue: coupon.discountValue,
|
||||
minPurchaseAmount: coupon.minPurchaseAmount
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getCart, addToCart, updateCartItem, removeCartItem, applyCoupon };
|
||||
|
||||
44
server/controllers/categoryController.js
Normal file
44
server/controllers/categoryController.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const Category = require('../models/Category');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getCategories = async (req, res, next) => {
|
||||
try {
|
||||
const categories = await Category.find({ isActive: true }).sort({ displayOrder: 1 });
|
||||
return successResponse(res, 'Categories retrieved', categories);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createCategory = async (req, res, next) => {
|
||||
try {
|
||||
const category = new Category(req.body);
|
||||
const saved = await category.save();
|
||||
return successResponse(res, 'Category created', saved, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCategory = async (req, res, next) => {
|
||||
try {
|
||||
const category = await Category.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
||||
if (!category) return errorResponse(res, 'Category not found', [], 404);
|
||||
return successResponse(res, 'Category updated', category);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = async (req, res, next) => {
|
||||
try {
|
||||
const category = await Category.findByIdAndDelete(req.params.id);
|
||||
if (!category) return errorResponse(res, 'Category not found', [], 404);
|
||||
return successResponse(res, 'Category deleted');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getCategories, createCategory, updateCategory, deleteCategory };
|
||||
|
||||
34
server/controllers/contactController.js
Normal file
34
server/controllers/contactController.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const ContactMessage = require('../models/ContactMessage');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const submitContactForm = async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, phone, subject, message } = req.body;
|
||||
const msg = await ContactMessage.create({ name, email, phone, subject, message });
|
||||
return successResponse(res, 'Thank you! Your message has been received.', msg, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getContactMessages = async (req, res, next) => {
|
||||
try {
|
||||
const messages = await ContactMessage.find({}).sort({ createdAt: -1 });
|
||||
return successResponse(res, 'Contact messages retrieved', messages);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMessageStatus = async (req, res, next) => {
|
||||
try {
|
||||
const msg = await ContactMessage.findByIdAndUpdate(req.params.id, { status: req.body.status }, { new: true });
|
||||
if (!msg) return errorResponse(res, 'Message not found', [], 404);
|
||||
return successResponse(res, 'Message status updated', msg);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { submitContactForm, getContactMessages, updateMessageStatus };
|
||||
|
||||
34
server/controllers/couponController.js
Normal file
34
server/controllers/couponController.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const Coupon = require('../models/Coupon');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getCoupons = async (req, res, next) => {
|
||||
try {
|
||||
const coupons = await Coupon.find({}).sort({ createdAt: -1 });
|
||||
return successResponse(res, 'Coupons retrieved', coupons);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createCoupon = async (req, res, next) => {
|
||||
try {
|
||||
const coupon = new Coupon(req.body);
|
||||
const saved = await coupon.save();
|
||||
return successResponse(res, 'Coupon created', saved, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCoupon = async (req, res, next) => {
|
||||
try {
|
||||
const coupon = await Coupon.findByIdAndDelete(req.params.id);
|
||||
if (!coupon) return errorResponse(res, 'Coupon not found', [], 404);
|
||||
return successResponse(res, 'Coupon deleted');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getCoupons, createCoupon, deleteCoupon };
|
||||
|
||||
24
server/controllers/emailTemplateController.js
Normal file
24
server/controllers/emailTemplateController.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const EmailTemplate = require('../models/EmailTemplate');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getEmailTemplates = async (req, res, next) => {
|
||||
try {
|
||||
const templates = await EmailTemplate.find({});
|
||||
return successResponse(res, 'Email templates retrieved', templates);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEmailTemplate = async (req, res, next) => {
|
||||
try {
|
||||
const template = await EmailTemplate.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
||||
if (!template) return errorResponse(res, 'Template not found', [], 404);
|
||||
return successResponse(res, 'Email template updated', template);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEmailTemplates, updateEmailTemplate };
|
||||
|
||||
85
server/controllers/inventoryController.js
Normal file
85
server/controllers/inventoryController.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const Product = require('../models/Product');
|
||||
const InventoryHistory = require('../models/InventoryHistory');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
// @desc Get stock overview & alerts (Admin)
|
||||
// @route GET /api/inventory/overview
|
||||
// @access Private/Admin
|
||||
const getStockOverview = async (req, res, next) => {
|
||||
try {
|
||||
const products = await Product.find({})
|
||||
.select('name sku stock lowStockThreshold price category brand images')
|
||||
.populate('category', 'name')
|
||||
.populate('brand', 'name');
|
||||
|
||||
const lowStock = products.filter(p => p.stock > 0 && p.stock <= p.lowStockThreshold);
|
||||
const outOfStock = products.filter(p => p.stock === 0);
|
||||
|
||||
return successResponse(res, 'Stock overview retrieved', {
|
||||
totalProducts: products.length,
|
||||
lowStockCount: lowStock.length,
|
||||
outOfStockCount: outOfStock.length,
|
||||
lowStock,
|
||||
outOfStock,
|
||||
products
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Adjust inventory stock level (Admin)
|
||||
// @route POST /api/inventory/adjust
|
||||
// @access Private/Admin
|
||||
const adjustStock = async (req, res, next) => {
|
||||
try {
|
||||
const { productId, newStock, reason, changeType = 'Manual Adjustment' } = req.body;
|
||||
|
||||
const product = await Product.findById(productId);
|
||||
if (!product) {
|
||||
return errorResponse(res, 'Product not found', [], 404);
|
||||
}
|
||||
|
||||
const previousStock = product.stock;
|
||||
const quantityChanged = Number(newStock) - previousStock;
|
||||
|
||||
product.stock = Number(newStock);
|
||||
await product.save();
|
||||
|
||||
// Log history
|
||||
const history = await InventoryHistory.create({
|
||||
product: product._id,
|
||||
sku: product.sku,
|
||||
changeType,
|
||||
quantityChanged,
|
||||
previousStock,
|
||||
newStock: product.stock,
|
||||
reason: reason || 'Manual admin inventory adjustment',
|
||||
performedBy: req.user._id
|
||||
});
|
||||
|
||||
return successResponse(res, 'Stock adjusted successfully', { product, history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get complete inventory change audit log (Admin)
|
||||
// @route GET /api/inventory/history
|
||||
// @access Private/Admin
|
||||
const getInventoryHistory = async (req, res, next) => {
|
||||
try {
|
||||
const history = await InventoryHistory.find({})
|
||||
.populate('product', 'name images')
|
||||
.populate('performedBy', 'name email')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(100);
|
||||
|
||||
return successResponse(res, 'Inventory history audit log retrieved', history);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStockOverview, adjustStock, getInventoryHistory };
|
||||
|
||||
212
server/controllers/orderController.js
Normal file
212
server/controllers/orderController.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const Order = require('../models/Order');
|
||||
const Product = require('../models/Product');
|
||||
const InventoryHistory = require('../models/InventoryHistory');
|
||||
const Cart = require('../models/Cart');
|
||||
const { sendEmail } = require('../utils/emailSender');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
// @desc Create new order
|
||||
// @route POST /api/orders
|
||||
// @access Private
|
||||
const createOrder = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
orderItems,
|
||||
shippingAddress,
|
||||
paymentMethod,
|
||||
itemsPrice,
|
||||
taxPrice,
|
||||
shippingPrice,
|
||||
discountAmount,
|
||||
totalAmount
|
||||
} = req.body;
|
||||
|
||||
if (!orderItems || orderItems.length === 0) {
|
||||
return errorResponse(res, 'No order items provided', [], 400);
|
||||
}
|
||||
|
||||
// 1. Check stock and reduce inventory
|
||||
for (const item of orderItems) {
|
||||
const product = await Product.findById(item.product);
|
||||
if (!product) {
|
||||
return errorResponse(res, `Product ${item.name} not found`, [], 400);
|
||||
}
|
||||
if (product.stock < item.quantity) {
|
||||
return errorResponse(res, `Insufficient stock for product ${product.name}`, [], 400);
|
||||
}
|
||||
|
||||
// Record stock deduction
|
||||
const oldStock = product.stock;
|
||||
product.stock -= item.quantity;
|
||||
product.salesCount += item.quantity;
|
||||
await product.save();
|
||||
|
||||
await InventoryHistory.create({
|
||||
product: product._id,
|
||||
sku: product.sku,
|
||||
changeType: 'Order Placed',
|
||||
quantityChanged: -item.quantity,
|
||||
previousStock: oldStock,
|
||||
newStock: product.stock,
|
||||
reason: `Order placed by user ${req.user.name}`,
|
||||
performedBy: req.user._id
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Generate unique order number
|
||||
const orderNumber = `GTR-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
|
||||
// 3. Save Order
|
||||
const order = new Order({
|
||||
orderNumber,
|
||||
user: req.user._id,
|
||||
orderItems,
|
||||
shippingAddress,
|
||||
paymentMethod,
|
||||
itemsPrice,
|
||||
taxPrice,
|
||||
shippingPrice,
|
||||
discountAmount,
|
||||
totalAmount,
|
||||
isPaid: paymentMethod === 'Stripe', // Auto mark paid for simulated stripe
|
||||
paidAt: paymentMethod === 'Stripe' ? Date.now() : null,
|
||||
orderStatus: paymentMethod === 'Stripe' ? 'Payment Confirmed' : 'Pending',
|
||||
statusHistory: [
|
||||
{
|
||||
status: paymentMethod === 'Stripe' ? 'Payment Confirmed' : 'Pending',
|
||||
note: `Order created via ${paymentMethod}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const createdOrder = await order.save();
|
||||
|
||||
// 4. Clear user cart
|
||||
await Cart.findOneAndDelete({ user: req.user._id });
|
||||
|
||||
// 5. Send order confirmation email
|
||||
sendEmail({
|
||||
templateName: 'Order Confirmation',
|
||||
to: req.user.email,
|
||||
variables: {
|
||||
customerName: req.user.name,
|
||||
orderId: createdOrder.orderNumber,
|
||||
orderTotal: `$${createdOrder.totalAmount.toFixed(2)}`
|
||||
}
|
||||
});
|
||||
|
||||
return successResponse(res, 'Order created successfully', createdOrder, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get logged in user orders
|
||||
// @route GET /api/orders/my-orders
|
||||
// @access Private
|
||||
const getMyOrders = async (req, res, next) => {
|
||||
try {
|
||||
const orders = await Order.find({ user: req.user._id }).sort({ createdAt: -1 });
|
||||
return successResponse(res, 'User orders retrieved', orders);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get order by ID
|
||||
// @route GET /api/orders/:id
|
||||
// @access Private
|
||||
const getOrderById = async (req, res, next) => {
|
||||
try {
|
||||
const order = await Order.findById(req.params.id).populate('user', 'name email phone');
|
||||
if (!order) {
|
||||
return errorResponse(res, 'Order not found', [], 404);
|
||||
}
|
||||
|
||||
// Check authorization: customer can only view own order unless Admin
|
||||
if (order.user._id.toString() !== req.user._id.toString() && req.user.role !== 'Admin') {
|
||||
return errorResponse(res, 'Not authorized to view this order', [], 403);
|
||||
}
|
||||
|
||||
return successResponse(res, 'Order retrieved', order);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get all orders (Admin)
|
||||
// @route GET /api/orders
|
||||
// @access Private/Admin
|
||||
const getAllOrders = async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.query;
|
||||
const filter = {};
|
||||
if (status) filter.orderStatus = status;
|
||||
|
||||
const orders = await Order.find(filter)
|
||||
.populate('user', 'name email')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
return successResponse(res, 'All orders retrieved', orders);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update order status & shipping info (Admin)
|
||||
// @route PUT /api/orders/:id/status
|
||||
// @access Private/Admin
|
||||
const updateOrderStatus = async (req, res, next) => {
|
||||
try {
|
||||
const { orderStatus, trackingNumber, shippingProvider, note } = req.body;
|
||||
const order = await Order.findById(req.params.id).populate('user', 'name email');
|
||||
|
||||
if (!order) {
|
||||
return errorResponse(res, 'Order not found', [], 404);
|
||||
}
|
||||
|
||||
if (orderStatus) {
|
||||
order.orderStatus = orderStatus;
|
||||
order.statusHistory.push({
|
||||
status: orderStatus,
|
||||
note: note || `Status updated to ${orderStatus}`
|
||||
});
|
||||
}
|
||||
|
||||
if (trackingNumber) order.trackingNumber = trackingNumber;
|
||||
if (shippingProvider) order.shippingProvider = shippingProvider;
|
||||
|
||||
if (orderStatus === 'Delivered') {
|
||||
order.isDelivered = true;
|
||||
order.deliveredAt = Date.now();
|
||||
}
|
||||
|
||||
const updatedOrder = await order.save();
|
||||
|
||||
// Trigger status change email
|
||||
if (['Shipped', 'Delivered', 'Cancelled'].includes(orderStatus)) {
|
||||
sendEmail({
|
||||
templateName: orderStatus === 'Shipped' ? 'Order Shipped' : 'Order Confirmation',
|
||||
to: order.user.email,
|
||||
variables: {
|
||||
customerName: order.user.name,
|
||||
orderId: order.orderNumber,
|
||||
trackingNumber: order.trackingNumber || 'N/A'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return successResponse(res, 'Order status updated', updatedOrder);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createOrder,
|
||||
getMyOrders,
|
||||
getOrderById,
|
||||
getAllOrders,
|
||||
updateOrderStatus
|
||||
};
|
||||
|
||||
235
server/controllers/productController.js
Normal file
235
server/controllers/productController.js
Normal file
@@ -0,0 +1,235 @@
|
||||
const Product = require('../models/Product');
|
||||
const Category = require('../models/Category');
|
||||
const Brand = require('../models/Brand');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
// @desc Get all products with filtering, search, sorting & pagination
|
||||
// @route GET /api/products
|
||||
// @access Public
|
||||
const getProducts = async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 12;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = { isActive: true };
|
||||
|
||||
// Search query
|
||||
if (req.query.search) {
|
||||
const searchRegex = new RegExp(req.query.search, 'i');
|
||||
query.$or = [
|
||||
{ name: searchRegex },
|
||||
{ description: searchRegex },
|
||||
{ tags: searchRegex },
|
||||
{ sku: searchRegex }
|
||||
];
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (req.query.category) {
|
||||
const cat = await Category.findOne({ slug: req.query.category });
|
||||
if (cat) query.category = cat._id;
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if (req.query.brand) {
|
||||
const brand = await Brand.findOne({ slug: req.query.brand });
|
||||
if (brand) query.brand = brand._id;
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
if (req.query.minPrice || req.query.maxPrice) {
|
||||
query.price = {};
|
||||
if (req.query.minPrice) query.price.$gte = Number(req.query.minPrice);
|
||||
if (req.query.maxPrice) query.price.$lte = Number(req.query.maxPrice);
|
||||
}
|
||||
|
||||
// Guitar specific filters
|
||||
if (req.query.guitarType) {
|
||||
query['specs.guitarType'] = req.query.guitarType;
|
||||
}
|
||||
if (req.query.bodyShape) {
|
||||
query['specs.bodyShape'] = req.query.bodyShape;
|
||||
}
|
||||
if (req.query.handOrientation) {
|
||||
query['specs.handOrientation'] = req.query.handOrientation;
|
||||
}
|
||||
if (req.query.numberOfStrings) {
|
||||
query['specs.numberOfStrings'] = Number(req.query.numberOfStrings);
|
||||
}
|
||||
if (req.query.isFeatured) {
|
||||
query.isFeatured = true;
|
||||
}
|
||||
if (req.query.isNewArrival) {
|
||||
query.isNewArrival = true;
|
||||
}
|
||||
if (req.query.isBestSeller) {
|
||||
query.isBestSeller = true;
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let sortOption = { createdAt: -1 };
|
||||
if (req.query.sort === 'price_asc') sortOption = { price: 1 };
|
||||
if (req.query.sort === 'price_desc') sortOption = { price: -1 };
|
||||
if (req.query.sort === 'rating') sortOption = { rating: -1 };
|
||||
if (req.query.sort === 'bestselling') sortOption = { salesCount: -1 };
|
||||
|
||||
const total = await Product.countDocuments(query);
|
||||
const products = await Product.find(query)
|
||||
.populate('category', 'name slug')
|
||||
.populate('brand', 'name slug logo')
|
||||
.sort(sortOption)
|
||||
.skip(skip)
|
||||
.limit(limit);
|
||||
|
||||
return successResponse(res, 'Products retrieved successfully', {
|
||||
products,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pages: Math.ceil(total / limit),
|
||||
limit
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Guitar Finder Recommendation Tool
|
||||
// @route POST /api/products/guitar-finder
|
||||
// @access Public
|
||||
const getGuitarRecommendations = async (req, res, next) => {
|
||||
try {
|
||||
const { skillLevel, acousticElectric, musicStyle, maxBudget, handOrientation } = req.body;
|
||||
|
||||
const query = { isActive: true };
|
||||
|
||||
if (acousticElectric && acousticElectric !== 'Any') {
|
||||
query['specs.guitarType'] = acousticElectric;
|
||||
}
|
||||
|
||||
if (maxBudget) {
|
||||
query.price = { $lte: Number(maxBudget) };
|
||||
}
|
||||
|
||||
if (handOrientation) {
|
||||
query['specs.handOrientation'] = handOrientation;
|
||||
}
|
||||
|
||||
const recommendations = await Product.find(query)
|
||||
.populate('category', 'name slug')
|
||||
.populate('brand', 'name slug logo')
|
||||
.sort({ rating: -1, salesCount: -1 })
|
||||
.limit(6);
|
||||
|
||||
return successResponse(res, 'Guitar recommendations generated', recommendations);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get single product by slug
|
||||
// @route GET /api/products/:slug
|
||||
// @access Public
|
||||
const getProductBySlug = async (req, res, next) => {
|
||||
try {
|
||||
const product = await Product.findOne({ slug: req.params.slug })
|
||||
.populate('category', 'name slug')
|
||||
.populate('brand', 'name slug logo description');
|
||||
|
||||
if (!product) {
|
||||
return errorResponse(res, 'Product not found', [], 404);
|
||||
}
|
||||
|
||||
// Related products (same category)
|
||||
const relatedProducts = await Product.find({
|
||||
category: product.category._id,
|
||||
_id: { $ne: product._id },
|
||||
isActive: true
|
||||
})
|
||||
.limit(4)
|
||||
.select('name slug price salePrice images rating brand');
|
||||
|
||||
return successResponse(res, 'Product retrieved successfully', {
|
||||
product,
|
||||
relatedProducts
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Compare products by array of IDs
|
||||
// @route POST /api/products/compare
|
||||
// @access Public
|
||||
const compareProducts = async (req, res, next) => {
|
||||
try {
|
||||
const { productIds } = req.body;
|
||||
if (!productIds || !Array.isArray(productIds)) {
|
||||
return errorResponse(res, 'Please provide an array of product IDs', [], 400);
|
||||
}
|
||||
|
||||
const products = await Product.find({ _id: { $in: productIds } })
|
||||
.populate('brand', 'name logo')
|
||||
.populate('category', 'name');
|
||||
|
||||
return successResponse(res, 'Comparison data retrieved', products);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create new product (Admin)
|
||||
// @route POST /api/products
|
||||
// @access Private/Admin
|
||||
const createProduct = async (req, res, next) => {
|
||||
try {
|
||||
const product = new Product(req.body);
|
||||
const createdProduct = await product.save();
|
||||
return successResponse(res, 'Product created successfully', createdProduct, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update product (Admin)
|
||||
// @route PUT /api/products/:id
|
||||
// @access Private/Admin
|
||||
const updateProduct = async (req, res, next) => {
|
||||
try {
|
||||
const product = await Product.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
||||
if (!product) {
|
||||
return errorResponse(res, 'Product not found', [], 404);
|
||||
}
|
||||
return successResponse(res, 'Product updated successfully', product);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete product (Admin)
|
||||
// @route DELETE /api/products/:id
|
||||
// @access Private/Admin
|
||||
const deleteProduct = async (req, res, next) => {
|
||||
try {
|
||||
const product = await Product.findByIdAndDelete(req.params.id);
|
||||
if (!product) {
|
||||
return errorResponse(res, 'Product not found', [], 404);
|
||||
}
|
||||
return successResponse(res, 'Product deleted successfully');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getProducts,
|
||||
getGuitarRecommendations,
|
||||
getProductBySlug,
|
||||
compareProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct
|
||||
};
|
||||
|
||||
50
server/controllers/reviewController.js
Normal file
50
server/controllers/reviewController.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const Review = require('../models/Review');
|
||||
const Product = require('../models/Product');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getProductReviews = async (req, res, next) => {
|
||||
try {
|
||||
const reviews = await Review.find({ product: req.params.productId, isApproved: true })
|
||||
.populate('user', 'name avatar')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
return successResponse(res, 'Reviews retrieved', reviews);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createReview = async (req, res, next) => {
|
||||
try {
|
||||
const { productId, rating, title, comment } = req.body;
|
||||
|
||||
const existing = await Review.findOne({ user: req.user._id, product: productId });
|
||||
if (existing) {
|
||||
return errorResponse(res, 'You have already reviewed this product', [], 400);
|
||||
}
|
||||
|
||||
const review = await Review.create({
|
||||
user: req.user._id,
|
||||
product: productId,
|
||||
rating: Number(rating),
|
||||
title,
|
||||
comment
|
||||
});
|
||||
|
||||
// Update Product average rating
|
||||
const reviews = await Review.find({ product: productId, isApproved: true });
|
||||
const avgRating = reviews.reduce((acc, item) => item.rating + acc, 0) / reviews.length;
|
||||
|
||||
await Product.findByIdAndUpdate(productId, {
|
||||
rating: Number(avgRating.toFixed(1)),
|
||||
numReviews: reviews.length
|
||||
});
|
||||
|
||||
return successResponse(res, 'Review submitted successfully', review, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getProductReviews, createReview };
|
||||
|
||||
32
server/controllers/settingsController.js
Normal file
32
server/controllers/settingsController.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const WebsiteSettings = require('../models/WebsiteSettings');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getSettings = async (req, res, next) => {
|
||||
try {
|
||||
let settings = await WebsiteSettings.findOne({});
|
||||
if (!settings) {
|
||||
settings = await WebsiteSettings.create({});
|
||||
}
|
||||
return successResponse(res, 'Website settings retrieved', settings);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = async (req, res, next) => {
|
||||
try {
|
||||
let settings = await WebsiteSettings.findOne({});
|
||||
if (!settings) {
|
||||
settings = new WebsiteSettings(req.body);
|
||||
} else {
|
||||
Object.assign(settings, req.body);
|
||||
}
|
||||
await settings.save();
|
||||
return successResponse(res, 'Website settings updated successfully', settings);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getSettings, updateSettings };
|
||||
|
||||
33
server/controllers/socialMediaController.js
Normal file
33
server/controllers/socialMediaController.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const SocialMedia = require('../models/SocialMedia');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getSocialMedia = async (req, res, next) => {
|
||||
try {
|
||||
const socialLinks = await SocialMedia.find({ isEnabled: true });
|
||||
return successResponse(res, 'Social media links retrieved', socialLinks);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSocialMedia = async (req, res, next) => {
|
||||
try {
|
||||
const { links } = req.body; // Array of { platform, url, isEnabled }
|
||||
if (Array.isArray(links)) {
|
||||
for (const link of links) {
|
||||
await SocialMedia.findOneAndUpdate(
|
||||
{ platform: link.platform },
|
||||
{ url: link.url, isEnabled: link.isEnabled },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await SocialMedia.find({});
|
||||
return successResponse(res, 'Social media accounts updated', updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getSocialMedia, updateSocialMedia };
|
||||
|
||||
41
server/controllers/wishlistController.js
Normal file
41
server/controllers/wishlistController.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const Wishlist = require('../models/Wishlist');
|
||||
const { successResponse, errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const getWishlist = async (req, res, next) => {
|
||||
try {
|
||||
let wishlist = await Wishlist.findOne({ user: req.user._id }).populate('products');
|
||||
if (!wishlist) {
|
||||
wishlist = await Wishlist.create({ user: req.user._id, products: [] });
|
||||
}
|
||||
return successResponse(res, 'Wishlist retrieved', wishlist);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWishlist = async (req, res, next) => {
|
||||
try {
|
||||
const { productId } = req.body;
|
||||
let wishlist = await Wishlist.findOne({ user: req.user._id });
|
||||
|
||||
if (!wishlist) {
|
||||
wishlist = new Wishlist({ user: req.user._id, products: [productId] });
|
||||
} else {
|
||||
const exists = wishlist.products.includes(productId);
|
||||
if (exists) {
|
||||
wishlist.products.pull(productId);
|
||||
} else {
|
||||
wishlist.products.push(productId);
|
||||
}
|
||||
}
|
||||
|
||||
await wishlist.save();
|
||||
const updated = await Wishlist.findById(wishlist._id).populate('products');
|
||||
return successResponse(res, 'Wishlist updated', updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getWishlist, toggleWishlist };
|
||||
|
||||
43
server/middleware/authMiddleware.js
Normal file
43
server/middleware/authMiddleware.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const { errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const protect = async (req, res, next) => {
|
||||
let token;
|
||||
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return errorResponse(res, 'Not authorized to access this route', [], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'guitar_ecommerce_super_secret_jwt_key_2026');
|
||||
req.user = await User.findById(decoded.id).select('-password');
|
||||
if (!req.user) {
|
||||
return errorResponse(res, 'User account no longer exists', [], 401);
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
return errorResponse(res, 'Not authorized, token failed or expired', [], 401);
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !roles.includes(req.user.role)) {
|
||||
return errorResponse(
|
||||
res,
|
||||
`User role '${req.user ? req.user.role : 'Guest'}' is not authorized to access this resource`,
|
||||
[],
|
||||
403
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { protect, authorize };
|
||||
|
||||
31
server/middleware/errorHandler.js
Normal file
31
server/middleware/errorHandler.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { errorResponse } = require('../utils/apiResponse');
|
||||
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
let statusCode = err.statusCode || res.statusCode === 200 ? 500 : res.statusCode;
|
||||
let message = err.message || 'Server Error';
|
||||
|
||||
// Mongoose bad ObjectId
|
||||
if (err.name === 'CastError') {
|
||||
message = `Resource not found with id ${err.value}`;
|
||||
statusCode = 404;
|
||||
}
|
||||
|
||||
// Mongoose duplicate key
|
||||
if (err.code === 11000) {
|
||||
const field = Object.keys(err.keyValue)[0];
|
||||
message = `Duplicate field value entered for ${field}. Please use another value.`;
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
// Mongoose validation error
|
||||
if (err.name === 'ValidationError') {
|
||||
message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
console.error('[Error Handler]:', err);
|
||||
return errorResponse(res, message, err.errors || [], statusCode);
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
|
||||
40
server/middleware/upload.js
Normal file
40
server/middleware/upload.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(req, file, cb) {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename(req, file, cb) {
|
||||
cb(null, `${file.fieldname}-${Date.now()}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
function checkFileType(file, cb) {
|
||||
const filetypes = /jpg|jpeg|png|webp|svg|mp4|webm/;
|
||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = filetypes.test(file.mimetype);
|
||||
|
||||
if (extname && mimetype) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Images and video media only!'));
|
||||
}
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
||||
fileFilter: function (req, file, cb) {
|
||||
checkFileType(file, cb);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
|
||||
16
server/models/Banner.js
Normal file
16
server/models/Banner.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const bannerSchema = new mongoose.Schema({
|
||||
title: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
desktopImage: { type: String, required: true },
|
||||
mobileImage: { type: String, default: '' },
|
||||
buttonText: { type: String, default: 'Shop Now' },
|
||||
buttonLink: { type: String, default: '/shop' },
|
||||
overlayOpacity: { type: Number, default: 0.4 },
|
||||
displayOrder: { type: Number, default: 0 },
|
||||
isActive: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Banner', bannerSchema);
|
||||
|
||||
15
server/models/Brand.js
Normal file
15
server/models/Brand.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const brandSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, trim: true, unique: true },
|
||||
slug: { type: String, required: true, unique: true, lowercase: true },
|
||||
logo: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
website: { type: String, default: '' },
|
||||
metaTitle: { type: String, default: '' },
|
||||
metaDescription: { type: String, default: '' },
|
||||
isActive: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Brand', brandSchema);
|
||||
|
||||
20
server/models/Cart.js
Normal file
20
server/models/Cart.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const cartItemSchema = new mongoose.Schema({
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
|
||||
variantColor: { type: String, default: '' },
|
||||
variantFinish: { type: String, default: '' },
|
||||
quantity: { type: Number, required: true, default: 1, min: 1 },
|
||||
price: { type: Number, required: true }
|
||||
});
|
||||
|
||||
const cartSchema = new mongoose.Schema({
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
|
||||
sessionId: { type: String, default: null },
|
||||
items: [cartItemSchema],
|
||||
couponCode: { type: String, default: '' },
|
||||
discountAmount: { type: Number, default: 0 }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Cart', cartSchema);
|
||||
|
||||
16
server/models/Category.js
Normal file
16
server/models/Category.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const categorySchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, trim: true, unique: true },
|
||||
slug: { type: String, required: true, unique: true, lowercase: true },
|
||||
description: { type: String, default: '' },
|
||||
image: { type: String, default: 'https://images.unsplash.com/photo-1510915361894-db8b60106cb1?w=600' },
|
||||
homeImage: { type: String, default: '' },
|
||||
fallbackImage: { type: String, default: 'https://images.unsplash.com/photo-1510915361894-db8b60106cb1?w=600' },
|
||||
parentCategory: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null },
|
||||
displayOrder: { type: Number, default: 0 },
|
||||
isFeaturedHome: { type: Boolean, default: true },
|
||||
isActive: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Category', categorySchema);
|
||||
13
server/models/ContactMessage.js
Normal file
13
server/models/ContactMessage.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const contactMessageSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, required: true },
|
||||
phone: { type: String, default: '' },
|
||||
subject: { type: String, default: 'General Inquiry' },
|
||||
message: { type: String, required: true },
|
||||
status: { type: String, enum: ['Pending', 'Read', 'Resolved'], default: 'Pending' }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('ContactMessage', contactMessageSchema);
|
||||
|
||||
17
server/models/Coupon.js
Normal file
17
server/models/Coupon.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const couponSchema = new mongoose.Schema({
|
||||
code: { type: String, required: true, unique: true, uppercase: true, trim: true },
|
||||
discountType: { type: String, enum: ['percentage', 'fixed'], default: 'percentage' },
|
||||
discountValue: { type: Number, required: true },
|
||||
minPurchaseAmount: { type: Number, default: 0 },
|
||||
maxDiscountAmount: { type: Number, default: 0 },
|
||||
startDate: { type: Date, default: Date.now },
|
||||
endDate: { type: Date, required: true },
|
||||
usageLimit: { type: Number, default: 100 },
|
||||
usedCount: { type: Number, default: 0 },
|
||||
isActive: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Coupon', couponSchema);
|
||||
|
||||
11
server/models/EmailTemplate.js
Normal file
11
server/models/EmailTemplate.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const emailTemplateSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, unique: true }, // Welcome, Order Confirmation, Order Shipped, Password Reset, Low Stock, Contact Form
|
||||
subject: { type: String, required: true },
|
||||
bodyHtml: { type: String, required: true },
|
||||
placeholders: [{ type: String }] // e.g. ['{{customerName}}', '{{orderId}}', '{{orderTotal}}']
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('EmailTemplate', emailTemplateSchema);
|
||||
|
||||
19
server/models/InventoryHistory.js
Normal file
19
server/models/InventoryHistory.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const inventoryHistorySchema = new mongoose.Schema({
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
|
||||
sku: { type: String, required: true },
|
||||
changeType: {
|
||||
type: String,
|
||||
enum: ['Order Placed', 'Order Cancelled', 'Manual Adjustment', 'Restock', 'Returned Product'],
|
||||
required: true
|
||||
},
|
||||
quantityChanged: { type: Number, required: true }, // positive for addition, negative for deduction
|
||||
previousStock: { type: Number, required: true },
|
||||
newStock: { type: Number, required: true },
|
||||
reason: { type: String, default: '' },
|
||||
performedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('InventoryHistory', inventoryHistorySchema);
|
||||
|
||||
85
server/models/Order.js
Normal file
85
server/models/Order.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const orderItemSchema = new mongoose.Schema({
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
|
||||
name: { type: String, required: true },
|
||||
image: { type: String, required: true },
|
||||
price: { type: Number, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
variantColor: { type: String, default: '' },
|
||||
variantFinish: { type: String, default: '' },
|
||||
sku: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const statusHistorySchema = new mongoose.Schema({
|
||||
status: { type: String, required: true },
|
||||
note: { type: String, default: '' },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
const orderSchema = new mongoose.Schema({
|
||||
orderNumber: { type: String, required: true, unique: true },
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
orderItems: [orderItemSchema],
|
||||
|
||||
// Indian Shipping Address Schema
|
||||
shippingAddress: {
|
||||
fullName: { type: String, default: '' },
|
||||
mobileNumber: { type: String, required: true },
|
||||
addressLine1: { type: String, required: true },
|
||||
addressLine2: { type: String, default: '' },
|
||||
landmark: { type: String, default: '' },
|
||||
city: { type: String, required: true },
|
||||
district: { type: String, default: '' },
|
||||
state: { type: String, required: true },
|
||||
postalCode: { type: String, required: true }, // 6-digit PIN code
|
||||
country: { type: String, required: true, default: 'India' }
|
||||
},
|
||||
|
||||
// Payment Configuration
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['Razorpay', 'UPI', 'Stripe', 'Credit/Debit Card', 'Net Banking', 'Cash on Delivery']
|
||||
},
|
||||
paymentResult: {
|
||||
id: { type: String },
|
||||
status: { type: String },
|
||||
update_time: { type: String },
|
||||
email_address: { type: String }
|
||||
},
|
||||
|
||||
// Pricing & Indian GST breakdown
|
||||
itemsPrice: { type: Number, required: true, default: 0 },
|
||||
gstPercentage: { type: Number, default: 18 },
|
||||
cgst: { type: Number, default: 0 },
|
||||
sgst: { type: Number, default: 0 },
|
||||
igst: { type: Number, default: 0 },
|
||||
taxPrice: { type: Number, required: true, default: 0 },
|
||||
shippingPrice: { type: Number, required: true, default: 0 },
|
||||
discountAmount: { type: Number, default: 0 },
|
||||
totalAmount: { type: Number, required: true, default: 0 },
|
||||
|
||||
// Order Status Lifecycle
|
||||
orderStatus: {
|
||||
type: String,
|
||||
enum: ['Pending', 'Payment Pending', 'Payment Confirmed', 'Processing', 'Packed', 'Shipped', 'Out for Delivery', 'Delivered', 'Cancelled', 'Returned', 'Refunded'],
|
||||
default: 'Pending'
|
||||
},
|
||||
statusHistory: [statusHistorySchema],
|
||||
|
||||
// Indian Courier Logistics
|
||||
courierProvider: { type: String, default: 'Delhivery' }, // Delhivery, BlueDart, Bluedart, FedEx, Xpressbees
|
||||
awbNumber: { type: String, default: '' },
|
||||
trackingNumber: { type: String, default: '' },
|
||||
trackingUrl: { type: String, default: '' },
|
||||
estimatedDeliveryDate: { type: Date },
|
||||
|
||||
isPaid: { type: Boolean, default: false },
|
||||
paidAt: { type: Date },
|
||||
isDelivered: { type: Boolean, default: false },
|
||||
deliveredAt: { type: Date },
|
||||
notes: { type: String, default: '' }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Order', orderSchema);
|
||||
105
server/models/Product.js
Normal file
105
server/models/Product.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const variantSchema = new mongoose.Schema({
|
||||
color: { type: String, required: true },
|
||||
colorHex: { type: String, default: '#000000' },
|
||||
finish: { type: String, default: 'Gloss' },
|
||||
sku: { type: String, default: '' },
|
||||
price: { type: Number, required: true },
|
||||
salePrice: { type: Number, default: 0 },
|
||||
stock: { type: Number, required: true, default: 0 },
|
||||
image: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const customSpecSchema = new mongoose.Schema({
|
||||
key: { type: String, required: true },
|
||||
value: { type: String, required: true }
|
||||
});
|
||||
|
||||
const guitarSpecSchema = new mongoose.Schema({
|
||||
guitarType: { type: String, default: 'Electric' }, // Electric, Acoustic, Bass, Classical, Accessories
|
||||
bodyShape: { type: String, default: 'Stratocaster' },
|
||||
bodyMaterial: { type: String, default: 'Alder' },
|
||||
topMaterial: { type: String, default: '' },
|
||||
backMaterial: { type: String, default: '' },
|
||||
sideMaterial: { type: String, default: '' },
|
||||
neckMaterial: { type: String, default: 'Maple' },
|
||||
fingerboardMaterial: { type: String, default: 'Rosewood' },
|
||||
scaleLength: { type: String, default: '25.5"' },
|
||||
numberOfStrings: { type: Number, default: 6 },
|
||||
frets: { type: Number, default: 22 },
|
||||
pickupConfiguration: { type: String, default: 'SSS' }, // SSS, HSS, HH, Single, Acoustic Pickup
|
||||
pickupType: { type: String, default: 'Passive Single-Coil' },
|
||||
bridgeType: { type: String, default: 'Tremolo' },
|
||||
tuningMachines: { type: String, default: 'Standard Die-Cast' },
|
||||
handOrientation: { type: String, enum: ['Right-Handed', 'Left-Handed'], default: 'Right-Handed' },
|
||||
color: { type: String, default: '' },
|
||||
finish: { type: String, default: 'Gloss Polyurethane' },
|
||||
includesCase: { type: Boolean, default: false },
|
||||
customSpecs: [customSpecSchema]
|
||||
});
|
||||
|
||||
const productSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, trim: true },
|
||||
slug: { type: String, required: true, unique: true, lowercase: true },
|
||||
sku: { type: String, required: true, unique: true },
|
||||
brand: { type: mongoose.Schema.Types.ObjectId, ref: 'Brand', required: true },
|
||||
category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', required: true },
|
||||
description: { type: String, required: true },
|
||||
shortDescription: { type: String, default: '' },
|
||||
|
||||
// Pricing & Discounts
|
||||
price: { type: Number, required: true, min: 0 },
|
||||
salePrice: { type: Number, default: 0, min: 0 },
|
||||
discountType: { type: String, enum: ['percentage', 'fixed'], default: 'percentage' },
|
||||
discountValue: { type: Number, default: 0, min: 0 },
|
||||
costPrice: { type: Number, default: 0 },
|
||||
|
||||
// Inventory
|
||||
stock: { type: Number, required: true, default: 10, min: 0 },
|
||||
lowStockThreshold: { type: Number, default: 3 },
|
||||
|
||||
// Media
|
||||
images: [{ type: String }],
|
||||
primaryImage: { type: String, default: '' },
|
||||
videoUrl: { type: String, default: '' },
|
||||
audioDemoUrl: { type: String, default: '' },
|
||||
|
||||
// Specs & Variants
|
||||
specs: guitarSpecSchema,
|
||||
variants: [variantSchema],
|
||||
tags: [{ type: String }],
|
||||
weightKg: { type: Number, default: 4.5 },
|
||||
dimensionsCm: { type: String, default: '105 x 38 x 10' },
|
||||
|
||||
// Rating & Sales Metrics
|
||||
rating: { type: Number, default: 4.8 },
|
||||
numReviews: { type: Number, default: 0 },
|
||||
salesCount: { type: Number, default: 0 },
|
||||
|
||||
// Badges & Flags
|
||||
isFeatured: { type: Boolean, default: false },
|
||||
isNewArrival: { type: Boolean, default: false },
|
||||
isBestSeller: { type: Boolean, default: false },
|
||||
isActive: { type: Boolean, default: true },
|
||||
|
||||
// SEO
|
||||
metaTitle: { type: String, default: '' },
|
||||
metaDescription: { type: String, default: '' }
|
||||
}, { timestamps: true });
|
||||
|
||||
// Auto-generate SKU before validation if missing
|
||||
productSchema.pre('validate', function(next) {
|
||||
if (!this.sku) {
|
||||
const randomNum = Math.floor(10000 + Math.random() * 90000);
|
||||
this.sku = `GTR-2026-${randomNum}`;
|
||||
}
|
||||
if (this.images && this.images.length > 0 && !this.primaryImage) {
|
||||
this.primaryImage = this.images[0];
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
productSchema.index({ name: 'text', description: 'text', tags: 'text' });
|
||||
|
||||
module.exports = mongoose.model('Product', productSchema);
|
||||
17
server/models/Review.js
Normal file
17
server/models/Review.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const reviewSchema = new mongoose.Schema({
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
|
||||
rating: { type: Number, required: true, min: 1, max: 5 },
|
||||
title: { type: String, required: true, trim: true },
|
||||
comment: { type: String, required: true },
|
||||
isVerifiedPurchase: { type: Boolean, default: true },
|
||||
isApproved: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
// Prevent multiple reviews per product by same user
|
||||
reviewSchema.index({ user: 1, product: 1 }, { unique: true });
|
||||
|
||||
module.exports = mongoose.model('Review', reviewSchema);
|
||||
|
||||
10
server/models/SocialMedia.js
Normal file
10
server/models/SocialMedia.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const socialMediaSchema = new mongoose.Schema({
|
||||
platform: { type: String, required: true, unique: true }, // instagram, facebook, youtube, tiktok, x, linkedin
|
||||
url: { type: String, required: true },
|
||||
isEnabled: { type: Boolean, default: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('SocialMedia', socialMediaSchema);
|
||||
|
||||
47
server/models/User.js
Normal file
47
server/models/User.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const addressSchema = new mongoose.Schema({
|
||||
fullName: { type: String, default: '' },
|
||||
mobileNumber: { type: String, default: '' },
|
||||
addressLine1: { type: String, default: '' },
|
||||
addressLine2: { type: String, default: '' },
|
||||
landmark: { type: String, default: '' },
|
||||
street: { type: String, default: '' },
|
||||
city: { type: String, required: true },
|
||||
district: { type: String, default: '' },
|
||||
state: { type: String, required: true },
|
||||
postalCode: { type: String, required: true },
|
||||
country: { type: String, required: true, default: 'India' },
|
||||
isDefault: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
name: { type: String, required: [true, 'Please provide your name'], trim: true },
|
||||
email: { type: String, required: [true, 'Please provide your email'], unique: true, lowercase: true, trim: true },
|
||||
password: { type: String, required: [true, 'Please provide a password'], minlength: 6, select: false },
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['Customer', 'Admin', 'Staff', 'Warehouse Manager', 'Order Manager'],
|
||||
default: 'Customer'
|
||||
},
|
||||
phone: { type: String, default: '' },
|
||||
avatar: { type: String, default: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300' },
|
||||
addresses: [addressSchema],
|
||||
isVerified: { type: Boolean, default: true },
|
||||
resetPasswordToken: String,
|
||||
resetPasswordExpire: Date
|
||||
}, { timestamps: true });
|
||||
|
||||
userSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) return next();
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
});
|
||||
|
||||
userSchema.methods.matchPassword = async function(enteredPassword) {
|
||||
return await bcrypt.compare(enteredPassword, this.password);
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
34
server/models/WebsiteSettings.js
Normal file
34
server/models/WebsiteSettings.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const websiteSettingsSchema = new mongoose.Schema({
|
||||
siteName: { type: String, default: 'Antigravity Guitars India' },
|
||||
tagline: { type: String, default: 'Crafted Sound. Pure Perfection.' },
|
||||
contactEmail: { type: String, default: 'support@antigravityguitars.in' },
|
||||
phone: { type: String, default: '+91 98765 43210' },
|
||||
businessName: { type: String, default: 'Antigravity Music Private Limited' },
|
||||
gstin: { type: String, default: '29ABCDE1234F1Z5' },
|
||||
addressLine1: { type: String, default: '100 Brigade Road, Ashok Nagar' },
|
||||
addressLine2: { type: String, default: 'Suite 400, Music Square' },
|
||||
city: { type: String, default: 'Bengaluru' },
|
||||
district: { type: String, default: 'Bengaluru Urban' },
|
||||
state: { type: String, default: 'Karnataka' },
|
||||
pincode: { type: String, default: '560025' },
|
||||
country: { type: String, default: 'India' },
|
||||
currencySymbol: { type: String, default: '₹' },
|
||||
currencyCode: { type: String, default: 'INR' },
|
||||
timezone: { type: String, default: 'Asia/Kolkata' },
|
||||
logoPrimary: { type: String, default: '/assets/logo-primary.svg' },
|
||||
logoDark: { type: String, default: '/assets/logo-dark.svg' },
|
||||
logoLight: { type: String, default: '/assets/logo-light.svg' },
|
||||
logoMobile: { type: String, default: '/assets/logo-mobile.svg' },
|
||||
favicon: { type: String, default: '/favicon.ico' },
|
||||
freeShippingThreshold: { type: Number, default: 5000 }, // ₹5,000 threshold
|
||||
codEnabled: { type: Boolean, default: true },
|
||||
maxCodAmount: { type: Number, default: 50000 }, // ₹50,000 max COD
|
||||
aboutUsText: { type: String, default: 'We are India’s premier music destination providing hand-crafted acoustic, electric, and custom shop guitars.' },
|
||||
privacyPolicy: { type: String, default: 'Your privacy is paramount to us under Indian IT Act regulations.' },
|
||||
termsConditions: { type: String, default: 'All sales governed by Antigravity Guitars warranty policies.' },
|
||||
returnPolicy: { type: String, default: '10-day hassle-free returns on all standard model guitars in original condition.' }
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('WebsiteSettings', websiteSettingsSchema);
|
||||
9
server/models/Wishlist.js
Normal file
9
server/models/Wishlist.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const wishlistSchema = new mongoose.Schema({
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
|
||||
products: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Product' }]
|
||||
}, { timestamps: true });
|
||||
|
||||
module.exports = mongoose.model('Wishlist', wishlistSchema);
|
||||
|
||||
1821
server/package-lock.json
generated
Normal file
1821
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "guitar-ecommerce-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Modern Guitar Ecommerce Application",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"seed": "node seed/seeder.js"
|
||||
},
|
||||
"keywords": ["mern", "ecommerce", "guitar", "express", "mongodb"],
|
||||
"author": "Antigravity",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.2.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.3.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"stripe": "^15.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
12
server/routes/authRoutes.js
Normal file
12
server/routes/authRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { registerUser, loginUser, getUserProfile, updateUserProfile } = require('../controllers/authController');
|
||||
const { protect } = require('../middleware/authMiddleware');
|
||||
|
||||
router.post('/register', registerUser);
|
||||
router.post('/login', loginUser);
|
||||
router.get('/profile', protect, getUserProfile);
|
||||
router.put('/profile', protect, updateUserProfile);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
12
server/routes/bannerRoutes.js
Normal file
12
server/routes/bannerRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanners, createBanner, updateBanner, deleteBanner } = require('../controllers/bannerController');
|
||||
const { protect, authorize } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', getBanners);
|
||||
router.post('/', protect, authorize('Admin'), createBanner);
|
||||
router.put('/:id', protect, authorize('Admin'), updateBanner);
|
||||
router.delete('/:id', protect, authorize('Admin'), deleteBanner);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
12
server/routes/brandRoutes.js
Normal file
12
server/routes/brandRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBrands, createBrand, updateBrand, deleteBrand } = require('../controllers/brandController');
|
||||
const { protect, authorize } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', getBrands);
|
||||
router.post('/', protect, authorize('Admin'), createBrand);
|
||||
router.put('/:id', protect, authorize('Admin'), updateBrand);
|
||||
router.delete('/:id', protect, authorize('Admin'), deleteBrand);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
12
server/routes/cartRoutes.js
Normal file
12
server/routes/cartRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getCart, addToCart, updateCartItem, removeCartItem, applyCoupon } = require('../controllers/cartController');
|
||||
|
||||
router.get('/', getCart);
|
||||
router.post('/add', addToCart);
|
||||
router.put('/update', updateCartItem);
|
||||
router.delete('/item/:itemId', removeCartItem);
|
||||
router.post('/coupon', applyCoupon);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
12
server/routes/categoryRoutes.js
Normal file
12
server/routes/categoryRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getCategories, createCategory, updateCategory, deleteCategory } = require('../controllers/categoryController');
|
||||
const { protect, authorize } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', getCategories);
|
||||
router.post('/', protect, authorize('Admin'), createCategory);
|
||||
router.put('/:id', protect, authorize('Admin'), updateCategory);
|
||||
router.delete('/:id', protect, authorize('Admin'), deleteCategory);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user