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>
This commit is contained in:
173
src/components/shop/CartStub.jsx
Normal file
173
src/components/shop/CartStub.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// <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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user