From f251fdf823b811fcca62ba8a5839d1e57c89e5fb Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Sat, 26 Apr 2025 23:43:27 -0500 Subject: [PATCH] orders page --- backend/src/index.js | 2 + backend/src/routes/orderAdmin.js | 11 +- backend/src/routes/userOrders.js | 103 +++++ frontend/src/App.jsx | 42 ++- frontend/src/hooks/apiHooks.js | 36 ++ frontend/src/layouts/MainLayout.jsx | 14 +- frontend/src/pages/Admin/OrdersPage.jsx | 155 +++++++- frontend/src/pages/UserOrdersPage.jsx | 474 ++++++++++++++++++++++++ 8 files changed, 814 insertions(+), 23 deletions(-) create mode 100644 backend/src/routes/userOrders.js create mode 100644 frontend/src/pages/UserOrdersPage.jsx diff --git a/backend/src/index.js b/backend/src/index.js index 8dcf561..a928e18 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -20,6 +20,7 @@ const productAdminRoutes = require('./routes/productAdmin'); const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes const usersAdminRoutes = require('./routes/userAdmin'); const ordersAdminRoutes = require('./routes/orderAdmin'); +const userOrdersRoutes = require('./routes/userOrders'); // Create Express app const app = express(); const port = config.port || 4000; @@ -245,6 +246,7 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/products', productRoutes(pool, query)); app.use('/api/auth', authRoutes(pool, query)); +app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query))); app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query))); app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); diff --git a/backend/src/routes/orderAdmin.js b/backend/src/routes/orderAdmin.js index 458ade6..0d9214c 100644 --- a/backend/src/routes/orderAdmin.js +++ b/backend/src/routes/orderAdmin.js @@ -60,9 +60,16 @@ module.exports = (pool, query, authMiddleware) => { }); } - // Get order details + // Get order details with shipping information const orderResult = await query(` - SELECT o.*, u.email, u.first_name, u.last_name + SELECT o.*, + u.email, + u.first_name, + u.last_name, + (SELECT EXISTS( + SELECT 1 FROM notification_logs + WHERE order_id = o.id AND notification_type = 'shipping_notification' + )) as notification_sent FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id = $1 diff --git a/backend/src/routes/userOrders.js b/backend/src/routes/userOrders.js new file mode 100644 index 0000000..9db4dff --- /dev/null +++ b/backend/src/routes/userOrders.js @@ -0,0 +1,103 @@ +// Create a new file called userOrders.js in the routes directory + +const express = require('express'); +const router = express.Router(); + +module.exports = (pool, query, authMiddleware) => { + // Apply authentication middleware to all routes + router.use(authMiddleware); + + // Get all orders for the authenticated user + router.get('/', async (req, res, next) => { + try { + const userId = req.user.id; + + // Get orders with basic information + const result = await query(` + SELECT + o.id, + o.status, + o.total_amount, + o.shipping_address, + o.created_at, + o.updated_at, + o.payment_completed, + o.shipping_date, + COUNT(oi.id) AS item_count, + CASE WHEN o.shipping_info IS NOT NULL THEN true ELSE false END AS has_shipping_info, + (SELECT SUM(quantity) FROM order_items WHERE order_id = o.id) AS total_items + FROM orders o + LEFT JOIN order_items oi ON o.id = oi.order_id + WHERE o.user_id = $1 + GROUP BY o.id + ORDER BY o.created_at DESC + `, [userId]); + + res.json(result.rows); + } catch (error) { + next(error); + } + }); + + // Get a single order with details for the authenticated user + router.get('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.id; + + // Get order with verification that it belongs to the current user + const orderResult = await query(` + SELECT o.* + FROM orders o + WHERE o.id = $1 AND o.user_id = $2 + `, [id, userId]); + + if (orderResult.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Order not found' + }); + } + + // Get order items with product details + const itemsResult = await query(` + SELECT + oi.id, + oi.product_id, + oi.quantity, + oi.price_at_purchase, + p.name as product_name, + p.description as product_description, + pc.name as product_category, + ( + SELECT json_agg( + json_build_object( + 'id', pi.id, + 'path', pi.image_path, + 'isPrimary', pi.is_primary, + 'displayOrder', pi.display_order + ) ORDER BY pi.display_order + ) + FROM product_images pi + WHERE pi.product_id = p.id + ) AS product_images + FROM order_items oi + JOIN products p ON oi.product_id = p.id + JOIN product_categories pc ON p.category_id = pc.id + WHERE oi.order_id = $1 + `, [id]); + + // Combine order with items + const order = { + ...orderResult.rows[0], + items: itemsResult.rows + }; + + res.json(order); + } catch (error) { + next(error); + } + }); + + return router; +}; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d8ffacd..518a2b9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,24 +11,25 @@ import AuthLayout from './layouts/AuthLayout'; import AdminLayout from './layouts/AdminLayout'; // Pages - lazy loaded to reduce initial bundle size -const HomePage = lazy(() => import('./pages/HomePage')); -const ProductsPage = lazy(() => import('./pages/ProductsPage')); -const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage')); -const CartPage = lazy(() => import('./pages/CartPage')); -const CheckoutPage = lazy(() => import('./pages/CheckoutPage')); -const PaymentSuccessPage = lazy(() => import('./pages/PaymentSuccessPage')); -const PaymentCancelPage = lazy(() => import('./pages/PaymentCancelPage')); -const LoginPage = lazy(() => import('./pages/LoginPage')); -const RegisterPage = lazy(() => import('./pages/RegisterPage')); -const VerifyPage = lazy(() => import('./pages/VerifyPage')); -const AdminDashboardPage = lazy(() => import('./pages/Admin/DashboardPage')); -const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage')); -const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage')); -const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage')); -const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage')); -const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage')); -const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage')); -const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); +const HomePage = lazy(() => import('@pages/HomePage')); +const ProductsPage = lazy(() => import('@pages/ProductsPage')); +const ProductDetailPage = lazy(() => import('@pages/ProductDetailPage')); +const CartPage = lazy(() => import('@pages/CartPage')); +const CheckoutPage = lazy(() => import('@pages/CheckoutPage')); +const PaymentSuccessPage = lazy(() => import('@pages/PaymentSuccessPage')); +const PaymentCancelPage = lazy(() => import('@pages/PaymentCancelPage')); +const LoginPage = lazy(() => import('@pages/LoginPage')); +const RegisterPage = lazy(() => import('@pages/RegisterPage')); +const VerifyPage = lazy(() => import('@pages/VerifyPage')); +const AdminDashboardPage = lazy(() => import('@pages/Admin/DashboardPage')); +const AdminProductsPage = lazy(() => import('@pages/Admin/ProductsPage')); +const AdminProductEditPage = lazy(() => import('@pages/Admin/ProductEditPage')); +const AdminCategoriesPage = lazy(() => import('@pages/Admin/CategoriesPage')); +const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage')); +const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage')); +const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage')); +const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage')); +const NotFoundPage = lazy(() => import('@pages/NotFoundPage')); // Loading component for suspense fallback const LoadingComponent = () => ( @@ -45,6 +46,11 @@ function App() { {/* Main routes with MainLayout */} }> + + + + } /> } /> } /> } /> diff --git a/frontend/src/hooks/apiHooks.js b/frontend/src/hooks/apiHooks.js index 3452250..ec48e60 100644 --- a/frontend/src/hooks/apiHooks.js +++ b/frontend/src/hooks/apiHooks.js @@ -268,4 +268,40 @@ export const useCheckout = () => { ); }, }); +}; + + + +/** + * Hook for fetching user's orders + */ +export const useUserOrders = () => { + const { user } = useAuth(); + + return useQuery({ + queryKey: ['user-orders', user], + queryFn: async () => { + const response = await apiClient.get('/user/orders'); + return response.data; + }, + enabled: !!user, + staleTime: 60000 // 1 minute + }); +}; + +/** + * Hook for fetching a single user order by ID + * @param {string} id - Order ID + */ +export const useUserOrder = (id) => { + const { user } = useAuth(); + + return useQuery({ + queryKey: ['user-order', id], + queryFn: async () => { + const response = await apiClient.get(`/user/orders/${id}`); + return response.data; + }, + enabled: !!id && !!user + }); }; \ No newline at end of file diff --git a/frontend/src/layouts/MainLayout.jsx b/frontend/src/layouts/MainLayout.jsx index 0810864..7d1ac36 100644 --- a/frontend/src/layouts/MainLayout.jsx +++ b/frontend/src/layouts/MainLayout.jsx @@ -14,6 +14,7 @@ import LogoutIcon from '@mui/icons-material/Logout'; import DashboardIcon from '@mui/icons-material/Dashboard'; import Brightness4Icon from '@mui/icons-material/Brightness4'; import Brightness7Icon from '@mui/icons-material/Brightness7'; +import ReceiptIcon from '@mui/icons-material/Receipt'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks'; import Footer from '../components/Footer'; @@ -37,11 +38,22 @@ const MainLayout = () => { navigate('/'); }; - const mainMenu = [ + let mainMenu = [ { text: 'Home', icon: , path: '/' }, { text: 'Products', icon: , path: '/products' }, { text: 'Cart', icon: , path: '/cart', badge: itemCount > 0 ? itemCount : null }, ]; + if (isAuthenticated) { + mainMenu.push( + { text: 'My Orders', icon: , path: '/account/orders' } + ); + } + + if (isAuthenticated && isAdmin) { + mainMenu.push( + { text: 'Admin Dashboard', icon: , path: '/admin' } + ); + } const authMenu = isAuthenticated ? [ diff --git a/frontend/src/pages/Admin/OrdersPage.jsx b/frontend/src/pages/Admin/OrdersPage.jsx index 75c67ac..983ce4d 100644 --- a/frontend/src/pages/Admin/OrdersPage.jsx +++ b/frontend/src/pages/Admin/OrdersPage.jsx @@ -32,13 +32,15 @@ import { FormControl, InputLabel, Select, - Tooltip + Tooltip, + Link } from '@mui/material'; import { Search as SearchIcon, Clear as ClearIcon, Visibility as ViewIcon, - LocalShipping as ShippingIcon + LocalShipping as ShippingIcon, + CheckCircle as CheckCircleIcon } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import apiClient from '@services/api'; @@ -441,6 +443,155 @@ const AdminOrdersPage = () => { + {/* Shipping Information - Only show for shipped orders */} + {orderDetails && orderDetails.status === 'shipped' && orderDetails.shipping_info && ( + + + + + Shipping Information + + + + {(() => { + // Parse shipping info from JSON if needed + let shippingInfo = orderDetails.shipping_info; + if (typeof shippingInfo === 'string') { + try { + shippingInfo = JSON.parse(shippingInfo); + } catch (e) { + console.error('Failed to parse shipping info:', e); + } + } + + // Generate tracking link + let trackingLink = '#'; + const shipper = shippingInfo.shipper || ''; + const trackingNumber = shippingInfo.trackingNumber; + + if (trackingNumber) { + // Match exactly with the values from the dropdown + switch(shipper) { + case 'USPS': + trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`; + break; + case 'UPS': + trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`; + break; + case 'FedEx': + trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`; + break; + case 'DHL': + trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`; + break; + case 'Canada Post': + trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`; + break; + case 'Purolator': + trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`; + break; + default: + trackingLink = '#'; + break; + } + } + + return ( + + + + + Carrier: + + + {shippingInfo.shipper || 'Not specified'} + + + + + + Tracking Number: + + + {trackingNumber ? ( + + {trackingNumber} + + ) : ( + 'Not available' + )} + + + + {shippingInfo.shipmentId && ( + + + Shipment ID: + + + {shippingInfo.shipmentId} + + + )} + + + + Shipped Date: + + + {orderDetails.shipping_date + ? formatDate(orderDetails.shipping_date) + : (shippingInfo.shippedDate + ? formatDate(shippingInfo.shippedDate) + : 'Not available')} + + + + {shippingInfo.estimatedDelivery && ( + + + Estimated Delivery: + + + {shippingInfo.estimatedDelivery} + + + )} + + {shippingInfo.customerMessage && ( + + + Message to Customer: + + + + {shippingInfo.customerMessage} + + + + )} + + + {/* Check if a notification was sent */} + {orderDetails.notification_sent && ( + + } + label="Shipping notification email sent" + color="success" + size="small" + /> + + )} + + ); + })()} + + + + )} + + {/* Order Items */} diff --git a/frontend/src/pages/UserOrdersPage.jsx b/frontend/src/pages/UserOrdersPage.jsx new file mode 100644 index 0000000..eed55c1 --- /dev/null +++ b/frontend/src/pages/UserOrdersPage.jsx @@ -0,0 +1,474 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Chip, + CircularProgress, + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + Card, + CardContent, + List, + ListItem, + ListItemText, + Divider, + Link +} from '@mui/material'; +import { format } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; +import { useUserOrders, useUserOrder } from '../hooks/apiHooks'; +import ProductImage from '../components/ProductImage'; + +const UserOrdersPage = () => { + const navigate = useNavigate(); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + const [viewDialogOpen, setViewDialogOpen] = useState(false); + const [selectedOrderId, setSelectedOrderId] = useState(null); + + // Fetch user's orders + const { data: orders, isLoading, error } = useUserOrders(); + + // Fetch selected order details when dialog is open + const { + data: orderDetails, + isLoading: isLoadingDetails, + } = useUserOrder(selectedOrderId); + + // Handle pagination + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + // Handle opening order details dialog + const handleViewOrder = (orderId) => { + setSelectedOrderId(orderId); + setViewDialogOpen(true); + }; + + // Close view dialog + const handleCloseViewDialog = () => { + setViewDialogOpen(false); + setSelectedOrderId(null); + }; + + // Format date + const formatDate = (dateString) => { + if (!dateString) return 'N/A'; + try { + return format(new Date(dateString), 'MMM d, yyyy h:mm a'); + } catch (error) { + return dateString; + } + }; + + // Get status chip color + const getStatusColor = (status) => { + switch (status) { + case 'pending': + return 'default'; + case 'processing': + return 'primary'; + case 'shipped': + return 'info'; + case 'delivered': + return 'success'; + case 'cancelled': + return 'error'; + case 'refunded': + return 'warning'; + default: + return 'default'; + } + }; + + // Loading state + if (isLoading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + Error loading your orders: {error.message} + + ); + } + + // Paginated orders + const paginatedOrders = orders?.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage + ) || []; + + return ( + + + My Orders + + + {/* Orders Table */} + + + + + + Order ID + Date + Status + Total + Items + + + + + {paginatedOrders.length > 0 ? ( + paginatedOrders.map((order) => ( + + + + {order.id.substring(0, 8)}... + + + {formatDate(order.created_at)} + + + + + ${parseFloat(order.total_amount).toFixed(2)} + + {order.total_items || 0} + + + + + )) + ) : ( + + + + You haven't placed any orders yet. + + + + + )} + +
+
+ + {orders && orders.length > 0 && ( + + )} +
+ + {/* Order Details Dialog */} + + + Order Details + {orderDetails && ( + + Order ID: {orderDetails.id} + + )} + + + {isLoadingDetails ? ( + + + + ) : orderDetails ? ( + + {/* Order Summary */} + + + + + Order Summary + + + + + + + Date: + + + + + {formatDate(orderDetails.created_at)} + + + + + + Status: + + + + + + + + + Total: + + + + + ${parseFloat(orderDetails.total_amount).toFixed(2)} + + + + + + + + {/* Shipping Address */} + + + + + Shipping Address + + + + + {orderDetails.shipping_address} + + + + + + {/* Shipping Information - Show only for shipped orders */} + {orderDetails.status === 'shipped' && orderDetails.shipping_info && ( + + + + + Shipping Information + + + + {(() => { + // Parse shipping info from JSON if needed + let shippingInfo = orderDetails.shipping_info; + if (typeof shippingInfo === 'string') { + try { + shippingInfo = JSON.parse(shippingInfo); + } catch (e) { + console.error('Failed to parse shipping info:', e); + } + } + + // Generate tracking link + let trackingLink = '#'; + const shipper = shippingInfo.shipper || ''; + const trackingNumber = shippingInfo.trackingNumber; + + if (trackingNumber) { + // Match exactly with the values from the dropdown + switch(shipper) { + case 'USPS': + trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`; + break; + case 'UPS': + trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`; + break; + case 'FedEx': + trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`; + break; + case 'DHL': + trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`; + break; + case 'Canada Post': + trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`; + break; + case 'Purolator': + trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`; + break; + default: + trackingLink = '#'; + break; + } + } + + return ( + + + + Shipping Carrier: + + + {shippingInfo.shipper || 'Not specified'} + + + + + + Tracking Number: + + + {trackingNumber ? ( + + {trackingNumber} + + ) : ( + 'Not available' + )} + + + + + + Shipped Date: + + + {orderDetails.shipping_date + ? formatDate(orderDetails.shipping_date) + : (shippingInfo.shippedDate + ? formatDate(shippingInfo.shippedDate) + : 'Not available')} + + + + {shippingInfo.estimatedDelivery && ( + + + Estimated Delivery: + + + {shippingInfo.estimatedDelivery} + + + )} + + ); + })()} + + + + )} + + {/* Order Items */} + + + + + Order Items + + + + + {orderDetails.items && orderDetails.items.map((item, index) => ( + + + + + + + + + + Category: {item.product_category} + + + } + /> + + + + ${parseFloat(item.price_at_purchase).toFixed(2)} × {item.quantity} + + + + + ${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)} + + + + + {index < orderDetails.items.length - 1 && } + + ))} + + +
+
+ + ) : ( + Failed to load order details + )} + + + + + + + ); +}; + +export default UserOrdersPage; \ No newline at end of file