feat: add customer cart with expiry, shipping addresses with USPS validation, and multi-item PayPal checkout
This commit is contained in:
@@ -11,6 +11,7 @@ import '@uiw/react-markdown-preview/markdown.css';
|
||||
import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
|
||||
import { useThemeMode } from '../theme/ThemeContext';
|
||||
import Customers from './Customers';
|
||||
import Settings from './Settings';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
@@ -37,10 +38,7 @@ function Inventory() {
|
||||
|
||||
function openEdit(item: Item) {
|
||||
setEditingItem(item);
|
||||
form.setFieldsValue({
|
||||
name: item.name,
|
||||
price: item.price_cents / 100
|
||||
});
|
||||
form.setFieldsValue({ name: item.name, price: item.price_cents / 100 });
|
||||
setFileList([]);
|
||||
setDescription(item.description || '');
|
||||
setModalOpen(true);
|
||||
@@ -52,9 +50,7 @@ function Inventory() {
|
||||
fd.append('name', values.name);
|
||||
fd.append('description', description);
|
||||
fd.append('price', String(values.price));
|
||||
fileList.forEach(f => {
|
||||
if (f.originFileObj) fd.append('images', f.originFileObj as File);
|
||||
});
|
||||
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
||||
await saveItem(editingItem?.id ?? null, fd);
|
||||
message.success(editingItem ? 'Item updated' : 'Item added');
|
||||
setModalOpen(false);
|
||||
@@ -85,26 +81,18 @@ function Inventory() {
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src={images[0].image_path} style={{ width: 60 }} />
|
||||
{images.length > 1 && (
|
||||
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>
|
||||
+{images.length - 1}
|
||||
</Tag>
|
||||
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
|
||||
)}
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
{ title: 'Name', dataIndex: 'name' },
|
||||
{
|
||||
title: 'Price',
|
||||
dataIndex: 'price_cents',
|
||||
render: (v: number) => `$${(v / 100).toFixed(2)}`
|
||||
},
|
||||
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>{status.toUpperCase()}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -129,61 +117,34 @@ function Inventory() {
|
||||
</div>
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
<Modal
|
||||
title={editingItem ? 'Edit Item' : 'Add Item'}
|
||||
open={modalOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnClose
|
||||
width={720}
|
||||
>
|
||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose width={720}>
|
||||
<div data-color-mode={mode}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Description (Markdown supported)">
|
||||
<MDEditor
|
||||
value={description}
|
||||
onChange={(val) => setDescription(val || '')}
|
||||
height={220}
|
||||
preview="live"
|
||||
/>
|
||||
<MDEditor value={description} onChange={(val) => setDescription(val || '')} height={220} preview="live" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{editingItem && editingItem.images.length > 0 && (
|
||||
<Form.Item label="Existing Images (front / back / etc.)">
|
||||
<Space wrap>
|
||||
{editingItem.images.map(img => (
|
||||
<div key={img.id} style={{ position: 'relative' }}>
|
||||
<AntImage src={img.image_path} width={80} height={80} style={{ objectFit: 'cover' }} />
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ position: 'absolute', top: 0, right: 0 }}
|
||||
onClick={() => handleDeleteImage(editingItem.id, img.id)}
|
||||
/>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} style={{ position: 'absolute', top: 0, right: 0 }}
|
||||
onClick={() => handleDeleteImage(editingItem.id, img.id)} />
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
||||
<Upload
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList }) => setFileList(fileList.slice(-6))}
|
||||
maxCount={6}
|
||||
multiple
|
||||
listType="picture-card"
|
||||
>
|
||||
<Upload fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))}
|
||||
maxCount={6} multiple listType="picture-card">
|
||||
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
@@ -200,16 +161,8 @@ export default function Admin() {
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header
|
||||
className="site-header"
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
|
||||
Admin
|
||||
</Title>
|
||||
<Header className="site-header" style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>Admin</Title>
|
||||
<div className="site-header-actions">
|
||||
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
||||
</div>
|
||||
@@ -219,10 +172,11 @@ export default function Admin() {
|
||||
defaultActiveKey="inventory"
|
||||
items={[
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> }
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||
]}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Form, InputNumber, Button, Typography, message, Card } from 'antd';
|
||||
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminSettings().then(s => {
|
||||
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
|
||||
setLoading(false);
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
async function handleSave() {
|
||||
const values = await form.validateFields();
|
||||
await updateAdminSettings(values);
|
||||
message.success('Settings saved');
|
||||
}
|
||||
|
||||
return (
|
||||
<Card style={{ maxWidth: 480 }}>
|
||||
<Title level={4}>Cart Settings</Title>
|
||||
<Text type="secondary">
|
||||
How long an item stays reserved in a customer's cart before it's automatically released back to available inventory.
|
||||
</Text>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }} disabled={loading}>
|
||||
<Form.Item name="cartExpiryHours" label="Cart expiry (hours)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AdminSettings {
|
||||
cartExpiryHours: number;
|
||||
}
|
||||
|
||||
export async function fetchAdminSettings(): Promise<AdminSettings> {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAdminSettings(settings: AdminSettings): Promise<AdminSettings> {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
Reference in New Issue
Block a user