Thirteen of SonarQube's fourteen code smells, with the fourteenth argued as a false positive rather than coded around. Four of these were not stylistic. The Remove button in Cart's List.Item actions array carried no key, so React could not match it across renders and rebuilt it on every cart render. The cart, customer-auth and favorites providers each passed a freshly allocated object as their context value, which re-renders every consumer whenever the provider renders, cart or session unchanged — and these three wrap the entire storefront, so the fan-out was the whole tree. Two of them also rebuilt a Set on every render for good measure. All four now memoized on the values they actually derive from. The four cognitive-complexity findings wanted real restructuring rather than suppression. parseItemFilters splits into one helper per field, leaving the function with the order those helpers run in and the single rule that spans two fields; the order is preserved exactly, because a query wrong in two ways reports the first field and rearranging the calls would change which error a caller sees. adminCategories' PUT extracts the parent resolution — the existence check and the cycle check — into a resolver that returns the refusal rather than sending it. App's four-way render chain becomes a Catalogue component, which also removes two of the nested ternaries. Customers' confirm dialog moves to module level, since a function's cognitive complexity counts everything declared inside it and that dialog branches on `disabling` five times. The rest were mechanical: two more nested ternaries — a status-colour lookup and a pluralisation helper — and one type assertion that asserted the type the expression already had. Left alone: S6478 on CategoryTreeSelect's popupRender. That is antd's render prop, called as a function and spliced in, never mounted as a component type, so the destroy-the-subtree failure the rule describes cannot happen. Marked false positive in SonarQube with that reasoning rather than contorting the component around a rule that misread it. Verified rather than assumed. Backend unit 78 pass, integration 134 pass, end-to-end 83 pass, both workspaces build clean. ESLint warnings drop from 35 to 31 with no new file warning — the same React and SonarJS rules #60 turned on are what surfaced this backlog in the first place. Refs #81 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.9 KiB
TypeScript
250 lines
9.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Layout, Typography, List, Button, Empty, Card, Radio, Form, Input,
|
|
Checkbox, Modal, message, Tag, Spin, theme, Space
|
|
} from 'antd';
|
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
|
import { useNavigate, Link } 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);
|
|
void 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);
|
|
})
|
|
// Anything here failing left the cart on a spinner with no explanation.
|
|
.catch(() => message.error('Could not load your cart'))
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
useEffect(() => { if (customer) loadAll(); }, [customer]);
|
|
|
|
// compute the total
|
|
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');
|
|
// refreshCartContext is a useCallback with an empty dependency list, so
|
|
// naming it here cannot re-run this effect and re-render the PayPal
|
|
// buttons — it just makes the dependency honest.
|
|
}, [paypalReady, selectedAddressId, items.length, refreshCartContext]);
|
|
|
|
if (authLoading || loading) return <Spin style={{ margin: 48 }} />;
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
<Link to="/">
|
|
<Button icon={<ArrowLeftOutlined />}>Back to Shop</Button>
|
|
</Link>
|
|
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Your Cart</Title>
|
|
</Header>
|
|
<Content style={{ padding: 24, maxWidth: 700, margin: '0 auto', width: '100%' }}>
|
|
{items.length === 0 ? (
|
|
<Space direction="vertical" align="center" style={{ width: '100%', marginTop: 24 }}>
|
|
<Empty description="Your cart is empty" />
|
|
<Link to="/"><Button type="primary">Continue Shopping</Button></Link>
|
|
</Space>
|
|
) : (
|
|
<>
|
|
<List
|
|
dataSource={items}
|
|
renderItem={item => (
|
|
<List.Item actions={[<Button key="remove" danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
|
|
<List.Item.Meta
|
|
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" 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>
|
|
);
|
|
} |