670 lines
No EOL
20 KiB
JavaScript
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; |