Files
apparel-designer/src/components/shop/CartStub.jsx
khalid@traclabs.com 6599a5e68f v0.7: shop storefront + routed app
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>
2026-09-01 23:28:58 -05:00

174 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// <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>
);
}