diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 93bc564d..66e9d633 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -27,7 +27,10 @@ module.exports = { 'react/self-closing-comp': 0, 'react/jsx-props-no-spreading': 0, '@typescript-eslint/no-explicit-any': 0, + 'jsx-a11y/label-has-associated-control': 0, + 'jsx-a11y/control-has-associated-label': 0, 'react/no-array-index-key': 0, + 'no-nested-ternary': 0, 'no-param-reassign': [ 'error', { props: true, ignorePropertyModificationsFor: ['state'] }, diff --git a/.hintrc b/.hintrc index b7cc97d9..fabba2c1 100644 --- a/.hintrc +++ b/.hintrc @@ -17,6 +17,12 @@ { "button-name": "off" } + ], + "axe/parsing": [ + "default", + { + "duplicate-id-active": "off" + } ] } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9467f54e..c4dad4b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@mui/material": "^5.16.4", "@react-jvectormap/core": "^1.0.4", "@react-jvectormap/world": "^1.1.2", - "@reduxjs/toolkit": "^2.2.5", + "@reduxjs/toolkit": "^2.2.6", "@testing-library/user-event": "^14.5.2", "@types/react-redux": "^7.1.33", "@types/react-router-dom": "^5.3.3", @@ -51,7 +51,7 @@ "yup": "^1.4.0" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.5", + "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@types/dotenv": "^8.2.0", "@types/jest": "^29.5.12", @@ -60,7 +60,7 @@ "@types/node": "^20.14.7", "@types/react": "^18.3.3", "@types/react-color": "^3.0.12", - "@types/react-dom": "^18.2.22", + "@types/react-dom": "^18.3.0", "@types/react-simple-maps": "^3.0.4", "@types/react-slider": "^1.3.6", "@types/redux-mock-store": "^1.0.6", @@ -2149,15 +2149,6 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" }, - "node_modules/@mswjs/cookies": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-1.1.1.tgz", - "integrity": "sha512-W68qOHEjx1iD+4VjQudlx26CPIoxmIAtK4ZCexU0/UJBG6jYhcuyzKJx+Iw8uhBIGd9eba64XgWVgo20it1qwA==", - "dev": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.29.1", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", @@ -2781,9 +2772,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.7.tgz", - "integrity": "sha512-GaKJ0nijoNf30dWSOOzQEBkWBRk4rG3C/efw8zKrimNuZpnS/6/AEwo0WvZHgJxG84cNCgxt+mtbe1fsvfLp2A==", + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", + "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", "dev": true, "dependencies": { "@adobe/css-tools": "^4.4.0", @@ -2799,30 +2790,6 @@ "node": ">=14", "npm": ">=6", "yarn": ">=1" - }, - "peerDependencies": { - "@jest/globals": ">= 28", - "@types/bun": "latest", - "@types/jest": ">= 28", - "jest": ">= 28", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@types/bun": { - "optional": true - }, - "@types/jest": { - "optional": true - }, - "jest": { - "optional": true - }, - "vitest": { - "optional": true - } } }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { @@ -3121,9 +3088,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", - "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "version": "20.14.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", + "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -4443,9 +4410,9 @@ ] }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -4454,12 +4421,21 @@ "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" } }, + "node_modules/chai/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4682,9 +4658,9 @@ } }, "node_modules/cloudinary": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.3.0.tgz", - "integrity": "sha512-QBa/ePVVfVcVOB1Vut236rjAbTZAArzOm0e2IWUkQJSZFS65Sjf+i3DyRGen4QX8GZzrcbzvKI9b8BTHAv1zqQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.3.1.tgz", + "integrity": "sha512-DW63pfRhN6Ob9czyLSG8G3Vz+m1tijLYyTZMOuFpFWPwtVXZVdFrc2ggDtGrbIBYpvr94tjPOkOdWgYW2ae3rA==", "dependencies": { "lodash": "^4.17.21", "q": "^1.5.1" @@ -5315,9 +5291,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.833", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.833.tgz", - "integrity": "sha512-aVGP9xK70Ysrzip1m5LoJjCp1EDrYzZ7Pg/O3QR1h3PAhmc8SNfSXV3kmmtkg5rNO42EcTYmLX3eFMgqALlGIA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.1.tgz", + "integrity": "sha512-FKbOCOQ5QRB3VlIbl1LZQefWIYwszlBloaXcY2rbfpu9ioJnNh3TK03YtIDKDo3WKBi8u+YV4+Fn2CkEozgf4w==", "dev": true }, "node_modules/emittery": { @@ -9286,16 +9262,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msw": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.2.tgz", - "integrity": "sha512-vDn6d6a50vxPE+HnaKQfpmZ4SVXlOjF97yD5FJcUT3v2/uZ65qvTYNL25yOmnrfCNWZ4wtAS7EbtXxygMug2Tw==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.4.tgz", + "integrity": "sha512-sHMlwrajgmZSA2l1o7qRSe+azm/I+x9lvVVcOxAzi4vCtH8uVPJk1K5BQYDkzGl+tt0RvM9huEXXdeGrgcc79g==", "dev": true, "hasInstallScript": true, "dependencies": { "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/cookies": "^1.1.0", "@mswjs/interceptors": "^0.29.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", @@ -9915,9 +9891,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.4.40", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", + "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", "dev": true, "funding": [ { @@ -11465,9 +11441,9 @@ } }, "node_modules/swiper": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.1.5.tgz", - "integrity": "sha512-JJQWFXdxiMGC2j6ZGTYat5Z7NN9JORJBgHp0/joX9uPod+cRj0wr5H5VnWSNIz9JeAamQvYKaG7aFrGHIF9OEg==", + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.1.7.tgz", + "integrity": "sha512-2EpQvhgKb+DNbi8/i9uRXhddivcMZQxca341t2NZYV1xroCR2p4YtYd3azuqRQ4OEBGcG4nv3aN24O80bMipow==", "funding": [ { "type": "patreon", @@ -11505,9 +11481,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", - "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", + "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -12020,9 +11996,9 @@ } }, "node_modules/vite": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", - "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", + "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", "dev": true, "dependencies": { "esbuild": "^0.21.3", diff --git a/package.json b/package.json index 99d85d94..d353a0c7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@mui/material": "^5.16.4", "@react-jvectormap/core": "^1.0.4", "@react-jvectormap/world": "^1.1.2", - "@reduxjs/toolkit": "^2.2.5", + "@reduxjs/toolkit": "^2.2.6", "@testing-library/user-event": "^14.5.2", "@types/react-redux": "^7.1.33", "@types/react-router-dom": "^5.3.3", @@ -59,7 +59,7 @@ "yup": "^1.4.0" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.5", + "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@types/dotenv": "^8.2.0", "@types/jest": "^29.5.12", @@ -68,7 +68,7 @@ "@types/node": "^20.14.7", "@types/react": "^18.3.3", "@types/react-color": "^3.0.12", - "@types/react-dom": "^18.2.22", + "@types/react-dom": "^18.3.0", "@types/react-simple-maps": "^3.0.4", "@types/react-slider": "^1.3.6", "@types/redux-mock-store": "^1.0.6", diff --git a/src/__test__/Checkout/checkout.test.tsx b/src/__test__/Checkout/checkout.test.tsx index 26d52ab3..0c5a3609 100644 --- a/src/__test__/Checkout/checkout.test.tsx +++ b/src/__test__/Checkout/checkout.test.tsx @@ -28,8 +28,8 @@ describe('checkoutSlice', () => { it('should handle initial state', () => { expect(store.getState().checkout).toEqual({ checkout: { - id: 31, - totalAmount: 160, + id: -1, + totalAmount: 0, status: 'Pending', couponCode: '', deliveryInfo: { @@ -91,7 +91,7 @@ describe('checkoutSlice', () => { city: 'Anytown', zip: '12345', }, - id: 31, + id: -1, orderDetails: [ { id: 41, @@ -102,7 +102,7 @@ describe('checkoutSlice', () => { paid: true, paymentInfo: null, status: 'Pending', - totalAmount: 160, + totalAmount: 0, trackingNumber: 'Tr280585', updatedAt: '2024-07-22T11:01:20.291Z', }, diff --git a/src/__test__/Coupons/CouponsFeature.test.ts b/src/__test__/Coupons/CouponsFeature.test.ts new file mode 100644 index 00000000..441e64b7 --- /dev/null +++ b/src/__test__/Coupons/CouponsFeature.test.ts @@ -0,0 +1,185 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import couponsReducer, { + fetchCoupons, + fetchMyCoupons, + createCoupon, + updateCoupon, + deleteCoupon, +} from '@/features/Coupons/CouponsFeature'; + +vi.mock('axios'); + +const createTestStore = () => + configureStore({ reducer: { coupons: couponsReducer } }); +let store: any; + +describe('couponsSlice', () => { + beforeEach(() => { + store = createTestStore(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should handle initial state', () => { + const { coupons } = store.getState(); + expect(coupons).toEqual({ + coupons: [], + loading: false, + error: null, + }); + }); + + describe('fetchCoupons', () => { + it('should handle fetchCoupons.pending', () => { + store.dispatch(fetchCoupons.pending('requestId')); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(true); + }); + + it('should handle fetchCoupons.fulfilled', async () => { + const mockCoupons = [{ id: 1, code: 'TEST' }]; + (axios.get as any).mockResolvedValueOnce({ data: mockCoupons }); + + await store.dispatch(fetchCoupons()); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.coupons).toEqual(mockCoupons); + }); + + it('should handle fetchCoupons.rejected', async () => { + const error = 'Failed to fetch coupons'; + (axios.get as any).mockRejectedValueOnce(new Error(error)); + + await store.dispatch(fetchCoupons()); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.error).toBe(error); + }); + }); + + describe('fetchMyCoupons', () => { + it('should handle fetchMyCoupons.pending', () => { + store.dispatch(fetchMyCoupons.pending('requestId')); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(true); + }); + + it('should handle fetchMyCoupons.fulfilled', async () => { + const mockCoupons = [{ id: 1, code: 'MYCOUPON' }]; + (axios.get as any).mockResolvedValueOnce({ data: mockCoupons }); + + await store.dispatch(fetchMyCoupons()); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.coupons).toEqual(mockCoupons); + }); + + it('should handle fetchMyCoupons.rejected', async () => { + const error = 'Failed to fetch coupons'; + (axios.get as any).mockRejectedValueOnce(new Error(error)); + + await store.dispatch(fetchMyCoupons()); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.error).toBe(error); + }); + }); + describe('createCoupon', () => { + it('should handle createCoupon.pending', () => { + const dummyRequestId = 'dummyRequestId'; + const dummyArgs = { + newCoupon: { + description: '', + percentage: 0, + expirationDate: '', + applicableProducts: [], + }, + token: 'dummyToken', + }; + + store.dispatch(createCoupon.pending(dummyRequestId, dummyArgs)); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(true); + }); + + it('should handle createCoupon.fulfilled', async () => { + const newCoupon = { + id: 1, + description: 'NEWCOUPON', + percentage: 1, + expirationDate: '2024-02-01', + applicableProducts: [1, 2], + }; + (axios.post as any).mockResolvedValueOnce({ data: newCoupon }); + + await store.dispatch(createCoupon({ newCoupon, token: 'testToken' })); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.coupons).toContainEqual(newCoupon); + }); + + it('should handle createCoupon.rejected', async () => { + const error = 'Failed to create coupon'; + (axios.post as any).mockRejectedValueOnce(new Error(error)); + const invalidCoupon = { + description: '', + percentage: 0, + expirationDate: '', + applicableProducts: [], + }; + + await store.dispatch( + createCoupon({ newCoupon: invalidCoupon, token: 'testToken' }) + ); + const { coupons } = store.getState(); + expect(coupons.loading).toBe(false); + expect(coupons.error).toBe(error); + }); + }); + + describe('updateCoupon', () => { + it('should handle updateCoupon.rejected', async () => { + const error = 'Failed to update coupon'; + (axios.put as any).mockRejectedValueOnce(new Error(error)); + + const updatedCoupon = { + id: 1, + description: 'UPDATED DESCRIPTION', + percentage: 10, + expirationDate: '2024-12-31', + applicableProducts: [1, 2, 3], + code: 'UPDATEDCOUPON', + }; + + await store.dispatch(updateCoupon(updatedCoupon)); + const { coupons } = store.getState(); + expect(coupons.error).toBe(error); + }); + }); + + describe('deleteCoupon', () => { + it('should handle deleteCoupon.fulfilled', async () => { + const couponId = 1; + (axios.delete as any).mockResolvedValueOnce({ data: { id: couponId } }); + + await store.dispatch(deleteCoupon({ couponId, token: 'testToken' })); + const { coupons } = store.getState(); + const coupon = coupons.coupons.find((c: any) => c.id === couponId); + expect(coupon).toBeUndefined(); + }); + + it('should handle deleteCoupon.rejected', async () => { + const error = 'Failed to delete coupon'; + (axios.delete as any).mockRejectedValueOnce(new Error(error)); + + await store.dispatch(deleteCoupon({ couponId: 1, token: 'testToken' })); + const { coupons } = store.getState(); + expect(coupons.error).toBe(error); + }); + }); +}); diff --git a/src/__test__/TableUserRole.test.tsx b/src/__test__/TableUserRole.test.tsx new file mode 100644 index 00000000..ca45761a --- /dev/null +++ b/src/__test__/TableUserRole.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { vi } from 'vitest'; +import axios from 'axios'; +import TableUserRole from '@/components/dashBoard/UserRole'; +import { showErrorToast } from '@/utils/ToastConfig'; +import userRoleSlice from '@/features/userRole/userRoleSlice'; + +vi.mock('@/utils/ToastConfig', () => ({ + showErrorToast: vi.fn(), + showSuccessToast: vi.fn(), +})); + +vi.mock('axios'); + +const renderWithProviders = (ui: React.ReactElement) => { + const store = configureStore({ + reducer: { + userRoles: userRoleSlice, + }, + }); + return render({ui}); +}; + +describe('TableUserRole', () => { + beforeEach(() => { + vi.clearAllMocks(); + (axios.get as jest.Mock).mockResolvedValue({ + data: { data: [{ id: 1, name: 'Admin', permissions: [] }] }, + }); + }); + + it('renders TableUserRole component', () => { + renderWithProviders(); + expect(screen.getByText('Register Role')).toBeInTheDocument(); + }); + + it('fetches all roles on mount', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Register Role')).toBeInTheDocument(); + }); + }); + + it('shows error when role name is empty', async () => { + renderWithProviders(); + + fireEvent.click(screen.getByText('Add Role')); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith('Role name cannot be empty'); + }); + }); + + it('adds and removes permissions', async () => { + renderWithProviders(); + + const permissionInput = screen.getByPlaceholderText('Enter permissions'); + fireEvent.change(permissionInput, { target: { value: 'Permission 1' } }); + fireEvent.click(screen.getByText('+ Add Permissions')); + + expect(screen.getByText('Permission 1')).toBeInTheDocument(); + + const removePermissionButton = screen.getByText('X', { + selector: 'button', + }); + fireEvent.click(removePermissionButton); + + await waitFor(() => { + expect(screen.queryByText('Permission 1')).not.toBeInTheDocument(); + }); + }); + + it('adds a new role successfully', async () => { + renderWithProviders(); + + const roleNameInput = screen.getByPlaceholderText('Role Name'); + const permissionInput = screen.getByPlaceholderText('Enter permissions'); + const addPermissionsButton = screen.getByText('+ Add Permissions'); + const addRoleButton = screen.getByText('Add Role'); + + fireEvent.change(roleNameInput, { target: { value: 'New Role' } }); + fireEvent.change(permissionInput, { target: { value: 'Permission 1' } }); + fireEvent.click(addPermissionsButton); + + fireEvent.click(addRoleButton); + + await waitFor(() => { + expect(screen.queryByText('Permission 1')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__test__/dashBoard/dashBoardSideBar.test.tsx b/src/__test__/dashBoard/dashBoardSideBar.test.tsx index 32e9df43..1c800749 100644 --- a/src/__test__/dashBoard/dashBoardSideBar.test.tsx +++ b/src/__test__/dashBoard/dashBoardSideBar.test.tsx @@ -1,9 +1,58 @@ -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import signInReducer from '@/features/Auth/SignInSlice'; import DashboardSideNav from '@/components/dashBoard/DashboardSideNav'; +const createTestStore = () => + configureStore({ + reducer: { signIn: signInReducer }, + + preloadedState: { + signIn: { + token: 'test token', + user: { + email: 'test@gmail.com', + firstName: 'Test', + id: 1, + lastName: 'User', + picture: 'http://fakeimage.png', + userType: { + id: 1, + name: 'Admin', + permissions: ['crud'], + }, + }, + loading: false, + error: null, + message: null, + role: null, + needsVerification: false, + needs2FA: false, + vendor: { + id: null, + email: null, + }, + }, + }, + }); + +const store = createTestStore(); + +const renderDashboardSideNav = () => { + return render( + + + + + + ); +}; + describe('DashboardSideNav', () => { it('renders the sidebar items', () => { - const { getByText } = render(); + const { getByText } = renderDashboardSideNav(); expect(getByText('Dashboard')).toBeInTheDocument(); expect(getByText('Orders')).toBeInTheDocument(); expect(getByText('Customers')).toBeInTheDocument(); @@ -11,7 +60,7 @@ describe('DashboardSideNav', () => { }); it('expands and collapses the subitems', () => { - const { getByText, queryByText } = render(); + const { getByText, queryByText } = renderDashboardSideNav(); const productsItem = getByText('Products'); fireEvent.click(productsItem); expect(getByText(/all products/i)).toBeVisible(); @@ -19,18 +68,18 @@ describe('DashboardSideNav', () => { expect(queryByText(/all products/i)).not.toBeInTheDocument(); }); - it('renders the subitems correctly', () => { - const { getByText } = render(); + it('renders the subitems correctly', async () => { + const { getByText } = renderDashboardSideNav(); const productsItem = getByText('Products'); fireEvent.click(productsItem); - expect(getByText('All Products')).toBeInTheDocument(); - expect(getByText('Add New')).toBeInTheDocument(); - expect(getByText('Categories')).toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); + await waitFor(() => { + expect(getByText('All Products')).toBeInTheDocument(); + expect(getByText('Categories')).toBeInTheDocument(); + }); }); it('handles keydown events for subitems', () => { - const { getByText, getAllByText } = render(); + const { getByText, getAllByText } = renderDashboardSideNav(); const productsItem = getByText('Products'); fireEvent.keyDown(productsItem, { key: 'Enter' }); const allProductsElements = getAllByText(/all products/i); @@ -41,7 +90,7 @@ describe('DashboardSideNav', () => { }); it('toggles sidebar visibility', () => { - const { getByLabelText } = render(); + const { getByLabelText } = renderDashboardSideNav(); const toggleButton = getByLabelText('Toggle Menu'); fireEvent.click(toggleButton); expect(getByLabelText('Close Menu')).toBeInTheDocument(); diff --git a/src/__test__/dashBoard/dashboardHome.test.tsx b/src/__test__/dashBoard/dashboardHome.test.tsx index 4303c968..a33e7779 100644 --- a/src/__test__/dashBoard/dashboardHome.test.tsx +++ b/src/__test__/dashBoard/dashboardHome.test.tsx @@ -3,19 +3,59 @@ import { render, screen } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; import HomeDashboard from '@/components/dashBoard/HomeDash'; -import { fetchBuyers } from '@/app/Dashboard/buyerSlice'; -import { fetchOrders } from '@/app/Dashboard/orderSlice'; +import BuyerReducer, { fetchBuyers } from '@/app/Dashboard/buyerSlice'; +import OrderReducer, { fetchOrders } from '@/app/Dashboard/orderSlice'; import { fetchProducts } from '@/features/Products/ProductSlice'; -import { store as appStore } from '@/app/store'; +import signInReducer from '@/features/Auth/SignInSlice'; vi.mock('react-chartjs-2', () => ({ Line: () =>
, })); +const createTestStore = () => + configureStore({ + reducer: { + signIn: signInReducer, + buyer: BuyerReducer, + order: OrderReducer, + }, + + preloadedState: { + signIn: { + token: 'test token', + user: { + email: 'test@gmail.com', + firstName: 'Test', + id: 1, + lastName: 'User', + picture: 'http://fakeimage.png', + userType: { + id: 1, + name: 'Admin', + permissions: ['crud'], + }, + }, + loading: false, + error: null, + message: null, + role: null, + needsVerification: false, + needs2FA: false, + vendor: { + id: null, + email: null, + }, + }, + }, + }); + +const store = createTestStore(); + const renderWithProviders = (ui: React.ReactElement) => { return render( - + {ui} ); @@ -23,9 +63,9 @@ const renderWithProviders = (ui: React.ReactElement) => { describe('HomeDashboard', () => { beforeEach(async () => { - await appStore.dispatch(fetchProducts()); - await appStore.dispatch(fetchBuyers()); - await appStore.dispatch(fetchOrders()); + await store.dispatch(fetchProducts()); + await store.dispatch(fetchBuyers()); + await store.dispatch(fetchOrders()); }); test('renders the HomeDashboard component', () => { @@ -45,7 +85,7 @@ describe('HomeDashboard', () => { renderWithProviders(); expect( screen.getByText( - `${appStore.getState().buyer.buyers.filter((buyer) => buyer.userType.name === 'Buyer').length}` + `${store.getState().buyer.buyers.filter((buyer) => buyer.userType.name === 'Buyer').length}` ) ).toBeInTheDocument(); }); diff --git a/src/__test__/dashBoard/dashboardProductSlice.test.tsx b/src/__test__/dashBoard/dashboardProductSlice.test.tsx index 0ca21cb4..b3efa06f 100644 --- a/src/__test__/dashBoard/dashboardProductSlice.test.tsx +++ b/src/__test__/dashBoard/dashboardProductSlice.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import DeshboardProductsSlice, { initialState, fetchDashboardProduct, -} from '@/features/Dashboard/dashboardProductsSlice'; // Adjust path as needed +} from '@/features/Dashboard/dashboardProductsSlice'; describe('DeshboardProductsSlice reducer', () => { it('should return the initial state', () => { diff --git a/src/__test__/home/productCard.test.tsx b/src/__test__/home/productCard.test.tsx index f8ec34fe..bff773a2 100644 --- a/src/__test__/home/productCard.test.tsx +++ b/src/__test__/home/productCard.test.tsx @@ -101,7 +101,7 @@ describe('ProductCard Component', () => { expect(halfStar.length).toBe(1); const emptyStar = screen.getAllByTestId('emptyStar'); - expect(emptyStar.length).toBe(Math.floor(4 - mockProduct.averageRating)); + expect(emptyStar.length).toBe(Math.floor(5 - mockProduct.averageRating)); const addToCartIcon = screen.getByTestId('addToCart'); expect(addToCartIcon).toBeInTheDocument(); diff --git a/src/__test__/userRoleSlice.test.tsx b/src/__test__/userRoleSlice.test.tsx new file mode 100644 index 00000000..12bb7826 --- /dev/null +++ b/src/__test__/userRoleSlice.test.tsx @@ -0,0 +1,181 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import axios from 'axios'; +import userRoleSlice, { + fetchUserRoles, + createUserRole, + deleteUserRole, + updateUserRole, +} from '@/features/userRole/userRoleSlice'; + +vi.mock('axios'); + +const initialState = { + roles: [], + status: 'idle' as 'idle' | 'loading' | 'succeeded', + error: null as string | null, +}; + +describe('userRoleSlice', () => { + let store: any; + + beforeEach(() => { + store = configureStore({ + reducer: { + userRoles: userRoleSlice, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should have the correct initial state', () => { + const state = store.getState().userRoles; + expect(state).toEqual(initialState); + }); + + it('should handle fetchUserRoles.pending', async () => { + const action = { type: fetchUserRoles.pending.type }; + const state = userRoleSlice(initialState, action); + expect(state.status).toBe('loading'); + }); + + it('should handle fetchUserRoles.fulfilled', async () => { + const mockRoles = [ + { id: 1, name: 'Admin', permissions: ['read', 'write'] }, + { id: 2, name: 'User', permissions: ['read'] }, + ]; + + (axios.get as any).mockResolvedValue({ data: { roles: mockRoles } }); + + const action = { type: fetchUserRoles.fulfilled.type, payload: mockRoles }; + const state = userRoleSlice(initialState, action); + + expect(state.status).toBe('succeeded'); + expect(state.roles).toEqual(mockRoles); + }); + + it('should handle fetchUserRoles.rejected', async () => { + (axios.get as any).mockRejectedValue(new Error('Failed to fetch roles')); + + const action = { + type: fetchUserRoles.rejected.type, + error: { message: 'Failed to fetch roles' }, + }; + const state = userRoleSlice(initialState, action); + + expect(state.status).toBe('failed'); + expect(state.error).toBe('Failed to fetch roles'); + }); + + it('should handle deleteUserRole.pending', async () => { + const action = { type: deleteUserRole.pending.type }; + const state = userRoleSlice(initialState, action); + expect(state.status).toBe('loading'); + }); + + it('should handle deleteUserRole.fulfilled', async () => { + const existingRoles = [ + { id: 1, name: 'Admin', permissions: ['read', 'write'] }, + { id: 2, name: 'User', permissions: ['read'] }, + ]; + + (axios.delete as any).mockResolvedValue({}); + + const action = { type: deleteUserRole.fulfilled.type, payload: 1 }; + const state = userRoleSlice( + { ...initialState, roles: existingRoles }, + action + ); + + expect(state.roles).toEqual([ + { id: 2, name: 'User', permissions: ['read'] }, + ]); + }); + + it('should handle deleteUserRole.rejected', async () => { + (axios.delete as any).mockRejectedValue(new Error('Failed to delete role')); + + const action = { + type: deleteUserRole.rejected.type, + error: { message: 'Failed to delete role' }, + }; + const state = userRoleSlice( + { + ...initialState, + roles: [{ id: 1, name: 'Admin', permissions: ['read', 'write'] }], + }, + action + ); + + expect(state.status).toBe('failed'); + expect(state.error).toBe('Failed to delete role'); + }); + + it('should handle createUserRole.pending', async () => { + const action = { type: createUserRole.pending.type }; + const state = userRoleSlice(initialState, action); + expect(state.status).toBe('loading'); + }); + + it('should handle createUserRole.fulfilled', async () => { + const newRole = { id: 3, name: 'Editor', permissions: ['read', 'write'] }; + + (axios.post as any).mockResolvedValue({ data: { role: newRole } }); + + const action = { type: createUserRole.fulfilled.type, payload: newRole }; + const state = userRoleSlice({ ...initialState, roles: [] }, action); + + expect(state.roles).toEqual([newRole]); + }); + + it('should handle updateUserRole.pending', async () => { + const action = { type: updateUserRole.pending.type }; + const state = userRoleSlice(initialState, action); + expect(state.status).toBe('loading'); + }); + + it('should handle updateUserRole.fulfilled', async () => { + const existingRoles = [ + { id: 1, name: 'Admin', permissions: ['read', 'write'] }, + { id: 2, name: 'User', permissions: ['read'] }, + ]; + + const updatedRole = { + id: 1, + name: 'Super Admin', + permissions: ['read', 'write', 'delete'], + }; + + (axios.put as any).mockResolvedValue({ data: updatedRole }); + + const action = { + type: updateUserRole.fulfilled.type, + payload: updatedRole, + }; + const state = userRoleSlice( + { ...initialState, roles: existingRoles }, + action + ); + + expect(state.roles).toEqual([ + updatedRole, + { id: 2, name: 'User', permissions: ['read'] }, + ]); + }); + + it('should handle updateUserRole.rejected', async () => { + (axios.put as any).mockRejectedValue(new Error('Failed to update role')); + + const action = { + type: updateUserRole.rejected.type, + error: { message: 'Failed to update role' }, + }; + const state = userRoleSlice(initialState, action); + + expect(state.status).toBe('failed'); + expect(state.error).toBe('Failed to update role'); + }); +}); diff --git a/src/app/store.ts b/src/app/store.ts index 0f7f79c9..a9229a19 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -21,6 +21,8 @@ import checkoutSlice from '@/features/Checkout/checkoutSlice'; import ordersSliceReducer from '@/features/Orders/ordersSlice'; import contactReducer from '@/features/contact/contactSlice'; +import userRoleSlice from '@/features/userRole/userRoleSlice'; +import couponsSliceReducer from '@/features/Coupons/CouponsFeature'; export const store = configureStore({ reducer: { @@ -43,6 +45,8 @@ export const store = configureStore({ allProducts: allProductSlice, contact: contactReducer, checkout: checkoutSlice, + coupons: couponsSliceReducer, + userRoles: userRoleSlice, }, }); diff --git a/src/assets/TableLogo.png b/src/assets/TableLogo.png new file mode 100644 index 00000000..b07a1047 Binary files /dev/null and b/src/assets/TableLogo.png differ diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx index 0be7c7d4..0531108b 100644 --- a/src/components/Cart/Cart.tsx +++ b/src/components/Cart/Cart.tsx @@ -83,11 +83,18 @@ export default function Cart() { /> ))}
-
-

Total:

- ${total} -
- + {cartItems.length > 0 && ( +
+

Total:

+ ${total} +
+ )} + {cartItems.length === 0 && ( +

Cart Empty

+ )} + {cartItems.length > 0 && ( + + )}
diff --git a/src/components/Checkout/Checkout.tsx b/src/components/Checkout/Checkout.tsx index faa8d299..94d8ad82 100644 --- a/src/components/Checkout/Checkout.tsx +++ b/src/components/Checkout/Checkout.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, SetStateAction } from 'react'; import { useNavigate } from 'react-router-dom'; import CardInput, { Card } from './CardInput'; import { RootState } from '@/app/store'; -import { fetchCartItems } from '@/features/Cart/cartSlice'; +import { fetchCartItems, selectCartItems } from '@/features/Cart/cartSlice'; import { Checkout as CheckoutType } from '@/interfaces/checkout'; import BeatLoader from 'react-spinners/BeatLoader'; @@ -11,6 +11,7 @@ import { placeOrder, makePayment, updateStatus, + resetState, } from '@/features/Checkout/checkoutSlice'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { @@ -48,7 +49,12 @@ function Checkout() { const dispatch = useAppDispatch(); const navigate = useNavigate(); const user = useAppSelector((state) => state.signIn.user); - + const cartItems = useAppSelector((state: RootState) => + selectCartItems(state) + ); + const total = cartItems.reduce((acc, curr) => { + return acc + curr.product.salesPrice * curr.quantity; + }, 0); function handleAdding() { setAdding(!adding); } @@ -60,14 +66,17 @@ function Checkout() { function handleSave(newCard: Card) { setCards((prev) => [...prev, newCard]); + } + function applyCoupon(e: React.ChangeEvent) { + setCoupon(e.target.value); const checkout: CheckoutType = { deliveryInfo: { address, city, zip: '12345', }, - couponCode: coupon, + couponCode: e.target.value, email: user?.email || '', firstName: user?.firstName || '', lastName: user?.lastName || '', @@ -77,8 +86,22 @@ function Checkout() { function handlePayment() { - console.log(order.id) - dispatch(makePayment(order.id)); + if (order.id === -1) { + const checkout: CheckoutType = { + deliveryInfo: { + address, + city, + zip: '12345', + }, + couponCode: '', + email: user?.email || '', + firstName: user?.firstName || '', + lastName: user?.lastName || '', + }; + dispatch(placeOrder(checkout)).then((res) => + dispatch(makePayment(res.payload.id)) + ); + } else dispatch(makePayment(order.id)); } useEffect(() => { @@ -88,6 +111,7 @@ function Checkout() { showSuccessToast('Succesfully Paid'); dispatch(updateStatus(false)); dispatch(fetchCartItems()); + dispatch(resetState()); navigate('/'); } else if (paying && error) { showErrorToast('failed'); @@ -319,7 +343,7 @@ function Checkout() { setCoupon(e.target.value)} + onChange={applyCoupon} value={coupon} />
@@ -389,7 +413,7 @@ function Checkout() {
Total Cost - ${checkoutState.checkout.totalAmount} + ${order.totalAmount || total}
diff --git a/src/components/TopCategories.tsx b/src/components/TopCategories.tsx index 087357d1..bdce3c7d 100644 --- a/src/components/TopCategories.tsx +++ b/src/components/TopCategories.tsx @@ -29,7 +29,7 @@ function TopCategories() { }, [token]); return ( -
+

Top Categories

Categories

diff --git a/src/components/dashBoard/BestSellingProducts.tsx b/src/components/dashBoard/BestSellingProducts.tsx index 2c50bbac..6c4d2158 100644 --- a/src/components/dashBoard/BestSellingProducts.tsx +++ b/src/components/dashBoard/BestSellingProducts.tsx @@ -19,13 +19,13 @@ function ProductTable() { }); }, [token]); return ( -
+

Best selling Products

- +
- - + + @@ -35,7 +35,7 @@ function ProductTable() { {bestselling.map((product, idx) => ( - +
ID
No IMAGE PRODUCT NAME CATEGORY
{idx + 1} , + role: ['Admin', 'Vendor'], }, { path: '/dashboard/orders', name: 'Orders', icon: , + subItems: [ + { + path: '/dashboard/orders', + name: 'All orders', + role: ['Admin', 'Vendor'], + }, + ], + role: ['Admin', 'Vendor'], }, { - path: '/customers', + path: '/dashboard/customers', name: 'Customers', icon: , + subItems: [ + { + name: 'All customers', + path: '/dashboard/customers', + role: ['Admin'], + }, + ], + role: ['Admin'], }, { name: 'seller', @@ -33,12 +54,15 @@ const sideBarItems = [ { name: 'All Seller', path: '/dashboard/seller', + role: ['Admin'], }, { name: 'Add New', path: '/dashboard/addSeller', + role: ['Admin'], }, ], + role: ['Admin'], }, { name: 'Products', @@ -47,20 +71,50 @@ const sideBarItems = [ { path: '/dashboard/product', name: 'All Products', + role: ['Admin', 'Vendor'], }, { path: '/dashboard/addProduct', name: 'Add New', + role: ['Vendor'], }, { path: '/products/categories', name: 'Categories', + role: ['Admin'], + }, + ], + role: ['Admin', 'Vendor'], + }, + { + path: '/dashboard/coupons', + name: 'Coupons', + icon: , + subItems: [ + { + path: '/dashboard/coupons', + name: 'All coupons', + role: ['Admin', 'Vendor'], }, { - path: '/products/tags', - name: 'Tags', + path: '/dashboard/addCoupons', + name: 'Add New', + role: ['Vendor'], + }, + ], + role: ['Admin', 'Vendor'], + }, + { + name: 'user Role', + icon: , + subItems: [ + { + name: 'Add & All Roles', + path: '/dashboard/userRole', + role: ['Admin'], }, ], + role: ['Admin'], }, ]; @@ -69,15 +123,20 @@ interface SideBarItemProps { path?: string; name: string; icon: React.ReactNode; - subItems?: { path: string; name: string }[]; + subItems?: { path: string; name: string; role: string[] }[]; }; activeItem: string; setActiveItem: React.Dispatch>; + Role: string; } -function SideBarItem({ item, activeItem, setActiveItem }: SideBarItemProps) { +function SideBarItem({ + item, + activeItem, + setActiveItem, + Role, +}: SideBarItemProps) { const [expanded, setExpanded] = useState(false); - const handleExpand = () => { setExpanded(!expanded); }; @@ -109,7 +168,9 @@ function SideBarItem({ item, activeItem, setActiveItem }: SideBarItemProps) { >
{item.icon} - {item.name} + + {item.name} +
{item.subItems && (expanded ? ( @@ -120,20 +181,26 @@ function SideBarItem({ item, activeItem, setActiveItem }: SideBarItemProps) { {expanded && item.subItems && (
    - {item.subItems.map((subItem) => ( -
  • - setActiveItem(subItem.name)} - > - {subItem.name} - -
  • - ))} + {item.subItems.map((subItem) => { + if (subItem.role.includes(Role!)) { + return ( +
  • + setActiveItem(subItem.name)} + > + {subItem.name} + +
  • + ); + } + + return null; + })}
)} @@ -141,6 +208,8 @@ function SideBarItem({ item, activeItem, setActiveItem }: SideBarItemProps) { } function DashboardSideNav() { + const Role = useAppSelector((state) => state.signIn.user?.userType.name); + const [isVisible, setIsVisible] = useState(false); const [activeItem, setActiveItem] = useState('Dashboard'); @@ -172,14 +241,20 @@ function DashboardSideNav() { - {sideBarItems.map((item) => ( - - ))} + {sideBarItems.map((item) => { + if (item.role.includes(Role!)) { + return ( + + ); + } + return null; + })} diff --git a/src/components/dashBoard/EditProduct.tsx b/src/components/dashBoard/EditProduct.tsx index 45322097..e67bd1e8 100644 --- a/src/components/dashBoard/EditProduct.tsx +++ b/src/components/dashBoard/EditProduct.tsx @@ -1,5 +1,3 @@ -/* eslint-disable jsx-a11y/label-has-associated-control */ - import axios from 'axios'; import { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; diff --git a/src/components/dashBoard/HomeDash.tsx b/src/components/dashBoard/HomeDash.tsx index 054e5599..4c5914cb 100644 --- a/src/components/dashBoard/HomeDash.tsx +++ b/src/components/dashBoard/HomeDash.tsx @@ -10,6 +10,8 @@ import SalesMap from '../salesMap/SalesMap'; import ProductTable from './BestSellingProducts'; function HomeDash() { + const Role = useAppSelector((state) => state.signIn.user?.userType.name); + function getGreeting(): string { const now = new Date(); const hour = now.getHours(); @@ -69,87 +71,93 @@ function HomeDash() { farmer - -
-
-

Total Sales Available

- -
-

Sales Summary

-
-
-
-
- Sales -
-
-
{sum}$
-
-
-
- Total Sales + {Role === 'Admin' && ( + <> +
+
+

Total Sales Available

+
-

All Products Sales

-
-
-
-
- Order +

Sales Summary

+
+
+
+
+ Sales +
+
+
{sum}$
+
+
+
+ Total Sales +
+

+ All Products Sales +

-
{order.length}
-
-
- Total Order -
-

All Orders

-
-
-
-
- Product Sold +
+
+
+ Order +
+
{order.length}
+
+
+ Total Order +
+

All Orders

-
{tproduct}
-
-
- Product Sold -
-

All Products Sold

-
-
-
-
- New Customers +
+
+
+ Product Sold +
+
{tproduct}
+
+
+ Product Sold +
+

+ All Products Sold{' '} +

-
- {buyers && - buyers.filter( - (user) => user.userType && user.userType.name === 'Buyer' - ).length} +
+
+
+ New Customers +
+
+ {buyers && + buyers.filter( + (user) => + user.userType && user.userType.name === 'Buyer' + ).length} +
+
+
+ New Customers +
+

All Buyers

-
- New Customers -
-

All Buyers

-
-
-
- - -
-
- -
-
- -
+
+ + +
+
+ + +
+ + )}
); diff --git a/src/components/dashBoard/UserRole.tsx b/src/components/dashBoard/UserRole.tsx new file mode 100644 index 00000000..6ac15ba7 --- /dev/null +++ b/src/components/dashBoard/UserRole.tsx @@ -0,0 +1,357 @@ +/* eslint-disable react/button-has-type */ +import { useEffect, useState, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { FaRegTrashAlt } from 'react-icons/fa'; +import { MdOutlineEdit } from 'react-icons/md'; +import BeatLoader from 'react-spinners/BeatLoader'; +import { RootState, AppDispatch } from '@/app/store'; +import { showErrorToast, showSuccessToast } from '@/utils/ToastConfig'; +import { + fetchUserRoles, + deleteUserRole, + createUserRole, + updateUserRole, +} from '@/features/userRole/userRoleSlice'; +import ConfirmationCard from './ConfirmationCard'; +import CircularPagination from './NavigateonPage'; +import TableLogo from '@/assets/TableLogo.png'; + +function TableUserRole() { + const dispatch: AppDispatch = useDispatch(); + const { roles, status } = useSelector((state: RootState) => state.userRoles); + const [editRole, setEditRole] = useState<{ + id: number; + name: string; + permissions: string[]; + } | null>(null); + + const [isConfirmationModalVisible, setModalVisible] = useState(false); + const [itemSelected, setItemSelected] = useState(null); + const [mode, setMode] = useState(''); + const [permissions, setPermissions] = useState([]); + const [newPermission, setNewPermission] = useState(''); + const [roleName, setRoleName] = useState(''); + const [deleting, setDeleting] = useState(false); + const [popupMessage, setPopupMessage] = useState(null); + const [isPopupVisible, setIsPopupVisible] = useState(false); + const [color, setColor] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + useEffect(() => { + dispatch(fetchUserRoles()); + }, [dispatch]); + + const handleDelete = useCallback(async () => { + if (itemSelected !== null) { + const response = await dispatch(deleteUserRole(itemSelected)); + if (deleteUserRole.fulfilled.match(response)) { + setPopupMessage('User Role deleted permanently'); + setColor('green'); + dispatch(fetchUserRoles()); + } else { + setPopupMessage( + 'Delete User Role failed. This may be due to a network issue.' + ); + setColor('red'); + } + setDeleting(false); + setModalVisible(false); + setIsPopupVisible(true); + setTimeout(() => { + setIsPopupVisible(false); + }, 10000); + } + }, [dispatch, itemSelected]); + + useEffect(() => { + if (deleting) { + handleDelete(); + } + }, [deleting, handleDelete]); + + useEffect(() => { + if (editRole) { + setRoleName(editRole.name); + setPermissions(editRole.permissions); + } else { + setRoleName(''); + setPermissions([]); + } + }, [editRole]); + + const addPermission = () => { + const trimmedPermission = newPermission.trim(); + if (trimmedPermission && !permissions.includes(trimmedPermission)) { + setPermissions([...permissions, trimmedPermission]); + setNewPermission(''); + } else { + showErrorToast('Permission cannot be empty'); + } + }; + + const removePermission = (permissionToRemove: string) => { + setPermissions( + permissions.filter((permission) => permission !== permissionToRemove) + ); + }; + + const handleDeleteClick = (roleId: number) => { + setItemSelected(roleId); + setMode('delete'); + setModalVisible(true); + }; + + const handleAddRole = async () => { + if (roleName.trim() === '') { + showErrorToast('Role name cannot be empty'); + return; + } + dispatch(fetchUserRoles()); + setIsSubmitting(true); + await dispatch(createUserRole({ name: roleName, permissions })); + showSuccessToast('Success Role added'); + setRoleName(''); + setPermissions([]); + setIsSubmitting(false); + }; + + const handleEditRole = async () => { + if (editRole && roleName.trim() !== '') { + setIsSubmitting(true); + try { + const response = await dispatch( + updateUserRole({ + id: editRole.id, + name: roleName, + permissions, + }) + ); + + if (updateUserRole.fulfilled.match(response)) { + dispatch(fetchUserRoles()); + setEditRole(null); + showSuccessToast('Success Role updated'); + } else { + showErrorToast('Failed to update role'); + } + } catch (error) { + showErrorToast('Failed to update role'); + } + setIsSubmitting(false); + } else { + showErrorToast('Role name cannot be empty'); + } + }; + + return ( +
+

Manage User Role

+
+
+ + + + + + + + + + + {status === 'loading' && + Array(6) + .fill(null) + .map((_, index) => ( + + + + ))} + {status === 'failed' && + Array(6) + .fill(null) + .map((_, index) => ( + + + + ))} + {status === 'succeeded' && + roles?.map((role) => ( + + + + + + ))} + +
+ ID + + Role Name + + Action +
+
+
+
+ Failed to load roles... +
+
{role.id}{role.name} +
+ setEditRole(role)} + /> +
+ +
+ handleDeleteClick(role.id)} + className="text-red-500 cursor-pointer h-[16px] w-[16px]" + /> +
+
+
+ {}} + /> +
+
+
+
+ +

Register Role

+
+
+ + setRoleName(e.target.value)} + className="w-[100%] border relative bg-grayLight text-black duration-100 outline-none justify-between flex items-center gap-2 px-3 rounded-md font-light group-hover:border-grayDark p-3 mb-5 lg:w-[70%] md:w-[70%]" + /> +
+ +
+ +
+
+ {permissions.map((permission, index) => ( + + {permission} + + + ))} +
+
+
+ setNewPermission(e.target.value)} + className="w-[100%] border relative bg-grayLight duration-100 outline-none justify-between flex items-center gap-2 px-3 rounded-md font-light group-hover:border-grayDark p-3 lg:w-[70%] md:w-[70%]" + /> + +
+
+ +
+ {editRole ? ( + + ) : ( + + )} +
+
+ + {mode === 'delete' && ( +
+ setModalVisible(false)} + onConfirm={() => setDeleting(true)} + message="Are you sure you want to delete this user role?" + /> +
+ )} + + {isPopupVisible && ( +
+
+
+ + + +
+
+

DELETE ROLE

+

{popupMessage}

+
+
+
+ )} +
+
+ ); +} + +export default TableUserRole; diff --git a/src/components/dashBoard/addProducts.tsx b/src/components/dashBoard/addProducts.tsx index b2a2206a..36fed32b 100644 --- a/src/components/dashBoard/addProducts.tsx +++ b/src/components/dashBoard/addProducts.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/label-has-associated-control */ import React, { useState, ChangeEvent, useEffect } from 'react'; import { Formik, diff --git a/src/components/home/ProductCard.tsx b/src/components/home/ProductCard.tsx index 0e7755b9..5afa3a60 100644 --- a/src/components/home/ProductCard.tsx +++ b/src/components/home/ProductCard.tsx @@ -1,18 +1,22 @@ import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; import { FaRegHeart, FaHeart } from 'react-icons/fa'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import Cart from '@/interfaces/cart'; import { addToWishlist, removeFromWishlist, } from '@/features/Products/ProductSlice'; import { Product } from '@/types/Product'; -import { addCartItem } from '@/features/Cart/cartSlice'; +import { addCartItem, removeCartItem } from '@/features/Cart/cartSlice'; +import { showSuccessToast } from '@/utils/ToastConfig'; interface ProductCardProps { product: Product; } function ProductCard({ product }: ProductCardProps) { + const [cartId, setCartId] = useState(null); const navigate = useNavigate(); const dispatch = useAppDispatch(); const { token } = useAppSelector((state) => state.signIn); @@ -22,6 +26,26 @@ function ProductCard({ product }: ProductCardProps) { return wishlistProds?.some((wishlistProd) => wishlistProd.id === prod.id); }; + function handleAddtoCart(e: React.MouseEvent) { + const element = e.target as HTMLElement; + const [sibling, message, action] = element.classList.contains('bg-red-600') + ? [ + element.nextSibling, + 'Product Removed From Cart', + dispatch(removeCartItem(cartId as number)), + ] + : [ + element.previousSibling, + 'Product added to cart', + dispatch(addCartItem({ productId: product.id, quantity: 1 })), + ]; + (sibling as HTMLElement)?.style.setProperty('display', 'inline'); + element.style.setProperty('display', 'none'); + action.then((res) => { + setCartId((res.payload as Cart).id || null); + showSuccessToast(message); + }); + } return (
- {Array.from({ length: Math.floor(4 - product.averageRating) }).map( + {Array.from({ length: Math.floor(5 - product.averageRating) }).map( (_, index) => { return (
@@ -162,13 +194,25 @@ function ProductCard({ product }: ProductCardProps) { ${product.regularPrice}
-
category.id === focused) + ?.name || categories[0].name + } />
diff --git a/src/features/Cart/cartSlice.tsx b/src/features/Cart/cartSlice.tsx index a6837279..bdb3180b 100644 --- a/src/features/Cart/cartSlice.tsx +++ b/src/features/Cart/cartSlice.tsx @@ -40,7 +40,19 @@ export const fetchCartItems = createAsyncThunk( Authorization: `Bearer ${tokenFromStorage}`, }, }); - return response.data.cartItems; + const res = response.data.cartItems; + res.reduce((acc: Cart[], curr: Cart) => { + const existingItem = acc.find( + (item) => item.product.id === curr.product.id + ); + if (existingItem) { + existingItem.quantity += curr.quantity; + } else { + acc.push(curr); + } + return acc; + }, []); + return res; } ); diff --git a/src/features/Checkout/checkoutSlice.ts b/src/features/Checkout/checkoutSlice.ts index 2aecf29b..a44ffe7d 100644 --- a/src/features/Checkout/checkoutSlice.ts +++ b/src/features/Checkout/checkoutSlice.ts @@ -13,8 +13,8 @@ export interface CheckoutState { } const initialOrder: Order = { - id: 31, - totalAmount: 160, + id: -1, + totalAmount: 0, status: 'Pending', couponCode: '', deliveryInfo: { @@ -165,6 +165,9 @@ const checkoutSlice = createSlice({ updateLastName: (state, action: PayloadAction) => { state.checkout.lastName = action.payload; }, + resetState: () => { + return initialState; + }, }, extraReducers: (builder) => { builder @@ -214,6 +217,7 @@ export const { updateFirstName, updateLastName, updateStatus, + resetState, } = checkoutSlice.actions; export const selectCheckout = (state: RootState) => state.checkout; diff --git a/src/features/Coupons/CouponsFeature.ts b/src/features/Coupons/CouponsFeature.ts new file mode 100644 index 00000000..d72a7d2f --- /dev/null +++ b/src/features/Coupons/CouponsFeature.ts @@ -0,0 +1,191 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { showSuccessToast } from '@/utils/ToastConfig'; +import Product from '@/interfaces/product'; + +interface Coupon { + id: number; + code: string; + description: string; + percentage: number; + expirationDate: string; + status: string; + applicableProducts: Product[]; + vendor: { + id: number; + firstName: string; + lastName: string; + }; +} + +interface CouponsState { + coupons: Coupon[]; + loading: boolean; + error: string | null; +} + +const initialState: CouponsState = { + coupons: [], + loading: false, + error: null, +}; + +export const fetchCoupons = createAsyncThunk( + 'coupons/fetchCoupons', + async () => { + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/coupons` + ); + return response.data; + } +); +export const fetchMyCoupons = createAsyncThunk( + 'coupons/fetchMyCoupons', + async () => { + const token = localStorage.getItem('token'); + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/coupons/mine`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; + } +); + +interface NewCoupon { + description: string; + percentage: number; + expirationDate: string; + applicableProducts: number[]; +} +interface UpdatedCoupon { + id: number; + description: string; + percentage: number; + expirationDate: string; + applicableProducts: number[]; +} + +export const createCoupon = createAsyncThunk( + 'coupons/createCoupon', + async ({ newCoupon, token }: { newCoupon: NewCoupon; token: string }) => { + const response = await axios.post( + `${import.meta.env.VITE_BASE_URL}/coupons`, + newCoupon, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; + } +); + +export const updateCoupon = createAsyncThunk( + 'coupons/updateCoupon', + async (updatedCoupon: UpdatedCoupon) => { + const token = localStorage.getItem('token'); + const response = await axios.put( + `${import.meta.env.VITE_BASE_URL}/coupons/${updatedCoupon.id}`, + updatedCoupon, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; + } +); + +export const deleteCoupon = createAsyncThunk( + 'coupons/deleteCoupon', + async ({ couponId, token }: { couponId: number; token: string }) => { + const response = await axios.delete( + `${import.meta.env.VITE_BASE_URL}/coupons/${couponId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; + } +); + +const couponsSlice = createSlice({ + name: 'coupons', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchCoupons.pending, (state) => { + state.loading = true; + }) + .addCase(fetchCoupons.fulfilled, (state, action) => { + state.loading = false; + state.coupons = action.payload; + state.error = null; + }) + .addCase(fetchCoupons.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch coupons'; + }) + .addCase(fetchMyCoupons.pending, (state) => { + state.loading = true; + }) + .addCase(fetchMyCoupons.fulfilled, (state, action) => { + state.loading = false; + state.coupons = action.payload; + state.error = null; + }) + .addCase(fetchMyCoupons.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch coupons'; + }) + .addCase(createCoupon.pending, (state) => { + state.loading = true; + }) + .addCase(createCoupon.fulfilled, (state, action) => { + state.loading = false; + state.coupons.push(action.payload); + showSuccessToast(action.payload.message); + }) + .addCase(createCoupon.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to create coupon'; + }) + .addCase(updateCoupon.pending, (state) => { + state.loading = true; + }) + .addCase(updateCoupon.fulfilled, (state, action) => { + const index = state.coupons.findIndex( + (coupon) => coupon.id === action.payload.id + ); + if (index !== -1) { + state.coupons[index] = action.payload; + } + showSuccessToast('Coupon updated successfully'); + }) + .addCase(updateCoupon.rejected, (state, action) => { + state.error = action.error.message || 'Failed to update coupon'; + }) + .addCase(deleteCoupon.fulfilled, (state, action) => { + state.coupons = state.coupons.filter( + (coupon) => coupon.id !== action.payload + ); + showSuccessToast(action.payload.message); + }) + .addCase(deleteCoupon.rejected, (state, action) => { + state.error = action.error.message || 'Failed to delete coupon'; + }); + }, +}); + +export const selectCoupons = (state: { coupons: CouponsState }) => + state.coupons; +export default couponsSlice.reducer; diff --git a/src/features/SaleByCountry/SaleByCountrySlice.ts b/src/features/SaleByCountry/SaleByCountrySlice.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/features/userRole/userRoleSlice.tsx b/src/features/userRole/userRoleSlice.tsx new file mode 100644 index 00000000..38951cab --- /dev/null +++ b/src/features/userRole/userRoleSlice.tsx @@ -0,0 +1,132 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; + +interface UserRole { + id: number; + name: string; + permissions: string[]; +} + +interface UserRoleState { + roles: UserRole[]; + status: 'idle' | 'loading' | 'succeeded' | 'failed'; + error: string | null; +} + +const initialState: UserRoleState = { + roles: [], + status: 'idle', + error: null, +}; + +export const fetchUserRoles = createAsyncThunk( + 'userRoles/fetchUserRoles', + async () => { + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/roles/get_roles` + ); + return response.data.roles; + } +); + +export const deleteUserRole = createAsyncThunk( + 'userRoles/deleteUserRole', + async (id: number) => { + try { + await axios.delete( + `${import.meta.env.VITE_BASE_URL}/roles/delete_role/${id}` + ); + return id; + } catch (error) { + throw new Error('Failed to delete role'); + } + } +); + +export const createUserRole = createAsyncThunk( + 'userRoles/createUserRole', + async (roleData: { name: string; permissions: string[] }) => { + const response = await axios.post( + `${import.meta.env.VITE_BASE_URL}/roles/create_role`, + roleData + ); + return response.data.role; + } +); + +export const updateUserRole = createAsyncThunk( + 'userRoles/updateUserRole', + async (roleData: { id: number; name: string; permissions: string[] }) => { + const response = await axios.put( + 'https://dynamites-ecomm-be.onrender.com/api/v1/roles/update_role', + roleData + ); + return response.data; + } +); + +const userRoleSlice = createSlice({ + name: 'userRoles', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchUserRoles.pending, (state) => { + state.status = 'loading'; + }) + .addCase( + fetchUserRoles.fulfilled, + (state, action: PayloadAction) => { + state.status = 'succeeded'; + state.roles = action.payload; + } + ) + .addCase(fetchUserRoles.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message || 'Failed to fetch roles'; + }) + .addCase( + deleteUserRole.fulfilled, + (state, action: PayloadAction) => { + state.roles = state.roles.filter( + (role) => role.id !== action.payload + ); + } + ) + .addCase(createUserRole.pending, (state) => { + state.status = 'loading'; + }) + .addCase( + createUserRole.fulfilled, + (state, action: PayloadAction) => { + state.roles.push(action.payload); + } + ) + .addCase(deleteUserRole.pending, (state) => { + state.status = 'loading'; + }) + .addCase(deleteUserRole.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message || 'Failed to delete role'; + }) + .addCase(updateUserRole.pending, (state) => { + state.status = 'loading'; + }) + + .addCase(updateUserRole.fulfilled, (state, action) => { + const index = state.roles.findIndex( + (role) => role.id === action.payload.id + ); + if (index !== -1) { + state.roles[index] = action.payload; // Update the existing role + } + state.status = 'succeeded'; + }) + .addCase(updateUserRole.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message || 'Failed to update role'; // Set error message + }); + }, +}); + +export default userRoleSlice.reducer; diff --git a/src/layout/DashbordLayout.tsx b/src/layout/DashbordLayout.tsx index acc72875..3d87ada9 100644 --- a/src/layout/DashbordLayout.tsx +++ b/src/layout/DashbordLayout.tsx @@ -1,5 +1,4 @@ import { Outlet } from 'react-router-dom'; -// import { ToastContainer } from 'react-toastify'; import DashboardSideNav from '@/components/dashBoard/DashboardSideNav'; import Navbar from '@/components/dashBoard/dashBoardNav'; diff --git a/src/pages/AddCoupon.tsx b/src/pages/AddCoupon.tsx new file mode 100644 index 00000000..39229066 --- /dev/null +++ b/src/pages/AddCoupon.tsx @@ -0,0 +1,287 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import * as Yup from 'yup'; +import { Formik, Field, Form, FieldArray, ErrorMessage } from 'formik'; +import BeatLoader from 'react-spinners/BeatLoader'; +import { IoIosCloseCircle } from 'react-icons/io'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector, useAppDispatch } from '@/app/hooks'; +import { createCoupon } from '@/features/Coupons/CouponsFeature'; +import { RootState } from '@/app/store'; +import { showErrorToast } from '@/utils/ToastConfig'; +import Product from '@/interfaces/product'; + +interface CouponFormData { + description: string; + percentage: number; + expirationDate: string; + applicableProducts: Product[]; +} + +function CouponForm() { + const [products, setProducts] = useState([]); + const token = useAppSelector((state: RootState) => state.signIn.token)!; + const dispatch = useAppDispatch(); + const { loading } = useAppSelector((state) => state.coupons); + const navigate = useNavigate(); + + useEffect(() => { + axios + .get(`${import.meta.env.VITE_BASE_URL}/product/mine`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => { + setProducts(response.data.data); + }) + .catch((err) => { + showErrorToast(err.message); + }); + }, [token]); + + const handleSubmit = ( + values: CouponFormData, + { resetForm }: { resetForm: () => void } + ) => { + const newCoupon = { + description: values.description, + percentage: values.percentage, + expirationDate: values.expirationDate, + applicableProducts: values.applicableProducts.map( + (product) => product.id + ), + }; + + dispatch(createCoupon({ newCoupon, token })) + .unwrap() + .then(() => { + resetForm(); + navigate('/dashboard/coupons'); + }) + .catch((err) => { + showErrorToast(err.message); + }); + }; + + return ( +
+
+

Create New Coupon

+ + {({ values, setFieldValue, errors, touched }) => ( +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + {({ push }) => ( + <> + + + + )} + +
+
+
+
+ {values.applicableProducts.map((product) => ( +
+
+ {product.name} +
+

{product.name}

+

+ ${product.salesPrice} +

+
+
+
+ +
+
+ ))} +
+
+
+
+ +
+
+ )} +
+
+
+ ); +} + +export default CouponForm; diff --git a/src/pages/Coupons.tsx b/src/pages/Coupons.tsx new file mode 100644 index 00000000..2842afa1 --- /dev/null +++ b/src/pages/Coupons.tsx @@ -0,0 +1,353 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { FaRegTrashAlt } from 'react-icons/fa'; +import { MdOutlineEdit } from 'react-icons/md'; +import { IoIosArrowDown, IoIosSearch } from 'react-icons/io'; +import PuffLoader from 'react-spinners/PuffLoader'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppDispatch, RootState } from '@/app/store'; +import { + fetchCoupons, + deleteCoupon, + selectCoupons, + fetchMyCoupons, +} from '@/features/Coupons/CouponsFeature'; + +import CircularPagination from '@/components/dashBoard/NavigateonPage'; +import { useAppSelector } from '@/app/hooks'; +import { showErrorToast } from '@/utils/ToastConfig'; +import Button from '@/components/form/Button'; + +function Coupons() { + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + const [expandedRows, setExpandedRows] = useState([]); + const [deleteModal, setDeleteModal] = useState(false); + const [selectedCouponId, setSelectedCouponId] = useState(null); + const token = useAppSelector((state: RootState) => state.signIn.token)!; + const Role = useAppSelector((state) => state.signIn.user?.userType.name); + const User = useAppSelector((state) => state.signIn.user?.lastName); + + const dispatch: AppDispatch = useDispatch(); + const { coupons, loading, error } = useSelector(selectCoupons); + + useEffect(() => { + if (Role === 'Admin') { + dispatch(fetchCoupons()); + } else { + dispatch(fetchMyCoupons()); + } + }, [dispatch, Role]); + + const toggleRow = (id: number) => { + setExpandedRows((prev: any) => + prev.includes(id) + ? prev.filter((rowId: number) => rowId !== id) + : [...prev, id] + ); + }; + + const showDeleteModal = (couponId: number) => { + setSelectedCouponId(couponId); + setDeleteModal(true); + }; + + const handleConfirmDelete = () => { + if (selectedCouponId !== null) { + dispatch(deleteCoupon({ couponId: selectedCouponId, token })) + .unwrap() + .then(() => { + setDeleteModal(false); + setSelectedCouponId(null); + dispatch(fetchCoupons()); + }) + .catch((Anerror) => { + showErrorToast(Anerror || 'Failed to delete the coupon:'); + }); + } + }; + + const handleCancelDelete = () => { + setDeleteModal(false); + setSelectedCouponId(null); + }; + + const getStatus = (expirationDate: string) => { + const currentDate = new Date(); + const expiration = new Date(expirationDate); + return expiration >= currentDate ? 'Active' : 'Expired'; + }; + + // ********* Pagination ********** + const COUPONS_PER_PAGE = 8; + const totalPages = Math.ceil(coupons.length / COUPONS_PER_PAGE); + const startIndex = (currentPage - 1) * COUPONS_PER_PAGE; + + const paginatedData = coupons + .filter( + (coupon) => + coupon.description.toLowerCase().includes(searchTerm.toLowerCase()) || + coupon.code.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .slice(startIndex, startIndex + COUPONS_PER_PAGE); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + return ( +
+
+

Coupons

+
+
+
    +
  • + + ({coupons.length}) +
  • + | +
  • + + ( + { + coupons.filter( + (coupon) => getStatus(coupon.expirationDate) === 'Active' + ).length + } + ) +
  • + | +
  • + + ( + { + coupons.filter( + (coupon) => getStatus(coupon.expirationDate) === 'Expired' + ).length + } + ) +
  • +
+
+ + setSearchTerm(e.target.value)} + className="w-full outline-none bg-white placeholder:text-gray-400 font-light" + /> +
+
+ {/* Table */} + {deleteModal && ( +
+
+
+ Are you sure you want to Delete? +
+

This coupons

+
+
+
+
+
+
+ )} + {loading ? ( +
+ +
+ ) : error ? ( + <>{showErrorToast(error)} + ) : ( +
+ + + + + + + + + + + + + + + {paginatedData.map((coupon) => ( + + + + + + + + + + + {Role === 'Vendor' ? ( +
+
+ + + ) : ( +

+ Contact Own{' '} +

+ )} + + {expandedRows.includes(coupon.id) && ( + + + + )} + + ))} + +
+ Code + + Description + + Percentage(%) + + Expiration Date + + Status + + A/P + + Seller + + Action +
+ toggleRow(coupon.id)} + /> + {coupon.code}{coupon.description}{coupon.percentage}%{coupon.expirationDate} + + {getStatus(coupon.expirationDate)} + + + {coupon.applicableProducts.length} + + {coupon.vendor?.firstName || User} + + + + + + showDeleteModal(coupon.id)} + /> +
+
+

+ Applicable Products +

+
+ {coupon.applicableProducts.length > 0 && + coupon.applicableProducts.map((product) => ( +
+
+ {product.name} +
+
+

{product.name}

+

+ {' '} + + Quantity: + {' '} + {product.quantity} +

+

+ + Prices: + {' '} + + {' '} + $ + {product.salesPrice === 0 + ? product.regularPrice + : product.salesPrice} + +

+
+
+ ))} +
+
+
+
+ +
+
+ )} +
+ ); +} + +export default Coupons; diff --git a/src/pages/EditCoupon.tsx b/src/pages/EditCoupon.tsx new file mode 100644 index 00000000..a1fdd75f --- /dev/null +++ b/src/pages/EditCoupon.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useParams, useNavigate } from 'react-router-dom'; +import * as Yup from 'yup'; +import { Formik, Field, Form, FieldArray, ErrorMessage } from 'formik'; +import BeatLoader from 'react-spinners/BeatLoader'; +import { IoIosCloseCircle } from 'react-icons/io'; +import PuffLoader from 'react-spinners/PuffLoader'; +import { useAppSelector, useAppDispatch } from '@/app/hooks'; +import { updateCoupon } from '@/features/Coupons/CouponsFeature'; +import { RootState } from '@/app/store'; +import { showErrorToast } from '@/utils/ToastConfig'; +import Product from '@/interfaces/product'; + +interface CouponFormData { + description: string; + percentage: number; + expirationDate: string; + applicableProducts: Product[]; +} + +function EditCoupon() { + const [products, setProducts] = useState([]); + const [initialValues, setInitialValues] = useState( + null + ); + const token = useAppSelector((state: RootState) => state.signIn.token)!; + const dispatch = useAppDispatch(); + const { loading } = useAppSelector((state) => state.coupons); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + useEffect(() => { + axios + .get(`${import.meta.env.VITE_BASE_URL}/product/mine`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => { + setProducts(response.data.data); + }) + .catch((err) => { + showErrorToast(err.message); + }); + + axios + .get(`${import.meta.env.VITE_BASE_URL}/coupons/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => { + const coupon = response.data; + setInitialValues({ + description: coupon.description, + percentage: coupon.percentage, + expirationDate: coupon.expirationDate, + applicableProducts: coupon.applicableProducts, + }); + }) + .catch((err) => { + showErrorToast(err.message); + }); + }, [token, id]); + + const handleSubmit = ( + values: CouponFormData, + { resetForm }: { resetForm: () => void } + ) => { + const updatedCoupon = { + id: parseInt(id!, 10), + description: values.description, + percentage: values.percentage, + expirationDate: values.expirationDate, + applicableProducts: values.applicableProducts.map( + (product) => product.id + ), + }; + + dispatch(updateCoupon(updatedCoupon)) + .unwrap() + .then(() => { + resetForm(); + navigate('/dashboard/coupons'); + }) + .catch((err) => { + showErrorToast(err.message); + }); + }; + + if (!initialValues) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Edit Coupon

+ + {({ values, setFieldValue, errors, touched }) => ( +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + {({ push }) => ( + <> + + + + )} + +
+
+
+
+ {values.applicableProducts.map((product) => ( +
+
+ {product.name} +
+

{product.name}

+

+ ${product.salesPrice} +

+
+
+
+ +
+
+ ))} +
+
+
+
+ +
+
+ )} +
+
+
+ ); +} + +export default EditCoupon; diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index aed5e19a..37d2583e 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -6,6 +6,8 @@ import ClipLoader from 'react-spinners/ClipLoader'; import { IoClose } from 'react-icons/io5'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import Button from '@/components/form/Button'; +import { addCartItem, removeCartItem } from '@/features/Cart/cartSlice'; +import Cart from '@/interfaces/cart'; import { addToWishlist, fetchProductDetails, @@ -161,6 +163,7 @@ function ProductDetails() { const [toggleLoginOverlay, setToggleLoginOverlay] = useState(false); const [isVisible, setIsVisible] = useState({ state: true, name: 'details' }); const [activeImg, setActiveImg] = useState(''); + const [cartId, setCartId] = useState(null); const wishlistProducts = useAppSelector( (state) => state.products.wishlistProducts ); @@ -233,6 +236,25 @@ function ProductDetails() { showErrorToast((errorObj.response.data as { message: string }).message); } }; + function handleAddtoCart(e: React.MouseEvent) { + const element = e.target as HTMLElement; + const [, message, action] = + element.textContent === 'Remove from Cart' + ? [ + (element.textContent = 'Add to Cart'), + 'Product Removed From Cart', + dispatch(removeCartItem(cartId as number)), + ] + : [ + (element.textContent = 'Remove from Cart'), + 'Product added to cart', + dispatch(addCartItem({ productId: product!.id, quantity: 1 })), + ]; + action.then((res) => { + setCartId((res.payload as Cart).id || null); + showSuccessToast(message); + }); + } return (
@@ -343,45 +365,47 @@ function ProductDetails() { ); })}
- - - - - - - - - + {product.averageRating % 1 !== 0 && ( + + + + + + + + + + )}
{Array.from({ - length: Math.floor(4 - product.averageRating), + length: Math.floor(5 - product.averageRating), }).map((_, index) => { return (
@@ -436,6 +460,7 @@ function ProductDetails() {
+
+
+ )} + + {activate && ( +
+
+
+ Are you sure you want to activate? +
+ {clickedcustomer?.firstName} +
+
+
+
+
+
+ )} + +
+
+
Customers
+
+
+
+

All ({customers.length})

+

+ Approved ({customers.filter((v) => v.status === 'active').length}) +

+

+ Suspended ( + {customers.filter((v) => v.status === 'inactive').length}) +

+
+
+ + HandleSearch(e.target.value)} + /> +
+
+
+
+ {status === 'loading' && ( +
+ +
+ )} +
+
+
+
+
Image
+
+ First Name +
+
+ Last Name +
+
Email
+
Date
+
Status
+
Role
+
Action
+
+ {customers.length > 0 ? ( + visiblePage.map((v, id) => ( +
+
+ +
+
+ {v.firstName} +
+
+ {v.lastName} +
+
+ {v.email} +
+
+ {DateFormat(v.updatedAt)} +
+
+ + {v.status} + +
+
+ Buyer +
+ handleRole(v)} + /> +
+
+
+ + + +
+
+ )) + ) : ( +
No customer Found
+ )} +
+ + {pages && + pages.map((page) => ( + + ))} + +
+
+
+ +
+ {customers && + visiblePage.map((v, id) => ( +
+
+ +
+

+ {v.firstName} {v.lastName} +

+

{v.email}

+
+
+
+

First Name: {v.firstName}

+

Date: {DateFormat(v.updatedAt)}

+

+ Status: + + {v.status} + +

+
+
+ + + +
+
+ ))} +
+ +
+
+ +
+
+ + {/* -------------------------------------------------------- */} + {updateRole && ( +
+
+
+
+

Change Role

+
+
Current Details:
+
+

Name:

+

+ {clickedcustomer?.firstName} {clickedcustomer?.lastName} +

+
+
+

Email:

+

{clickedcustomer?.email}

+
+
+

Status:

+ + {clickedcustomer?.status} + +
+
+

Current Role:

+ + Buyer + +
+
+
+
+ Make change: +
+
+
Select Role
+
+ +
+
+
+
+ +
+ +
+
+ +
+ + +
+
+
+ )} + {/* -------------------------------------------------------- */} +
+ ); +} + +export default Customer; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 4c6dc335..bb9c1d16 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -23,7 +23,11 @@ import Cart from '@/components/Cart/Cart'; import Seller from '@/pages/Seller'; import CheckoutPage from '@/pages/Checkout'; import Aboutus from '@/components/home/Aboutus'; -import OrderCompletion from '@/pages/OrderCompletion'; +import AddCoupon from '@/pages/AddCoupon'; +import Coupons from '@/pages/Coupons'; +import EditCoupon from '@/pages/EditCoupon'; +import TableUserRole from '@/components/dashBoard/UserRole'; +import Customer from '@/pages/customer'; function AppRoutes() { return ( @@ -45,7 +49,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> } /> @@ -64,6 +67,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/src/services/apiService b/src/services/apiService deleted file mode 100644 index e69de29b..00000000 diff --git a/vite.config.ts b/vite.config.ts index 03ea1849..52d33ca7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,6 +28,10 @@ export default defineConfig({ 'src/main.tsx', 'src/components/dashBoard/Admin.tsx', 'src/components/dashBoard/Table.tsx', + 'src/pages/AddCoupon.tsx', + 'src/pages/Coupons.tsx', + 'src/pages/EditCoupon.tsx', + 'src/pages/customer.tsx', ], }, },