Files
redefined-designs/frontend/src/cart/Cart.tsx
T
bermudalambandClaude Opus 5 b897e99363 fix(cart): say what the demo button does rather than what the shop does (#203)
#195 added a notice reading "Demonstration only — This shop is not taking payments at the moment", gated on `demoMode` alone. That claim is false in a configuration the ops runbook actively steers towards.

`demoMode` and `paypalClientId` are independent. `checkDemoMode` and `checkPayPal` only make the PayPal secrets *required* when `DEMO_MODE=false`; nothing forbids them while it is `true`. And `production-stack-cutover.md:65` says flipping to `false` without all three crash-loops the container — so the only safe order is to populate the secrets while demo mode is still on, verify, then flip. In that window `Cart.tsx` renders live PayPal buttons directly beneath a banner telling the customer the shop takes no payments, and it is precisely the window in which someone is clicking around production checking their work.

That is the same failure #195 fixed, pointed the other way: silent where a warning was needed, then confidently wrong where a customer can actually be charged. Telling someone nothing will be shipped above a live PayPal button is worse than saying nothing.

The notice now describes the button instead of the shop, which is true in both configurations and stays visible in the one with two controls that do different things — where a customer most needs to be told they differ. Gating it on `!paypalClientId` would also have removed the false claim, by hiding the notice exactly there, which is the worse trade.

The test for it was also not testing it. "Says so before the customer commits" seeded an address with `isDefault: true`, and the cart auto-selects the default on load — so an address was already selected and the button already rendered when it asserted. It would have passed with the notice moved inside the `selectedAddressId` guard, which is the regression it exists to catch. It now seeds no address and asserts the notice is up while the checkout button is absent, which states the property directly.

Mutation-tested rather than assumed: moving the Alert inside that guard fails the new test, and would not have failed the old one.

Verified: 3 end-to-end tests pass against a browser, frontend build clean, lint 0 errors (2 pre-existing warnings in `src/filters.ts`).

Closes #203
Refs #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:05:21 -05:00

324 lines
14 KiB
TypeScript

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<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));
}
// 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 <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={uploadUrl(item.images[0].image_path)} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
title={item.name}
description={
<Text type={isExpiringSoon(item.added_at, item.expires_at, now) ? 'danger' : 'secondary'}>
{timeRemaining(item.expires_at, now)}
</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 }}>
{/*
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 && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="Demo checkout"
description="The Checkout (Demo) button places a pretend order: nothing is charged, and nothing will be shipped."
/>
)}
{!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
// Secondary when real PayPal buttons sit above it, primary when
// it is the only way to check out. The label does not follow
// that: a demo order is a demo order either way, and gating the
// word on a PayPal client id meant production — which runs demo
// mode precisely because it has no PayPal credentials — was the
// one place the label never appeared. #195.
type={config.paypalClientId ? 'default' : 'primary'}
style={{ marginTop: 8 }}
loading={checkingOut}
onClick={handleDemoCheckout}
>
Checkout (Demo)
</Button>
)}
</Card>
</>
)}
</Content>
<Modal
title="Add Shipping Address"
open={addAddressOpen}
onOk={handleAddAddress}
onCancel={() => setAddAddressOpen(false)}
destroyOnHidden
>
<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>
);
}