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
+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>
);