import { useEffect, useState } from 'react'; import Layout from 'antd/es/layout'; import Typography from 'antd/es/typography'; import List from 'antd/es/list'; import Button from 'antd/es/button'; import Empty from 'antd/es/empty'; import Card from 'antd/es/card'; import Alert from 'antd/es/alert'; import Radio from 'antd/es/radio'; import Form from 'antd/es/form'; import Input from 'antd/es/input'; import Checkbox from 'antd/es/checkbox'; import Modal from 'antd/es/modal'; import message from 'antd/es/message'; import Tag from 'antd/es/tag'; import Spin from 'antd/es/spin'; import theme from 'antd/es/theme'; import Space from 'antd/es/space'; 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'; import { useNow } from './useNow'; import { timeRemaining, isExpiringSoon, hasLapsedItem } from './reservation'; import { uploadUrl } from '../uploadUrl'; // The display has one-minute resolution, so half a minute keeps it honest // without being busy. Once a second would be wasted work. const COUNTDOWN_INTERVAL_MS = 30_000; const { Header, Content } = Layout; const { Title, Text } = Typography; export default function Cart() { const [items, setItems] = useState([]); const [addresses, setAddresses] = useState([]); const [selectedAddressId, setSelectedAddressId] = useState(null); const [addAddressOpen, setAddAddressOpen] = useState(false); const [config, setConfig] = useState(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)); } // Load-on-mount once the session resolves. `loadAll` sets a pending flag // first, which is what the rule sees. // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { if (customer) loadAll(); }, [customer]); // Only ticks while there is something to count down, so an empty cart does // not wake React forever. const now = useNow(COUNTDOWN_INTERVAL_MS, items.length > 0); const lapsed = hasLapsedItem(items, now); // Past the deadline the item is still held until the server's sweep releases // it, and the client has no way to know when that lands. Refetching on each // tick while anything is lapsed means the row clears within one interval of // the release rather than sitting at "expiring…" until the page is reloaded. useEffect(() => { if (!lapsed) return; // Refetching from the server on a tick, which is synchronisation rather // than derived state — the release happens server-side and the client // has no way to know when. // eslint-disable-next-line react-hooks/set-state-in-effect loadAll(); // The header badge counts held items too, so it goes stale in exactly the // same way. Safe as a dependency: CartContext memoizes it with an empty // dependency list, so it is stable across renders and cannot re-trigger // this effect on its own. refreshCartContext(); // `now` is what paces this: it changes once per tick, and re-running while // an item is lapsed is the point. }, [lapsed, now, refreshCartContext]); // 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); // The item really is marked sold and everyone who favorited it really is // emailed, so the one moment the customer is told what happened is the one // moment a demo order has to stop looking like a real one. #195. message.success('Demo order complete — nothing was charged and nothing will be shipped.'); 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.paypal) return; container.innerHTML = ''; window.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 ; return (
Your Cart
{items.length === 0 ? ( ) : ( <> ( handleRemove(item.item_id)}>Remove]}> } title={item.name} description={ {timeRemaining(item.expires_at, now)} } />
${(item.price_cents / 100).toFixed(2)}
)} /> Total: ${(total / 100).toFixed(2)} {addresses.length === 0 ? ( No saved addresses yet. ) : ( setSelectedAddressId(e.target.value)} style={{ display: 'flex', flexDirection: 'column', gap: 8 }} > {addresses.map(a => ( {a.full_name}, {a.address_line1}{a.address_line2 ? `, ${a.address_line2}` : ''}, {a.city}, {a.state} {a.postal_code}{' '} {a.usps_validated ? USPS Verified : Not Verified} ))} )} {/* Shown for the whole of demo mode: before an address is picked, and whether or not PayPal is configured. The word on the button is the smaller half of this — it asks the customer to notice a parenthesis on the control they have already decided to press. It describes the button rather than the shop, and that is the point rather than a phrasing preference. demoMode and paypalClientId are independent — checkPayPal only *requires* credentials when DEMO_MODE=false, it never forbids them when it is true — and the documented cutover order is to populate the PayPal secrets while demo mode is still on, then flip. In that window live PayPal buttons render directly below this notice, so anything claiming the shop is not taking payments would be false exactly where a customer can be charged. See #203. */} {config?.demoMode && ( )} {!selectedAddressId && Select a shipping address to check out.} {config?.paypalClientId && selectedAddressId &&
} {config?.demoMode && selectedAddressId && ( )} )} setAddAddressOpen(false)} destroyOnHidden >
Make this my default address
); }