From fcea37b91b2813d90a8a916ecd466a033b82503e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 20:12:45 -0700 Subject: [PATCH] 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 --- modules/rcart/components/folk-cart-shop.ts | 229 +++++++++++++++++++-- 1 file changed, 214 insertions(+), 15 deletions(-) diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index df7fd9a..fbe9c7e 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -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 { @@ -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 `
- ${this.orders.map((order) => ` -
+ ${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 ` +
+ ${firstItem?.image_url ? `` : ''}

${this.esc(order.artifact_title || "Order")}

- ${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""} - ${order.quantity > 1 ? ` • Qty: ${order.quantity}` : ""} + ${itemCount} item${itemCount !== 1 ? 's' : ''}${order.provider?.name ? ` • ${this.esc(order.provider.name)}` : (order.provider_name ? ` • ${this.esc(order.provider_name)}` : '')}
${order.status}
$${parseFloat(order.total_price || 0).toFixed(2)}
+
`; + }).join("")} +
`; + } + + // ── Order detail view ── + + private renderOrderDetail(): string { + const order = this.selectedOrder; + if (!order) return `
Order not found.
`; + + const items = order.items || []; + const firstImage = items.find((i: any) => i.image_url)?.image_url; + const chainNames: Record = { 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) => ` +
+ ${item.image_url ? `` : `
📦
`} +
+ ${this.esc(item.title)} +
Qty: ${item.quantity} • $${(item.unit_price * item.quantity).toFixed(2)}
+
+
+ `).join(''); + + const timelineHtml = [ + order.created_at ? `
Order placed
${new Date(order.created_at).toLocaleString()}
` : '', + order.paid_at ? `
Payment confirmed
${new Date(order.paid_at).toLocaleString()}
` : '', + order.shipped_at ? `
Shipped
${new Date(order.shipped_at).toLocaleString()}
` : '', + order.status === 'completed' ? `
Completed
${order.shipped_at ? new Date(new Date(order.shipped_at).getTime() + 3 * 86400000).toLocaleString() : ''}
` : '', + ].filter(Boolean).join(''); + + return ` +
+ +
+
+
+ ${firstImage + ? `${this.esc(order.artifact_title)}` + : `
No image
`} +
+
+

${this.esc(order.artifact_title || 'Order')}

+ ${order.status} + +
+
Items
+ ${itemsHtml} +
+ +
+ Total + $${parseFloat(order.total_price).toFixed(2)} ${order.currency || 'USD'} +
+ + ${order.payment ? ` +
+
Payment
+
Method: ${this.esc(order.payment.method || 'n/a')}
+
Token: ${this.esc(order.payment.token || '')} on ${chainName}
+ ${order.payment.tx_hash ? `
Tx: ${order.payment.tx_hash.slice(0, 10)}...${order.payment.tx_hash.slice(-6)}
` : `
Awaiting payment
`} +
` : ''} + + ${order.buyer ? ` +
+
Buyer
+
${this.esc(order.buyer.name)}${order.buyer.email ? ` • ${this.esc(order.buyer.email)}` : ''}
+
` : ''} + + ${order.provider ? ` +
+
${this.esc(order.provider.name)}
+
${this.esc(order.provider.city)} • ${this.esc(order.provider.turnaround)}
+
` : ''} + + ${timelineHtml ? ` +
+
Timeline
+ ${timelineHtml} +
` : ''}
- `).join("")}
`; } @@ -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; }