AppRouter replaces the single-page App as the mounted root: shop routes (/ and /shop → ShopHome, /shop/:productSlug → ProductPage, /cart → CartStub, /customize → CustomizeRoute) plus the template-manager admin surfaces (/admin/templates|products|featured|trash, v0.8 adds /admin/inventory + RequireAdmin wrapping). App.jsx stays on disk one release as the documented legacy fallback — nothing imports it. New shop UI: components/shop/ (ShopHome/Hero/ProductGrid/ProductCard/ ProductPage/ProductGallery/ProductInfo/ProductBuyPanel/CartStub/ ShopHeader/TemplateGalleryForProduct), hooks (useShopProducts/ useShopProduct/useCart/useFacetCatalog), and shop.css + admin.css. Header gains the module-gated Clear button (onCanClearChange / onElementCountChange mirror the editor module's history-commit signals); i18n keys follow. vite proxies /products to the Node server (template-manager serves product imagery — without it the SPA fallback returns index.html as an image) and adds a template-manager chunk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
7.0 KiB
JavaScript
174 lines
7.0 KiB
JavaScript
// <CartStub> — minimal cart preview at /cart.
|
||
//
|
||
// Reads from useCart(). Renders a table of line items with thumbnail,
|
||
// label, size, option choices, quantity controls, line total, and a
|
||
// grand total. The "Proceed to checkout" button shows an inline
|
||
// banner with "Checkout coming soon" — checkout is downstream.
|
||
//
|
||
// Why a stub
|
||
// ──────────
|
||
// Per locked decision #14: checkout is intentionally out of scope
|
||
// for v0.7. The cart is the hand-off point. When a real checkout
|
||
// lands (Stripe Checkout session, custom flow, whatever), this
|
||
// component is the place to wire it — replace the banner with a
|
||
// POST to the order endpoint and a redirect.
|
||
|
||
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { ShopHeader } from './ShopHeader.jsx';
|
||
import { useCart } from '../../hooks/useCart.js';
|
||
|
||
function formatUSD(cents) {
|
||
return `$${((cents || 0) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
}
|
||
|
||
function describeOptions(optionChoices) {
|
||
const entries = Object.entries(optionChoices || {});
|
||
if (entries.length === 0) return null;
|
||
return entries
|
||
.map(([groupSlug, valueSlug]) => `${groupSlug}: ${valueSlug}`)
|
||
.join(' · ');
|
||
}
|
||
|
||
export function CartStub() {
|
||
const { items, count, totalCents, updateItem, removeItem, clear } = useCart();
|
||
const [checkoutBanner, setCheckoutBanner] = useState(false);
|
||
|
||
const handleCheckout = () => {
|
||
setCheckoutBanner(true);
|
||
// Auto-dismiss after a few seconds so the page doesn't get
|
||
// stuck in the toast state. Re-clicking the button re-arms it.
|
||
setTimeout(() => setCheckoutBanner(false), 4000);
|
||
};
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<ShopHeader />
|
||
<main className="shop-main">
|
||
<section className="shop-cart">
|
||
<header className="shop-cart__head">
|
||
<h1 className="shop-cart__title">Your cart</h1>
|
||
<p className="shop-cart__count">
|
||
{count === 0 ? 'Empty' : count === 1 ? '1 item' : `${count} items`}
|
||
</p>
|
||
</header>
|
||
|
||
{items.length === 0 ? (
|
||
<div className="shop-cart__empty">
|
||
<p>Your cart is empty.</p>
|
||
<Link to="/shop" className="shop-cart__continue">Continue shopping →</Link>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ul className="shop-cart__items">
|
||
{items.map((line) => (
|
||
<li key={line.id} className="shop-cart__line">
|
||
<div className="shop-cart__thumb">
|
||
{line.imageUrl ? (
|
||
<img src={line.imageUrl} alt={line.productLabel} loading="lazy" />
|
||
) : (
|
||
<div className="shop-cart__thumb-fallback" aria-hidden="true">🐾</div>
|
||
)}
|
||
</div>
|
||
<div className="shop-cart__details">
|
||
<h3 className="shop-cart__label">{line.productLabel}</h3>
|
||
<p className="shop-cart__meta">
|
||
{line.sizeLabel && <span>{line.sizeLabel}</span>}
|
||
{describeOptions(line.optionChoices) && (
|
||
<>
|
||
<span aria-hidden="true"> · </span>
|
||
<span>{describeOptions(line.optionChoices)}</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
<p className="shop-cart__unit">{formatUSD(line.unitPriceCents)} each</p>
|
||
</div>
|
||
<div className="shop-cart__qty">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateItem(line.id, { quantity: Math.max(1, line.quantity - 1) })}
|
||
aria-label="Decrease quantity"
|
||
className="shop-qty-btn"
|
||
disabled={line.quantity <= 1}
|
||
>−</button>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="99"
|
||
value={line.quantity}
|
||
onChange={(e) => {
|
||
const n = parseInt(e.target.value, 10);
|
||
if (Number.isFinite(n) && n > 0) {
|
||
updateItem(line.id, { quantity: Math.min(99, n) });
|
||
}
|
||
}}
|
||
className="shop-qty-input"
|
||
aria-label="Quantity"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateItem(line.id, { quantity: Math.min(99, line.quantity + 1) })}
|
||
aria-label="Increase quantity"
|
||
className="shop-qty-btn"
|
||
>+</button>
|
||
</div>
|
||
<div className="shop-cart__line-total">
|
||
{formatUSD(line.unitPriceCents * line.quantity)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeItem(line.id)}
|
||
className="shop-cart__remove"
|
||
aria-label={`Remove ${line.productLabel} from cart`}
|
||
>
|
||
✕
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="shop-cart__summary">
|
||
<div className="shop-cart__total">
|
||
<span>Total</span>
|
||
<strong className="shop-price-large">{formatUSD(totalCents)}</strong>
|
||
</div>
|
||
<p className="shop-cart__shipping-note">
|
||
Shipping and taxes calculated at checkout.
|
||
</p>
|
||
{checkoutBanner && (
|
||
<p className="shop-cart__checkout-banner" role="status">
|
||
🚧 Checkout coming soon! We're still building this part — your cart will be here when it's ready.
|
||
</p>
|
||
)}
|
||
<div className="shop-cart__actions">
|
||
<Link to="/shop" className="shop-cart__continue">
|
||
← Continue shopping
|
||
</Link>
|
||
<div className="shop-cart__actions-right">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (window.confirm('Clear all items from your cart?')) clear();
|
||
}}
|
||
className="shop-cart__clear"
|
||
>
|
||
Clear cart
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleCheckout}
|
||
className="shop-cart__checkout"
|
||
>
|
||
Proceed to checkout →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|