E-Commerce-Module/frontend/src/pages/CheckoutPage.jsx
2025-04-28 00:54:03 -05:00

670 lines
No EOL
20 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Grid,
Stepper,
Step,
StepLabel,
Button,
Divider,
TextField,
FormControlLabel,
Checkbox,
CircularProgress,
List,
ListItem,
ListItemText,
Alert,
Radio,
RadioGroup,
FormControl,
FormLabel
} from '@mui/material';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { useAuth, useCart } from '../hooks/reduxHooks';
import { useCheckout } from '../hooks/apiHooks';
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
import apiClient from '../services/api';
// Checkout steps
const steps = ['Shipping Address', 'Shipping Method', 'Review Order', 'Payment', 'Confirmation'];
const CheckoutPage = () => {
const navigate = useNavigate();
const { user, userData } = useAuth();
const { items, total, itemCount } = useCart();
const checkout = useCheckout();
const { createCheckoutSession, isLoading: isStripeLoading } = useStripe();
// State for checkout steps
const [activeStep, setActiveStep] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
const [error, setError] = useState(null);
const [orderId, setOrderId] = useState(null);
const [checkoutUrl, setCheckoutUrl] = useState(null);
// State for shipping options
const [shippingRates, setShippingRates] = useState([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState(null);
const [shippingCost, setShippingCost] = useState(0);
const [shipmentId, setShipmentId] = useState(null);
// State for form data
const [formData, setFormData] = useState({
firstName: userData?.first_name || '',
lastName: userData?.last_name || '',
email: userData?.email || '',
address: '',
city: '',
province: '',
postalCode: '',
country: '',
saveAddress: false,
});
// Handle form changes
const handleChange = (e) => {
const { name, value, checked } = e.target;
setFormData({
...formData,
[name]: name === 'saveAddress' ? checked : value,
});
// Clear validation error when field is edited
if (error) {
setError(null);
}
};
// Handle next step
const handleNext = () => {
if (activeStep === steps.length - 1) {
// Complete checkout - placeholder
return;
}
// If on shipping address step, validate form
if (activeStep === 0) {
if (!validateShippingForm()) {
return;
}
fetchShippingRates();
return
}
// If on shipping method step, validate selection
if (activeStep === 1) {
if (!selectedShippingMethod) {
setError('Please select a shipping method');
return;
}
setError(null);
}
// If on review step, process checkout
if (activeStep === 2) {
handlePlaceOrder();
return;
}
setActiveStep((prevStep) => prevStep + 1);
};
// Handle back step
const handleBack = () => {
setActiveStep((prevStep) => prevStep - 1);
};
// Validate shipping form
const validateShippingForm = () => {
const requiredFields = ['firstName', 'lastName', 'email', 'address', 'city', 'province', 'postalCode', 'country'];
for (const field of requiredFields) {
if (!formData[field]) {
// In a real app, you'd set specific errors for each field
setError(`Please fill in all required fields`);
return false;
}
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
setError('Please enter a valid email address');
return false;
}
setError(null);
return true;
};
// Fetch shipping rates based on address
const fetchShippingRates = async () => {
try {
setIsLoadingShipping(true);
setError(null);
// Format shipping address
const shippingAddress = {
name: `${formData.firstName} ${formData.lastName}`,
street: formData.address,
city: formData.city,
state: formData.province,
zip: formData.postalCode,
country: formData.country,
email: formData.email
};
// Call API to get shipping rates
const response = await apiClient.post('/cart/shipping-rates', {
userId: user,
shippingAddress
});
if (response.data.rates && response.data.rates.length > 0) {
setShippingRates(response.data.rates);
// Store shipment_id if provided (needed for actual shipment creation)
if (response.data.shipment_id) {
setShipmentId(response.data.shipment_id);
}
// Default to lowest cost option
const lowestCostOption = response.data.rates.reduce(
(lowest, current) => current.rate < lowest.rate ? current : lowest,
response.data.rates[0]
);
setSelectedShippingMethod(lowestCostOption);
setShippingCost(lowestCostOption.rate);
} else {
setShippingRates([]);
setSelectedShippingMethod(null);
setShipmentId(null);
setShippingCost(0);
setError('No shipping options available for this address');
}
// Move to next step
setActiveStep((prevStep) => prevStep + 1);
} catch (error) {
console.error('Error fetching shipping rates:', error);
setError('Failed to retrieve shipping options. Please try again.');
} finally {
setIsLoadingShipping(false);
}
};
// Handle shipping method selection
const handleShippingMethodChange = (event) => {
const selectedMethodId = event.target.value;
const method = shippingRates.find(rate => rate.id === selectedMethodId);
if (method) {
setSelectedShippingMethod(method);
setShippingCost(method.rate);
}
};
// Handle place order
const handlePlaceOrder = async () => {
if (!user || !items || items.length === 0) {
return;
}
setIsProcessing(true);
setError(null);
try {
// Format shipping address
const shippingAddress = `${formData.firstName} ${formData.lastName}
${formData.address}
${formData.city}, ${formData.province} ${formData.postalCode}
${formData.country}
${formData.email}`;
// Call the checkout API to create the order
const orderResponse = await checkout.mutateAsync({
userId: user,
shippingAddress,
shippingMethod: selectedShippingMethod
});
// Store the order ID for later use
setOrderId(orderResponse.orderId);
// Use the shipping data from the response if available
const shippingDetails = orderResponse.shipmentData ? {
shipping_cost: orderResponse.shippingCost,
shipping_method: `${orderResponse.shipmentData.carrier} - ${orderResponse.shipmentData.service}`,
tracking_code: orderResponse.shipmentData.tracking_code,
label_url: orderResponse.shipmentData.label_url
} : {
shipping_cost: orderResponse.shippingCost,
shipping_method: selectedShippingMethod ?
`${selectedShippingMethod.carrier} - ${selectedShippingMethod.service}` :
'Standard Shipping'
};
// Proceed to payment step
setActiveStep(3);
// Create a Stripe checkout session
const session = await createCheckoutSession(
orderResponse.cartItems,
orderResponse.orderId,
shippingAddress,
user,
shippingDetails
);
// Redirect to Stripe Checkout
if (session.url) {
setCheckoutUrl(session.url);
}
} catch (error) {
console.error('Checkout error:', error);
setError(error.message || 'An error occurred during checkout');
} finally {
setIsProcessing(false);
}
};
// Redirect to Stripe checkout when the URL is available
useEffect(() => {
if (checkoutUrl) {
window.location.href = checkoutUrl;
}
}, [checkoutUrl]);
// If no items in cart, redirect to cart page
if (!items || items.length === 0) {
return (
<Box sx={{ textAlign: 'center', py: 6 }}>
<Typography variant="h5" gutterBottom>
Your cart is empty
</Typography>
<Typography variant="body1" paragraph>
You need to add items to your cart before checkout.
</Typography>
<Button
variant="contained"
component={RouterLink}
to="/products"
sx={{ mr: 2 }}
>
Browse Products
</Button>
<Button
variant="outlined"
component={RouterLink}
to="/cart"
>
View Cart
</Button>
</Box>
);
}
// Render different step content based on active step
const getStepContent = (step) => {
switch (step) {
case 0:
return (
<Box component="form" sx={{ mt: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="firstName"
label="First Name"
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="lastName"
label="Last Name"
name="lastName"
value={formData.lastName}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
id="email"
label="Email Address"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
id="address"
label="Address"
name="address"
value={formData.address}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="city"
label="City"
name="city"
value={formData.city}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="province"
label="Province/State"
name="province"
value={formData.province}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="postalCode"
label="Postal / Zip code"
name="postalCode"
value={formData.postalCode}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="country"
label="Country"
name="country"
value={formData.country}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
name="saveAddress"
color="primary"
checked={formData.saveAddress}
onChange={handleChange}
/>
}
label="Save this address for future orders"
/>
</Grid>
</Grid>
</Box>
);
case 1:
return (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Shipping Method
</Typography>
{isLoadingShipping ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : shippingRates.length > 0 ? (
<FormControl component="fieldset" sx={{ width: '100%' }}>
<RadioGroup
aria-label="shipping-method"
name="shipping-method"
value={selectedShippingMethod?.id || ''}
onChange={handleShippingMethodChange}
>
{shippingRates.map((rate) => (
<Paper
key={rate.id}
variant="outlined"
sx={{
mb: 2,
p: 2,
border: selectedShippingMethod?.id === rate.id ? 2 : 1,
borderColor: selectedShippingMethod?.id === rate.id ? 'primary.main' : 'divider'
}}
>
<FormControlLabel
value={rate.id}
control={<Radio />}
label={
<Box>
<Typography variant="subtitle1">
{rate.carrier} - {rate.service}
</Typography>
<Typography variant="body2" color="text.secondary">
Estimated delivery: {rate.delivery_days} days
</Typography>
<Typography variant="h6" color="primary" sx={{ mt: 1 }}>
{rate.rate > 0 ? `$${rate.rate.toFixed(2)}` : 'FREE'}
</Typography>
</Box>
}
sx={{ width: '100%', m: 0 }}
/>
</Paper>
))}
</RadioGroup>
</FormControl>
) : (
<Alert severity="warning">
No shipping options available for this address. Please check your shipping address or contact support.
</Alert>
)}
</Box>
);
case 2:
return (
<Box sx={{ mt: 3 }}>
{/* Order summary */}
<Typography variant="h6" gutterBottom>
Order Summary
</Typography>
<List disablePadding>
{items.map((item) => (
<ListItem key={item.product_id} sx={{ py: 1, px: 0 }}>
<ListItemText
primary={item.name}
secondary={`Quantity: ${item.quantity}`}
/>
<Typography variant="body2">
${(parseFloat(item.price) * item.quantity).toFixed(2)}
</Typography>
</ListItem>
))}
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText primary="Subtotal" />
<Typography variant="body2">${total.toFixed(2)}</Typography>
</ListItem>
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText
primary="Shipping"
secondary={selectedShippingMethod ? `${selectedShippingMethod.carrier} - ${selectedShippingMethod.service}` : 'Standard Shipping'}
/>
<Typography variant="body2">
{shippingCost > 0 ? `$${shippingCost.toFixed(2)}` : 'Free'}
</Typography>
</ListItem>
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText primary="Total" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
${(total + shippingCost).toFixed(2)}
</Typography>
</ListItem>
</List>
<Divider sx={{ my: 2 }} />
{/* Shipping address */}
<Typography variant="h6" gutterBottom>
Shipping Address
</Typography>
<Typography gutterBottom>
{formData.firstName} {formData.lastName}
</Typography>
<Typography gutterBottom>
{formData.address}
</Typography>
<Typography gutterBottom>
{formData.city}, {formData.province} {formData.postalCode}
</Typography>
<Typography gutterBottom>
{formData.country}
</Typography>
<Typography gutterBottom>
{formData.email}
</Typography>
</Box>
);
case 3:
return (
<Box sx={{ mt: 3 }}>
{isStripeLoading || isProcessing ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={40} />
<Typography variant="h6" sx={{ ml: 2 }}>
Preparing secure payment...
</Typography>
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
You will be redirected to our secure payment processor.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
</Box>
);
case 4:
return (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Your order has been placed successfully!
</Alert>
<Typography variant="h5" gutterBottom>
Thank you for your order
</Typography>
<Typography paragraph>
Your order number is: #{orderId?.substring(0, 8) || Math.floor(100000 + Math.random() * 900000)}
</Typography>
<Typography paragraph>
We will send you a confirmation email with your order details.
</Typography>
<Button
variant="contained"
component={RouterLink}
to="/products"
sx={{ mt: 3 }}
>
Continue Shopping
</Button>
</Box>
);
default:
return <Typography>Unknown step</Typography>;
}
};
return (
<Box sx={{ mb: 8 }}>
<Typography variant="h4" component="h1" gutterBottom>
Checkout
</Typography>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Paper variant="outlined" sx={{ p: 3 }}>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{getStepContent(activeStep)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
{activeStep !== 0 && activeStep !== 3 && activeStep !== 4 && !isProcessing && !isLoadingShipping && (
<Button
onClick={handleBack}
sx={{ mr: 1 }}
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
>
Back
</Button>
)}
{activeStep !== 3 && activeStep !== 4 && (
<Button
variant="contained"
onClick={handleNext}
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
>
{activeStep === steps.length - 3 ? 'Place Order' : 'Next'}
{(checkout.isLoading || isProcessing || isLoadingShipping) && (
<CircularProgress size={24} sx={{ ml: 1 }} />
)}
</Button>
)}
{activeStep === 4 && (
<Button
variant="contained"
component={RouterLink}
to="/"
>
Return to Home
</Button>
)}
</Box>
</Paper>
</Box>
);
};
export default CheckoutPage;