Files
frontend/src/pages/Order.jsx
2026-01-17 15:16:36 -05:00

799 lines
30 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { Form, Input, Button, DatePicker, Steps, Radio, Card, message, Alert, Descriptions, Breadcrumb, Typography, Modal, Row, Col, Select, Tag } from 'antd';
import { useNavigate, Link } from 'react-router-dom';
import { HomeOutlined, CreditCardOutlined, PlusOutlined, CheckCircleFilled } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../utils/api';
import { colors, spacing, fontSize, commonStyles } from '../theme';
const { Option } = Select;
const { Step } = Steps;
const { TextArea } = Input;
const { Title } = Typography;
// Card type colors
const cardTypeColors = {
'Visa': '#1a1f71',
'Mastercard': '#eb001b',
'American Express': '#006fcf',
'Discover': '#ff6000',
'default': colors.primary,
};
const Order = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const [pricing, setPricing] = useState({ price_per_gallon: 0, date: null });
const [totalPrice, setTotalPrice] = useState(0);
const [selectedGallons, setSelectedGallons] = useState(100);
const [selectedDate, setSelectedDate] = useState(dayjs().add(1, 'day'));
const [customerTown, setCustomerTown] = useState(null);
const [serviceType, setServiceType] = useState(null); // null, 'same_day', 'emergency'
const [primeSelected, setPrimeSelected] = useState(false);
const [formData, setFormData] = useState({
gallons_ordered: 100,
expected_delivery_date: dayjs().add(1, 'day'),
dispatcher_notes: '',
payment_type: 1, // credit
});
const [notesLength, setNotesLength] = useState(0);
// Saved cards state
const [savedCards, setSavedCards] = useState([]);
const [loadingCards, setLoadingCards] = useState(true);
const [selectedCardId, setSelectedCardId] = useState(null);
const [addCardModalVisible, setAddCardModalVisible] = useState(false);
const [addCardLoading, setAddCardLoading] = useState(false);
const [addCardForm] = Form.useForm();
// Generate year options for card expiration
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 11 }, (_, i) => currentYear + i);
// Fetch pricing on component mount and set up auto-refresh
useEffect(() => {
const fetchPricing = async () => {
try {
const response = await api.get('/info/pricing/current');
setPricing(response.data);
} catch (error) {
console.error('Failed to fetch pricing:', error);
}
};
fetchPricing();
// Auto-refresh pricing every 30 seconds
const interval = setInterval(fetchPricing, 30000);
return () => clearInterval(interval);
}, []);
// Fetch customer town on component mount
useEffect(() => {
const fetchCustomerData = async () => {
try {
const response = await api.get('/auth/me');
setCustomerTown(response.data.customer_town);
} catch (error) {
console.error('Failed to fetch customer data:', error);
}
};
fetchCustomerData();
}, []);
// Fetch saved cards on component mount
useEffect(() => {
const fetchSavedCards = async () => {
setLoadingCards(true);
try {
const response = await api.get('/payment/cards');
setSavedCards(response.data);
// Auto-select default card if exists
const defaultCard = response.data.find(card => card.main_card);
if (defaultCard) {
setSelectedCardId(defaultCard.id);
} else if (response.data.length > 0) {
setSelectedCardId(response.data[0].id);
}
} catch (error) {
console.error('Failed to fetch saved cards:', error);
} finally {
setLoadingCards(false);
}
};
fetchSavedCards();
}, []);
// Calculate total price when gallons, pricing, service type, or prime changes
useEffect(() => {
const basePrice = pricing.price_per_gallon * formData.gallons_ordered;
const surcharge = getSurcharge();
const calculatedTotal = basePrice + surcharge;
setTotalPrice(calculatedTotal);
}, [pricing.price_per_gallon, formData.gallons_ordered, serviceType, primeSelected]);
const next = () => {
setCurrentStep(currentStep + 1);
};
const prev = () => {
setCurrentStep(currentStep - 1);
};
const handleGallonsChange = (value) => {
setFormData({ ...formData, gallons_ordered: value });
setSelectedGallons(value);
};
const handleDateChange = (date) => {
setFormData({ ...formData, expected_delivery_date: date });
setSelectedDate(date);
};
const handleNotesChange = (e) => {
setFormData({ ...formData, dispatcher_notes: e.target.value });
setNotesLength(e.target.value.length);
};
const handlePaymentChange = (e) => {
setFormData({ ...formData, payment_type: e.target.value });
};
// Towns where 100 gallons is restricted
const restrictedTowns = ['Sutton', 'Millbury', 'Paxton', 'Grafton'];
// Check if same day service is available (6am-2pm)
const isSameDayAvailable = () => {
const now = dayjs();
const hour = now.hour();
return hour >= 6 && hour < 14; // 6am to 2pm (14:00)
};
// Get surcharge based on service type and prime
const getSurcharge = () => {
let surcharge = 0;
if (serviceType === 'same_day') surcharge += 45;
if (serviceType === 'emergency') surcharge += 250;
if (primeSelected) surcharge += serviceType === 'emergency' ? 50 : 25;
return surcharge;
};
// Handle same day service selection
const handleSameDaySelect = () => {
if (serviceType === 'same_day') {
// Deselect same day
setServiceType(null);
handleDateChange(dayjs().add(1, 'day')); // Reset to tomorrow
} else {
// Select same day
setServiceType('same_day');
handleDateChange(dayjs()); // Set to today
}
};
// Handle emergency service selection
const handleEmergencySelect = () => {
if (serviceType === 'emergency') {
// Deselect emergency
setServiceType(null);
handleDateChange(dayjs().add(1, 'day')); // Reset to tomorrow
handleGallonsChange(100); // Reset to default gallons
} else {
// Select emergency
setServiceType('emergency');
handleDateChange(dayjs()); // Set to today
handleGallonsChange(225); // Force 225 gallons
}
};
// Helper function to mask card number
const maskCardNumber = (cardNumber) => {
if (!cardNumber) return '';
const digits = cardNumber.replace(/\s/g, '');
const last4 = digits.slice(-4);
return `**** **** **** ${last4}`;
};
// Handle add new card
const handleAddCard = () => {
addCardForm.resetFields();
setAddCardModalVisible(true);
};
const handleAddCardSubmit = async () => {
try {
const values = await addCardForm.validateFields();
setAddCardLoading(true);
const response = await api.post('/payment/cards', {
card_number: values.card_number.replace(/\s/g, ''),
name_on_card: values.name_on_card,
expiration_month: values.expiration_month,
expiration_year: values.expiration_year,
security_number: values.security_number,
zip_code: values.zip_code,
});
message.success('Card added successfully');
setAddCardModalVisible(false);
addCardForm.resetFields();
// Refresh cards and select the new one
const cardsResponse = await api.get('/payment/cards');
setSavedCards(cardsResponse.data);
setSelectedCardId(response.data.id);
} catch (error) {
if (error.errorFields) {
return;
}
const errorMsg = error.response?.data?.detail || 'Failed to add card';
message.error(errorMsg);
console.error('Error adding card:', error);
} finally {
setAddCardLoading(false);
}
};
// Get selected card details
const getSelectedCard = () => {
return savedCards.find(card => card.id === selectedCardId);
};
const onFinish = async () => {
setLoading(true);
try {
if (formData.payment_type === 1) {
// Validate card selection
if (!selectedCardId) {
message.error('Please select a payment card or add a new one');
setLoading(false);
return;
}
// Process credit card payment using saved card
const paymentData = {
amount: totalPrice,
card_id: selectedCardId,
};
const paymentResponse = await api.post('/payment/process', paymentData);
if (paymentResponse.data.status === 'approved') {
message.success(`Payment approved! Transaction ID: ${paymentResponse.data.auth_net_transaction_id}`, 4.5);
// Now create the delivery
const deliveryData = {
gallons_ordered: formData.gallons_ordered,
expected_delivery_date: formData.expected_delivery_date.format('YYYY-MM-DD'),
dispatcher_notes: formData.dispatcher_notes,
payment_type: formData.payment_type,
prime: primeSelected ? 1 : 0,
same_day: serviceType === 'same_day' ? 1 : 0,
emergency: serviceType === 'emergency' ? 1 : 0,
pre_charge_amount: totalPrice,
};
const deliveryResponse = await api.post('/order/deliveries', deliveryData);
message.success('Delivery created successfully!', 4.5);
navigate('/');
// Reset form
setFormData({
gallons_ordered: 100,
expected_delivery_date: dayjs().add(1, 'day'),
dispatcher_notes: '',
payment_type: 1,
});
setServiceType(null);
setPrimeSelected(false);
setCurrentStep(0);
} else {
message.error('Payment was declined. Please check your card details.', 4.5);
}
} else {
// Cash payment - just create delivery
const data = {
gallons_ordered: formData.gallons_ordered,
expected_delivery_date: formData.expected_delivery_date.format('YYYY-MM-DD'),
dispatcher_notes: formData.dispatcher_notes,
payment_type: formData.payment_type,
prime: primeSelected ? 1 : 0,
same_day: serviceType === 'same_day' ? 1 : 0,
emergency: serviceType === 'emergency' ? 1 : 0,
pre_charge_amount: totalPrice,
};
const response = await api.post('/order/deliveries', data);
message.success('Delivery created successfully!', 4.5);
navigate('/');
// Reset form
setFormData({
gallons_ordered: 100,
expected_delivery_date: dayjs().add(1, 'day'),
dispatcher_notes: '',
payment_type: 1,
});
setServiceType(null);
setPrimeSelected(false);
setCurrentStep(0);
}
} catch (error) {
message.error('Failed to process order: ' + error.response?.data?.detail || error.message, 4.5);
} finally {
setLoading(false);
}
};
const steps = [
{
title: 'Order Details',
content: (
<div>
{/* Part 1: Gallons and Date */}
<div style={{ marginBottom: 24, padding: 16, backgroundColor: '#fff', borderRadius: 8, border: '1px solid #e8e8e8' }}>
<Form.Item label="Gallons Ordered" rules={[{ required: true, message: 'Please enter gallons ordered' }]}>
<div style={{ marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{[100, 125, 150, 175, 200, 225, 250].map(gallons => (
<Button
key={gallons}
type={selectedGallons === gallons ? "primary" : "default"}
onClick={() => handleGallonsChange(gallons)}
disabled={
(gallons === 100 && customerTown && restrictedTowns.some(town => town.toLowerCase() === customerTown.toLowerCase())) ||
(serviceType === 'emergency' && gallons !== 225)
}
size="small"
>
{gallons}
</Button>
))}
</div>
<Input
type="number"
value={formData.gallons_ordered}
onChange={(e) => handleGallonsChange(parseInt(e.target.value))}
min={100}
disabled={serviceType === 'emergency'}
/>
{formData.gallons_ordered < 100 && (
<div style={{ color: 'red' }}>200$ charge below 100 gallons</div>
)}
</Form.Item>
<Form.Item label="Delivery Date" rules={[{ required: true, message: 'Please select a delivery date' }]}>
<div style={{ marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 5 }}>
<Button
type={selectedDate.isSame(dayjs().add(1, 'day'), 'day') ? "primary" : "default"}
onClick={() => handleDateChange(dayjs().add(1, 'day'))}
disabled={serviceType !== null}
size="small"
>
Tomorrow
</Button>
<Button
type={selectedDate.isSame(dayjs().add(2, 'day'), 'day') ? "primary" : "default"}
onClick={() => handleDateChange(dayjs().add(2, 'day'))}
disabled={serviceType !== null}
size="small"
>
In 2 days
</Button>
<Button
type={selectedDate.isSame(dayjs().add(3, 'day'), 'day') ? "primary" : "default"}
onClick={() => handleDateChange(dayjs().add(3, 'day'))}
disabled={serviceType !== null}
size="small"
>
In 3 days
</Button>
</div>
<DatePicker
value={formData.expected_delivery_date}
onChange={handleDateChange}
disabledDate={(current) => current && current < dayjs().startOf('day')}
/>
</Form.Item>
</div>
{/* Part 2: Service Options */}
<div style={{ marginBottom: 24, padding: 16, backgroundColor: '#fff', borderRadius: 8, border: '1px solid #e8e8e8' }}>
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Service Options</div>
<div style={{ marginBottom: 10 }}>
{isSameDayAvailable() && (
<Button
type={serviceType === 'same_day' ? "primary" : "default"}
onClick={handleSameDaySelect}
style={{ marginRight: 10 }}
>
Same Day (+$45)
</Button>
)}
{!isSameDayAvailable() && (
<div>
<div style={{ marginBottom: 8, fontSize: '14px', color: '#666' }}>
After hours immediate delivery
</div>
<Button
type={serviceType === 'emergency' ? "primary" : "default"}
onClick={handleEmergencySelect}
>
Emergency (+$250)
</Button>
</div>
)}
</div>
{serviceType === 'same_day' && (
<div style={{ color: 'blue', fontSize: '12px' }}>
Same day service selected - delivery today
</div>
)}
{serviceType === 'emergency' && (
<div style={{ color: 'blue', fontSize: '12px' }}>
Emergency service selected - 225 gallons only
</div>
)}
<div style={{ marginTop: 16 }}>
<div style={{ marginBottom: 8, fontSize: '14px', color: '#666' }}>
Ran out of oil? You will need a prime to restart your system
</div>
<Button
type={primeSelected ? "primary" : "default"}
onClick={() => setPrimeSelected(!primeSelected)}
>
Prime (+${serviceType === 'emergency' ? 50 : 25})
</Button>
</div>
</div>
{/* Part 3: Notes */}
<div style={{ padding: 16, backgroundColor: '#fff', borderRadius: 8, border: '1px solid #e8e8e8' }}>
<Form.Item label="Notes for office" layout="vertical" style={{ width: '100%' }}>
<TextArea
value={formData.dispatcher_notes}
onChange={handleNotesChange}
placeholder="Notes for the dispatcher"
style={{ width: '100%', borderColor: notesLength > 250 ? 'red' : undefined }}
/>
</Form.Item>
<div style={{ color: notesLength > 250 ? 'red' : 'green', fontSize: '12px', textAlign: 'right' }}>
{notesLength}/250
</div>
</div>
</div>
),
},
{
title: 'Payment',
content: (
<div>
<Form.Item label="Payment Method">
<Radio.Group onChange={handlePaymentChange} value={formData.payment_type}>
<Radio value={0}>Cash</Radio>
<Radio value={1}>Credit</Radio>
</Radio.Group>
</Form.Item>
{formData.payment_type === 1 && (
<div>
{loadingCards ? (
<Card style={{ textAlign: 'center', padding: spacing.lg }}>
<div>Loading saved cards...</div>
</Card>
) : savedCards.length === 0 ? (
<Card style={{ textAlign: 'center', padding: spacing.xl }}>
<CreditCardOutlined style={{ fontSize: 48, color: colors.gray[300], marginBottom: spacing.md }} />
<div style={{ marginBottom: spacing.md, color: colors.text.secondary }}>
No saved payment methods
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddCard}>
Add New Card
</Button>
</Card>
) : (
<div>
<div style={{ marginBottom: spacing.sm, fontWeight: 500 }}>Select a payment card:</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: spacing.sm, marginBottom: spacing.md }}>
{savedCards.map((card) => {
const cardColor = cardTypeColors[card.type_of_card] || cardTypeColors.default;
const isSelected = selectedCardId === card.id;
return (
<Card
key={card.id}
size="small"
style={{
cursor: 'pointer',
border: isSelected ? `2px solid ${colors.primary}` : '1px solid #d9d9d9',
backgroundColor: isSelected ? '#f0f5ff' : '#fff',
}}
onClick={() => setSelectedCardId(card.id)}
styles={{ body: { padding: spacing.sm } }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.sm }}>
<CreditCardOutlined style={{ fontSize: 24, color: cardColor }} />
<div>
<div style={{ fontWeight: 500 }}>
{card.type_of_card || 'Card'} {card.last_four_digits}
{card.main_card && (
<Tag color="gold" style={{ marginLeft: spacing.xs }}>Default</Tag>
)}
</div>
<div style={{ fontSize: fontSize.xs, color: colors.text.secondary }}>
{card.name_on_card} | Expires {card.expiration_month}/{card.expiration_year?.slice(-2)}
</div>
</div>
</div>
{isSelected && (
<CheckCircleFilled style={{ fontSize: 20, color: colors.primary }} />
)}
</div>
</Card>
);
})}
</div>
<Button icon={<PlusOutlined />} onClick={handleAddCard}>
Add New Card
</Button>
</div>
)}
</div>
)}
{formData.payment_type === 0 && (
<Card style={{ textAlign: 'center', padding: spacing.md }}>
<div style={{ color: colors.text.secondary }}>
Cash payment - pay the driver upon delivery
</div>
</Card>
)}
</div>
),
},
{
title: 'Review',
content: (
<div>
<Descriptions title="Order Summary" bordered column={1}>
<Descriptions.Item label="Gallons">{formData.gallons_ordered}</Descriptions.Item>
<Descriptions.Item label="Price per Gallon">${pricing.price_per_gallon.toFixed(2)}</Descriptions.Item>
{serviceType && (
<Descriptions.Item label="Service Surcharge">${serviceType === 'same_day' ? 45 : 250} ({serviceType === 'same_day' ? 'Same Day' : 'Emergency'})</Descriptions.Item>
)}
{primeSelected && (
<Descriptions.Item label="Prime Surcharge">${serviceType === 'emergency' ? 50 : 25}</Descriptions.Item>
)}
<Descriptions.Item label="Total Amount">${totalPrice.toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="Delivery Date">{formData.expected_delivery_date.format('YYYY-MM-DD')}</Descriptions.Item>
<Descriptions.Item label="Notes">{formData.dispatcher_notes || 'None'}</Descriptions.Item>
<Descriptions.Item label="Payment Method">{formData.payment_type === 0 ? 'Cash' : 'Credit Card'}</Descriptions.Item>
</Descriptions>
{formData.payment_type === 1 && getSelectedCard() && (
<Card
style={{
marginTop: 16,
width: 300,
background: `linear-gradient(135deg, ${cardTypeColors[getSelectedCard().type_of_card] || cardTypeColors.default} 0%, ${cardTypeColors[getSelectedCard().type_of_card] || cardTypeColors.default}dd 100%)`,
color: colors.white,
borderRadius: 12,
}}
>
<div style={{ fontSize: fontSize.sm, marginBottom: spacing.xs }}>{getSelectedCard().type_of_card || 'Credit Card'}</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', fontFamily: 'monospace', letterSpacing: 2 }}>
{getSelectedCard().last_four_digits}
</div>
<div style={{ marginTop: spacing.sm }}>{getSelectedCard().name_on_card}</div>
<div style={{ marginTop: 4, fontSize: '14px' }}>
{getSelectedCard().expiration_month}/{getSelectedCard().expiration_year?.slice(-2)}
</div>
</Card>
)}
</div>
),
},
];
return (
<div style={{ padding: '4px 0' }}>
<Breadcrumb style={{ marginBottom: 16 }}>
<Breadcrumb.Item>
<Link to="/">
<HomeOutlined /> Home
</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>Order Oil</Breadcrumb.Item>
</Breadcrumb>
<Title level={1} style={{ marginBottom: 16 }}>Order Oil</Title>
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
{/* Dynamic Pricing Display */}
<Alert
message={`Current Oil Price: $${pricing.price_per_gallon.toFixed(2)} per gallon`}
description={`Total for ${formData.gallons_ordered} gallons: $${totalPrice.toFixed(2)}`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Steps current={currentStep}>
{steps.map((item) => (
<Step key={item.title} title={item.title} />
))}
</Steps>
<div style={{ marginTop: 8 }}>
<Card style={commonStyles.cardGray} bodyStyle={{ padding: spacing.sm }}>
{steps[currentStep].content}
<div style={{ marginTop: 24 }}>
{currentStep < steps.length - 1 && (
<Button type="primary" onClick={next} disabled={notesLength > 250}>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button type="primary" onClick={onFinish} loading={loading} disabled={notesLength > 250}>
Submit
</Button>
)}
{currentStep > 0 && (
<Button style={{ marginLeft: 8 }} onClick={prev}>
Previous
</Button>
)}
</div>
</Card>
</div>
</div>
{/* Add Card Modal */}
<Modal
title="Add New Card"
open={addCardModalVisible}
onOk={handleAddCardSubmit}
onCancel={() => {
setAddCardModalVisible(false);
addCardForm.resetFields();
}}
confirmLoading={addCardLoading}
okText="Add Card"
cancelText="Cancel"
destroyOnClose
>
<Form
form={addCardForm}
layout="vertical"
style={{ marginTop: spacing.md }}
>
<Form.Item
name="name_on_card"
label="Name on Card"
rules={[
{ required: true, message: 'Please enter the name on card' },
{ min: 2, message: 'Name must be at least 2 characters' },
]}
>
<Input placeholder="John Doe" autoComplete="cc-name" />
</Form.Item>
<Form.Item
name="card_number"
label="Card Number"
rules={[
{ required: true, message: 'Please enter your card number' },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const digits = value.replace(/\s/g, '');
if (!/^\d+$/.test(digits)) {
return Promise.reject('Card number must contain only digits');
}
if (digits.length < 13 || digits.length > 19) {
return Promise.reject('Card number must be between 13 and 19 digits');
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder="1234 5678 9012 3456"
maxLength={19}
autoComplete="cc-number"
onChange={(e) => {
// Auto-format with spaces every 4 digits
const value = e.target.value.replace(/\s/g, '').replace(/\D/g, '');
const formatted = value.match(/.{1,4}/g)?.join(' ') || value;
addCardForm.setFieldValue('card_number', formatted);
}}
/>
</Form.Item>
<Row gutter={spacing.sm}>
<Col span={8}>
<Form.Item
name="expiration_month"
label="Month"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="MM" autoComplete="cc-exp-month">
{Array.from({ length: 12 }, (_, i) => {
const month = String(i + 1).padStart(2, '0');
return (
<Option key={month} value={month}>
{month}
</Option>
);
})}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="expiration_year"
label="Year"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="YYYY" autoComplete="cc-exp-year">
{yearOptions.map((year) => (
<Option key={year} value={String(year)}>
{year}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="security_number"
label="CVV"
rules={[
{ required: true, message: 'Required' },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
if (!/^\d{3,4}$/.test(value)) {
return Promise.reject('3-4 digits');
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder="123"
maxLength={4}
autoComplete="cc-csc"
style={{ textAlign: 'center' }}
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="zip_code"
label="Billing Zip Code"
rules={[
{ required: true, message: 'Please enter your billing zip code' },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
if (!/^\d{5}(-\d{4})?$/.test(value)) {
return Promise.reject('Enter a valid US zip code');
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="12345" maxLength={10} autoComplete="billing postal-code" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default Order;