diff --git a/package-lock.json b/package-lock.json index 49660a2a..fb034343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "axios": "^1.7.2", "axios-mock-adapter": "^1.22.0", "chart.js": "^4.4.3", + "date-fns": "^3.6.0", "dotenv": "^16.4.5", "formik": "^2.4.6", "hero-slider": "^3.2.1", @@ -4610,6 +4611,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index b7127677..9d6c05e8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@types/swiper": "^6.0.0", "axios": "^1.7.2", "axios-mock-adapter": "^1.22.0", + "date-fns": "^3.6.0", "chart.js": "^4.4.3", "dotenv": "^16.4.5", "formik": "^2.4.6", diff --git a/public/iphone.svg b/public/iphone.svg new file mode 100644 index 00000000..fe9c46a7 --- /dev/null +++ b/public/iphone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/product.svg b/public/product.svg new file mode 100644 index 00000000..e2eab7a3 --- /dev/null +++ b/public/product.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/__test__/Orders/Orders.test.tsx b/src/__test__/Orders/Orders.test.tsx new file mode 100644 index 00000000..0ae08535 --- /dev/null +++ b/src/__test__/Orders/Orders.test.tsx @@ -0,0 +1,173 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Provider } from 'react-redux'; +import { describe, expect, beforeEach } from 'vitest'; +import configureStore from 'redux-mock-store'; +import { Orders } from '@/components/Orders/Orders'; +import { Order } from '@/interfaces/order'; + +const mockOrders: Order[] = [ + { + id: 1, + trackingNumber: '123', + updatedAt: '2023-07-17T00:00:00Z', + status: 'Pending', + totalAmount: 100, + deliveryInfo: + '{"address": "123 Main St", "city": "Anytown", "country": "USA"}', + paymentInfo: null, + createdAt: '', + paid: false, + orderDetails: [], + }, + { + id: 2, + trackingNumber: '456', + updatedAt: '2023-07-16T00:00:00Z', + status: 'Completed', + totalAmount: 200, + deliveryInfo: + '{"address": "456 Elm St", "city": "Othertown", "country": "USA"}', + paymentInfo: null, + createdAt: '', + paid: false, + orderDetails: [], + }, +]; + +const mockStore = configureStore([]); + +describe('Orders Component', () => { + let store: ReturnType; + + beforeEach(() => { + store = mockStore({ + orders: { + orders: mockOrders, + }, + }); + }); + + test('renders without crashing', () => { + render( + + + + ); + expect(screen.getByTestId('trackingNumber')).toBeInTheDocument(); + expect(screen.getByText(/ID/i)).toBeInTheDocument(); + }); + + test('renders table headers correctly', () => { + render( + + + + ); + expect(screen.getByTestId('trackingNumber')).toBeInTheDocument(); + expect(screen.getByTestId('updatedAt')).toBeInTheDocument(); + expect(screen.getByTestId('status')).toBeInTheDocument(); + expect(screen.getByTestId('totalAmount')).toBeInTheDocument(); + expect(screen.getByTestId('action')).toBeInTheDocument(); + }); + + test('renders pagination controls', () => { + render( + + + + ); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + + test('sorts by column when header is clicked', () => { + render( + + + + ); + const orderColumn = screen.getByTestId('trackingNumber'); + fireEvent.click(orderColumn); + const sortedOrders = [...mockOrders].sort((a, b) => + a.trackingNumber.localeCompare(b.trackingNumber) + ); + const rows = screen.getAllByRole('row'); + rows.forEach((row, index) => { + if (index === 0) return; // Skip header row + expect(row).toHaveTextContent(sortedOrders[index - 1].trackingNumber); + }); + }); + + test('filters orders by status when filter button is clicked', () => { + render( + + + + ); + const pendingFilter = screen.getByTestId('pending-filter'); + fireEvent.click(pendingFilter); + const filteredOrders = mockOrders.filter( + (order) => order.status === 'Pending' + ); + const rows = screen.getAllByRole('row'); + expect(rows.length - 1).toBe(filteredOrders.length); // Subtract 1 for header row + rows.forEach((row, index) => { + if (index === 0) return; // Skip header row + expect(row).toHaveTextContent(filteredOrders[index - 1].trackingNumber); + }); + }); + + test('searches orders when typing in search bar', () => { + render( + + + + ); + const searchInput = screen.getByPlaceholderText(/Search order/i); + fireEvent.change(searchInput, { target: { value: '123' } }); + const filteredOrders = mockOrders.filter((order) => + order.trackingNumber.includes('123') + ); + const rows = screen.getAllByRole('row'); + expect(rows.length - 1).toBe(filteredOrders.length); // Subtract 1 for header row + rows.forEach((row, index) => { + if (index === 0) return; // Skip header row + expect(row).toHaveTextContent(filteredOrders[index - 1].trackingNumber); + }); + }); + + test('opens OrderDetailsModal when view button is clicked', () => { + render( + + + + ); + const viewButton = screen.getAllByRole('button', { name: /view/i })[0]; + fireEvent.click(viewButton); + expect(screen.getByText(/Order #234/i)).toBeInTheDocument(); + }); + + test('pagination controls work correctly', () => { + const manyOrders = Array.from({ length: 15 }, (_, index) => ({ + id: index + 1, + trackingNumber: `TRACK${index + 1}`, + updatedAt: `2023-07-${index < 9 ? 0 : ''}${index + 1}T00:00:00Z`, + status: index % 2 === 0 ? 'Pending' : 'Completed', + totalAmount: (index + 1) * 100, + deliveryInfo: + '{"address": "Address", "city": "City", "country": "Country"}', + })); + const newStore = mockStore({ + orders: { + orders: manyOrders, + }, + }); + render( + + + + ); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(11); // + }); +}); diff --git a/src/app/store.ts b/src/app/store.ts index 69bee3e8..34b32c6b 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -14,6 +14,8 @@ import { import buyerSlice from '@/app/Dashboard/buyerSlice'; import orderSlice from './Dashboard/orderSlice'; +import ordersSliceReducer from '@/features/Orders/ordersSlice'; + export const store = configureStore({ reducer: { products: productsReducer, @@ -28,6 +30,7 @@ export const store = configureStore({ passwordReset: passwordResetReducer, buyer: buyerSlice, order: orderSlice, + orders: ordersSliceReducer, }, }); diff --git a/src/components/Orders/EditableOrder.tsx b/src/components/Orders/EditableOrder.tsx new file mode 100644 index 00000000..f218cf09 --- /dev/null +++ b/src/components/Orders/EditableOrder.tsx @@ -0,0 +1,186 @@ +import { ChangeEvent, useState } from 'react'; + +export default function EditableOrderModal({ onClose }: any) { + const [order, setOrder] = useState({ + name: 'eric manzi', + address: 'kk 4 st', + city: 'Kigali, Kigali Province, Rwanda', + email: 'eric.manzi98@gmail.com', + phone: '+250781440175', + paymentMethod: 'MTN Mobile Money', + products: [ + { + id: 1, + name: 'Hisense 43 inch 4K Smart TV', + quantity: 2, + price: 450000, + }, + { + id: 2, + name: 'LG TOP Load Washers Silver 8 KGS Vietnam', + quantity: 12, + price: 210000, + }, + { id: 3, name: 'Crystal Sunflower Oil /5l', quantity: 23, price: 42000 }, + ], + }); + + const handleChange = (e: any) => { + const { name, value } = e.target; + setOrder({ ...order, [name]: value }); + }; + + const handleProductChange = ( + index: number, + e: ChangeEvent + ) => { + const { name, value } = e.target; + const newProducts = [...order.products]; + if (name === 'name' || name === 'quantity' || name === 'price') { + const updatedValue = + name === 'quantity' || name === 'price' ? parseInt(value, 10) : value; + newProducts[index] = { ...newProducts[index], [name]: updatedValue }; + setOrder({ ...order, products: newProducts }); + } + }; + + const handleSave = () => { + onClose(); + }; + + return ( +
+
+
+

