feat: add customer cart with expiry, shipping addresses with USPS validation, and multi-item PayPal checkout
SonarQube Analysis / sonarqube (pull_request) Failing after 59s
Tests / backend-unit (pull_request) Successful in 34s
Tests / backend-integration (pull_request) Failing after 1m33s
Tests / frontend-e2e (pull_request) Failing after 1m5s

This commit is contained in:
2026-08-14 14:02:25 -05:00
parent 433d7e1db5
commit 9ab689e624
18 changed files with 1242 additions and 189 deletions
+14 -10
View File
@@ -1,29 +1,28 @@
import { useEffect, useState, useCallback } from 'react';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme } from 'antd';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd';
import { ShoppingCartOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { Item, SiteConfig, fetchItems, fetchConfig } from './api';
import { Item, fetchItems } from './api';
import ItemCard from './components/ItemCard';
import { useThemeMode } from './theme/ThemeContext';
import { useCustomerAuth } from './customer/CustomerAuthContext';
import { useCart } from './cart/CartContext';
const { Header, Content, Footer } = Layout;
const { Title } = Typography;
export default function App() {
const [items, setItems] = useState<Item[]>([]);
const [config, setConfig] = useState<SiteConfig | null>(null);
const { mode, toggle } = useThemeMode();
const { customer } = useCustomerAuth();
const { items: cartItems } = useCart();
const { token } = theme.useToken();
const load = useCallback(() => {
fetchItems().then(setItems);
}, []);
useEffect(() => {
load();
fetchConfig().then(setConfig);
}, [load]);
useEffect(() => { load(); }, [load]);
return (
<Layout style={{ minHeight: '100vh' }}>
@@ -39,6 +38,11 @@ export default function App() {
</Title>
<div className="site-header-actions">
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
<Link to="/cart">
<Badge count={cartItems.length} size="small">
<Button icon={<ShoppingCartOutlined />} />
</Badge>
</Link>
{customer ? (
<Link to="/account"><Button>My Account</Button></Link>
) : (
@@ -50,11 +54,11 @@ export default function App() {
</div>
</Header>
<Content style={{ padding: 24 }}>
{!config ? <Spin /> : (
{!items.length ? <Spin /> : (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} config={config} onPurchased={load} />
<ItemCard item={item} onChanged={load} />
</Col>
))}
</Row>
@@ -65,4 +69,4 @@ export default function App() {
</Footer>
</Layout>
);
}
}
+17 -63
View File
@@ -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>
);
}
}
+38
View File
@@ -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>
);
}
+17
View File
@@ -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();
}
+1 -31
View File
@@ -1,15 +1,9 @@
export interface ItemImage {
id: number;
image_path: string;
sort_order: number;
}
export interface Item {
id: number;
name: string;
description: string | null;
price_cents: number;
images: ItemImage[];
images: { id: number; image_path: string; sort_order: number }[];
status: 'available' | 'reserved' | 'sold';
}
@@ -57,27 +51,3 @@ export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
return res.json();
}
export async function createPaypalOrder(itemId: number): Promise<string> {
const res = await fetch(`/api/checkout/paypal/${itemId}/create`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not start checkout');
return data.orderID;
}
export async function capturePaypalOrder(itemId: number, orderID: string): Promise<void> {
const res = await fetch(`/api/checkout/paypal/${itemId}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID })
});
if (!res.ok) throw new Error('Payment captured but confirmation failed — contact the seller.');
}
export async function demoPurchase(itemId: number): Promise<void> {
const res = await fetch(`/api/checkout/demo/${itemId}/purchase`, { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Demo purchase failed');
}
}
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react';
import {
Layout, Typography, List, Button, Empty, Card, Radio, Form, Input,
Checkbox, Modal, message, Tag, Spin, theme
} from 'antd';
import { useNavigate } from 'react-router-dom';
import {
CartItem, ShippingAddress, fetchCart, removeFromCart, fetchAddresses,
createAddress, createCartPaypalOrder, captureCartPaypalOrder, demoCartPurchase
} from './cartApi';
import { fetchConfig, SiteConfig } from '../api';
import { loadPaypalSdk } from '../paypal';
import { useCart } from './CartContext';
import { useCustomerAuth } from '../customer/CustomerAuthContext';
const { Header, Content } = Layout;
const { Title, Text } = Typography;
function timeRemaining(expiresAt: string): string {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (diffMs <= 0) return 'expiring…';
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${hours}h ${mins}m left`;
}
export default function Cart() {
const [items, setItems] = useState<CartItem[]>([]);
const [addresses, setAddresses] = useState<ShippingAddress[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<number | null>(null);
const [addAddressOpen, setAddAddressOpen] = useState(false);
const [config, setConfig] = useState<SiteConfig | null>(null);
const [loading, setLoading] = useState(true);
const [checkingOut, setCheckingOut] = useState(false);
const [form] = Form.useForm();
const { refresh: refreshCartContext } = useCart();
const { customer, loading: authLoading } = useCustomerAuth();
const navigate = useNavigate();
const { token } = theme.useToken();
useEffect(() => {
if (!authLoading && !customer) navigate('/login');
}, [authLoading, customer, navigate]);
function loadAll() {
setLoading(true);
Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => {
setItems(cartData.items);
setAddresses(addrs);
const def = addrs.find(a => a.is_default);
setSelectedAddressId(def ? def.id : (addrs[0]?.id ?? null));
setConfig(cfg);
setLoading(false);
});
}
useEffect(() => { if (customer) loadAll(); }, [customer]);
const total = items.reduce((sum, i) => sum + i.price_cents, 0);
async function handleRemove(itemId: number) {
await removeFromCart(itemId);
message.success('Removed from cart');
refreshCartContext();
loadAll();
}
async function handleAddAddress() {
const values = await form.validateFields();
const result = await createAddress(values);
if (result.uspsConfigured && !result.uspsCheck.deliverable) {
Modal.warning({
title: 'Address could not be verified',
content: result.uspsCheck.reason || 'USPS could not confirm this address is deliverable. It has been saved, but double-check it before checkout.'
});
}
message.success('Address saved');
setAddAddressOpen(false);
form.resetFields();
loadAll();
}
async function handleDemoCheckout() {
if (!selectedAddressId) { message.error('Select a shipping address first'); return; }
setCheckingOut(true);
try {
await demoCartPurchase(selectedAddressId);
message.success('Order complete!');
refreshCartContext();
loadAll();
} catch (err) {
message.error((err as Error).message);
} finally {
setCheckingOut(false);
}
}
const [paypalReady, setPaypalReady] = useState(false);
useEffect(() => {
if (config?.paypalClientId) {
loadPaypalSdk(config.paypalClientId, config.currency).then(() => setPaypalReady(true)).catch(() => {});
}
}, [config]);
useEffect(() => {
if (!paypalReady || !selectedAddressId || items.length === 0) return;
const container = document.getElementById('paypal-cart-buttons');
if (!container || !(window as any).paypal) return;
container.innerHTML = '';
(window as any).paypal.Buttons({
createOrder: async () => {
const { orderID } = await createCartPaypalOrder(selectedAddressId);
return orderID;
},
onApprove: async (data: { orderID: string }) => {
try {
await captureCartPaypalOrder(data.orderID);
message.success('Order complete!');
refreshCartContext();
loadAll();
} catch (err) {
message.error((err as Error).message);
}
},
onError: (err: unknown) => {
console.error(err);
message.error('Checkout error, please try again.');
}
}).render('#paypal-cart-buttons');
}, [paypalReady, selectedAddressId, items.length]);
if (authLoading || loading) return <Spin style={{ margin: 48 }} />;
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Title level={3} style={{ color: token.colorText, margin: 0, lineHeight: '64px' }}>Your Cart</Title>
</Header>
<Content style={{ padding: 24, maxWidth: 700, margin: '0 auto', width: '100%' }}>
{items.length === 0 ? (
<Empty description="Your cart is empty" />
) : (
<>
<List
dataSource={items}
renderItem={item => (
<List.Item actions={[<Button danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
<List.Item.Meta
avatar={item.images[0] && <img src={item.images[0].image_path} style={{ width: 60, height: 60, objectFit: 'cover' }} />}
title={item.name}
description={
<Text type={new Date(item.expires_at).getTime() - Date.now() < 60 * 60 * 1000 ? 'danger' : 'secondary'}>
{timeRemaining(item.expires_at)}
</Text>
}
/>
<div>${(item.price_cents / 100).toFixed(2)}</div>
</List.Item>
)}
/>
<Title level={4} style={{ textAlign: 'right', marginTop: 16 }}>Total: ${(total / 100).toFixed(2)}</Title>
<Card title="Shipping Address" style={{ marginTop: 24 }}>
{addresses.length === 0 ? (
<Text type="secondary">No saved addresses yet.</Text>
) : (
<Radio.Group
value={selectedAddressId}
onChange={(e) => setSelectedAddressId(e.target.value)}
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
{addresses.map(a => (
<Radio key={a.id} value={a.id}>
{a.full_name}, {a.address_line1}{a.address_line2 ? `, ${a.address_line2}` : ''}, {a.city}, {a.state} {a.postal_code}{' '}
{a.usps_validated
? <Tag color="green">USPS Verified</Tag>
: <Tag color="default">Not Verified</Tag>}
</Radio>
))}
</Radio.Group>
)}
<Button style={{ marginTop: 12 }} onClick={() => setAddAddressOpen(true)}>Add New Address</Button>
</Card>
<Card title="Checkout" style={{ marginTop: 24 }}>
{!selectedAddressId && <Text type="warning">Select a shipping address to check out.</Text>}
{config?.paypalClientId && selectedAddressId && <div id="paypal-cart-buttons" />}
{config?.demoMode && selectedAddressId && (
<Button
block
type={config.paypalClientId ? 'default' : 'primary'}
style={{ marginTop: 8 }}
loading={checkingOut}
onClick={handleDemoCheckout}
>
{config.paypalClientId ? 'Checkout (Demo)' : 'Checkout'}
</Button>
)}
</Card>
</>
)}
</Content>
<Modal
title="Add Shipping Address"
open={addAddressOpen}
onOk={handleAddAddress}
onCancel={() => setAddAddressOpen(false)}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="fullName" label="Full Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="addressLine1" label="Address Line 1" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="addressLine2" label="Address Line 2">
<Input />
</Form.Item>
<Form.Item name="city" label="City" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="state" label="State" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="postalCode" label="ZIP Code" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="isDefault" valuePropName="checked" initialValue={addresses.length === 0}>
<Checkbox>Make this my default address</Checkbox>
</Form.Item>
</Form>
</Modal>
</Layout>
);
}
+35
View File
@@ -0,0 +1,35 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { CartItem, fetchCart } from './cartApi';
import { useCustomerAuth } from '../customer/CustomerAuthContext';
interface CartContextValue {
items: CartItem[];
itemIds: Set<number>;
refresh: () => void;
}
const CartContext = createContext<CartContextValue>({ items: [], itemIds: new Set(), refresh: () => {} });
export function useCart() {
return useContext(CartContext);
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const { customer } = useCustomerAuth();
const refresh = useCallback(() => {
if (!customer) { setItems([]); return; }
fetchCart().then(data => setItems(data.items)).catch(() => setItems([]));
}, [customer]);
useEffect(() => { refresh(); }, [refresh]);
const itemIds = new Set(items.map(i => i.item_id));
return (
<CartContext.Provider value={{ items, itemIds, refresh }}>
{children}
</CartContext.Provider>
);
}
+97
View File
@@ -0,0 +1,97 @@
export interface CartItem {
item_id: number;
name: string;
price_cents: number;
status: string;
added_at: string;
expires_at: string;
images: { id: number; image_path: string }[];
}
export interface ShippingAddress {
id: number;
full_name: string;
address_line1: string;
address_line2: string | null;
city: string;
state: string;
postal_code: string;
country: string;
is_default: boolean;
usps_validated: boolean;
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
return res.json();
}
export function fetchCart(): Promise<{ items: CartItem[] }> {
return fetch('/api/cart').then(res => handle(res));
}
export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> {
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' }).then(res => handle(res));
}
export function removeFromCart(itemId: number): Promise<void> {
return fetch(`/api/cart/items/${itemId}`, { method: 'DELETE' }).then(() => undefined);
}
export function fetchAddresses(): Promise<ShippingAddress[]> {
return fetch('/api/customers/me/addresses').then(res => handle(res));
}
export interface AddressInput {
fullName: string;
addressLine1: string;
addressLine2?: string;
city: string;
state: string;
postalCode: string;
country?: string;
isDefault?: boolean;
}
export function createAddress(input: AddressInput): Promise<{ address: ShippingAddress; uspsCheck: { validated: boolean; deliverable: boolean | null; reason?: string }; uspsConfigured: boolean }> {
return fetch('/api/customers/me/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
}).then(res => handle(res));
}
export function deleteAddress(id: number): Promise<void> {
return fetch(`/api/customers/me/addresses/${id}`, { method: 'DELETE' }).then(() => undefined);
}
export function setDefaultAddress(id: number): Promise<ShippingAddress> {
return fetch(`/api/customers/me/addresses/${id}/set-default`, { method: 'POST' }).then(res => handle(res));
}
export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orderID: string }> {
return fetch('/api/checkout/cart/paypal/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shippingAddressId })
}).then(res => handle(res));
}
export function captureCartPaypalOrder(orderID: string): Promise<void> {
return fetch('/api/checkout/cart/paypal/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID })
}).then(res => handle(res));
}
export function demoCartPurchase(shippingAddressId: number): Promise<void> {
return fetch('/api/checkout/cart/demo/purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shippingAddressId })
}).then(res => handle(res));
}
+52 -72
View File
@@ -1,66 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { useState, useRef } from 'react';
import { Card, Badge, Typography, Carousel, Button, message } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel';
import { Item, SiteConfig, createPaypalOrder, capturePaypalOrder, demoPurchase } from '../api';
import { loadPaypalSdk } from '../paypal';
import { Item } from '../api';
import MarkdownView from './MarkdownView';
import { addToCart } from '../cart/cartApi';
import { useCart } from '../cart/CartContext';
import { useCustomerAuth } from '../customer/CustomerAuthContext';
import AuthPromptModal from '../customer/AuthPromptModal';
const { Text, Title } = Typography;
declare global {
interface Window { paypal?: any; }
}
interface Props {
item: Item;
config: SiteConfig;
onPurchased: () => void;
onChanged: () => void;
}
export default function ItemCard({ item, config, onPurchased }: Props) {
const slotRef = useRef<HTMLDivElement>(null);
export default function ItemCard({ item, onChanged }: Props) {
const carouselRef = useRef<CarouselRef>(null);
const [paypalReady, setPaypalReady] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [adding, setAdding] = useState(false);
const { customer } = useCustomerAuth();
const { itemIds, refresh: refreshCart } = useCart();
useEffect(() => {
if (item.status !== 'available' || !config.paypalClientId) return;
loadPaypalSdk(config.paypalClientId, config.currency)
.then(() => setPaypalReady(true))
.catch(err => console.error(err));
}, [item.status, config.paypalClientId, config.currency]);
const inMyCart = itemIds.has(item.id);
useEffect(() => {
if (!paypalReady || !window.paypal || !slotRef.current) return;
slotRef.current.innerHTML = '';
window.paypal.Buttons({
createOrder: () => createPaypalOrder(item.id),
onApprove: async (data: { orderID: string }) => {
try {
await capturePaypalOrder(item.id, data.orderID);
message.success('Purchase complete — thank you!');
onPurchased();
} catch (err) {
message.error((err as Error).message);
}
},
onError: (err: unknown) => {
console.error(err);
message.error('Checkout error, please try again.');
}
}).render(slotRef.current);
}, [paypalReady, item.id]);
async function handleDemoBuy() {
async function doAddToCart() {
setAdding(true);
try {
await demoPurchase(item.id);
message.success('Demo purchase complete — item marked sold.');
onPurchased();
await addToCart(item.id);
message.success('Added to cart');
refreshCart();
onChanged();
} catch (err) {
message.error((err as Error).message);
} finally {
setAdding(false);
}
}
function handleAddClick() {
if (!customer) {
setAuthModalOpen(true);
return;
}
doAddToCart();
}
const hasMultiple = item.images.length > 1;
const cover = item.images.length ? (
@@ -74,20 +60,10 @@ export default function ItemCard({ item, config, onPurchased }: Props) {
</Carousel>
{hasMultiple && (
<>
<Button
className="carousel-arrow carousel-arrow-left"
shape="circle"
size="small"
icon={<LeftOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }}
/>
<Button
className="carousel-arrow carousel-arrow-right"
shape="circle"
size="small"
icon={<RightOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }}
/>
<Button className="carousel-arrow carousel-arrow-left" shape="circle" size="small" icon={<LeftOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }} />
<Button className="carousel-arrow carousel-arrow-right" shape="circle" size="small" icon={<RightOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }} />
<div className="carousel-count">{item.images.length} photos</div>
</>
)}
@@ -96,26 +72,30 @@ export default function ItemCard({ item, config, onPurchased }: Props) {
<div className="card-cover-placeholder" />
);
let actionButton = null;
if (item.status === 'available') {
actionButton = (
<Button block type="primary" loading={adding} onClick={handleAddClick} style={{ marginTop: 8 }}>
Add to Cart
</Button>
);
} else if (item.status === 'reserved') {
actionButton = inMyCart
? <Button block disabled style={{ marginTop: 8 }}>In Your Cart</Button>
: <Button block disabled style={{ marginTop: 8 }}>Reserved</Button>;
}
const card = (
<Card hoverable cover={cover} className="item-card">
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
<MarkdownView content={item.description} />
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
{item.status === 'available' && (
<>
{config.paypalClientId && <div className="paypal-slot" ref={slotRef} />}
{config.demoMode && (
<Button
block
type={config.paypalClientId ? 'default' : 'primary'}
onClick={handleDemoBuy}
style={{ marginTop: 8 }}
>
{config.paypalClientId ? 'Buy Now (Demo)' : 'Buy Now'}
</Button>
)}
</>
)}
{actionButton}
<AuthPromptModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }}
/>
</Card>
);
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Modal, Form, Input, Button, Checkbox, Tabs, Alert } from 'antd';
import { registerCustomer, loginCustomer } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
interface Props {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
const [tab, setTab] = useState<'register' | 'login'>('register');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const { refresh } = useCustomerAuth();
async function handleRegister(values: any) {
setLoading(true);
setError(null);
try {
await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent);
refresh();
onSuccess();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
async function handleLogin(values: any) {
setLoading(true);
setError(null);
try {
await loginCustomer(values.email, values.password);
refresh();
onSuccess();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<Modal
title="Create an account to continue"
open={open}
onCancel={onClose}
footer={null}
destroyOnClose
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as 'register' | 'login')}
items={[
{
key: 'register',
label: 'Create Account',
children: (
<Form form={form} layout="vertical" onFinish={handleRegister}>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>Send me occasional emails about new one-of-a-kind items.</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Create account &amp; continue</Button>
</Form>
)
},
{
key: 'login',
label: 'Log In',
children: (
<Form layout="vertical" onFinish={handleLogin}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Log in &amp; continue</Button>
</Form>
)
}
]}
/>
</Modal>
);
}
+6 -1
View File
@@ -9,7 +9,9 @@ import Login from './customer/Login';
import Register from './customer/Register';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Cart from './cart/Cart';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css';
@@ -29,6 +31,7 @@ function Root() {
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account" element={<Account />} />
<Route path="/cart" element={<Cart />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
</Routes>
</BrowserRouter>
@@ -40,7 +43,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeModeProvider>
<CustomerAuthProvider>
<Root />
<CartProvider>
<Root />
</CartProvider>
</CustomerAuthProvider>
</ThemeModeProvider>
</React.StrictMode>