diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ + diff --git a/README.md b/README.md index f1fdfc3..8b13789 100644 --- a/README.md +++ b/README.md @@ -1,212 +1 @@ -# πŸŽ“ Geeta University MerchStore -> Official e-commerce platform for authentic Geeta University branded merchandise. - -[![Status](https://img.shields.io/badge/status-in--development-yellow)]() -[![License](https://img.shields.io/badge/license-Internal-blue)]() - ---- - -## πŸ“– Overview - -**Geeta University MerchStore** is a dedicated B2C e-commerce web application built exclusively for the Geeta University community. Students, faculty, and staff can browse, order, and pay for officially branded merchandise β€” notebooks, T-shirts, hoodies, caps, stationery, and collectibles β€” through a single authoritative platform, eliminating fake or low-quality items in circulation. - -An integrated **Admin Dashboard** allows the store manager to handle inventory, pricing, order fulfillment, and sales analytics. The platform supports both **online payments (Stripe)** and **Cash on Delivery (COD)** for on-campus orders. - -| | | -|---|---| -| **Client** | Geeta University Store Department | -| **Contact** | Store Manager / Dean of Student Affairs | -| **Project Type** | B2C E-Commerce Web Application | -| **Target Users** | Students, Faculty & Staff of Geeta University | -| **Timeline** | 8 Weeks | -| **Scope** | Academic / Internal Internship Project | - ---- - -## ✨ Key Features - -| Module | Description | Priority | -|---|---|---| -| πŸ” Auth | University email + Google OAuth login (JWT + bcrypt) | P0 | -| πŸ›οΈ Product Catalog | Browse with filters (category, size, color) | P0 | -| πŸ–ΌοΈ Product Detail | Image gallery, size chart, stock status | P0 | -| πŸ›’ Cart & Checkout | Add/remove items, quantity update, address selection | P0 | -| πŸ’³ Payments | Stripe online payment + Cash on Delivery | P0 | -| πŸ“¦ Order Tracking | Real-time status: Placed β†’ Packed β†’ Shipped β†’ Delivered | P1 | -| πŸ§‘β€πŸ’Ό Admin Dashboard | Manage products, orders, and order status | P0 | -| πŸ“Š Inventory Mgmt | Stock tracking with low-stock email alerts | P1 | -| ⭐ Reviews & Ratings | Verified-purchase product reviews | P2 | -| 🎟️ Coupon System | Event-based discount codes (e.g. `FEST20`) | P2 | -| πŸ“ˆ Sales Reports | Weekly/monthly revenue analytics | P1 | -| πŸ“± Responsive UI | Full mobile & desktop support | P0 | - -> **Priority key:** P0 = Must Have Β· P1 = Should Have Β· P2 = Nice to Have - ---- - -## 🧱 Tech Stack -``` -**Frontend** β€” React.js (Vite), React Router v6, Redux Toolkit, Tailwind CSS, Axios -**Backend** β€” Node.js, Express.js, REST API architecture, Multer -**Database** β€” MongoDB Atlas, Mongoose ODM -**Auth** β€” JWT, bcrypt.js, Role-Based Access Control (RBAC) -**Integrations** β€” Stripe (payments), Cloudinary (image CDN) -**Dev Tools** β€” VS Code, Postman, Git/GitHub, ESLint, Prettier, dotenv -**Deployment** β€” Frontend: Vercel/Netlify Β· Backend: Render.com Β· DB: MongoDB Atlas -``` ---- - -## πŸ—οΈ Architecture - -``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ React Client β”‚ - β”‚ (Vite + Redux) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ Axios (REST) - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Express.js API β”‚ - β”‚ (JWT + RBAC) β”‚ - β””β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜ - β”‚ β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ MongoDB Atlasβ”‚ β”‚ Stripe API β”‚ - β”‚ (Mongoose) β”‚ β”‚ Cloudinary β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - ---- - -## πŸ—‚οΈ Database Schema (Collections) - -| Collection | Key Fields | -|---|---| -| **Users** | `name`, `email`, `password`, `role`, `addresses[]` | -| **Products** | `name`, `description`, `category`, `price`, `images[]`, `sizes[]`, `stock`, `ratings[]` | -| **Orders** | `userId`, `items[]`, `totalAmount`, `paymentMethod`, `status`, `address` | -| **Cart** | `userId`, `items[{productId, qty, size}]` | -| **Coupons** | `code`, `discountPct`, `expiresAt`, `usageLimit` | -| **Reviews** | `userId`, `productId`, `rating`, `comment`, `createdAt` | - ---- - -## πŸ”Œ API Reference - -| Method | Endpoint | Description | -|---|---|---| -| `POST` | `/api/auth/register` | Register a new user | -| `POST` | `/api/auth/login` | Authenticate user, issue JWT | -| `GET` | `/api/products` | List products with filters | -| `GET` | `/api/products/:id` | Get single product details | -| `POST` | `/api/cart/add` | Add item to cart | -| `PUT` | `/api/cart/update` | Update cart item quantity | -| `POST` | `/api/orders/create` | Place a new order | -| `GET` | `/api/orders/:userId` | Get order history for a user | -| `PUT` | `/api/admin/orders/:id/status` | Admin: update order status | -| `GET` | `/api/admin/analytics/revenue` | Admin: revenue analytics | -| `POST` | `/api/coupons/validate` | Validate a discount coupon | -| `POST` | `/api/reviews/:productId` | Submit a product review | - -> Full request/response schemas available in the [Postman Collection](#) *(link to be added)*. - ---- - -## πŸ–₯️ Screens - -- **Home / Landing** β€” Hero banner, featured products, category grid, announcements -- **Product Listing** β€” Filter sidebar, product grid, pagination, sort by price/rating -- **Product Detail** β€” Image slider, size selector, add-to-cart, related products -- **Cart** β€” Item list, quantity stepper, price breakdown -- **Checkout** β€” Address form, payment method, order summary -- **Order Confirmation** β€” Success state, order ID, estimated delivery -- **User Dashboard** β€” Profile, order history, saved addresses -- **Admin: Products** β€” Data table with add/edit/delete modal -- **Admin: Orders** β€” Order table with status update & filters -- **Admin: Analytics** β€” Revenue charts, top products, order count cards - ---- - -## πŸš€ Getting Started - -### Prerequisites -- Node.js β‰₯ 18.x -- MongoDB Atlas account -- Stripe account (test mode keys) -- Cloudinary account - -### Installation - -```bash -# Clone the repository -git clone https://github.com//geeta-university-merchstore.git -cd geeta-university-merchstore - -# Install backend dependencies -cd server -npm install - -# Install frontend dependencies -cd ../client -npm install -``` - -### Environment Variables - -Create a `.env` file in `/server`: - -```env -PORT=5000 -MONGODB_URI=your_mongodb_atlas_uri -JWT_SECRET=your_jwt_secret -STRIPE_SECRET_KEY=your_stripe_secret_key -CLOUDINARY_CLOUD_NAME=your_cloud_name -CLOUDINARY_API_KEY=your_api_key -CLOUDINARY_API_SECRET=your_api_secret -GOOGLE_CLIENT_ID=your_google_oauth_client_id -``` - -### Run Locally - -```bash -# Backend -cd server -npm run dev - -# Frontend (in a new terminal) -cd client -npm run dev -``` - -App will be available at `http://localhost:5173` (frontend) and `http://localhost:5000` (API). - ---- - - - -## πŸ”’ Security & Non-Functional Requirements - -- **Security** β€” Stateless JWT auth, bcrypt hashing, HTTPS only, input validation (Joi/Zod), API rate limiting -- **Performance** β€” API response time < 500ms (95th percentile), DB indexing, lazy loading -- **Scalability** β€” Stateless backend (horizontally scalable), MongoDB Atlas managed scaling -- **Responsiveness** β€” Mobile-first (320px–1920px), tested on Chrome, Firefox, Safari -- **Accessibility** β€” WCAG 2.1 AA compliant, semantic HTML, keyboard navigation, ARIA labels -- **Code Quality** β€” ESLint + Prettier, Git branching (`main`/`dev`/`feature/*`), PR-based reviews -- **Testing** β€” Jest unit tests, manual E2E checklist, automated Postman collection - ---- - -## 🀝 Contributing - -1. Fork the repo and create your branch from `dev` (`feature/`) -2. Commit changes with clear messages -3. Open a Pull Request against `dev` for review -4. Ensure ESLint/Prettier checks pass before requesting review - ---- - -## πŸ“„ License - -Internal academic project β€” Geeta University. Not for external commercial distribution. - ---- diff --git a/client/package-lock.json b/client/package-lock.json index edf8f14..daa3b11 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -50,6 +50,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -829,7 +852,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.9.0.tgz", "integrity": "sha512-Vwqe6Q5cU4i82tPyAv2BpaW/fQSNdOSO4/J8EeDLPp5/oIZiMmdB+Hgh863zFH+rtoxpuWGvD1L7QPh8k1Rdvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -851,7 +873,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1065,7 +1086,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -1214,8 +1234,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -1738,7 +1757,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2322,7 +2340,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2518,7 +2535,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2528,7 +2544,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2541,7 +2556,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -2590,7 +2604,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -2687,8 +2700,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3058,7 +3070,6 @@ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3166,7 +3177,6 @@ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/client/src/App.jsx b/client/src/App.jsx index c8ee493..785ebfc 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,7 @@ import Cart from './pages/Cart'; import Checkout from './pages/Checkout'; import OrderConfirm from './pages/OrderConfirm'; import UserDashboard from './pages/UserDashboard'; +import OAuthSuccess from './pages/OAuthSuccess'; // State Actions import { fetchCurrentUser } from './features/auth/authSlice'; @@ -69,6 +70,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/FilterSidebar.jsx b/client/src/components/FilterSidebar.jsx index 8f0ce0b..8548c79 100644 --- a/client/src/components/FilterSidebar.jsx +++ b/client/src/components/FilterSidebar.jsx @@ -7,7 +7,8 @@ const FilterSidebar = ({ filters, onFilterChange, onReset, onClose }) => { { name: 'Varsity Hoodies', value: 'hoodies' }, { name: 'Heritage Tees', value: 'tshirts' }, { name: 'Crewneck Sweatshirts', value: 'sweatshirts' }, - { name: 'Academic Accessories', value: 'accessories' } + { name: 'Academic Accessories', value: 'accessories' }, + { name: 'University Stationery', value: 'stationery' } ]; const sizes = ['S', 'M', 'L', 'XL', 'One Size', 'Standard']; diff --git a/client/src/features/auth/authSlice.js b/client/src/features/auth/authSlice.js index a20c871..417270c 100644 --- a/client/src/features/auth/authSlice.js +++ b/client/src/features/auth/authSlice.js @@ -1,6 +1,31 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import api from '../../utils/api'; +const normalizeAuthPayload = (payload) => { + if (!payload || typeof payload !== 'object') { + return { token: null, user: null }; + } + + const source = payload.data && typeof payload.data === 'object' ? payload.data : payload; + + return { + token: source.token || null, + user: source.user || null, + }; +}; + +const normalizeUserPayload = (payload) => { + if (!payload || typeof payload !== 'object') { + return null; + } + + if (payload.data && typeof payload.data === 'object') { + return payload.data; + } + + return payload.user || null; +}; + // Async Thunks export const registerUser = createAsyncThunk( 'auth/register', @@ -86,11 +111,14 @@ const authSlice = createSlice({ state.error = null; }) .addCase(registerUser.fulfilled, (state, action) => { + const { token, user } = normalizeAuthPayload(action.payload); state.loading = false; - state.token = action.payload.token; - state.user = action.payload.user; + state.token = token; + state.user = user; state.success = true; - localStorage.setItem('token', action.payload.token); + if (token) { + localStorage.setItem('token', token); + } }) .addCase(registerUser.rejected, (state, action) => { state.loading = false; @@ -103,11 +131,14 @@ const authSlice = createSlice({ state.error = null; }) .addCase(loginUser.fulfilled, (state, action) => { + const { token, user } = normalizeAuthPayload(action.payload); state.loading = false; - state.token = action.payload.token; - state.user = action.payload.user; + state.token = token; + state.user = user; state.success = true; - localStorage.setItem('token', action.payload.token); + if (token) { + localStorage.setItem('token', token); + } }) .addCase(loginUser.rejected, (state, action) => { state.loading = false; @@ -120,7 +151,7 @@ const authSlice = createSlice({ }) .addCase(fetchCurrentUser.fulfilled, (state, action) => { state.loading = false; - state.user = action.payload.user; + state.user = normalizeUserPayload(action.payload); }) .addCase(fetchCurrentUser.rejected, (state) => { state.loading = false; @@ -132,7 +163,8 @@ const authSlice = createSlice({ // Add Address .addCase(addAddress.fulfilled, (state, action) => { if (state.user) { - state.user.addresses = action.payload.addresses; + // Backend returns { success, data: { addresses } } + state.user.addresses = action.payload.addresses || action.payload.data?.addresses || []; } }); }, diff --git a/client/src/features/cart/cartSlice.js b/client/src/features/cart/cartSlice.js index 2845e58..1ff87bc 100644 --- a/client/src/features/cart/cartSlice.js +++ b/client/src/features/cart/cartSlice.js @@ -87,7 +87,8 @@ const cartSlice = createSlice({ }) .addCase(fetchCart.fulfilled, (state, action) => { state.loading = false; - state.items = action.payload.cart; + // Backend returns { success, data: { items, itemCount, subtotal } } + state.items = action.payload?.data?.items ?? action.payload?.cart ?? []; }) .addCase(fetchCart.rejected, (state, action) => { state.loading = false; diff --git a/client/src/features/orders/orderSlice.js b/client/src/features/orders/orderSlice.js index a87f955..0c10f20 100644 --- a/client/src/features/orders/orderSlice.js +++ b/client/src/features/orders/orderSlice.js @@ -11,7 +11,9 @@ export const createOrder = createAsyncThunk( dispatch(clearCartLocal()); // Clear local Redux cart return response.data; } catch (error) { - return rejectWithValue(error.response?.data?.message || 'Failed to place order'); + const errData = error.response?.data; + const validationMsg = errData?.errors?.[0]?.message; + return rejectWithValue(validationMsg || errData?.message || 'Failed to place order'); } } ); @@ -21,7 +23,9 @@ export const validateCoupon = createAsyncThunk( async (code, { rejectWithValue }) => { try { const response = await api.post('/coupons/validate', { code }); - return { code, discountPct: response.data.discountPct }; + // Backend returns { success, data: { code, discountPct, discountAmount, ... } } + const payload = response.data?.data || response.data; + return { code, discountPct: payload.discountPct }; } catch (error) { return rejectWithValue(error.response?.data?.message || 'Invalid coupon code'); } @@ -30,9 +34,14 @@ export const validateCoupon = createAsyncThunk( export const fetchUserOrders = createAsyncThunk( 'orders/fetchAll', - async (_, { rejectWithValue }) => { + async (_, { rejectWithValue, getState }) => { try { - const response = await api.get('/orders'); + const userId = getState().auth.user?._id; + if (!userId) { + return rejectWithValue('Not authenticated'); + } + + const response = await api.get(`/orders/${userId}`); return response.data; } catch (error) { return rejectWithValue(error.response?.data?.message || 'Failed to load order history'); @@ -86,9 +95,10 @@ const orderSlice = createSlice({ }) .addCase(createOrder.fulfilled, (state, action) => { state.loading = false; - state.currentOrder = action.payload.order; + const data = action.payload?.data; + state.currentOrder = data?.order ?? (data?._id ? data : null) ?? action.payload?.order ?? null; state.success = true; - state.activeCoupon = null; // Reset coupon after purchase + state.activeCoupon = null; }) .addCase(createOrder.rejected, (state, action) => { state.loading = false; @@ -114,7 +124,9 @@ const orderSlice = createSlice({ }) .addCase(fetchUserOrders.fulfilled, (state, action) => { state.loading = false; - state.list = action.payload.orders; + // Backend returns { success, data: [...orders] } + const raw = action.payload?.data; + state.list = Array.isArray(raw) ? raw : (action.payload?.orders ?? []); }) .addCase(fetchUserOrders.rejected, (state, action) => { state.loading = false; @@ -127,7 +139,8 @@ const orderSlice = createSlice({ }) .addCase(fetchOrderById.fulfilled, (state, action) => { state.loading = false; - state.currentOrder = action.payload.order; + const data = action.payload?.data; + state.currentOrder = data?.order ?? (data?._id ? data : null) ?? action.payload?.order ?? null; }) .addCase(fetchOrderById.rejected, (state, action) => { state.loading = false; diff --git a/client/src/features/products/productSlice.js b/client/src/features/products/productSlice.js index 7f0ca41..7d8d52e 100644 --- a/client/src/features/products/productSlice.js +++ b/client/src/features/products/productSlice.js @@ -1,6 +1,30 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import api from '../../utils/api'; +const normalizeProductsPayload = (payload) => { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + + if (Array.isArray(payload.data)) return payload.data; + if (Array.isArray(payload.products)) return payload.products; + + return []; +}; + +const normalizeProductPayload = (payload) => { + if (!payload || typeof payload !== 'object') return null; + + if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) { + return payload.data; + } + + if (payload.product && typeof payload.product === 'object' && !Array.isArray(payload.product)) { + return payload.product; + } + + return null; +}; + // Async Thunks export const fetchProducts = createAsyncThunk( 'products/fetchAll', @@ -64,7 +88,7 @@ const productSlice = createSlice({ }) .addCase(fetchProducts.fulfilled, (state, action) => { state.loading = false; - state.items = action.payload.products; + state.items = normalizeProductsPayload(action.payload); }) .addCase(fetchProducts.rejected, (state, action) => { state.loading = false; @@ -78,7 +102,7 @@ const productSlice = createSlice({ }) .addCase(fetchProductById.fulfilled, (state, action) => { state.loading = false; - state.selectedProduct = action.payload.product; + state.selectedProduct = normalizeProductPayload(action.payload); }) .addCase(fetchProductById.rejected, (state, action) => { state.loading = false; diff --git a/client/src/pages/Cart.jsx b/client/src/pages/Cart.jsx index a3f0d36..73f9eb0 100644 --- a/client/src/pages/Cart.jsx +++ b/client/src/pages/Cart.jsx @@ -10,23 +10,22 @@ const Cart = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const { items: cartItems, loading } = useSelector((state) => state.cart); + const safeCartItems = Array.isArray(cartItems) ? cartItems : []; useEffect(() => { dispatch(fetchCart()); }, [dispatch]); - // Calculations - const subtotal = cartItems.reduce((acc, item) => acc + (item.product?.price || 0) * item.qty, 0); - const totalItemsCount = cartItems.reduce((acc, item) => acc + item.qty, 0); - const estimatedDelivery = subtotal > 1000 ? 0 : 60; // Free delivery above 1000 INR + // Calculations - backend returns flat items: { productId, name, price, image, size, qty, stock } + const subtotal = safeCartItems.reduce((acc, item) => acc + (item.price || 0) * item.qty, 0); + const totalItemsCount = safeCartItems.reduce((acc, item) => acc + item.qty, 0); + const estimatedDelivery = subtotal > 1000 ? 0 : 60; const handleQtyChange = (item, newQty) => { if (newQty < 1) return; - // Check stock limit for that product size - const sizeObj = item.product?.sizes?.find(s => s.size === item.size); - if (sizeObj && newQty > sizeObj.stock) { - toast.error(`Sorry, only ${sizeObj.stock} units available in size ${item.size}.`); + if (item.stock !== undefined && newQty > item.stock) { + toast.error(`Sorry, only ${item.stock} units available in size ${item.size}.`); return; } @@ -53,13 +52,13 @@ const Cart = () => { return (
- {loading && cartItems.length === 0 && } + {loading && safeCartItems.length === 0 && }

