feat: add customer cart with expiry, shipping addresses with USPS validation, and multi-item PayPal checkout
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user