add all frontend files

This commit is contained in:
2026-01-17 15:16:36 -05:00
parent ff16ae7858
commit e40287e4aa
25704 changed files with 1935289 additions and 0 deletions

107
src/App.css Normal file
View File

@@ -0,0 +1,107 @@
.layout {
min-height: 100vh;
max-width: 100vw;
overflow-x: hidden;
}
.logo {
color: var(--color-white);
font-size: var(--font-size-lg);
font-weight: bold;
}
.site-layout-content {
background: var(--color-white);
padding: var(--spacing-lg);
min-height: 280px;
}
/* Mobile responsive styles */
@media (max-width: 768px) {
.ant-layout-content {
padding: 0 var(--spacing-md) !important;
}
.site-layout-content {
padding: var(--spacing-md);
}
}
@media (max-width: 576px) {
.ant-layout-content {
padding: 0 var(--spacing-xs) !important;
}
.site-layout-content {
padding: var(--spacing-sm);
}
}
/* Prevent horizontal scrolling on mobile */
@media (max-width: 768px) {
body, html {
overflow-x: hidden;
max-width: 100vw;
}
.ant-layout {
min-width: 0;
max-width: 100vw;
}
.ant-card {
min-width: 0;
}
.ant-table {
min-width: 0;
}
/* Ensure buttons don't overflow */
.ant-btn {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Fix table scrolling on mobile */
.ant-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.ant-table-tbody > tr > td {
word-break: break-word;
}
}
/* Oil-themed color classes */
.btn-blue-oil {
background-color: var(--color-primary);
}
.bg-blue-oil {
background-color: var(--color-primary);
}
.text-blue-oil {
color: var(--color-primary);
}
.bg-orange-oil {
background-color: var(--color-secondary);
}
.bg-gray-oil {
background-color: var(--color-gray-700);
}
.text-orange-oil {
color: var(--color-secondary);
}
/* Delivery status styles */
.waiting-delivery {
background-color: var(--color-success-light);
}
.out-for-delivery {
border: 2px solid var(--color-gold);
box-shadow: var(--shadow-glow-gold);
}

155
src/App.jsx Normal file
View File

@@ -0,0 +1,155 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Layout, Button, message, Grid, Skeleton, Dropdown } from 'antd';
import { DownOutlined, KeyOutlined, LogoutOutlined, CreditCardOutlined } from '@ant-design/icons';
import Index from './pages/Index';
import Tank from './pages/Tank';
import EditCustomer from './pages/EditCustomer';
import Order from './pages/Order';
import Login from './pages/auth/Login';
import Register from './pages/auth/Register';
import New from './pages/auth/new';
import ForgotPassword from './pages/auth/ForgotPassword';
import ResetPassword from './pages/auth/ResetPassword';
import ChangePassword from './pages/auth/ChangePassword';
import Payments from './pages/Payments';
import logo from './assets/images/1.png';
import './App.css';
import api from './utils/api';
import { colors, spacing, fontSize, buttonStyles } from './theme';
const { Header, Content, Footer } = Layout;
const { useBreakpoint } = Grid;
function AppContent() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const location = useLocation();
const screens = useBreakpoint();
useEffect(() => {
checkAuthStatus();
}, [location]); // Re-check auth when location changes
const checkAuthStatus = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
// Try to get current user info from backend
const response = await api.get('/auth/me');
setUser(response.data);
} catch (error) {
// Token invalid, remove it
localStorage.removeItem('token');
setUser(null);
}
} else {
setUser(null);
}
setLoading(false);
};
const handleLogout = () => {
localStorage.removeItem('token');
setUser(null);
message.success('Logged out successfully');
};
if (loading) {
return (
<Layout className="layout">
<Header className="bg-blue-oil">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: `0 ${spacing.md}px` }}>
<Skeleton.Input active style={{ width: 120, height: 32 }} />
<Skeleton.Input active style={{ width: 80, height: 24 }} />
</div>
</Header>
<Content style={{ padding: `0 ${spacing.content.desktop}px` }}>
<div className="site-layout-content">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
</Content>
</Layout>
);
}
return (
<Layout className="layout">
<Header className="bg-blue-oil">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', height: '100%', padding: `0 ${spacing.md}px` }}>
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}><img src={logo} alt="Oil Customer Gateway" className="logo" style={{ height: 32, width: 'auto' }} /></Link>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.xs }}>
{user ? (
<Dropdown
menu={{
items: [
{
key: 'payments',
icon: <CreditCardOutlined />,
label: <Link to="/payments" style={{ color: 'inherit' }}>Payments</Link>,
},
{
key: 'change-password',
icon: <KeyOutlined />,
label: <Link to="/change-password" style={{ color: 'inherit' }}>Change Password</Link>,
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: handleLogout,
},
],
}}
trigger={['click']}
>
<Button type="text" size="small" style={buttonStyles.headerText}>
<span style={{ display: screens.sm ? 'inline' : 'none' }}>Welcome, {user.customer_first_name}</span>
<span style={{ display: screens.sm ? 'none' : 'inline' }}>{user.customer_first_name}</span>
<DownOutlined style={{ marginLeft: 4, fontSize: 10 }} />
</Button>
</Dropdown>
) : (
<>
<Link to="/login"><Button type="text" size="small" style={buttonStyles.headerText}>Login</Button></Link>
<Link to="/register"><Button type="text" size="small" style={{ ...buttonStyles.headerText, display: screens.sm ? 'inline' : 'none' }}>Register</Button></Link>
<Link to="/new"><Button type="text" size="small" style={{ ...buttonStyles.headerText, display: screens.sm ? 'inline' : 'none' }}>New</Button></Link>
</>
)}
</div>
</div>
</Header>
<Content style={{ padding: screens.md ? `0 ${spacing.content.desktop}px` : `0 ${spacing.md}px` }}>
<div className="site-layout-content">
<Routes>
<Route path="/" element={<Index />} />
<Route path="/tank" element={<Tank />} />
<Route path="/edit-customer" element={<EditCustomer />} />
<Route path="/order" element={<Order />} />
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/payments" element={<Payments />} />
<Route path="/new" element={<New />} />
<Route path="/register" element={<Register />} />
</Routes>
</div>
</Content>
<Footer style={{ textAlign: 'center' }}>Rocket Services LLC ©{new Date().getFullYear()}</Footer>
</Layout>
);
}
function App() {
return (
<Router>
<AppContent />
</Router>
);
}
export default App;

BIN
src/assets/images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

13
src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import { injectCSSVariables } from './theme'
// Inject CSS custom properties for design tokens
injectCSSVariables()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

318
src/pages/EditCustomer.jsx Normal file
View File