Your Shopping Cart

- {cartItems.length === 0 ? ( + {safeCartItems.length === 0 ? ( /* Empty Cart State */
@@ -81,7 +80,7 @@ const Cart = () => {
{/* LEFT: CART ITEMS LIST (lg:col-span-8) */}
- {cartItems.map((item) => ( + {safeCartItems.map((item) => (
{ {/* Product Thumbnail */}
{item.product?.name}
@@ -98,13 +97,13 @@ const Cart = () => { {/* Info details */}

- {item.product?.name} + {item.name || item.product?.name}

Size: {item.size} - β‚Ή{item.product?.price?.toLocaleString('en-IN')}.00 each + β‚Ή{(item.price || item.product?.price || 0).toLocaleString('en-IN')}.00 each
@@ -132,7 +131,7 @@ const Cart = () => { {/* Total Item Price */} - β‚Ή{((item.product?.price || 0) * item.qty).toLocaleString('en-IN')}.00 + β‚Ή{((item.price || item.product?.price || 0) * item.qty).toLocaleString('en-IN')}.00 {/* Trash Bin button */} diff --git a/client/src/pages/Checkout.jsx b/client/src/pages/Checkout.jsx index 675aa61..07b1523 100644 --- a/client/src/pages/Checkout.jsx +++ b/client/src/pages/Checkout.jsx @@ -26,6 +26,7 @@ const Checkout = () => { const navigate = useNavigate(); const { items: cartItems, loading: cartLoading } = useSelector((state) => state.cart); + const safeCartItems = Array.isArray(cartItems) ? cartItems : []; const { user } = useSelector((state) => state.auth); const { activeCoupon, couponError, loading: orderLoading } = useSelector((state) => state.orders); @@ -66,14 +67,14 @@ const Checkout = () => { } }, [user]); - // Calculations - const subtotal = cartItems.reduce((acc, item) => acc + (item.product?.price || 0) * item.qty, 0); + // Calculations - backend returns flat items: { productId, name, price, image, size, qty } + const subtotal = safeCartItems.reduce((acc, item) => acc + (item.price || item.product?.price || 0) * item.qty, 0); const delivery = subtotal > 1000 ? 0 : 60; const discount = activeCoupon ? Math.round(subtotal * (activeCoupon.discountPct / 100)) : 0; const total = subtotal + delivery - discount; // Safeguard redirect if cart empty - if (!cartLoading && cartItems.length === 0) { + if (!cartLoading && safeCartItems.length === 0) { return (

Your Cart is Empty

@@ -129,26 +130,29 @@ const Checkout = () => { }; const handlePlaceOrder = () => { - // Get address text - let finalAddressText = ''; - if (selectedAddressId) { - const selected = user.addresses.find(a => a._id === selectedAddressId); - if (selected) { - finalAddressText = `${selected.street}, ${selected.city}, ${selected.state} - ${selected.pincode}`; - } - } - - if (!finalAddressText) { + if (!selectedAddressId || !user?.addresses?.length) { toast.error('Shipping address details missing.'); return; } const orderPayload = { - address: finalAddressText, + addressId: selectedAddressId, paymentMethod, couponCode: activeCoupon?.code || null, }; + const navigateToConfirmation = (res) => { + const orderId = + res?.data?.orderId || + res?.data?.order?._id || + res?.order?._id; + if (!orderId) { + toast.error('Order placed but confirmation page could not be loaded.'); + return; + } + navigate(`/order-confirm/${orderId}`); + }; + if (paymentMethod === 'stripe') { // Basic credit card validate mockup if (!cardNumber || !cardExpiry || !cardCvc) { @@ -164,7 +168,7 @@ const Checkout = () => { .then((res) => { setIsProcessingPayment(false); toast.success('Payment authorized! Order placed.'); - navigate(`/order-confirm/${res.order._id}`); + navigateToConfirmation(res); }) .catch((err) => { setIsProcessingPayment(false); @@ -177,7 +181,7 @@ const Checkout = () => { .unwrap() .then((res) => { toast.success('Order placed successfully (COD)'); - navigate(`/order-confirm/${res.order._id}`); + navigateToConfirmation(res); }) .catch((err) => { toast.error(err || 'Order processing failed'); @@ -567,14 +571,14 @@ const Checkout = () => {

Items List

- {cartItems.map((item, idx) => ( + {safeCartItems.map((item, idx) => (
- {item.product?.name} + {item.name || item.product?.name} ({item.size})
- {item.qty} Γ— β‚Ή{item.product?.price} + {item.qty} Γ— β‚Ή{item.price || item.product?.price}
))} diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx index 3a51da7..65df86e 100644 --- a/client/src/pages/Home.jsx +++ b/client/src/pages/Home.jsx @@ -9,6 +9,7 @@ import Loader from '../components/Loader'; const Home = () => { const dispatch = useDispatch(); const { items: products, loading } = useSelector((state) => state.products); + const safeProducts = Array.isArray(products) ? products : []; useEffect(() => { // Fetch products on mount @@ -16,7 +17,7 @@ const Home = () => { }, [dispatch]); // Featured products - const featuredProducts = products.filter(p => p.isFeatured).slice(0, 4); + const featuredProducts = safeProducts.filter(p => p.isFeatured).slice(0, 4); const categories = [ { diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx index 342c310..e698ef2 100644 --- a/client/src/pages/Login.jsx +++ b/client/src/pages/Login.jsx @@ -1,161 +1,254 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; -import { Mail, Lock, ArrowRight } from 'lucide-react'; +import { Mail, Lock, Eye, EyeOff, ArrowRight, ShieldCheck } from 'lucide-react'; import toast from 'react-hot-toast'; import { loginUser, clearAuthError } from '../features/auth/authSlice'; import Loader from '../components/Loader'; -// Zod Schema +const GU_DOMAINS = ['@geeta.ac.in', '@geetauniversity.ac.in', '@geetauniversity.edu.in']; +const isGUEmail = (val) => GU_DOMAINS.some(d => val.toLowerCase().endsWith(d)); + const loginSchema = z.object({ - email: z.string().min(1, 'Email is required').email('Please enter a valid email address'), + email: z.string() + .min(1, 'Email is required') + .email('Please enter a valid email address') + .refine(isGUEmail, { message: 'Only Geeta University emails are allowed' }), password: z.string().min(6, 'Password must be at least 6 characters'), }); const Login = () => { const dispatch = useDispatch(); - const navigate = useNavigate(); - const location = useLocation(); - const { loading, error, token } = useSelector((state) => state.auth); - + const navigate = useNavigate(); + const location = useLocation(); + const { loading, error, token } = useSelector((s) => s.auth); const from = location.state?.from?.pathname || '/dashboard'; - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ + const [showPassword, setShowPassword] = useState(false); + const [emailFocused, setEmailFocused] = useState(false); + const [passFocused, setPassFocused] = useState(false); + + const { register, handleSubmit, watch, formState: { errors } } = useForm({ resolver: zodResolver(loginSchema), }); + const emailVal = watch('email', ''); + const emailValid = isGUEmail(emailVal) && emailVal.includes('@'); + useEffect(() => { - // If already logged in, redirect away - if (token) { - navigate(from, { replace: true }); - } - return () => { - dispatch(clearAuthError()); - }; + if (token) navigate(from, { replace: true }); + return () => { dispatch(clearAuthError()); }; }, [token, navigate, from, dispatch]); const onSubmit = (data) => { dispatch(loginUser(data)) .unwrap() - .then(() => { - toast.success('Welcome back to GU MerchStore!'); - }) - .catch((err) => { - toast.error(err || 'Failed to authenticate'); - }); + .then(() => toast.success('Welcome back to GU MerchStore! πŸŽ“')) + .catch((err) => toast.error(err || 'Failed to authenticate')); + }; + + const handleGoogle = () => { + window.location.href = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'}/auth/google`; }; return ( -
+
{loading && } - -
- {/* Card Header branding */} -
-
- GU + + {/* ── Animated Background Orbs ─────────────────────────── */} +
+
+
+
+ {/* Grid pattern */} +
+
+ + {/* ── Left Hero Panel ──────────────────────────────────── */} +
+ {/* University branding */} +
+
+ GU +
+
+

Geeta University

+

MerchStore Portal

+
+
+ + {/* Big headline */} +
+
+ + University Exclusive Access
-

- Sign In -

-

- Access your university account +

+ Welcome Back to{' '} + + GU Merch + +

+

+ Your one-stop campus store for official Geeta University merchandise, stationery and gear.

+ + {/* Stats row */} +
+ {[['500+', 'Students'], ['50+', 'Products'], ['4.9β˜…', 'Rating']].map(([val, label]) => ( +
+

{val}

+

{label}

+
+ ))} +
- {/* Form Card */} -
+ {/* Bottom tag */} +

Β© 2026 Geeta University. All rights reserved.

+
+ + {/* ── Right Form Panel ─────────────────────────────────── */} +
+ {/* Glassmorphism card */} +
+ + {/* Mobile logo */} +
+
+ GU +
+ GU MerchStore +
+ +
+

Sign In

+

Use your official Geeta University email

+
+ + {/* Error Banner */} {error && ( -
- {error} +
+ ⚠ + {error}
)} -
- {/* Email field */} + + {/* Email */}
-