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:
khalid@traclabs.com
2026-09-01 23:28:58 -05:00
parent aba2dbedfb
commit 6599a5e68f
23 changed files with 5928 additions and 41 deletions

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