Edit Order #234

+ +
+ +
+
+

Billing details

+ + + +
+
+

Contact details

+ + +
+
+ +
+

Payment via

+ +
+ +
+ + + + + + + + + + + {order.products.map((product, index) => ( + + + + + + + ))} + +
NoPRODUCTQuantityPRICE
{index + 1} + handleProductChange(index, e)} + className="block w-full mt-1 border border-gray-300 rounded-md" + /> + + handleProductChange(index, e)} + className="block w-full mt-1 border border-gray-300 rounded-md" + /> + + handleProductChange(index, e)} + className="block w-full mt-1 border border-gray-300 rounded-md" + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/Orders/ElipsisIcon.tsx b/src/components/Orders/ElipsisIcon.tsx new file mode 100644 index 00000000..ff5d379e --- /dev/null +++ b/src/components/Orders/ElipsisIcon.tsx @@ -0,0 +1,33 @@ +export default function ElipsisIcon() { + return ( + + + + + + ); +} diff --git a/src/components/Orders/FilterBar.tsx b/src/components/Orders/FilterBar.tsx new file mode 100644 index 00000000..1f849232 --- /dev/null +++ b/src/components/Orders/FilterBar.tsx @@ -0,0 +1,90 @@ +import Order from '@/interfaces/order'; + +interface PageProps { + orders: Order[]; + handleFilter: (status: string) => void; + search: (value: string) => void; +} + +export default function FilterBar({ orders, handleFilter, search }: PageProps) { + return ( +
+
    +
  • + + ({orders.length}) +
  • + | +
  • + + ({orders.filter((order) => order.status === 'Pending').length}) +
  • + | +
  • + + ({orders.filter((order) => order.status === 'Completed').length}) +
  • + | +
  • + + ({orders.filter((order) => order.status === 'Canceled').length}) +
  • +
+
+ + + + + + + + + + + + search(e.target.value)} + id="searchInput" + placeholder="Search order..." + type="text" + className="w-full outline-none bg-white placeholder:text-gray-400 font-light" + style={{ fontFamily: "'Manrope', sans-serif" }} + /> +
+
+ ); +} diff --git a/src/components/Orders/NavigationIcon.tsx b/src/components/Orders/NavigationIcon.tsx new file mode 100644 index 00000000..7dfd976f --- /dev/null +++ b/src/components/Orders/NavigationIcon.tsx @@ -0,0 +1,48 @@ +type Pageprops = { + direction: 'next' | 'previous'; + onClick: () => void; +}; + +function NextIcon(onClick: () => void) { + return ( + + ); +} + +function PreviousIcon(onClick: () => void) { + return ( + + ); +} + +export default function NavigationIcon({ direction, onClick }: Pageprops) { + return direction === 'next' ? NextIcon(onClick) : PreviousIcon(onClick); +} diff --git a/src/components/Orders/OrderDetailsModal.tsx b/src/components/Orders/OrderDetailsModal.tsx new file mode 100644 index 00000000..a98b69e2 --- /dev/null +++ b/src/components/Orders/OrderDetailsModal.tsx @@ -0,0 +1,113 @@ +import Order from '@/interfaces/order'; + +interface ModalProps { + order: Order; + close: () => void; +} + +function OrderDetailsModal({ close, order }: ModalProps) { + const billigDetails = JSON.parse(order.deliveryInfo); + return ( +
+
+

Order #234

+
+ + {order.status} + + +
+
+ +
+
+

Billing details

+

eric manzi

+

{billigDetails.address}

+

+ {billigDetails.city}, {billigDetails.country} +

+
+
+

Billing details

+

eric manzi

+

{billigDetails.address}

+

+ {billigDetails.city}, {billigDetails.country} +

+
+
+

Email

+

eric.manzi98@gmail.com

+
+
+

Phone

+

+250781440175

+
+
+ +
+

Payment via

+

MTN Mobile Money

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NoPRODUCTQuantityPRICE
1Hisense 43 inch 4K Smart TV2450,000
2 + LG TOP Load Washers Silver 8 KGS Vietnam + 12210,000
3Crystal Sunflower Oil /5l2342,000
+
+ +
+ + +
+
+ ); +} + +export default OrderDetailsModal; diff --git a/src/components/Orders/Orders.tsx b/src/components/Orders/Orders.tsx new file mode 100644 index 00000000..03780cb8 --- /dev/null +++ b/src/components/Orders/Orders.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; +import WebFont from 'webfontloader'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { selectOrders, cancelOrder } from '@/features/Orders/ordersSlice'; +import { RootState } from '@/app/store'; +import Order from '@/interfaces/order'; +import Table from './Table'; +import Pagination from './Pagination'; +import FilterBar from './FilterBar'; +import OrderDetailsModal from './OrderDetailsModal'; + +WebFont.load({ + google: { + families: ['Manrope:400,500,600,700,800'], + }, +}); + +export function Orders() { + const dispatch = useAppDispatch(); + const { orders, loading, error } = useAppSelector((state: RootState) => + selectOrders(state) + ); + + const [currentPage, setCurrentPage] = useState(1); + const [ordersPerPage] = useState(10); + const [sortColumn, setSortColumn] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + const [filterStatus, setFilterStatus] = useState('All'); + const [searchResults, setSearchResults] = useState([]); + const [searchMode, setSearchMode] = useState(false); + const [selected, setSelected] = useState(-1); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
Error: {error}
; + } + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortOrder('asc'); + } + }; + + const handleFilter = (status: string) => { + setFilterStatus(status); + }; + + const filteredOrders = orders.filter( + (order: Order) => filterStatus === 'All' || order.status === filterStatus + ); + + const sortedOrders = [...filteredOrders].sort((a, b) => { + if (sortColumn) { + const aValue = a[sortColumn as keyof typeof a]; + const bValue = b[sortColumn as keyof typeof b]; + if (aValue === null || bValue === null) { + return 0; + } + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + } + return 0; + }); + + const search = (text: string) => { + const query = text.trim().toLowerCase(); + const results = sortedOrders.filter( + (order) => + order.id.toString().includes(query) || + order.deliveryInfo.toLowerCase().includes(query) || + order.trackingNumber.toLowerCase().includes(query) || + order.paymentInfo?.toLowerCase().includes(query) || + order.updatedAt.toLowerCase().includes(query) + ); + setSearchMode(true); + setSearchResults(results); + }; + + const indexOfLastOrder = currentPage * ordersPerPage; + const indexOfFirstOrder = indexOfLastOrder - ordersPerPage; + const displayOrders = searchMode ? searchResults : sortedOrders; + const currentOrders = displayOrders.slice( + indexOfFirstOrder, + indexOfLastOrder + ); + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + return ( +
+
+

Orders

+ +
+ +
+ setSelected(id)} + cancel={(id: number) => dispatch(cancelOrder(id))} + /> + + + {selected >= 0 && ( +
+ setSelected(-1)} + order={orders.find((order) => order.id === selected) as Order} + /> +
+ )} + + ); +} diff --git a/src/components/Orders/Pagination.tsx b/src/components/Orders/Pagination.tsx new file mode 100644 index 00000000..6ed2c19c --- /dev/null +++ b/src/components/Orders/Pagination.tsx @@ -0,0 +1,73 @@ +import Order from '@/interfaces/order'; +import ElipsisIcon from './ElipsisIcon'; +import NavigationIcon from './NavigationIcon'; + +interface PageProps { + orders: Order[]; + currentPage: number; + ordersPerPage: number; + paginate: (page: number) => void; +} +export default function Pagination({ + orders, + currentPage, + ordersPerPage, + paginate, +}: PageProps) { + const pages: number[] = Array.from( + { length: orders.length / ordersPerPage }, + (_, index) => index + ); + + return ( +
+ +
+ ); +} diff --git a/src/components/Orders/Table.tsx b/src/components/Orders/Table.tsx new file mode 100644 index 00000000..12b8ca07 --- /dev/null +++ b/src/components/Orders/Table.tsx @@ -0,0 +1,134 @@ +import { format, parseISO } from 'date-fns'; +import Order from '@/interfaces/order'; + +interface TableProps { + orders: Order[]; + handleSort: (column: string) => void; + select: (id: number) => void; + cancel: (id: number) => void; +} + +export default function Table({ + orders, + handleSort, + cancel, + select, +}: TableProps) { + return ( +
+
+ + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + ))} + +
handleSort('id')} + > + ID + handleSort('trackingNumber')} + data-testid="trackingNumber" + > + Order + handleSort('updatedAt')} + data-testid="updatedAt" + > + Date + handleSort('status')} + data-testid="status" + > + Status + handleSort('totalAmount')} + data-testid="totalAmount" + > + Total + + Action +
+ {order.id} + + {order.trackingNumber} + + {format(parseISO(order.updatedAt), 'MMM dd, yyyy')} + + {order.status} + + {order.totalAmount} + + + +
+
+ ); +} diff --git a/src/features/Orders/ordersSlice.ts b/src/features/Orders/ordersSlice.ts new file mode 100644 index 00000000..06161e70 --- /dev/null +++ b/src/features/Orders/ordersSlice.ts @@ -0,0 +1,75 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import Order from '@/interfaces/order'; +import { RootState } from '../../app/store'; + +interface OrdersState { + orders: Order[]; + loading: boolean; + error: string | null; +} + +const initialState: OrdersState = { + orders: [], + loading: false, + error: null, +}; + +const baseUrl = import.meta.env.VITE_BASE_URL; + +interface Payload { + orders: Order[]; +} + +export const fetchOrders = createAsyncThunk('orders/fetchOrders', async () => { + const tokenFromStorage = localStorage.getItem('token') || ''; + const response = await axios.get( + `${baseUrl}/checkout/getall-order`, + { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + } + ); + return response.data.orders; +}); + +const ordersSlice = createSlice({ + name: 'orders', + initialState, + reducers: { + cancelOrder: (state, action: PayloadAction) => { + const orderId = action.payload; + const orderToCancel = state.orders.find((order) => order.id === orderId); + + if (orderToCancel) { + const tokenFromStorage = localStorage.getItem('token') || ''; + axios.delete(`${baseUrl}/checkout/cancel-order/${orderId}`, { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + }); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchOrders.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchOrders.fulfilled, (state, action) => { + state.loading = false; + state.orders = action.payload; + }) + .addCase(fetchOrders.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch orders'; + }); + }, +}); + +export const { cancelOrder } = ordersSlice.actions; + +export const selectOrders = (state: RootState) => state.orders; +export default ordersSlice.reducer; diff --git a/src/features/Products/categorySlice.ts b/src/features/Products/categorySlice.ts index 157ba3f0..2521f9b7 100644 --- a/src/features/Products/categorySlice.ts +++ b/src/features/Products/categorySlice.ts @@ -1,4 +1,3 @@ -// In your counterSlice.ts or a similar file import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from 'axios'; import { Category } from '@/types/Product'; diff --git a/src/interfaces/order.ts b/src/interfaces/order.ts new file mode 100644 index 00000000..61edd65f --- /dev/null +++ b/src/interfaces/order.ts @@ -0,0 +1,20 @@ +export interface Order { + id: number; + totalAmount: number; + status: string; + deliveryInfo: string; + paymentInfo: string | null; + trackingNumber: string; + createdAt: string; + updatedAt: string; + paid: boolean; + orderDetails: OrderDetail[]; +} + +interface OrderDetail { + id: number; + quantity: number; + price: number; +} + +export default Order; diff --git a/src/pages/Orders.tsx b/src/pages/Orders.tsx new file mode 100644 index 00000000..577f2fc6 --- /dev/null +++ b/src/pages/Orders.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useAppDispatch } from '@/app/hooks'; +import { fetchOrders } from '@/features/Orders/ordersSlice'; +import { Orders as OrdersComponent } from '@/components/Orders/Orders'; + +export default function Orders() { + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(fetchOrders()); + }, [dispatch]); + + return ; +} diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 013d18d0..105def79 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -11,6 +11,7 @@ import ResetPasswordForm from '@/components/password/PasswordResetForm'; import AdminRoutes from '@/pages/AdminRoutes'; import Admin from '@/pages/Admin'; import Shop from '@/pages/Shop'; +import { Orders } from '@/components/Orders/Orders'; function AppRoutes() { return ( @@ -34,8 +35,11 @@ function AppRoutes() { } > } /> + } /> } /> + } /> + } /> ); } diff --git a/tsconfig.json b/tsconfig.json index 351a77fa..a8b922bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": [".eslintrc.cjs", "src"], + "include": [".eslintrc.cjs", "src", "dist/Cart"], "references": [{ "path": "./tsconfig.node.json" }] }