@@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message, Divider, Select, Breadcrumb, Skeleton } from 'antd';
import { UserOutlined, PhoneOutlined, HomeOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import api from '../utils/api';
import { colors, spacing, commonStyles } from '../theme';
const { TextArea } = Input;
function EditCustomer() {
const navigate = useNavigate();
const [wordCount, setWordCount] = useState(0);
const [loading, setLoading] = useState(true);
const [form] = Form.useForm();
useEffect(() => {
fetchCustomerData();
}, []);
const fetchCustomerData = async () => {
try {
const response = await api.get('/auth/me');
const userData = response.data;
// Fetch house description if exists
let houseDescription = '';
try {
// This would need to be implemented in the API if we want to fetch description
// For now, we'll leave it empty and let user fill it
} catch (error) {
// Description might not exist, that's ok
}
// Pre-populate form with user data
form.setFieldsValue({
customer_first_name: userData.customer_first_name || '',
customer_last_name: userData.customer_last_name || '',
customer_phone_number: userData.customer_phone_number || '',
customer_address: userData.customer_address || '',
customer_apt: userData.customer_apt || '',
customer_town: userData.customer_town || '',
customer_zip: userData.customer_zip || '',
customer_home_type: userData.customer_home_type || 0,
house_description: userData.house_description || ''
});
setLoading(false);
} catch (error) {
message.error('Failed to load customer data');
console.error('Error fetching customer data:', error);
setLoading(false);
}
};
const handleDescriptionChange = (e) => {
const value = e.target.value;
const words = value.trim() === '' ? 0 : value.trim().split(/\s+/).length;
setWordCount(words);
};
const formatPhoneNumber = (value) => {
// Remove all non-digit characters
const phoneNumber = value.replace(/\D/g, '');
// Limit to 10 digits
const limitedPhoneNumber = phoneNumber.slice(0, 10);
// Format as (XXX) XXX-XXXX
if (limitedPhoneNumber.length >= 6) {
return `(${limitedPhoneNumber.slice(0, 3)}) ${limitedPhoneNumber.slice(3, 6)}-${limitedPhoneNumber.slice(6)}`;
} else if (limitedPhoneNumber.length >= 3) {
return `(${limitedPhoneNumber.slice(0, 3)}) ${limitedPhoneNumber.slice(3)}`;
} else if (limitedPhoneNumber.length > 0) {
return `(${limitedPhoneNumber}`;
}
return limitedPhoneNumber;
};
const handlePhoneNumberChange = (e) => {
const formattedValue = formatPhoneNumber(e.target.value);
form.setFieldsValue({ customer_phone_number: formattedValue });
};
const handleNameChange = (fieldName) => (e) => {
const value = e.target.value;
// Capitalize first letter of each word (title case)
const formattedValue = value.replace(/\b\w/g, (char) => char.toUpperCase());
form.setFieldsValue({ [fieldName]: formattedValue });
};
const handleTownChange = (e) => {
const value = e.target.value;
// Capitalize first letter of each word for town names
const formattedValue = value.replace(/\b\w/g, (char) => char.toUpperCase());
form.setFieldsValue({ customer_town: formattedValue });
};
const onFinish = async (values) => {
try {
setLoading(true);
// Format phone number for backend (remove formatting)
const formattedData = {
...values,
customer_phone_number: values.customer_phone_number.replace(/\D/g, ''),
customer_state: 0 // MA is always 0
};
await api.put('/auth/update-customer', formattedData);
message.success('Customer information updated successfully!');
navigate('/');
} catch (error) {
if (error.response) {
message.error(`Update failed: ${error.response.data?.detail || 'Unknown error'}`);
} else {
message.error('Update failed: Network error');
}
console.error('Error updating customer:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Card title="Edit Customer Information" style={{ maxWidth: 600, margin: '0 auto', ...commonStyles.cardGray }}>
<Skeleton active paragraph={{ rows: 10 }} />
</Card>
);
}
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }}>
<Breadcrumb.Item>
<Link to="/">
<HomeOutlined /> Home
</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>Edit Customer</Breadcrumb.Item>
</Breadcrumb>
<Card title="Edit Customer Information" style={{ maxWidth: 600, margin: '0 auto', ...commonStyles.cardGray }}>
<Form
name="edit-customer"
form={form}
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Divider>General Info</Divider>
<Form.Item
name="customer_first_name"
label="First Name"
hasFeedback
rules={[
{ required: true, message: 'Please input your first name!' },
{ min: 2, message: 'First name must be at least 2 characters' },
{ max: 50, message: 'First name must be no more than 50 characters' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="First Name"
onChange={handleNameChange('customer_first_name')}
/>
</Form.Item>
<Form.Item
name="customer_last_name"
label="Last Name"
hasFeedback
rules={[
{ required: true, message: 'Please input your last name!' },
{ min: 2, message: 'Last name must be at least 2 characters' },
{ max: 50, message: 'Last name must be no more than 50 characters' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="Last Name"
onChange={handleNameChange('customer_last_name')}
/>
</Form.Item>
<Form.Item
name="customer_phone_number"
label="Phone Number"
hasFeedback
rules={[
{ required: true, message: 'Please input your phone number!' },
{
validator: (_, value) => {
const digitsOnly = value ? value.replace(/\D/g, '') : '';
if (digitsOnly.length !== 10) {
return Promise.reject('Phone number must be 10 digits');
}
return Promise.resolve();
}
}
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="Phone Number (10 digits)"
onChange={handlePhoneNumberChange}
/>
</Form.Item>
<Form.Item
name="customer_address"
label="Street Address"
hasFeedback
rules={[
{ required: true, message: 'Please input your street address!' },
{ min: 3, message: 'Street address must be at least 3 characters' },
{ max: 100, message: 'Street address must be no more than 100 characters' }
]}
>
<Input prefix={<HomeOutlined />} placeholder="Street Address" />
</Form.Item>
<Form.Item
name="customer_apt"
label="Apt, suite, unit..."
>
<Input placeholder="Apt, suite, unit..." />
</Form.Item>
<Form.Item
name="customer_town"
label="Town"
hasFeedback
rules={[
{ required: true, message: 'Please input your town!' },
{ min: 3, message: 'Town must be at least 3 characters' },
{ max: 25, message: 'Town must be no more than 25 characters' }
]}
>
<Input
prefix={<EnvironmentOutlined />}
placeholder="Town"
onChange={handleTownChange}
/>
</Form.Item>
<Form.Item label="State">
<Input value="MA" disabled style={{ color: '#000', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item
name="customer_zip"
label="Zip Code"
hasFeedback
rules={[
{ required: true, message: 'Please input your zip code!' },
{ len: 5, message: 'Zip code must be exactly 5 characters' }
]}
>
<Input placeholder="Zip Code" />
</Form.Item>
<Form.Item
name="customer_home_type"
label="Home Type"
hasFeedback
rules={[{ required: true, message: 'Please select your home type!' }]}
>
<Select placeholder="Select home type">
<Select.Option value={0}>Residential</Select.Option>
<Select.Option value={1}>apartment</Select.Option>
<Select.Option value={2}>condo</Select.Option>
<Select.Option value={3}>commercial</Select.Option>
<Select.Option value={4}>business</Select.Option>
<Select.Option value={5}>construction</Select.Option>
<Select.Option value={6}>container</Select.Option>
</Select>
</Form.Item>
<Divider>Delivery Details</Divider>
<Form.Item
name="house_description"
label="Customer House Description (20 words or less, optional)"
hasFeedback
validateStatus={wordCount > 20 ? 'error' : wordCount > 0 ? 'success' : ''}
help={<span style={{ color: wordCount > 20 ? 'red' : 'green' }}>{wordCount}/20 words{wordCount > 20 ? ' (exceeded limit)' : ''}</span>}
rules={[
{
validator: (_, value) => {
if (value && value.split(' ').length > 20) {
return Promise.reject('Description must be 20 words or less');
}
return Promise.resolve();
}
}
]}
>
<TextArea placeholder="House Description" rows={4} onChange={handleDescriptionChange} />
</Form.Item>
<Form.Item style={{ marginTop: 24 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
Update Information
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
export default EditCustomer;

383
src/pages/Index.jsx Normal file
View File

@@ -0,0 +1,383 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Typography, Button, Space, message, Table, Image, Grid, Skeleton } from 'antd';
import { useNavigate, Link } from 'react-router-dom';
import api, { API_BASE_URL } from '../utils/api';
import { colors, spacing, fontSize, buttonStyles, commonStyles } from '../theme';
const { Title } = Typography;
const { useBreakpoint } = Grid;
const deliveriesColumns = [
{
title: 'Date',
dataIndex: 'delivery_date',
key: 'delivery_date',
render: (date) => date ? new Date(date).toLocaleDateString() : 'N/A'
},
{
title: 'Gallons',
dataIndex: 'gallons',
key: 'gallons',
render: (gallons) => gallons ? `${gallons} gal` : 'N/A'
},
{
title: 'Price',
dataIndex: 'customer_price',
key: 'customer_price',
render: (price) => price ? `$${price.toFixed(2)}` : 'N/A'
},
{
title: 'Status',
dataIndex: 'delivery_status',
key: 'delivery_status',
render: (status) => {
if (status === 'Pending') return 'pending cc charge';
if (status === 'Finalized') return 'Delivered/Charged';
if (status === 'Waiting') return 'waiting for delivery';
return status;
}
},
{
title: 'Payment Type',
dataIndex: 'payment_type',
key: 'payment_type'
}
];
function Index() {
const navigate = useNavigate();
const isLoggedIn = !!localStorage.getItem('token');
const [userData, setUserData] = useState(null);
const [deliveries, setDeliveries] = useState([]);
const [tankImages, setTankImages] = useState([]);
const [pricing, setPricing] = useState(null);
const [loadingUser, setLoadingUser] = useState(true);
const [loadingDeliveries, setLoadingDeliveries] = useState(true);
const [loadingTankImages, setLoadingTankImages] = useState(true);
const [loadingPricing, setLoadingPricing] = useState(true);
const screens = useBreakpoint();
const fetchTankImages = async (accountNumber) => {
setLoadingTankImages(true);
try {
const response = await api.get(`/auth/tank-images/${accountNumber}`);
// Get the latest image set (first one after sorting)
const imageGroups = response.data.image_sets.map(set => ({
date: set.date,
images: set.images.map(img => `${API_BASE_URL}${img}`)
}));
// Sort by date descending (newest first)
imageGroups.sort((a, b) => {
const parseDate = (dateStr) => {
if (dateStr.includes('_')) {
const [datePart, timePart] = dateStr.split('_');
const [year, month, day] = datePart.split('-');
const [hour, minute, second] = timePart.split('-');
return new Date(year, month - 1, day, hour, minute, second);
} else {
return new Date(dateStr);
}
};
return parseDate(b.date) - parseDate(a.date);
});
setTankImages(imageGroups[0]?.images || []);
} catch (error) {
console.error('Failed to load tank images:', error);
// No fallback to example images on main page
} finally {
setLoadingTankImages(false);
}
};
useEffect(() => {
if (isLoggedIn) {
const fetchUserData = async () => {
setLoadingUser(true);
try {
const response = await api.get('/auth/me');
setUserData(response.data);
await fetchTankImages(response.data.account_number);
} catch (error) {
message.error('Failed to load user data');
console.error('Error fetching user data:', error);
} finally {
setLoadingUser(false);
}
};
const fetchDeliveries = async () => {
setLoadingDeliveries(true);
try {
const response = await api.get('/info/deliveries');
setDeliveries(response.data);
} catch (error) {
message.error('Failed to load deliveries');
console.error('Error fetching deliveries:', error);
} finally {
setLoadingDeliveries(false);
}
};
const fetchPricing = async () => {
setLoadingPricing(true);
try {
const response = await api.get('/info/pricing/current');
setPricing(response.data);
} catch (error) {
console.error('Failed to load pricing:', error);
} finally {
setLoadingPricing(false);
}
};
fetchUserData();
fetchDeliveries();
fetchPricing();
} else {
// Reset loading states when not logged in
setLoadingUser(false);
setLoadingDeliveries(false);
setLoadingTankImages(false);
setLoadingPricing(false);
}
}, [isLoggedIn]);
if (!isLoggedIn) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', padding: '0 16px' }}>
<Title level={2} style={{ textAlign: 'center' }}>Welcome to Oil Customer Gateway</Title>
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: '400px' }}>
<div style={{ display: 'flex', flexDirection: screens.xs ? 'column' : 'row', alignItems: screens.xs ? 'flex-start' : 'center', gap: screens.xs ? '8px' : '16px' }}>
<Button type="primary" size={screens.xs ? 'default' : 'large'} block={screens.xs} onClick={() => navigate('/login')}>Login</Button>
<span style={{ fontSize: screens.xs ? '14px' : '16px' }}>For customers with an existing online account</span>
</div>
<div style={{ display: 'flex', flexDirection: screens.xs ? 'column' : 'row', alignItems: screens.xs ? 'flex-start' : 'center', gap: screens.xs ? '8px' : '16px' }}>
<Button type="primary" size={screens.xs ? 'default' : 'large'} block={screens.xs} onClick={() => navigate('/register')}>Register</Button>
<span style={{ fontSize: screens.xs ? '14px' : '16px' }}>For existing customers who haven't signed up for online access</span>
</div>
<div style={{ display: 'flex', flexDirection: screens.xs ? 'column' : 'row', alignItems: screens.xs ? 'flex-start' : 'center', gap: screens.xs ? '8px' : '16px' }}>
<Button type="primary" size={screens.xs ? 'default' : 'large'} block={screens.xs} onClick={() => navigate('/new')}>New Customer</Button>
<span style={{ fontSize: screens.xs ? '14px' : '16px' }}>For new customers to Auburn Oil</span>
</div>
</Space>
</div>
);
}
return (
<div style={{ maxWidth: '100%', overflow: 'hidden' }}>
{/* Row 1: Customer Information and Order Oil */}
<Row gutter={screens.xs ? 8 : 16} style={{ marginBottom: 16 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Card
title="Customer Information"
bordered={true}
style={commonStyles.cardGray}
extra={!loadingUser && userData && <Link to="/edit-customer" style={commonStyles.link}>Edit</Link>}
>
{loadingUser ? (
<>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<Skeleton.Input active style={{ width: 120, height: screens.xs ? 24 : 32 }} />
</div>
<Skeleton active paragraph={{ rows: 4 }} title={false} />
</>
) : userData ? (
<>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<Title level={1} style={{ margin: 0, fontSize: screens.xs ? '24px' : '32px' }}>{userData.account_number}</Title>
</div>
<p style={{ marginBottom: 4, fontSize: screens.xs ? '14px' : '16px', wordBreak: 'break-word' }}><strong>Name:</strong> {userData.customer_first_name} {userData.customer_last_name}</p>
<p style={{ marginBottom: 4, fontSize: screens.xs ? '14px' : '16px', wordBreak: 'break-word' }}><strong>Address:</strong> {userData.customer_address}, {userData.customer_town}, {userData.customer_state} {userData.customer_zip}</p>
<p style={{ marginBottom: 4, fontSize: screens.xs ? '14px' : '16px', wordBreak: 'break-word' }}><strong>Phone:</strong> {userData.customer_phone_number}</p>
<p style={{ marginBottom: 0, fontSize: screens.xs ? '14px' : '16px', wordBreak: 'break-word' }}><strong>Email:</strong> {userData.email}</p>
</>
) : (
<p style={{ fontSize: screens.xs ? '14px' : '16px' }}>Failed to load customer information.</p>
)}
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Card
title="Order Oil"
bordered={true}
style={{ height: screens.xs ? 'auto' : '100%', minHeight: screens.xs ? 120 : 'auto' }}
styles={{ body: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: screens.xs ? 80 : 120, gap: '16px' } }}
>
{loadingPricing ? (
<Skeleton.Input active style={{ width: 100 }} />
) : pricing ? (
<Title level={1} style={{ margin: 0, fontSize: screens.xs ? '24px' : '32px', color: 'black' }}>Todays Price: ${pricing.price_per_gallon.toFixed(2)}</Title>
) : (
<Title level={1} style={{ margin: 0, fontSize: screens.xs ? '24px' : '32px', color: 'black' }}>Todays Price: N/A</Title>
)}
<Button
type="primary"
size={screens.xs ? 'default' : 'large'}
style={screens.xs ? buttonStyles.ctaMobile : buttonStyles.ctaLarge}
onClick={() => navigate('/order')}
>
Place Order
</Button>
</Card>
</Col>
</Row>
{/* Row 2: Tank Images and Emergency Delivery Info */}
<Row gutter={screens.xs ? 8 : 16} style={{ marginBottom: 16 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Card
title="Upload / Edit Tank Images"
bordered={true}
style={!loadingTankImages && tankImages.length === 0 ? { borderColor: 'red' } : {}}
>
{loadingTankImages ? (
<>
<Row gutter={8} justify="center" style={{ marginBottom: 16 }}>
{[1, 2, 3].map((i) => (
<Col key={i}>
<Skeleton.Image active style={{ width: screens.xs ? 120 : 100, height: screens.xs ? 120 : 100 }} />
</Col>
))}
</Row>
<Skeleton.Input active size="small" style={{ width: 140 }} />
</>
) : tankImages.length > 0 ? (
<>
<Row gutter={8} justify="center" style={{ marginBottom: 16 }}>
{tankImages.map((image, index) => (
<Col key={index}>
<Image width={screens.xs ? 120 : 100} src={image} alt={`Tank ${index + 1}`} />
</Col>
))}
</Row>
<Link to="/tank" style={{ color: colors.text.link, fontSize: screens.xs ? '14px' : '16px' }}>Upload / Edit images</Link>
</>
) : (
<>
<Typography.Text type="warning" style={{ fontSize: screens.xs ? '14px' : '16px' }}>Please upload tank images</Typography.Text>
<br />
<br />
<Link to="/tank" style={{ color: colors.text.link, fontSize: screens.xs ? '14px' : '16px' }}>Upload / Edit images</Link>
</>
)}
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Card title="Emergency Deliveries" bordered={true} style={commonStyles.cardWarning}>
<Typography.Text style={{ fontSize: screens.xs ? '14px' : '16px', lineHeight: '1.6' }}>
We do deliveries after hours, nights, weekends, holidays as emergency. Snow may effect delivery. We reserve the right to cancel deliveries if tank is not safe to deliver.
</Typography.Text>
</Card>
</Col>
</Row>
{/* Row 3: My Deliveries (Full Width) */}
<Row gutter={screens.xs ? 8 : 16}>
<Col xs={24}>
<Card title="My Deliveries" bordered={false}>
{loadingDeliveries ? (
screens.xs && !screens.sm ? (
// Mobile: Skeleton Cards
<div style={{ maxHeight: '400px', overflowY: 'auto', overflowX: 'hidden' }}>
{[1, 2, 3].map((i) => (
<Card key={i} size="small" style={{ marginBottom: 8 }} bodyStyle={{ padding: '12px' }}>
<Row gutter={8} align="middle">
<Col xs={12}><Skeleton.Input active size="small" style={{ width: 80 }} /></Col>
<Col xs={12}><Skeleton.Input active size="small" style={{ width: 60 }} /></Col>
<Col xs={12}><Skeleton.Input active size="small" style={{ width: 70 }} /></Col>
<Col xs={12}><Skeleton.Input active size="small" style={{ width: 90 }} /></Col>
</Row>
</Card>
))}
</div>
) : (
// Desktop/Tablet: Skeleton Table
<div style={{ overflowX: 'auto' }}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
)
) : deliveries.length > 0 ? (
screens.xs && !screens.sm ? (
// Mobile: Cards
<div style={{ maxHeight: '400px', overflowY: 'auto', overflowX: 'hidden' }}>
{deliveries.map((delivery) => {
let cardStyle = { marginBottom: spacing.xs, maxWidth: '100%' };
if (delivery.delivery_status === 'Waiting') {
cardStyle.backgroundColor = colors.successLight;
} else if (delivery.delivery_status === 'Out for Delivery') {
cardStyle.border = `2px solid ${colors.gold}`;
cardStyle.boxShadow = colors.goldGlow;
}
return (
<Card
key={delivery.id}
size="small"
style={cardStyle}
bodyStyle={{ padding: '12px' }}
>
<Row gutter={8} align="middle">
<Col xs={12} sm={8}>
<strong style={{ fontSize: '12px' }}>Date:</strong><br />
<span style={{ fontSize: '12px', wordBreak: 'break-word' }}>{delivery.delivery_date ? new Date(delivery.delivery_date).toLocaleDateString() : 'N/A'}</span>
</Col>
<Col xs={12} sm={8}>
<strong style={{ fontSize: '12px' }}>Gallons:</strong><br />
<span style={{ fontSize: '12px' }}>{delivery.gallons ? `${delivery.gallons} gal` : 'N/A'}</span>
</Col>
<Col xs={12} sm={8}>
<strong style={{ fontSize: '12px' }}>Price:</strong><br />
<span style={{ fontSize: '12px' }}>{delivery.customer_price ? `$${delivery.customer_price.toFixed(2)}` : 'N/A'}</span>
</Col>
<Col xs={12} sm={8}>
<strong style={{ fontSize: '12px' }}>Status:</strong><br />
<span style={{ fontSize: '12px', wordBreak: 'break-word' }}>
{(() => {
if (delivery.delivery_status === 'Pending') return 'pending cc charge';
if (delivery.delivery_status === 'Finalized') return 'Delivered/Charged';
if (delivery.delivery_status === 'Waiting') return 'waiting for delivery';
return delivery.delivery_status;
})()}
</span>
</Col>
<Col xs={12} sm={8}>
<strong style={{ fontSize: '12px' }}>Payment:</strong><br />
<span style={{ fontSize: '12px', wordBreak: 'break-word' }}>{delivery.payment_type}</span>
</Col>
</Row>
</Card>);
})}
</div>
) : (
// Desktop/Tablet: Table
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<Table
columns={deliveriesColumns}
dataSource={deliveries}
rowKey="id"
pagination={false}
size="small"
scroll={{ y: 400, x: 'max-content' }}
style={{ minWidth: '100%' }}
rowClassName={(record) => {
if (record.delivery_status === 'Waiting') return 'waiting-delivery';
if (record.delivery_status === 'Out for Delivery') return 'out-for-delivery';
return '';
}}
/>
</div>
)
) : (
<p style={{ fontSize: screens.xs ? '14px' : '16px' }}>No deliveries found.</p>
)}
</Card>
</Col>
</Row>
</div>
)}
export default Index;

798
src/pages/Order.jsx Normal file
View File

@@ -0,0 +1,798 @@
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;

529
src/pages/Payments.jsx Normal file
View File

@@ -0,0 +1,529 @@
import React, { useEffect, useState } from 'react';
import { Card, Button, Modal, Form, Input, Select, Tag, Space, Row, Col, Typography, Grid, Skeleton, Empty, message } from 'antd';
import { CreditCardOutlined, EditOutlined, DeleteOutlined, StarOutlined, StarFilled, PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api';
import { colors, spacing, fontSize } from '../theme';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
const { Option } = Select;
// Card type colors
const cardTypeColors = {
'Visa': '#1a1f71',
'Mastercard': '#eb001b',
'American Express': '#006fcf',
'Discover': '#ff6000',
'default': colors.primary,
};
function Payments() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isLoggedIn = !!localStorage.getItem('token');
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingCard, setEditingCard] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [form] = Form.useForm();
const [addForm] = Form.useForm();
useEffect(() => {
if (!isLoggedIn) {
navigate('/login');
return;
}
fetchCards();
}, [isLoggedIn, navigate]);
const fetchCards = async () => {
setLoading(true);
try {
const response = await api.get('/payment/cards');
setCards(response.data);
} catch (error) {
message.error('Failed to load saved cards');
console.error('Error fetching cards:', error);
} finally {
setLoading(false);
}
};
const handleEdit = (card) => {
setEditingCard(card);
form.setFieldsValue({
name_on_card: card.name_on_card || '',
expiration_month: card.expiration_month || '',
expiration_year: card.expiration_year || '',
zip_code: card.zip_code || '',
});
setEditModalVisible(true);
};
const handleEditSubmit = async () => {
try {
const values = await form.validateFields();
setEditLoading(true);
await api.put(`/payment/cards/${editingCard.id}`, values);
message.success('Card updated successfully');
setEditModalVisible(false);
setEditingCard(null);
form.resetFields();
fetchCards();
} catch (error) {
if (error.errorFields) {
return; // Validation error, form will show it
}
message.error('Failed to update card');
console.error('Error updating card:', error);
} finally {
setEditLoading(false);
}
};
const handleDelete = (card) => {
Modal.confirm({
title: 'Delete Card',
content: (
<div style={{ textAlign: 'center', padding: `${spacing.md}px 0` }}>
<CreditCardOutlined style={{ fontSize: 48, color: cardTypeColors[card.type_of_card] || cardTypeColors.default, marginBottom: spacing.md }} />
<div style={{ marginBottom: spacing.xs }}>
<Text strong>{card.type_of_card || 'Card'}</Text>
</div>
<div style={{ marginBottom: spacing.md }}>
<Text>**** **** **** {card.last_four_digits}</Text>
</div>
<Text type="secondary">Are you sure you want to delete this card?</Text>
</div>
),
centered: true,
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
try {
await api.delete(`/payment/cards/${card.id}`);
message.success('Card deleted successfully');
fetchCards();
} catch (error) {
message.error('Failed to delete card');
console.error('Error deleting card:', error);
}
},
});
};
const handleSetDefault = async (card) => {
try {
await api.post(`/payment/cards/${card.id}/set-default`);
message.success('Default card updated');
fetchCards();
} catch (error) {
message.error('Failed to set default card');
console.error('Error setting default card:', error);
}
};
const handleAddCard = () => {
addForm.resetFields();
setAddModalVisible(true);
};
const handleAddSubmit = async () => {
try {
const values = await addForm.validateFields();
setAddLoading(true);
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');
setAddModalVisible(false);
addForm.resetFields();
fetchCards();
} catch (error) {
if (error.errorFields) {
return; // Validation error, form will show it
}
const errorMsg = error.response?.data?.detail || 'Failed to add card';
message.error(errorMsg);
console.error('Error adding card:', error);
} finally {
setAddLoading(false);
}
};
const formatExpirationDate = (month, year) => {
if (!month || !year) return 'N/A';
return `${month.padStart(2, '0')}/${year.slice(-2)}`;
};
const renderCardVisual = (card) => {
const cardColor = cardTypeColors[card.type_of_card] || cardTypeColors.default;
const isMobile = !screens.md;
return (
<Card
key={card.id}
style={{
background: `linear-gradient(135deg, ${cardColor} 0%, ${cardColor}dd 100%)`,
borderRadius: 12,
marginBottom: spacing.md,
color: colors.white,
position: 'relative',
minHeight: isMobile ? 180 : 200,
}}
styles={{
body: {
padding: isMobile ? spacing.md : spacing.lg,
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}
}}
>
{/* Default Tag */}
{card.main_card && (
<Tag
color="gold"
style={{
position: 'absolute',
top: spacing.sm,
right: spacing.sm,
margin: 0,
}}
>
<StarFilled /> Default
</Tag>
)}
{/* Card Type */}
<div style={{ marginBottom: spacing.md }}>
<Text style={{ color: colors.white, fontSize: fontSize.lg, fontWeight: 600 }}>
{card.type_of_card || 'Credit Card'}
</Text>
</div>
{/* Card Number (masked) */}
<div style={{ marginBottom: spacing.md }}>
<Text style={{ color: colors.white, fontSize: isMobile ? fontSize.lg : fontSize.xl, letterSpacing: 2, fontFamily: 'monospace' }}>
**** **** **** {card.last_four_digits}
</Text>
</div>
{/* Card Details Row */}
<Row gutter={spacing.md}>
<Col xs={12} sm={8}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: fontSize.xs, display: 'block' }}>
NAME ON CARD
</Text>
<Text style={{ color: colors.white, fontSize: fontSize.sm }}>
{card.name_on_card || 'N/A'}
</Text>
</Col>
<Col xs={6} sm={4}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: fontSize.xs, display: 'block' }}>
EXPIRES
</Text>
<Text style={{ color: colors.white, fontSize: fontSize.sm }}>
{formatExpirationDate(card.expiration_month, card.expiration_year)}
</Text>
</Col>
<Col xs={6} sm={4}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: fontSize.xs, display: 'block' }}>
ZIP
</Text>
<Text style={{ color: colors.white, fontSize: fontSize.sm }}>
{card.zip_code || 'N/A'}
</Text>
</Col>
</Row>
{/* Action Buttons */}
<div style={{ marginTop: spacing.md, display: 'flex', gap: spacing.xs, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(card)}
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderColor: 'transparent', color: colors.white }}
>
{!isMobile && 'Edit'}
</Button>
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(card)}
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderColor: 'transparent', color: colors.white }}
>
{!isMobile && 'Delete'}
</Button>
{!card.main_card && (
<Button
size="small"
icon={<StarOutlined />}
onClick={() => handleSetDefault(card)}
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderColor: 'transparent', color: colors.white }}
>
{!isMobile && 'Set Default'}
</Button>
)}
</div>
</Card>
);
};
// Generate year options (current year to +10 years)
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 11 }, (_, i) => currentYear + i);
if (loading) {
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: screens.md ? spacing.lg : spacing.md }}>
<Title level={2} style={{ marginBottom: spacing.lg }}>
<CreditCardOutlined style={{ marginRight: spacing.xs }} />
Saved Payment Methods
</Title>
<Row gutter={[spacing.md, spacing.md]}>
{[1, 2].map((i) => (
<Col xs={24} md={12} key={i}>
<Card style={{ borderRadius: 12 }}>
<Skeleton active paragraph={{ rows: 4 }} />
</Card>
</Col>
))}
</Row>
</div>
);
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: screens.md ? spacing.lg : spacing.md }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.lg, flexWrap: 'wrap', gap: spacing.sm }}>
<Title level={2} style={{ margin: 0 }}>
<CreditCardOutlined style={{ marginRight: spacing.xs }} />
Saved Payment Methods
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddCard}
>
Add Card
</Button>
</div>
{cards.length === 0 ? (
<Card style={{ textAlign: 'center', padding: spacing.xl }}>
<Empty
image={<CreditCardOutlined style={{ fontSize: 64, color: colors.gray[300] }} />}
description={
<span style={{ color: colors.text.secondary }}>
No saved payment methods
</span>
}
>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddCard}>
Add Your First Card
</Button>
</Empty>
</Card>
) : (
<Row gutter={[spacing.md, spacing.md]}>
{cards.map((card) => (
<Col xs={24} md={12} key={card.id}>
{renderCardVisual(card)}
</Col>
))}
</Row>
)}
{/* Edit Modal */}
<Modal
title="Edit Card Details"
open={editModalVisible}
onOk={handleEditSubmit}
onCancel={() => {
setEditModalVisible(false);
setEditingCard(null);
form.resetFields();
}}
confirmLoading={editLoading}
centered
okText="Save Changes"
>
<Form
form={form}
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' }]}
>
<Input placeholder="John Doe" />
</Form.Item>
<Row gutter={spacing.md}>
<Col xs={12}>
<Form.Item
name="expiration_month"
label="Expiration Month"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="Month">
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<Option key={month} value={String(month).padStart(2, '0')}>
{String(month).padStart(2, '0')}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={12}>
<Form.Item
name="expiration_year"
label="Expiration Year"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="Year">
{yearOptions.map((year) => (
<Option key={year} value={String(year)}>
{year}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="zip_code"
label="Billing ZIP Code"
rules={[{ required: true, message: 'Please enter the billing ZIP code' }]}
>
<Input placeholder="12345" maxLength={10} />
</Form.Item>
</Form>
</Modal>
{/* Add Card Modal */}
<Modal
title="Add New Card"
open={addModalVisible}
onOk={handleAddSubmit}
onCancel={() => {
setAddModalVisible(false);
addForm.resetFields();
}}
confirmLoading={addLoading}
centered
okText="Add Card"
>
<Form
form={addForm}
layout="vertical"
style={{ marginTop: spacing.md }}
>
<Form.Item
name="card_number"
label="Card Number"
rules={[
{ required: true, message: 'Please enter the card number' },
{
pattern: /^[\d\s]{13,19}$/,
message: 'Please enter a valid card number',
},
]}
>
<Input
placeholder="1234 5678 9012 3456"
maxLength={19}
onChange={(e) => {
// Format card number with spaces
const value = e.target.value.replace(/\s/g, '').replace(/(\d{4})/g, '$1 ').trim();
addForm.setFieldValue('card_number', value);
}}
/>
</Form.Item>
<Form.Item
name="name_on_card"
label="Name on Card"
rules={[{ required: true, message: 'Please enter the name on card' }]}
>
<Input placeholder="John Doe" />
</Form.Item>
<Row gutter={spacing.md}>
<Col xs={8}>
<Form.Item
name="expiration_month"
label="Month"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="MM">
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<Option key={month} value={String(month).padStart(2, '0')}>
{String(month).padStart(2, '0')}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={8}>
<Form.Item
name="expiration_year"
label="Year"
rules={[{ required: true, message: 'Required' }]}
>
<Select placeholder="YYYY">
{yearOptions.map((year) => (
<Option key={year} value={String(year)}>
{year}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={8}>
<Form.Item
name="security_number"
label="CVV"
rules={[
{ required: true, message: 'Required' },
{ pattern: /^\d{3,4}$/, message: 'Invalid CVV' },
]}
>
<Input placeholder="123" maxLength={4} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="zip_code"
label="Billing ZIP Code"
rules={[{ required: true, message: 'Please enter the billing ZIP code' }]}
>
<Input placeholder="12345" maxLength={10} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default Payments;

291
src/pages/Tank.jsx Normal file
View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect } from 'react';
import { Row, Col, Image, Button, Upload, message, Divider, Breadcrumb, Typography } from 'antd';
import { UploadOutlined, HomeOutlined } from '@ant-design/icons';
const { Title, Text } = Typography;
import { Link } from 'react-router-dom';
import api, { API_BASE_URL } from '../utils/api';
import { spacing } from '../theme';
function Tank() {
const [tankImageGroups, setTankImageGroups] = useState([]);
const [generalImage, setGeneralImage] = useState([]);
const [legsImage, setLegsImage] = useState([]);
const [frontImage, setFrontImage] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const [loading, setLoading] = useState(true);
const [accountNumber, setAccountNumber] = useState(null);
// Load user data and tank images on component mount
useEffect(() => {
loadUserData();
}, []);
const loadUserData = async () => {
try {
// Get current user info
const userResponse = await api.get('/auth/me');
const userAccountNumber = userResponse.data.account_number;
setAccountNumber(userAccountNumber);
// Load tank images for this user
await loadTankImages(userAccountNumber);
} catch (error) {
console.error('Failed to load user data:', error);
if (error.response?.status === 401) {
// User not authenticated - redirect to login
message.warning('Please log in to view your tank images');
// You could redirect here: window.location.href = '/login';
// For now, show a message and demo data
}
// Fallback for demo - use hardcoded account
setAccountNumber('AO-999999');
setTankImageGroups([
{
date: '2024-01-01',
images: [
`${API_BASE_URL}/src/assets/images/tank_example/tank_1.jpg`,
`${API_BASE_URL}/src/assets/images/tank_example/tank_2.jpg`,
`${API_BASE_URL}/src/assets/images/tank_example/tank_3.jpg`
]
}
]);
setLoading(false);
}
};
const loadTankImages = async (userAccountNumber) => {
try {
const response = await api.get(`/auth/tank-images/${userAccountNumber}`);
// Transform API response to component format - make image URLs absolute
const imageGroups = response.data.image_sets.map(set => ({
date: set.date,
images: set.images.map(img => `${API_BASE_URL}${img}`)
}));
// Sort by date descending (newest first) - handle both date and datetime formats
imageGroups.sort((a, b) => {
const parseDate = (dateStr) => {
// Handle datetime format: 2026-01-08_23-12-21
if (dateStr.includes('_')) {
const [datePart, timePart] = dateStr.split('_');
const [year, month, day] = datePart.split('-');
const [hour, minute, second] = timePart.split('-');
return new Date(year, month - 1, day, hour, minute, second);
}
// Handle date format: 2026-01-08
else {
return new Date(dateStr);
}
};
return parseDate(b.date) - parseDate(a.date);
});
setTankImageGroups(imageGroups);
} catch (error) {
console.error('Failed to load tank images:', error);
// Fallback to example images for demo
setTankImageGroups([
{
date: '2024-01-01',
images: [
`${API_BASE_URL}/src/assets/images/tank_example/tank_1.jpg`,
`${API_BASE_URL}/src/assets/images/tank_example/tank_2.jpg`,
`${API_BASE_URL}/src/assets/images/tank_example/tank_3.jpg`
]
}
]);
} finally {
setLoading(false);
}
};
const handleGeneralUpload = ({ fileList }) => {
setGeneralImage(fileList);
};
const handleLegsUpload = ({ fileList }) => {
setLegsImage(fileList);
};
const handleFrontUpload = ({ fileList }) => {
setFrontImage(fileList);
};
const handleUploadSubmit = async () => {
// Check if all 3 images are uploaded
if (generalImage.length === 0 || legsImage.length === 0 || frontImage.length === 0) {
message.error('Please upload all 3 required images');
return;
}
setIsUploading(true);
try {
// Prepare form data for upload
const formData = new FormData();
formData.append('account_number', accountNumber);
// Add images in correct order
formData.append('tank_image_1', generalImage[0].originFileObj);
formData.append('tank_image_2', legsImage[0].originFileObj);
formData.append('tank_image_3', frontImage[0].originFileObj);
// Upload to API
await api.post('/auth/upload-tank-images', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Reload images after successful upload
await loadTankImages(accountNumber);
// Clear uploaded images
setGeneralImage([]);
setLegsImage([]);
setFrontImage([]);
message.success('Images uploaded successfully!');
} catch (error) {
console.error('Upload failed:', error);
message.error('Upload failed: ' + (error.response?.data?.detail || 'Unknown error'));
} finally {
setIsUploading(false);
}
};
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }}>
<Breadcrumb.Item>
<Link to="/">
<HomeOutlined /> Home
</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>Tank Images</Breadcrumb.Item>
</Breadcrumb>
<Title level={1}>Tank Images</Title>
{/* Upload Section */}
<Divider>Upload New Images (3 required)</Divider>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title level={4}>Please take 3 images of your tank</Title>
</div>
<div style={{ display: 'flex', justifyContent: 'space-around', marginBottom: 24 }}>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_1.jpg"
alt="General picture of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14, marginBottom: 8 }}>General picture of your tank</p>
<Upload
listType="picture-card"
fileList={generalImage}
onChange={handleGeneralUpload}
beforeUpload={() => false}
maxCount={1}
accept="image/*"
>
{generalImage.length === 0 && (
<div>
<UploadOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
)}
</Upload>
</div>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_2.jpg"
alt="Picture of legs of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14, marginBottom: 8 }}>Picture of legs of your tank</p>
<Upload
listType="picture-card"
fileList={legsImage}
onChange={handleLegsUpload}
beforeUpload={() => false}
maxCount={1}
accept="image/*"
>
{legsImage.length === 0 && (
<div>
<UploadOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
)}
</Upload>
</div>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_3.jpg"
alt="Picture of front of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14, marginBottom: 8 }}>Picture of front of your tank</p>
<Upload
listType="picture-card"
fileList={frontImage}
onChange={handleFrontUpload}
beforeUpload={() => false}
maxCount={1}
accept="image/*"
>
{frontImage.length === 0 && (
<div>
<UploadOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
)}
</Upload>
</div>
</div>
{/* Upload Button */}
{generalImage.length > 0 && legsImage.length > 0 && frontImage.length > 0 && (
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Button
type="primary"
onClick={handleUploadSubmit}
loading={isUploading}
size="large"
>
Upload All Images
</Button>
</div>
)}
{/* Display Images Grouped by Upload Date */}
{tankImageGroups.map((group, groupIndex) => (
<div key={groupIndex} style={{ marginBottom: 32 }}>
<Title level={3}>Uploaded on: {group.date}</Title>
<Row gutter={16}>
{group.images.map((image, index) => (
<Col span={8} key={index}>
<Image
src={image}
alt={`Tank ${index + 1} - ${group.date}`}
style={{ width: '100%', height: 'auto' }}
/>
</Col>
))}
</Row>
</div>
))}
</div>
);
}
export default Tank;

View File

@@ -0,0 +1,82 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { MailOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../utils/api';
import { commonStyles } from '../../theme';
function ChangePassword() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [fetchingEmail, setFetchingEmail] = useState(true);
useEffect(() => {
const fetchUserEmail = async () => {
try {
const response = await api.get('/auth/me');
setUserEmail(response.data.email);
} catch (error) {
message.error('Please log in to change your password');
navigate('/login');
} finally {
setFetchingEmail(false);
}
};
fetchUserEmail();
}, [navigate]);
const onFinish = async () => {
setLoading(true);
try {
await api.post('/auth/forgot-password', { email: userEmail });
message.success('Password reset link sent to your email');
navigate('/');
} catch (error) {
message.error('Failed to send reset email: ' + (error.response?.data?.detail || 'Unknown error'));
} finally {
setLoading(false);
}
};
if (fetchingEmail) {
return (
<Card title="Change Password" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<p>Loading...</p>
</Card>
);
}
return (
<Card title="Change Password" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<p>We'll send a password reset link to your email address:</p>
<Form
name="change-password"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Form.Item>
<Input
prefix={<MailOutlined />}
value={userEmail}
disabled
style={{ backgroundColor: '#f5f5f5' }}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
Send Reset Link
</Button>
</Form.Item>
<Form.Item>
<Link to="/">Back to Home</Link>
</Form.Item>
</Form>
</Card>
);
}
export default ChangePassword;

View File

@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { MailOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../utils/api';
import { commonStyles } from '../../theme';
function ForgotPassword() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const onFinish = async (values) => {
setLoading(true);
try {
await api.post('/auth/forgot-password', values);
message.success('Password reset link sent to your email');
navigate('/login');
} catch (error) {
message.error('Failed to send reset email: ' + (error.response?.data?.detail || 'Unknown error'));
} finally {
setLoading(false);
}
};
return (
<Card title="Forgot Password" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<p>Enter your email address and we'll send you a link to reset your password.</p>
<Form
name="forgot-password"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
Send Reset Link
</Button>
</Form.Item>
<Form.Item>
<Link to="/login">Back to Login</Link>
</Form.Item>
</Form>
</Card>
);
}
export default ForgotPassword;

65
src/pages/auth/Login.jsx Normal file
View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { MailOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../utils/api';
import { commonStyles } from '../../theme';
function Login() {
const navigate = useNavigate();
const onFinish = async (values) => {
try {
const response = await api.post('/auth/login', values);
localStorage.setItem('token', response.data.access_token);
message.success('Login successful');
navigate('/');
} catch (error) {
message.error('Login failed: ' + (error.response?.data?.detail || 'Unknown error'));
}
};
return (
<Card title="Login" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please input your password!' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Login
</Button>
</Form.Item>
<Form.Item>
<Link to="/forgot-password">Forgot Password?</Link>
</Form.Item>
<Form.Item>
<Link to="/register">Don't have an account? Register</Link>
</Form.Item>
</Form>
</Card>
);
}
export default Login;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { HomeOutlined, MailOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../utils/api';
import { commonStyles } from '../../theme';
function Register() {
const navigate = useNavigate();
const onFinish = async (values) => {
try {
await api.post('/auth/register', values);
message.success('Registration successful, please login');
navigate('/login');
} catch (error) {
message.error('Registration failed: ' + (error.response?.data?.detail || 'Unknown error'));
}
};
return (
<Card title="Register" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<p>This is for existing Auburn Oil customers that do not have an online account.</p>
<Form
name="register"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Form.Item
name="account_number"
rules={[{ required: true, message: 'Please input your account number!' }]}
>
<Input placeholder="Account Number" />
</Form.Item>
<Form.Item
name="house_number"
rules={[{ required: true, message: 'Please input your house number!' }]}
>
<Input prefix={<HomeOutlined />} placeholder="House Number" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please input your password!' }]}
>
<Input.Password placeholder="Password" />
</Form.Item>
<Form.Item
name="confirm_password"
rules={[
{ required: true, message: 'Please confirm your password!' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('The two passwords do not match!'));
},
}),
]}
>
<Input.Password placeholder="Confirm Password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Register
</Button>
</Form.Item>
<Form.Item>
Already have an account? <Link to="/login">Login</Link>
</Form.Item>
</Form>
</Card>
);
}
export default Register;

View File

@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { LockOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import api from '../../utils/api';
import { commonStyles } from '../../theme';
function ResetPassword() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [token, setToken] = useState('');
useEffect(() => {
const tokenFromUrl = searchParams.get('token');
if (tokenFromUrl) {
setToken(tokenFromUrl);
} else {
message.error('No reset token provided');
navigate('/login');
}
}, [searchParams, navigate]);
const onFinish = async (values) => {
setLoading(true);
try {
await api.post('/auth/reset-password', {
token: token,
password: values.password,
confirm_password: values.confirm_password
});
message.success('Password reset successfully');
navigate('/login');
} catch (error) {
message.error('Failed to reset password: ' + (error.response?.data?.detail || 'Unknown error'));
} finally {
setLoading(false);
}
};
return (
<Card title="Reset Password" style={{ maxWidth: 400, margin: '0 auto', ...commonStyles.cardGray }}>
<p>Enter your new password below.</p>
<Form
name="reset-password"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please input your password!' },
{ min: 6, message: 'Password must be at least 6 characters' }
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="New Password" />
</Form.Item>
<Form.Item
name="confirm_password"
dependencies={['password']}
rules={[
{ required: true, message: 'Please confirm your password!' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('The two passwords do not match!'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Confirm New Password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
Reset Password
</Button>
</Form.Item>
<Form.Item>
<Link to="/login">Back to Login</Link>
</Form.Item>
</Form>
</Card>
);
}
export default ResetPassword;

512
src/pages/auth/new.jsx Normal file
View File

@@ -0,0 +1,512 @@
import React, { useState } from 'react';
import { Form, Input, Button, Card, message, Divider, Select, Steps, Upload, Image, Typography } from 'antd';
import { UserOutlined, PhoneOutlined, MailOutlined, HomeOutlined, EnvironmentOutlined, UploadOutlined } from '@ant-design/icons';
const { Title, Text } = Typography;
import { useNavigate } from 'react-router-dom';
import api from '../../utils/api';
import { colors, spacing, commonStyles } from '../../theme';
const { TextArea } = Input;
function New() {
const navigate = useNavigate();
const [wordCount, setWordCount] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [accountNumber, setAccountNumber] = useState('');
const [uploadedImages, setUploadedImages] = useState([]);
const [form] = Form.useForm();
const steps = [
{
title: 'Customer Information',
content: 'Step 1',
},
{
title: 'Account Setup',
content: 'Step 2',
},
{
title: 'Tank Inspection',
content: 'Step 3',
},
];
const handleDescriptionChange = (e) => {
const value = e.target.value;
const words = value.trim() === '' ? 0 : value.trim().split(/\s+/).length;
setWordCount(words);
};
const formatPhoneNumber = (value) => {
// Remove all non-digit characters
const phoneNumber = value.replace(/\D/g, '');
// Limit to 10 digits
const limitedPhoneNumber = phoneNumber.slice(0, 10);
// Format as (XXX) XXX-XXXX
if (limitedPhoneNumber.length >= 6) {
return `(${limitedPhoneNumber.slice(0, 3)}) ${limitedPhoneNumber.slice(3, 6)}-${limitedPhoneNumber.slice(6)}`;
} else if (limitedPhoneNumber.length >= 3) {
return `(${limitedPhoneNumber.slice(0, 3)}) ${limitedPhoneNumber.slice(3)}`;
} else if (limitedPhoneNumber.length > 0) {
return `(${limitedPhoneNumber}`;
}
return limitedPhoneNumber;
};
const handlePhoneNumberChange = (e) => {
const formattedValue = formatPhoneNumber(e.target.value);
form.setFieldsValue({ customer_phone_number: formattedValue });
};
const handleNameChange = (fieldName) => (e) => {
const value = e.target.value;
// Capitalize first letter of each word (title case)
const formattedValue = value.replace(/\b\w/g, (char) => char.toUpperCase());
form.setFieldsValue({ [fieldName]: formattedValue });
};
const handleTownChange = (e) => {
const value = e.target.value;
// Capitalize first letter of each word for town names
const formattedValue = value.replace(/\b\w/g, (char) => char.toUpperCase());
form.setFieldsValue({ customer_town: formattedValue });
};
const next = async () => {
try {
if (currentStep === 0) {
// Validate step 1 fields
await form.validateFields([
'customer_first_name', 'customer_last_name', 'customer_phone_number',
'customer_address', 'customer_town', 'customer_zip', 'customer_home_type'
]);
// Submit step 1 - get formatted phone number
const rawData = form.getFieldsValue([
'customer_first_name', 'customer_last_name', 'customer_phone_number',
'customer_address', 'customer_apt', 'customer_town', 'customer_zip',
'customer_home_type', 'house_description'
]);
// Send formatted phone number to backend
const step1Data = {
...rawData,
customer_phone_number: rawData.customer_phone_number // Already formatted by handlePhoneNumberChange
};
const response = await api.post('/auth/step1', step1Data);
setAccountNumber(response.data.account_number);
setCurrentStep(currentStep + 1);
} else if (currentStep === 1) {
// Validate step 2 fields
await form.validateFields(['password', 'confirm_password', 'customer_email']);
// Submit step 2
const step2Data = {
account_number: accountNumber,
password: form.getFieldValue('password'),
confirm_password: form.getFieldValue('confirm_password'),
customer_email: form.getFieldValue('customer_email')
};
await api.post('/auth/step2', step2Data);
setCurrentStep(currentStep + 1);
}
} catch (error) {
if (error.response) {
message.error(`Step ${currentStep + 1} failed: ` + (error.response.data?.detail || 'Unknown error'));
}
// Validation errors are handled by form
}
};
const prev = () => {
setCurrentStep(currentStep - 1);
};
const onFinish = async (values, skipImages = false) => {
if (currentStep === 2) {
if (!skipImages && uploadedImages.length < 3) {
message.error('Please upload all 3 tank images or skip for now');
return;
}
try {
if (!skipImages) {
// Upload tank images
const formData = new FormData();
formData.append('account_number', accountNumber);
// Get uploaded files from state
uploadedImages.forEach((file, index) => {
formData.append(`tank_image_${index + 1}`, file.originFileObj);
});
await api.post('/auth/step3', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
}
message.success('Customer registration completed successfully!');
navigate('/');
} catch (error) {
if (error.response?.status === 400 && error.response.data?.detail) {
message.error(error.response.data.detail);
} else {
message.error('Registration failed: ' + (error.response?.data?.detail || 'Unknown error'));
}
}
}
};
const handleSkipImages = () => {
onFinish(null, true);
};
return (
<Card title="New Customer Registration" style={{ maxWidth: 600, margin: '0 auto', ...commonStyles.cardGray }}>
<Steps current={currentStep} items={steps} style={{ marginBottom: 24 }} />
<Form
name="new-customer"
form={form}
onFinish={onFinish}
autoComplete="off"
layout="vertical"
initialValues={{ customer_home_type: 0 }}
>
{currentStep === 0 && (
<>
<Divider>General Info</Divider>
<Form.Item
name="customer_first_name"
label="First Name"
hasFeedback
rules={[
{ required: true, message: 'Please input your first name!' },
{ min: 2, message: 'First name must be at least 2 characters' },
{ max: 50, message: 'First name must be no more than 50 characters' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="First Name"
onChange={handleNameChange('customer_first_name')}
/>
</Form.Item>
<Form.Item
name="customer_last_name"
label="Last Name"
hasFeedback
rules={[
{ required: true, message: 'Please input your last name!' },
{ min: 2, message: 'Last name must be at least 2 characters' },
{ max: 50, message: 'Last name must be no more than 50 characters' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="Last Name"
onChange={handleNameChange('customer_last_name')}
/>
</Form.Item>
<Form.Item
name="customer_phone_number"
label="Phone Number"
hasFeedback
rules={[
{ required: true, message: 'Please input your phone number!' },
{
validator: (_, value) => {
const digitsOnly = value ? value.replace(/\D/g, '') : '';
if (digitsOnly.length !== 10) {
return Promise.reject('Phone number must be 10 digits');
}
return Promise.resolve();
}
}
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="Phone Number (10 digits)"
onChange={handlePhoneNumberChange}
/>
</Form.Item>
<Form.Item
name="customer_address"
label="Street Address"
hasFeedback
rules={[
{ required: true, message: 'Please input your street address!' },
{ min: 3, message: 'Street address must be at least 3 characters' },
{ max: 100, message: 'Street address must be no more than 100 characters' }
]}
>
<Input prefix={<HomeOutlined />} placeholder="Street Address" />
</Form.Item>
<Form.Item
name="customer_apt"
label="Apt, suite, unit..."
>
<Input placeholder="Apt, suite, unit..." />
</Form.Item>
<Form.Item
name="customer_town"
label="Town"
hasFeedback
rules={[
{ required: true, message: 'Please input your town!' },
{ min: 3, message: 'Town must be at least 3 characters' },
{ max: 25, message: 'Town must be no more than 25 characters' }
]}
>
<Input
prefix={<EnvironmentOutlined />}
placeholder="Town"
onChange={handleTownChange}
/>
</Form.Item>
<Form.Item label="State">
<Input value="MA" disabled style={{ color: '#000', backgroundColor: '#f5f5f5' }} />
</Form.Item>
<Form.Item
name="customer_zip"
label="Zip Code"
hasFeedback
rules={[
{ required: true, message: 'Please input your zip code!' },
{ len: 5, message: 'Zip code must be exactly 5 characters' }
]}
>
<Input placeholder="Zip Code" />
</Form.Item>
<Form.Item
name="customer_home_type"
label="Home Type"
hasFeedback
rules={[{ required: true, message: 'Please select your home type!' }]}
>
<Select placeholder="Select home type">
<Select.Option value={0}>Residential</Select.Option>
<Select.Option value={1}>apartment</Select.Option>
<Select.Option value={2}>condo</Select.Option>
<Select.Option value={3}>commercial</Select.Option>
<Select.Option value={4}>business</Select.Option>
<Select.Option value={5}>construction</Select.Option>
<Select.Option value={6}>container</Select.Option>
</Select>
</Form.Item>
<Divider>Delivery Details</Divider>
<Form.Item
name="house_description"
label="Customer House Description (20 words or less, optional)"
hasFeedback
validateStatus={wordCount > 20 ? 'error' : wordCount > 0 ? 'success' : ''}
help={<span style={{ color: wordCount > 20 ? 'red' : 'green' }}>{wordCount}/20 words{wordCount > 20 ? ' (exceeded limit)' : ''}</span>}
rules={[
{
validator: (_, value) => {
if (value && value.split(' ').length > 20) {
return Promise.reject('Description must be 20 words or less');
}
return Promise.resolve();
}
}
]}
>
<TextArea placeholder="House Description" rows={4} onChange={handleDescriptionChange} />
</Form.Item>
</>
)}
{currentStep === 1 && (
<>
<div style={{ textAlign: 'center', marginBottom: 24, fontSize: 16, fontWeight: 'bold' }}>
Your account number is: {accountNumber}
</div>
<Divider>Account Setup</Divider>
<Form.Item
name="password"
label="Password"
hasFeedback
rules={[{ required: true, message: 'Please input your password!' }]}
>
<Input.Password placeholder="Password" />
</Form.Item>
<Form.Item
name="confirm_password"
label="Confirm Password"
dependencies={['password']}
hasFeedback
rules={[
{ required: true, message: 'Please confirm your password!' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('The two passwords do not match!'));
},
}),
]}
>
<Input.Password placeholder="Confirm Password" />
</Form.Item>
<Form.Item
name="customer_email"
label="Email"
hasFeedback
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
</>
)}
{currentStep === 2 && (
<>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title level={4}>Please take 3 images of your tank</Title>
</div>
<div style={{ display: 'flex', justifyContent: 'space-around', marginBottom: 24 }}>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_1.jpg"
alt="General picture of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14 }}>General picture of your tank</p>
</div>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_2.jpg"
alt="Picture of legs of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14 }}>Picture of legs of your tank</p>
</div>
<div style={{ textAlign: 'center' }}>
<Image
width={150}
src="/src/assets/images/tank_example/tank_3.jpg"
alt="Picture of front of tank"
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 14 }}>Picture of front of your tank</p>
</div>
</div>
<Divider>Tank Images</Divider>
<Form.Item
label="Upload Tank Images (3 required)"
rules={[
{
validator: () => {
if (uploadedImages.length === 3) {
return Promise.resolve();
}
return Promise.reject(new Error('Please upload all 3 tank images'));
}
}
]}
>
<Upload
listType="picture-card"
fileList={uploadedImages}
onChange={({ fileList }) => setUploadedImages(fileList)}
beforeUpload={() => false} // Prevent auto upload
multiple
maxCount={3}
accept="image/*"
>
{uploadedImages.length < 3 && (
<div>
<UploadOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
)}
</Upload>
</Form.Item>
{uploadedImages.length === 3 && (
<div style={{ marginTop: 16 }}>
<Title level={5}>Uploaded Images Preview:</Title>
<div style={{ display: 'flex', justifyContent: 'space-around', marginTop: 16 }}>
{uploadedImages.map((file, index) => (
<div key={index} style={{ textAlign: 'center' }}>
<Image
width={120}
src={URL.createObjectURL(file.originFileObj)}
alt={`Tank image ${index + 1}`}
style={{ marginBottom: 8 }}
/>
<p style={{ fontSize: 12 }}>Image {index + 1}</p>
</div>
))}
</div>
</div>
)}
</>
)}
<Form.Item style={{ marginTop: 24 }}>
{currentStep === 0 && (
<Button type="primary" onClick={next} block>
Next
</Button>
)}
{currentStep === 1 && (
<>
<Button style={{ marginRight: 8 }} onClick={prev}>
Previous
</Button>
<Button type="primary" onClick={next} block>
Next
</Button>
</>
)}
{currentStep === 2 && (
<>
<Button style={{ marginRight: 8 }} onClick={prev}>
Previous
</Button>
<Button
type="default"
onClick={handleSkipImages}
style={{ marginRight: 8 }}
>
Skip for now...
</Button>
<Button type="primary" htmlType="submit">
Complete Registration
</Button>
</>
)}
</Form.Item>
</Form>
</Card>
);
}
export default New;

467
src/theme.js Normal file
View File

@@ -0,0 +1,467 @@
/**
* Design Token System for Oil Customer Gateway
*
* This file centralizes all design values to ensure consistency across the app.
* Import and use these tokens instead of hardcoding values.
*
* Usage:
* import { colors, spacing, fontSize, ... } from '../theme';
* style={{ color: colors.primary, padding: spacing.md }}
*/
// =============================================================================
// COLORS
// =============================================================================
export const colors = {
// Brand Colors
primary: '#14368f', // Oil blue - main brand color
secondary: '#ff6600', // Oil orange - accent/CTA color
// Neutral Colors
white: '#ffffff',
black: '#000000',
gray: {
50: '#fafafa',
100: '#f5f5f5', // Card backgrounds, subtle backgrounds
200: '#e8e8e8', // Borders, dividers
300: '#d9d9d9', // Input borders
400: '#bfbfbf',
500: '#8c8c8c',
600: '#666666', // Hint text, secondary text
700: '#333837', // Oil gray - dark text
800: '#262626',
900: '#1f1f1f',
},
// Semantic Colors
success: '#52c41a',
successLight: '#d4edda', // Light green for waiting delivery
warning: '#faad14',
warningLight: '#fff7e6', // Warning card background
warningBorder: '#ffcc02',
error: '#ff4d4f',
info: '#1890ff', // Links, info elements
// Status Colors
gold: '#ffd700', // Out for delivery highlight
goldGlow: 'rgba(255, 215, 0, 0.5)',
// Background Colors
background: {
page: '#ffffff',
card: '#f5f5f5',
overlay: 'rgba(0, 0, 0, 0.45)',
},
// Text Colors
text: {
primary: 'rgba(0, 0, 0, 0.85)',
secondary: 'rgba(0, 0, 0, 0.65)',
hint: '#666666',
inverse: '#ffffff',
link: '#1890ff',
},
};
// =============================================================================
// SPACING
// =============================================================================
export const spacing = {
xxs: 4,
xs: 8,
sm: 12,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
// Specific use cases
gutter: {
xs: 8, // Mobile gutter
md: 16, // Default gutter
lg: 24, // Large screens
},
// Content padding
content: {
mobile: 8,
tablet: 16,
desktop: 50,
},
// Card/section padding
card: {
xs: 12,
sm: 16,
md: 24,
},
};
// =============================================================================
// TYPOGRAPHY
// =============================================================================
export const fontSize = {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 32,
};
export const fontWeight = {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
};
export const lineHeight = {
tight: 1.25,
normal: 1.5,
relaxed: 1.6,
loose: 2,
};
// =============================================================================
// BORDERS
// =============================================================================
export const borderRadius = {
sm: 4,
md: 6,
lg: 8,
xl: 12,
full: 9999,
};
export const borderWidth = {
default: 1,
thick: 2,
};
// =============================================================================
// SHADOWS
// =============================================================================
export const shadows = {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
glow: {
gold: '0 0 10px rgba(255, 215, 0, 0.5)',
},
};
// =============================================================================
// BREAKPOINTS
// =============================================================================
export const breakpoints = {
xs: 480,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
};
// =============================================================================
// TRANSITIONS
// =============================================================================
export const transitions = {
fast: '0.15s ease',
normal: '0.3s ease',
slow: '0.5s ease',
};
// =============================================================================
// Z-INDEX
// =============================================================================
export const zIndex = {
dropdown: 1000,
sticky: 1020,
fixed: 1030,
modal: 1040,
popover: 1050,
tooltip: 1060,
};
// =============================================================================
// COMPONENT-SPECIFIC TOKENS
// =============================================================================
export const components = {
// Header
header: {
height: 64,
background: colors.primary,
},
// Logo
logo: {
height: 32,
},
// Buttons
button: {
height: {
sm: 32,
default: 40,
lg: 48,
xl: 60,
},
fontSize: {
sm: fontSize.sm,
default: fontSize.base,
lg: fontSize.lg,
},
},
// Cards
card: {
background: colors.background.card,
border: `1px solid ${colors.gray[200]}`,
borderRadius: borderRadius.lg,
},
// Tables
table: {
scrollHeight: 400,
},
// Forms
form: {
maxWidth: 400,
labelFontSize: fontSize.sm,
},
// Images
image: {
thumbnail: {
mobile: 120,
desktop: 100,
},
preview: 150,
},
};
// =============================================================================
// ANT DESIGN THEME CONFIG
// =============================================================================
/**
* Use this with Ant Design's ConfigProvider to apply consistent theming
*
* Usage:
* import { antdTheme } from './theme';
* <ConfigProvider theme={antdTheme}>
* <App />
* </ConfigProvider>
*/
export const antdTheme = {
token: {
colorPrimary: colors.primary,
colorLink: colors.text.link,
colorSuccess: colors.success,
colorWarning: colors.warning,
colorError: colors.error,
colorInfo: colors.info,
borderRadius: borderRadius.md,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: fontSize.base,
colorBgContainer: colors.white,
colorBgLayout: colors.gray[100],
paddingXS: spacing.xs,
paddingSM: spacing.sm,
padding: spacing.md,
paddingLG: spacing.lg,
paddingXL: spacing.xl,
},
components: {
Button: {
colorPrimaryHover: '#1e4db8',
borderRadius: borderRadius.md,
},
Card: {
colorBgContainer: colors.gray[100],
},
},
};
// =============================================================================
// CSS CUSTOM PROPERTIES
// =============================================================================
/**
* Injects CSS custom properties (variables) into the document root.
* Call this once at app initialization.
*
* Usage in CSS:
* color: var(--color-primary);
* padding: var(--spacing-md);
*/
export const injectCSSVariables = () => {
const root = document.documentElement;
// Colors
root.style.setProperty('--color-primary', colors.primary);
root.style.setProperty('--color-secondary', colors.secondary);
root.style.setProperty('--color-white', colors.white);
root.style.setProperty('--color-success', colors.success);
root.style.setProperty('--color-success-light', colors.successLight);
root.style.setProperty('--color-warning', colors.warning);
root.style.setProperty('--color-warning-light', colors.warningLight);
root.style.setProperty('--color-warning-border', colors.warningBorder);
root.style.setProperty('--color-error', colors.error);
root.style.setProperty('--color-info', colors.info);
root.style.setProperty('--color-gold', colors.gold);
root.style.setProperty('--color-text-primary', colors.text.primary);
root.style.setProperty('--color-text-secondary', colors.text.secondary);
root.style.setProperty('--color-text-hint', colors.text.hint);
root.style.setProperty('--color-text-inverse', colors.text.inverse);
root.style.setProperty('--color-text-link', colors.text.link);
root.style.setProperty('--color-bg-card', colors.background.card);
root.style.setProperty('--color-border', colors.gray[200]);
root.style.setProperty('--color-border-input', colors.gray[300]);
// Grays
Object.entries(colors.gray).forEach(([key, value]) => {
root.style.setProperty(`--color-gray-${key}`, value);
});
// Spacing
root.style.setProperty('--spacing-xxs', `${spacing.xxs}px`);
root.style.setProperty('--spacing-xs', `${spacing.xs}px`);
root.style.setProperty('--spacing-sm', `${spacing.sm}px`);
root.style.setProperty('--spacing-md', `${spacing.md}px`);
root.style.setProperty('--spacing-lg', `${spacing.lg}px`);
root.style.setProperty('--spacing-xl', `${spacing.xl}px`);
root.style.setProperty('--spacing-xxl', `${spacing.xxl}px`);
// Font sizes
root.style.setProperty('--font-size-xs', `${fontSize.xs}px`);
root.style.setProperty('--font-size-sm', `${fontSize.sm}px`);
root.style.setProperty('--font-size-base', `${fontSize.base}px`);
root.style.setProperty('--font-size-lg', `${fontSize.lg}px`);
root.style.setProperty('--font-size-xl', `${fontSize.xl}px`);
root.style.setProperty('--font-size-2xl', `${fontSize['2xl']}px`);
root.style.setProperty('--font-size-3xl', `${fontSize['3xl']}px`);
// Border radius
root.style.setProperty('--radius-sm', `${borderRadius.sm}px`);
root.style.setProperty('--radius-md', `${borderRadius.md}px`);
root.style.setProperty('--radius-lg', `${borderRadius.lg}px`);
root.style.setProperty('--radius-xl', `${borderRadius.xl}px`);
// Shadows
root.style.setProperty('--shadow-sm', shadows.sm);
root.style.setProperty('--shadow-md', shadows.md);
root.style.setProperty('--shadow-lg', shadows.lg);
root.style.setProperty('--shadow-glow-gold', shadows.glow.gold);
// Transitions
root.style.setProperty('--transition-fast', transitions.fast);
root.style.setProperty('--transition-normal', transitions.normal);
root.style.setProperty('--transition-slow', transitions.slow);
};
// =============================================================================
// STYLE UTILITIES
// =============================================================================
/**
* Pre-built button styles for common use cases.
* Import and spread these in your style prop.
*
* Usage:
* import { buttonStyles } from '../theme';
* <Button style={buttonStyles.ctaLarge}>Order Now</Button>
*/
export const buttonStyles = {
// Primary CTA button (orange, large)
ctaLarge: {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
height: 60,
fontSize: fontSize.lg,
minWidth: 150,
},
// Primary CTA button for mobile
ctaMobile: {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
height: 48,
fontSize: fontSize.base,
width: '100%',
},
// Standard primary button
primary: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
// Text button on dark background
headerText: {
color: colors.white,
},
};
/**
* Common style patterns for reuse.
*/
export const commonStyles = {
// Card with gray background
cardGray: {
backgroundColor: colors.gray[100],
},
// Warning/emergency card
cardWarning: {
backgroundColor: colors.warningLight,
borderColor: colors.warningBorder,
},
// Centered flex container
flexCenter: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
// Link styling
link: {
color: colors.text.link,
},
};
// =============================================================================
// DEFAULT EXPORT
// =============================================================================
const theme = {
colors,
spacing,
fontSize,
fontWeight,
lineHeight,
borderRadius,
borderWidth,
shadows,
breakpoints,
transitions,
zIndex,
components,
antdTheme,
injectCSSVariables,
buttonStyles,
commonStyles,
};
export default theme;

16
src/utils/api.js Normal file
View File

@@ -0,0 +1,16 @@
import axios from 'axios';
export const API_BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:8000';
const api = axios.create({ baseURL: API_BASE_URL });
// Add token to requests if available
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;