feat(rcart): clickable demo orders with detail view

Enrich 5 demo orders with items, buyer, payment, provider, and timeline.
Order cards show thumbnails and item counts; clicking opens a detail view
with payment info, buyer, provider, and timeline using the existing
catalog-detail 2-column layout. Demo payments expanded to 5 (3 linked).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 20:12:45 -07:00
parent 073a64fe56
commit fcea37b91b
1 changed files with 214 additions and 15 deletions

View File

@ -21,10 +21,11 @@ class FolkCartShop extends HTMLElement {
private carts: any[] = [];
private payments: any[] = [];
private groupBuys: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" | "group-buys" = "carts";
private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys" = "carts";
private selectedCartId: string | null = null;
private selectedCart: any = null;
private selectedCatalogItem: any = null;
private selectedOrder: any = null;
private detailQuantity = 1;
private orderQueue: any[] = [];
private orderQueueOpen = false;
@ -36,7 +37,7 @@ class FolkCartShop extends HTMLElement {
private creatingPayment = false;
private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" | "group-buys">("carts");
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts");
// Guided tour
private _tour!: TourEngine;
@ -69,7 +70,7 @@ class FolkCartShop extends HTMLElement {
// Read initial view from attribute (set by server routes) or URL params
const initView = this.getAttribute("initial-view");
if (initView && ["carts","catalog","orders","payments","group-buys","subscriptions"].includes(initView)) {
if (initView && ["carts","catalog","orders","order-detail","payments","group-buys","subscriptions"].includes(initView)) {
this.view = initView as any;
}
const params = new URLSearchParams(window.location.search);
@ -243,14 +244,72 @@ class FolkCartShop extends HTMLElement {
];
this.orders = [
{ id: "demo-ord-1001", total_price: "30.00", currency: "USD", status: "paid", created_at: new Date(now - 2 * 86400000).toISOString(), artifact_title: "Order #1001", quantity: 2 },
{ id: "demo-ord-1002", total_price: "25.00", currency: "USD", status: "pending", created_at: new Date(now - 1 * 86400000).toISOString(), artifact_title: "Order #1002", quantity: 1 },
{ id: "demo-ord-1003", total_price: "23.00", currency: "USD", status: "shipped", created_at: new Date(now - 5 * 86400000).toISOString(), artifact_title: "Order #1003", quantity: 3 },
{
id: "demo-ord-1001", total_price: "30.00", currency: "USD", status: "paid",
created_at: new Date(now - 2 * 86400000).toISOString(), artifact_title: "Order #1001", quantity: 2,
paid_at: new Date(now - 2 * 86400000 + 3600000).toISOString(), shipped_at: null,
items: [
{ title: "#DefectFi Tee", image_url: "/images/catalog/catalog-defectfi-tee.jpg", quantity: 1, unit_price: 25 },
{ title: "Cosmolocal Sticker Sheet", image_url: "/images/catalog/catalog-cosmolocal-stickers.jpg", quantity: 1, unit_price: 5 },
],
buyer: { name: "Alice", email: "alice@example.com" },
payment: { method: "wallet", token: "USDC", chain_id: 8453, tx_hash: "0x7a3b9c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" },
provider: { name: "Community Print Co-op", city: "Portland, OR", turnaround: "5-7 business days" },
},
{
id: "demo-ord-1002", total_price: "25.00", currency: "USD", status: "pending",
created_at: new Date(now - 1 * 86400000).toISOString(), artifact_title: "Order #1002", quantity: 1,
paid_at: null, shipped_at: null,
items: [
{ title: "Cosmolocal Network Tee", image_url: "/images/catalog/catalog-cosmolocal-tee.jpg", quantity: 1, unit_price: 25 },
],
buyer: { name: "Bob", email: "bob@example.com" },
payment: { method: "wallet", token: "USDC", chain_id: 8453, tx_hash: null },
provider: { name: "Community Print Co-op", city: "Portland, OR", turnaround: "5-7 business days" },
},
{
id: "demo-ord-1003", total_price: "32.00", currency: "USD", status: "shipped",
created_at: new Date(now - 5 * 86400000).toISOString(), artifact_title: "Order #1003", quantity: 3,
paid_at: new Date(now - 5 * 86400000 + 1800000).toISOString(), shipped_at: new Date(now - 3 * 86400000).toISOString(),
items: [
{ title: "The Commons", image_url: "/images/catalog/catalog-the-commons.jpg", quantity: 2, unit_price: 12 },
{ title: "Doughnut Economics Zine", image_url: "/images/catalog/catalog-doughnut-economics.jpg", quantity: 1, unit_price: 8 },
],
buyer: { name: "Carol", email: "carol@example.com" },
payment: { method: "wallet", token: "USDC", chain_id: 8453, tx_hash: "0x1122334455667788990011223344556677889900aabbccddeeff0011223344556" },
provider: { name: "Community Print Co-op", city: "Portland, OR", turnaround: "5-7 business days" },
},
{
id: "demo-ord-1004", total_price: "28.00", currency: "USD", status: "paid",
created_at: new Date(now - 4 * 86400000).toISOString(), artifact_title: "Order #1004", quantity: 5,
paid_at: new Date(now - 4 * 86400000 + 7200000).toISOString(), shipped_at: null,
items: [
{ title: "rSpace Logo Patch", image_url: "/images/catalog/catalog-rspace-patch.jpg", quantity: 3, unit_price: 6 },
{ title: "Cosmolocal Vinyl Stickers", image_url: "/images/catalog/catalog-cosmolocal-vinyl-stickers.jpg", quantity: 2, unit_price: 5 },
],
buyer: { name: "Dave", email: "dave@example.com" },
payment: { method: "wallet", token: "ETH", chain_id: 1, tx_hash: "0xaabbccdd11223344556677889900aabbccddeeff11223344556677889900aabb" },
provider: { name: "Community Print Co-op", city: "Portland, OR", turnaround: "5-7 business days" },
},
{
id: "demo-ord-1005", total_price: "18.00", currency: "USD", status: "completed",
created_at: new Date(now - 10 * 86400000).toISOString(), artifact_title: "Order #1005", quantity: 1,
paid_at: new Date(now - 10 * 86400000 + 900000).toISOString(), shipped_at: new Date(now - 7 * 86400000).toISOString(),
items: [
{ title: "Mycelium Networks", image_url: "/images/catalog/catalog-mycelium-networks.jpg", quantity: 1, unit_price: 18 },
],
buyer: { name: "Eve", email: "eve@example.com" },
payment: { method: "wallet", token: "USDC", chain_id: 11155111, tx_hash: "0xdeadbeef00112233445566778899aabbccddeeff00112233445566778899aabb" },
provider: { name: "Community Print Co-op", city: "Portland, OR", turnaround: "5-7 business days" },
},
];
this.payments = [
{ id: "demo-pay-1", description: "Coffee tip", amount: "5.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "paid", paymentMethod: "wallet", txHash: "0xabc123...", created_at: new Date(now - 1 * 86400000).toISOString(), paid_at: new Date(now - 1 * 86400000).toISOString() },
{ id: "demo-pay-2", description: "Invoice #42", amount: "25.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "pending", paymentMethod: null, txHash: null, created_at: new Date(now - 3600000).toISOString(), paid_at: null },
{ id: "demo-pay-1", description: "Order #1001 — #DefectFi Tee + stickers", amount: "30.00", token: "USDC", chainId: 8453, recipientAddress: "0x7a3b...9a0b", status: "paid", paymentMethod: "wallet", txHash: "0x7a3b9c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", created_at: new Date(now - 2 * 86400000).toISOString(), paid_at: new Date(now - 2 * 86400000 + 3600000).toISOString() },
{ id: "demo-pay-2", description: "Order #1003 — The Commons + zine", amount: "32.00", token: "USDC", chainId: 8453, recipientAddress: "0x1122...4556", status: "paid", paymentMethod: "wallet", txHash: "0x1122334455667788990011223344556677889900aabbccddeeff0011223344556", created_at: new Date(now - 5 * 86400000).toISOString(), paid_at: new Date(now - 5 * 86400000 + 1800000).toISOString() },
{ id: "demo-pay-3", description: "Order #1004 — patches + vinyl stickers", amount: "28.00", token: "ETH", chainId: 1, recipientAddress: "0xaabb...aabb", status: "paid", paymentMethod: "wallet", txHash: "0xaabbccdd11223344556677889900aabbccddeeff11223344556677889900aabb", created_at: new Date(now - 4 * 86400000).toISOString(), paid_at: new Date(now - 4 * 86400000 + 7200000).toISOString() },
{ id: "demo-pay-4", description: "Coffee tip", amount: "5.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "paid", paymentMethod: "wallet", txHash: "0xfeed1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678", created_at: new Date(now - 1 * 86400000).toISOString(), paid_at: new Date(now - 1 * 86400000).toISOString() },
{ id: "demo-pay-5", description: "Invoice #42", amount: "25.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "pending", paymentMethod: null, txHash: null, created_at: new Date(now - 3600000).toISOString(), paid_at: null },
];
this.groupBuys = [
@ -415,6 +474,25 @@ class FolkCartShop extends HTMLElement {
}
}
private async contributePay(cartId: string, amount: number, username: string) {
try {
const res = await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/contribute-pay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, username }),
});
if (!res.ok) {
const err = await res.json();
console.error("Failed to create payment:", err.error);
return;
}
const { payUrl } = await res.json();
window.location.href = payUrl;
} catch (e) {
console.error("Failed to create contribution payment:", e);
}
}
// ── Main render ──
private render() {
@ -431,6 +509,8 @@ class FolkCartShop extends HTMLElement {
content = this.renderCatalog();
} else if (this.view === "catalog-detail") {
content = this.renderCatalogDetail();
} else if (this.view === "order-detail") {
content = this.renderOrderDetail();
} else if (this.view === "payments") {
content = this.renderPayments();
} else if (this.view === "group-buys") {
@ -460,6 +540,7 @@ class FolkCartShop extends HTMLElement {
if (!prev) return;
this.view = prev.view;
if (prev.view !== "cart-detail") this.selectedCartId = null;
if (prev.view !== "order-detail") this.selectedOrder = null;
this.render();
}
@ -528,6 +609,13 @@ class FolkCartShop extends HTMLElement {
this.contribute(this.selectedCartId, parseFloat(amtInput.value), nameInput?.value || 'Anonymous');
}
});
this.shadow.querySelector("[data-action='contribute-pay']")?.addEventListener("click", () => {
const amtInput = this.shadow.querySelector("[data-field='contrib-amount']") as HTMLInputElement;
const nameInput = this.shadow.querySelector("[data-field='contrib-name']") as HTMLInputElement;
if (amtInput?.value && this.selectedCartId) {
this.contributePay(this.selectedCartId, parseFloat(amtInput.value), nameInput?.value || 'Anonymous');
}
});
// Payment request actions
const newPaymentBtn = this.shadow.querySelector("[data-action='new-payment']");
@ -557,6 +645,13 @@ class FolkCartShop extends HTMLElement {
});
});
// Order card clicks → detail view
this.shadow.querySelectorAll("[data-order-id]").forEach((el) => {
el.addEventListener("click", () => {
this.loadOrderDetail((el as HTMLElement).dataset.orderId!);
});
});
// Catalog card clicks → detail view
this.shadow.querySelectorAll("[data-catalog-id]").forEach((el) => {
el.addEventListener("click", () => {
@ -742,7 +837,12 @@ class FolkCartShop extends HTMLElement {
<div class="contribute-form" style="display:none">
<input data-field="contrib-name" type="text" placeholder="Your name" class="input" />
<input data-field="contrib-amount" type="number" placeholder="Amount ($)" class="input" step="0.01" min="0.01" />
<button data-action="submit-contribute" class="btn btn-primary btn-sm">Confirm</button>
<div style="display:flex; gap:0.5rem; width:100%">
${cart.recipientAddress
? `<button data-action="contribute-pay" class="btn btn-primary btn-sm" style="flex:1">Pay Now</button>`
: `<button class="btn btn-sm" style="flex:1; opacity:0.5; cursor:not-allowed" disabled title="Cart owner must set a receiving wallet">Pay Now</button>`}
<button data-action="submit-contribute" class="btn btn-sm" style="flex:1">Record Manual</button>
</div>
</div>
</div>
@ -797,6 +897,16 @@ class FolkCartShop extends HTMLElement {
this.render();
}
private loadOrderDetail(id: string) {
const order = this.orders.find((o: any) => o.id === id);
if (!order) return;
this.selectedOrder = order;
this._history.push(this.view);
this.view = "order-detail";
this._history.push("order-detail");
this.render();
}
private buildDemoFulfillOptions(entry: any) {
const basePrice = entry.price || 10;
return {
@ -1072,21 +1182,105 @@ class FolkCartShop extends HTMLElement {
}
return `<div class="grid">
${this.orders.map((order) => `
<div class="card" data-collab-id="order:${order.id}">
${this.orders.map((order) => {
const firstItem = order.items?.[0];
const itemCount = order.items?.reduce((s: number, i: any) => s + (i.quantity || 1), 0) || order.quantity || 0;
return `
<div class="card card-clickable" data-order-id="${order.id}" data-collab-id="order:${order.id}">
<div class="order-card">
${firstItem?.image_url ? `<img class="order-thumb" src="${this.esc(firstItem.image_url)}" alt="" />` : ''}
<div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
<div class="card-meta">
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
${order.quantity > 1 ? ` &bull; Qty: ${order.quantity}` : ""}
${itemCount} item${itemCount !== 1 ? 's' : ''}${order.provider?.name ? ` &bull; ${this.esc(order.provider.name)}` : (order.provider_name ? ` &bull; ${this.esc(order.provider_name)}` : '')}
</div>
<span class="status status-${order.status}">${order.status}</span>
</div>
<div class="order-price">$${parseFloat(order.total_price || 0).toFixed(2)}</div>
</div>
</div>`;
}).join("")}
</div>`;
}
// ── Order detail view ──
private renderOrderDetail(): string {
const order = this.selectedOrder;
if (!order) return `<div class="empty">Order not found.</div>`;
const items = order.items || [];
const firstImage = items.find((i: any) => i.image_url)?.image_url;
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum', 11155111: 'Sepolia' };
const chainName = order.payment?.chain_id ? (chainNames[order.payment.chain_id] || `Chain ${order.payment.chain_id}`) : '';
const itemsHtml = items.map((item: any) => `
<div class="item-row">
${item.image_url ? `<img class="item-thumb" src="${this.esc(item.image_url)}" alt="" />` : `<div class="item-thumb item-thumb-placeholder">📦</div>`}
<div class="item-info">
<span class="item-name">${this.esc(item.title)}</span>
<div class="item-meta">Qty: ${item.quantity} &bull; $${(item.unit_price * item.quantity).toFixed(2)}</div>
</div>
</div>
`).join('');
const timelineHtml = [
order.created_at ? `<div class="item-row"><div class="item-info"><span class="item-name">Order placed</span><div class="item-meta">${new Date(order.created_at).toLocaleString()}</div></div></div>` : '',
order.paid_at ? `<div class="item-row"><div class="item-info"><span class="item-name">Payment confirmed</span><div class="item-meta">${new Date(order.paid_at).toLocaleString()}</div></div></div>` : '',
order.shipped_at ? `<div class="item-row"><div class="item-info"><span class="item-name">Shipped</span><div class="item-meta">${new Date(order.shipped_at).toLocaleString()}</div></div></div>` : '',
order.status === 'completed' ? `<div class="item-row"><div class="item-info"><span class="item-name">Completed</span><div class="item-meta">${order.shipped_at ? new Date(new Date(order.shipped_at).getTime() + 3 * 86400000).toLocaleString() : ''}</div></div></div>` : '',
].filter(Boolean).join('');
return `
<div class="detail-back">
<button class="btn btn-sm" data-action="back">&larr; Back to orders</button>
</div>
<div class="catalog-detail-layout">
<div class="detail-image">
${firstImage
? `<img src="${this.esc(firstImage)}" alt="${this.esc(order.artifact_title)}" />`
: `<div class="detail-image-placeholder">No image</div>`}
</div>
<div class="detail-panel">
<h2 class="detail-title">${this.esc(order.artifact_title || 'Order')}</h2>
<span class="status status-${order.status}" style="margin-bottom:0.75rem;display:inline-block">${order.status}</span>
<div class="order-detail-section">
<div class="order-detail-label">Items</div>
${itemsHtml}
</div>
<div class="detail-total">
<span>Total</span>
<span class="price">$${parseFloat(order.total_price).toFixed(2)} ${order.currency || 'USD'}</span>
</div>
${order.payment ? `
<div class="order-detail-section">
<div class="order-detail-label">Payment</div>
<div class="card-meta">Method: ${this.esc(order.payment.method || 'n/a')}</div>
<div class="card-meta">Token: ${this.esc(order.payment.token || '')} on ${chainName}</div>
${order.payment.tx_hash ? `<div class="tx-hash">Tx: ${order.payment.tx_hash.slice(0, 10)}...${order.payment.tx_hash.slice(-6)}</div>` : `<div class="card-meta" style="color:#fbbf24">Awaiting payment</div>`}
</div>` : ''}
${order.buyer ? `
<div class="order-detail-section">
<div class="order-detail-label">Buyer</div>
<div class="card-meta">${this.esc(order.buyer.name)}${order.buyer.email ? ` &bull; ${this.esc(order.buyer.email)}` : ''}</div>
</div>` : ''}
${order.provider ? `
<div class="provider-info">
<div class="provider-name">${this.esc(order.provider.name)}</div>
<div class="provider-meta">${this.esc(order.provider.city)} &bull; ${this.esc(order.provider.turnaround)}</div>
</div>` : ''}
${timelineHtml ? `
<div class="order-detail-section">
<div class="order-detail-label">Timeline</div>
${timelineHtml}
</div>` : ''}
</div>
`).join("")}
</div>`;
}
@ -1260,9 +1454,14 @@ class FolkCartShop extends HTMLElement {
.contrib-name { color: var(--rs-text-primary); flex: 1; }
.contrib-amount { color: #4ade80; font-weight: 600; }
.order-card { display: flex; justify-content: space-between; align-items: center; }
.order-card { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; }
.order-info { flex: 1; }
.order-price { color: var(--rs-text-primary); font-weight: 600; font-size: 1.125rem; }
.order-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
.order-detail-section { margin: 1rem 0; }
.order-detail-label { color: var(--rs-text-secondary); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border-subtle); margin-bottom: 0.5rem; }
.tx-hash { font-family: monospace; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.25rem; word-break: break-all; }
.ext-banner { position: relative; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 12px; padding: 1rem 2.5rem 1rem 1.25rem; margin-bottom: 1rem; }
.ext-banner-dismiss { position: absolute; top: 0.5rem; right: 0.75rem; background: none; border: none; color: var(--rs-text-secondary); font-size: 1.25rem; cursor: pointer; padding: 0 4px; line-height: 1; }