orders page
This commit is contained in:
parent
82b3f2e1d1
commit
f251fdf823
8 changed files with 814 additions and 23 deletions
|
|
@ -20,6 +20,7 @@ const productAdminRoutes = require('./routes/productAdmin');
|
||||||
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
||||||
const usersAdminRoutes = require('./routes/userAdmin');
|
const usersAdminRoutes = require('./routes/userAdmin');
|
||||||
const ordersAdminRoutes = require('./routes/orderAdmin');
|
const ordersAdminRoutes = require('./routes/orderAdmin');
|
||||||
|
const userOrdersRoutes = require('./routes/userOrders');
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = config.port || 4000;
|
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/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
app.use('/api/products', productRoutes(pool, query));
|
app.use('/api/products', productRoutes(pool, query));
|
||||||
app.use('/api/auth', authRoutes(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/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
|
||||||
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,16 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get order details
|
// Get order details with shipping information
|
||||||
const orderResult = await query(`
|
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
|
FROM orders o
|
||||||
JOIN users u ON o.user_id = u.id
|
JOIN users u ON o.user_id = u.id
|
||||||
WHERE o.id = $1
|
WHERE o.id = $1
|
||||||
|
|
|
||||||
103
backend/src/routes/userOrders.js
Normal file
103
backend/src/routes/userOrders.js
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -11,24 +11,25 @@ import AuthLayout from './layouts/AuthLayout';
|
||||||
import AdminLayout from './layouts/AdminLayout';
|
import AdminLayout from './layouts/AdminLayout';
|
||||||
|
|
||||||
// Pages - lazy loaded to reduce initial bundle size
|
// Pages - lazy loaded to reduce initial bundle size
|
||||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
const HomePage = lazy(() => import('@pages/HomePage'));
|
||||||
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
|
const ProductsPage = lazy(() => import('@pages/ProductsPage'));
|
||||||
const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
|
const ProductDetailPage = lazy(() => import('@pages/ProductDetailPage'));
|
||||||
const CartPage = lazy(() => import('./pages/CartPage'));
|
const CartPage = lazy(() => import('@pages/CartPage'));
|
||||||
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
|
const CheckoutPage = lazy(() => import('@pages/CheckoutPage'));
|
||||||
const PaymentSuccessPage = lazy(() => import('./pages/PaymentSuccessPage'));
|
const PaymentSuccessPage = lazy(() => import('@pages/PaymentSuccessPage'));
|
||||||
const PaymentCancelPage = lazy(() => import('./pages/PaymentCancelPage'));
|
const PaymentCancelPage = lazy(() => import('@pages/PaymentCancelPage'));
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
const LoginPage = lazy(() => import('@pages/LoginPage'));
|
||||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
const RegisterPage = lazy(() => import('@pages/RegisterPage'));
|
||||||
const VerifyPage = lazy(() => import('./pages/VerifyPage'));
|
const VerifyPage = lazy(() => import('@pages/VerifyPage'));
|
||||||
const AdminDashboardPage = lazy(() => import('./pages/Admin/DashboardPage'));
|
const AdminDashboardPage = lazy(() => import('@pages/Admin/DashboardPage'));
|
||||||
const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage'));
|
const AdminProductsPage = lazy(() => import('@pages/Admin/ProductsPage'));
|
||||||
const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage'));
|
const AdminProductEditPage = lazy(() => import('@pages/Admin/ProductEditPage'));
|
||||||
const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage'));
|
const AdminCategoriesPage = lazy(() => import('@pages/Admin/CategoriesPage'));
|
||||||
const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage'));
|
const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage'));
|
||||||
const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage'));
|
const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage'));
|
||||||
const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage'));
|
const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
|
||||||
|
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
|
||||||
|
|
||||||
// Loading component for suspense fallback
|
// Loading component for suspense fallback
|
||||||
const LoadingComponent = () => (
|
const LoadingComponent = () => (
|
||||||
|
|
@ -45,6 +46,11 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Main routes with MainLayout */}
|
{/* Main routes with MainLayout */}
|
||||||
<Route path="/" element={<MainLayout />}>
|
<Route path="/" element={<MainLayout />}>
|
||||||
|
<Route path="account/orders" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<UserOrdersPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="products" element={<ProductsPage />} />
|
<Route path="products" element={<ProductsPage />} />
|
||||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||||
|
|
|
||||||
|
|
@ -269,3 +269,39 @@ 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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||||
|
import ReceiptIcon from '@mui/icons-material/Receipt';
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
|
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
|
|
@ -37,11 +38,22 @@ const MainLayout = () => {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const mainMenu = [
|
let mainMenu = [
|
||||||
{ text: 'Home', icon: <HomeIcon />, path: '/' },
|
{ text: 'Home', icon: <HomeIcon />, path: '/' },
|
||||||
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
|
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
|
||||||
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
|
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
|
||||||
];
|
];
|
||||||
|
if (isAuthenticated) {
|
||||||
|
mainMenu.push(
|
||||||
|
{ text: 'My Orders', icon: <ReceiptIcon />, path: '/account/orders' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && isAdmin) {
|
||||||
|
mainMenu.push(
|
||||||
|
{ text: 'Admin Dashboard', icon: <DashboardIcon />, path: '/admin' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const authMenu = isAuthenticated ?
|
const authMenu = isAuthenticated ?
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,15 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
Select,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
Link
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
Clear as ClearIcon,
|
Clear as ClearIcon,
|
||||||
Visibility as ViewIcon,
|
Visibility as ViewIcon,
|
||||||
LocalShipping as ShippingIcon
|
LocalShipping as ShippingIcon,
|
||||||
|
CheckCircle as CheckCircleIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import apiClient from '@services/api';
|
import apiClient from '@services/api';
|
||||||
|
|
@ -441,6 +443,155 @@ const AdminOrdersPage = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Shipping Information - Only show for shipped orders */}
|
||||||
|
{orderDetails && orderDetails.status === 'shipped' && orderDetails.shipping_info && (
|
||||||
|
<Grid item xs={12} sx={{ mt: 2 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Shipping Information
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// 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 (
|
||||||
|
<Box>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Carrier:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{shippingInfo.shipper || 'Not specified'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Tracking Number:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{trackingNumber ? (
|
||||||
|
<Link href={trackingLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
{trackingNumber}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'Not available'
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{shippingInfo.shipmentId && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Shipment ID:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{shippingInfo.shipmentId}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Shipped Date:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{orderDetails.shipping_date
|
||||||
|
? formatDate(orderDetails.shipping_date)
|
||||||
|
: (shippingInfo.shippedDate
|
||||||
|
? formatDate(shippingInfo.shippedDate)
|
||||||
|
: 'Not available')}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{shippingInfo.estimatedDelivery && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Estimated Delivery:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{shippingInfo.estimatedDelivery}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shippingInfo.customerMessage && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Message to Customer:
|
||||||
|
</Typography>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, mt: 1, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{shippingInfo.customerMessage}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Check if a notification was sent */}
|
||||||
|
{orderDetails.notification_sent && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Chip
|
||||||
|
icon={<CheckCircleIcon />}
|
||||||
|
label="Shipping notification email sent"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Order Items */}
|
{/* Order Items */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
|
|
|
||||||
474
frontend/src/pages/UserOrdersPage.jsx
Normal file
474
frontend/src/pages/UserOrdersPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }}>
|
||||||
|
Error loading your orders: {error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginated orders
|
||||||
|
const paginatedOrders = orders?.slice(
|
||||||
|
page * rowsPerPage,
|
||||||
|
page * rowsPerPage + rowsPerPage
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
My Orders
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Orders Table */}
|
||||||
|
<Paper variant="outlined">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Order ID</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Total</TableCell>
|
||||||
|
<TableCell>Items</TableCell>
|
||||||
|
<TableCell></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedOrders.length > 0 ? (
|
||||||
|
paginatedOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{order.id.substring(0, 8)}...
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(order.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={order.status}
|
||||||
|
color={getStatusColor(order.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
${parseFloat(order.total_amount).toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{order.total_items || 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleViewOrder(order.id)}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center">
|
||||||
|
<Typography variant="body1" py={3}>
|
||||||
|
You haven't placed any orders yet.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => navigate('/products')}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Browse Products
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{orders && orders.length > 0 && (
|
||||||
|
<TablePagination
|
||||||
|
rowsPerPageOptions={[5, 10, 25]}
|
||||||
|
component="div"
|
||||||
|
count={orders.length}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Order Details Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={viewDialogOpen}
|
||||||
|
onClose={handleCloseViewDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Order Details
|
||||||
|
{orderDetails && (
|
||||||
|
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
|
||||||
|
Order ID: {orderDetails.id}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{isLoadingDetails ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : orderDetails ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Order Summary */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Order Summary
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Date:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatDate(orderDetails.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Status:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Chip
|
||||||
|
label={orderDetails.status}
|
||||||
|
color={getStatusColor(orderDetails.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Total:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
${parseFloat(orderDetails.total_amount).toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Shipping Address */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Shipping Address
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{orderDetails.shipping_address}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Shipping Information - Show only for shipped orders */}
|
||||||
|
{orderDetails.status === 'shipped' && orderDetails.shipping_info && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Shipping Information
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// 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 (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Shipping Carrier:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{shippingInfo.shipper || 'Not specified'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Tracking Number:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{trackingNumber ? (
|
||||||
|
<Link href={trackingLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
{trackingNumber}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'Not available'
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Shipped Date:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{orderDetails.shipping_date
|
||||||
|
? formatDate(orderDetails.shipping_date)
|
||||||
|
: (shippingInfo.shippedDate
|
||||||
|
? formatDate(shippingInfo.shippedDate)
|
||||||
|
: 'Not available')}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{shippingInfo.estimatedDelivery && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Estimated Delivery:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{shippingInfo.estimatedDelivery}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Order Items
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{orderDetails.items && orderDetails.items.map((item, index) => (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
<ListItem alignItems="flex-start">
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={2} sm={1}>
|
||||||
|
<ProductImage
|
||||||
|
images={item.product_images}
|
||||||
|
alt={item.product_name}
|
||||||
|
sx={{ width: 50, height: 50, borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={10} sm={7}>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.product_name}
|
||||||
|
secondary={
|
||||||
|
<React.Fragment>
|
||||||
|
<Typography variant="caption" component="span" color="text.primary">
|
||||||
|
Category: {item.product_category}
|
||||||
|
</Typography>
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} sm={2} textAlign="center">
|
||||||
|
<Typography variant="body2">
|
||||||
|
${parseFloat(item.price_at_purchase).toFixed(2)} × {item.quantity}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} sm={2} textAlign="right">
|
||||||
|
<Typography variant="body1" fontWeight="bold">
|
||||||
|
${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ListItem>
|
||||||
|
{index < orderDetails.items.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Typography color="error">Failed to load order details</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseViewDialog}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserOrdersPage;
|
||||||
Loading…
Reference in a new issue