From cbf1ae0b2c8ac782f6a96935de40a3757fae3cec Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 18:29:32 -0700 Subject: [PATCH] feat(rcart): group buys tab + interactive what-if calculator Add Group Buys tab to the shop nav with listing of all ongoing group buys, showing progress bars, tier chips, and clickable cards that navigate to the full group buy page. Add "What If" simulator to the group buy page with slider-driven pledge projection, dynamic tier highlighting, and a commons revenue calculator with adjustable share percentage. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/components/folk-cart-shop.ts | 174 +++++++++++++++++- .../rcart/components/folk-group-buy-page.ts | 134 +++++++++++++- 2 files changed, 302 insertions(+), 6 deletions(-) diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index 92426c2..cb78f94 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -20,7 +20,8 @@ class FolkCartShop extends HTMLElement { private orders: any[] = []; private carts: any[] = []; private payments: any[] = []; - private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" = "carts"; + private groupBuys: any[] = []; + private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" | "group-buys" = "carts"; private selectedCartId: string | null = null; private selectedCart: any = null; private selectedCatalogItem: any = null; @@ -35,7 +36,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">("carts"); + private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" | "group-buys">("carts"); // Guided tour private _tour!: TourEngine; @@ -43,6 +44,7 @@ class FolkCartShop extends HTMLElement { { target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", advanceOnClick: true }, { target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items.", advanceOnClick: true }, { target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true }, + { target: '[data-view="group-buys"]', title: "Group Buys", message: "Join ongoing group buys to unlock volume pricing. The more people pledge, the cheaper it gets for everyone.", advanceOnClick: true }, { target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status.", advanceOnClick: true }, { target: '[data-view="payments"]', title: "Payments", message: "Create shareable payment requests with QR codes. Click to finish the tour!", advanceOnClick: true }, ]; @@ -73,6 +75,7 @@ class FolkCartShop extends HTMLElement { // Check URL params for initial tab const params = new URLSearchParams(window.location.search); if (params.get('tab') === 'catalog') this.view = 'catalog'; + if (params.get('tab') === 'group-buys') this.view = 'group-buys'; if (this.space === "demo") { this.loadDemoData(); @@ -251,6 +254,48 @@ class FolkCartShop extends HTMLElement { { 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 }, ]; + this.groupBuys = [ + { + id: "demo-gb-1", title: "Cosmolocal Network Tee", productType: "tee", + imageUrl: "/images/catalog/catalog-cosmolocal-tee.jpg", status: "OPEN", + totalPledged: 17, + tiers: [ + { min_qty: 1, per_unit: 25, currency: "USD" }, + { min_qty: 10, per_unit: 21.25, currency: "USD" }, + { min_qty: 25, per_unit: 18, currency: "USD" }, + { min_qty: 50, per_unit: 15, currency: "USD" }, + ], + closesAt: new Date(now + 14 * 86400000).toISOString(), + createdAt: new Date(now - 5 * 86400000).toISOString(), + }, + { + id: "demo-gb-2", title: "#DefectFi Tee", productType: "tee shirt", + imageUrl: "/images/catalog/catalog-defectfi-tee.jpg", status: "OPEN", + totalPledged: 8, + tiers: [ + { min_qty: 1, per_unit: 25, currency: "USD" }, + { min_qty: 10, per_unit: 21.25, currency: "USD" }, + { min_qty: 25, per_unit: 18, currency: "USD" }, + { min_qty: 50, per_unit: 15, currency: "USD" }, + ], + closesAt: new Date(now + 21 * 86400000).toISOString(), + createdAt: new Date(now - 2 * 86400000).toISOString(), + }, + { + id: "demo-gb-3", title: "Doughnut Economics Zine", productType: "zine", + imageUrl: "/images/catalog/catalog-doughnut-economics.jpg", status: "OPEN", + totalPledged: 32, + tiers: [ + { min_qty: 1, per_unit: 8, currency: "USD" }, + { min_qty: 15, per_unit: 6.80, currency: "USD" }, + { min_qty: 30, per_unit: 5.60, currency: "USD" }, + { min_qty: 60, per_unit: 4.80, currency: "USD" }, + ], + closesAt: new Date(now + 10 * 86400000).toISOString(), + createdAt: new Date(now - 8 * 86400000).toISOString(), + }, + ]; + this.loading = false; this.render(); } @@ -285,6 +330,15 @@ class FolkCartShop extends HTMLElement { this.payments = payData.payments || []; } } catch { /* unauthenticated */ } + + // Load group buys + try { + const gbRes = await fetch(`${this.getApiBase()}/api/group-buys`); + if (gbRes.ok) { + const gbData = await gbRes.json(); + this.groupBuys = gbData.groupBuys || []; + } + } catch { /* silent */ } } catch (e) { console.error("Failed to load cart data:", e); } @@ -380,6 +434,8 @@ class FolkCartShop extends HTMLElement { content = this.renderCatalogDetail(); } else if (this.view === "payments") { content = this.renderPayments(); + } else if (this.view === "group-buys") { + content = this.renderGroupBuys(); } else { content = this.renderOrders(); } @@ -391,6 +447,7 @@ class FolkCartShop extends HTMLElement {
+
@@ -514,6 +571,15 @@ class FolkCartShop extends HTMLElement { }); }); + // Group buy card clicks → navigate to group buy page + this.shadow.querySelectorAll("[data-group-buy-id]").forEach((el) => { + el.addEventListener("click", () => { + const buyId = (el as HTMLElement).dataset.groupBuyId!; + const url = `/${this.space}/rcart/buy/${buyId}`; + window.location.href = url; + }); + }); + // Catalog card clicks → detail view this.shadow.querySelectorAll("[data-catalog-id]").forEach((el) => { el.addEventListener("click", () => { @@ -905,7 +971,11 @@ class FolkCartShop extends HTMLElement { const host = window.location.host; const shareUrl = `https://${host}/demo/rcart/buy/${demoId}`; try { await navigator.clipboard.writeText(shareUrl); } catch {} - alert(`Group buy link copied!\n${shareUrl}`); + alert(`Group buy created! Link copied.\nView all group buys in the Group Buys tab.`); + this._history.push(this.view); + this.view = 'group-buys'; + this._history.push('group-buys'); + this.render(); return; } @@ -927,7 +997,17 @@ class FolkCartShop extends HTMLElement { if (res.ok) { const data = await res.json(); try { await navigator.clipboard.writeText(data.shareUrl); } catch {} - alert(`Group buy created! Link copied:\n${data.shareUrl}`); + // Refresh group buys list and navigate to tab + try { + const gbRes = await fetch(`${this.getApiBase()}/api/group-buys`); + if (gbRes.ok) { + const gbData = await gbRes.json(); + this.groupBuys = gbData.groupBuys || []; + } + } catch { /* silent */ } + this._history.push(this.view); + this.view = 'group-buys'; + this._history.push('group-buys'); } } catch (e) { console.error("Failed to create group buy:", e); @@ -936,6 +1016,77 @@ class FolkCartShop extends HTMLElement { this.render(); } + // ── Group Buys view ── + + private renderGroupBuys(): string { + const open = this.groupBuys.filter((g: any) => g.status === 'OPEN'); + const closed = this.groupBuys.filter((g: any) => g.status !== 'OPEN'); + + if (this.groupBuys.length === 0) { + return `
+

No group buys yet. Start one from any catalog item to unlock volume pricing together.

+ +
`; + } + + const renderCard = (gb: any) => { + const currentTier = [...gb.tiers].reverse().find((t: any) => gb.totalPledged >= t.min_qty) || gb.tiers[0]; + const nextTier = gb.tiers.find((t: any) => t.min_qty > gb.totalPledged); + const progressPct = nextTier ? Math.min(100, Math.round((gb.totalPledged / nextTier.min_qty) * 100)) : 100; + const remaining = nextTier ? nextTier.min_qty - gb.totalPledged : 0; + const closesDate = new Date(gb.closesAt); + const daysLeft = Math.max(0, Math.ceil((closesDate.getTime() - Date.now()) / 86400000)); + const bestTier = gb.tiers[gb.tiers.length - 1]; + const savings = currentTier && gb.tiers[0] ? Math.round((1 - currentTier.per_unit / gb.tiers[0].per_unit) * 100) : 0; + + return ` +
+
+ ${gb.imageUrl ? `${this.esc(gb.title)}` : `
🤝
`} +
+

${this.esc(gb.title)}

+
+ ${gb.productType ? `${this.esc(gb.productType)}` : ''} + ${gb.status} +
+
+ ${gb.totalPledged} pledged + ${daysLeft}d left + $${currentTier.per_unit.toFixed(2)}/ea${savings > 0 ? ` (-${savings}%)` : ''} +
+
+
+
+
+
+ ${nextTier + ? `${remaining} more to unlock $${nextTier.per_unit.toFixed(2)}/ea` + : `Best tier unlocked — $${bestTier.per_unit.toFixed(2)}/ea`} +
+
+
+ ${gb.tiers.map((t: any, i: number) => { + const reached = gb.totalPledged >= t.min_qty; + const isCurrent = currentTier && t.min_qty === currentTier.min_qty; + const tierSavings = i > 0 ? Math.round((1 - t.per_unit / gb.tiers[0].per_unit) * 100) : 0; + return `${t.min_qty}+ $${t.per_unit.toFixed(2)}${tierSavings > 0 ? ` (-${tierSavings}%)` : ''}`; + }).join('')} +
+
`; + }; + + return ` + ${open.length > 0 ? ` +

Open Group Buys

+
${open.map(renderCard).join('')}
+ ` : ''} + ${closed.length > 0 ? ` +

Past Group Buys

+
${closed.map(renderCard).join('')}
+ ` : ''} + `; + } + // ── Orders view ── private renderOrders(): string { @@ -1195,6 +1346,21 @@ class FolkCartShop extends HTMLElement { .queue-item-meta { color: var(--rs-text-muted); font-size: 0.75rem; } .queue-total { display: flex; justify-content: space-between; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px solid var(--rs-border); } + /* Group buy cards */ + .gb-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } + .gb-card { padding: 1rem; } + .gb-card-top { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; } + .gb-card-img { width: 64px; height: 64px; border-radius: 10px; object-fit: cover; flex-shrink: 0; } + .gb-card-img-placeholder { background: var(--rs-bg-surface-raised); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; } + .gb-card-info { flex: 1; min-width: 0; } + .gb-card-stats { display: flex; gap: 0.75rem; color: var(--rs-text-muted); font-size: 0.75rem; margin-top: 0.375rem; } + .gb-card-progress { margin-bottom: 0.5rem; } + .gb-card-progress-label { color: var(--rs-text-secondary); font-size: 0.75rem; margin-top: 0.25rem; } + .gb-card-tiers { display: flex; gap: 0.375rem; flex-wrap: wrap; } + .gb-tier-chip { padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; background: var(--rs-bg-surface-raised); color: var(--rs-text-muted); border: 1px solid transparent; } + .gb-tier-reached { color: #4ade80; background: rgba(34,197,94,0.08); } + .gb-tier-current { border-color: var(--rs-primary); color: var(--rs-primary-hover); background: rgba(99,102,241,0.1); font-weight: 600; } + @media (max-width: 768px) { .catalog-detail-layout { grid-template-columns: 1fr; } } @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } diff --git a/modules/rcart/components/folk-group-buy-page.ts b/modules/rcart/components/folk-group-buy-page.ts index 712ca25..f2e5c44 100644 --- a/modules/rcart/components/folk-group-buy-page.ts +++ b/modules/rcart/components/folk-group-buy-page.ts @@ -19,6 +19,8 @@ class FolkGroupBuyPage extends HTMLElement { private pledgeName = ''; private pledging = false; private pledged = false; + private simQty = 0; // "What If" simulator quantity (0 = off) + private commonsSharePct = 20; // % of revenue to commons constructor() { super(); @@ -174,6 +176,19 @@ class FolkGroupBuyPage extends HTMLElement { const closesDate = new Date(d.closesAt); const daysLeft = Math.max(0, Math.ceil((closesDate.getTime() - Date.now()) / 86400000)); + // "What If" simulator calculations + const simTotal = this.simQty > 0 ? d.totalPledged + this.simQty : d.totalPledged; + const simTier = [...d.tiers].reverse().find((t: any) => simTotal >= t.min_qty) || d.tiers[0]; + const simNextTier = d.tiers.find((t: any) => t.min_qty > simTotal); + const simProgressPct = simNextTier ? Math.min(100, Math.round((simTotal / simNextTier.min_qty) * 100)) : 100; + const maxTierQty = d.tiers[d.tiers.length - 1]?.min_qty || 50; + + // Revenue & commons calculations + const currentRevenue = d.totalPledged * (d.currentTier?.per_unit || d.tiers[0]?.per_unit || 0); + const simRevenue = simTotal * simTier.per_unit; + const commonsRevenue = simRevenue * (this.commonsSharePct / 100); + const currentCommons = currentRevenue * (this.commonsSharePct / 100); + this.shadow.innerHTML = `
@@ -198,12 +213,17 @@ class FolkGroupBuyPage extends HTMLElement { ${d.tiers.map((t: any, i: number) => { const active = d.currentTier && t.min_qty === d.currentTier.min_qty; const reached = d.totalPledged >= t.min_qty; + const simReached = simTotal >= t.min_qty; + const simActive = this.simQty > 0 && simTier && t.min_qty === simTier.min_qty; const savings = i > 0 ? Math.round((1 - t.per_unit / d.tiers[0].per_unit) * 100) : 0; - return `
+ const tierRevenue = t.min_qty * t.per_unit; + const tierCommons = tierRevenue * (this.commonsSharePct / 100); + return `
${t.min_qty}+ $${t.per_unit.toFixed(2)}/ea ${savings > 0 ? `-${savings}%` : ``} - ${reached ? `` : ''} + $${tierCommons.toFixed(0)} commons + ${reached ? `` : this.simQty > 0 && simReached ? `` : ''}
`; }).join('')}
@@ -215,6 +235,64 @@ class FolkGroupBuyPage extends HTMLElement {
${d.totalPledged} / ${nextTierQty}
` : `
Best tier unlocked!
`} +
+

What If...?

+

Explore how more pledges unlock better pricing and grow the commons.

+
+ + + +
+ ${this.simQty > 0 ? ` +
+
+ Total pledged + ${d.totalPledged} + ${this.simQty} = ${simTotal} +
+
+ Tier unlocked + $${simTier.per_unit.toFixed(2)}/ea${simTier.per_unit < (d.currentTier?.per_unit || d.tiers[0].per_unit) ? ` (better!)` : ''} +
+
+ Total revenue + $${simRevenue.toFixed(2)} +
+ ${simNextTier ? ` +
+ Next unlock + ${simNextTier.min_qty - simTotal} more for $${simNextTier.per_unit.toFixed(2)}/ea +
+
+ ` : `
Best tier unlocked!
`} +
+ ` : ''} + +
+

Commons Revenue

+
+ + + ${this.commonsSharePct}% +
+
+
+
$${currentCommons.toFixed(2)}
+
Current
+
+
${this.simQty > 0 ? '→' : ''}
+ ${this.simQty > 0 ? ` +
+
$${commonsRevenue.toFixed(2)}
+
Projected
+
+
+
+$${(commonsRevenue - currentCommons).toFixed(2)}
+
Growth
+
` : ''} +
+
+
+

Pledges (${d.pledges.length})

${d.pledges.map((p: any) => ` @@ -278,6 +356,23 @@ class FolkGroupBuyPage extends HTMLElement { const btn = this.shadow.querySelector("[data-action='copy-link']") as HTMLElement; if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Share Link'; }, 2000); } }); + + // "What If" simulator controls + const simSlider = this.shadow.querySelector("[data-field='sim-qty']") as HTMLInputElement; + const simNum = this.shadow.querySelector("[data-field='sim-qty-num']") as HTMLInputElement; + simSlider?.addEventListener("input", (e) => { + this.simQty = parseInt((e.target as HTMLInputElement).value) || 0; + this.render(); + }); + simNum?.addEventListener("change", (e) => { + this.simQty = Math.max(0, parseInt((e.target as HTMLInputElement).value) || 0); + this.render(); + }); + // Commons share slider + this.shadow.querySelector("[data-field='commons-pct']")?.addEventListener("input", (e) => { + this.commonsSharePct = parseInt((e.target as HTMLInputElement).value) || 0; + this.render(); + }); } private getStyles(): string { @@ -352,11 +447,46 @@ class FolkGroupBuyPage extends HTMLElement { .pledge-success-icon { font-size: 2rem; color: #4ade80; margin-bottom: 0.5rem; } .pledge-success p { color: var(--rs-text-secondary); font-size: 0.875rem; margin: 0.5rem 0 1rem; } + /* Tier commons column */ + .tier-commons { color: var(--rs-text-muted); font-size: 0.6875rem; min-width: 5rem; text-align: right; } + + /* Simulated tier states */ + .tier-sim-active { background: rgba(168,85,247,0.1); border-left: 3px solid #a855f7; } + .tier-sim-reached { } + .tier-check-sim { color: #a855f7; opacity: 0.7; } + + /* "What If" simulator */ + .sim-section { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; margin: 1.5rem 0; } + .sim-desc { color: var(--rs-text-secondary); font-size: 0.8125rem; margin: 0 0 1rem; line-height: 1.4; } + .sim-controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } + .sim-label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; white-space: nowrap; } + .sim-slider { flex: 1; accent-color: #a855f7; height: 6px; cursor: pointer; } + .sim-qty-input { width: 60px; } + .sim-results { background: rgba(168,85,247,0.05); border: 1px solid rgba(168,85,247,0.2); border-radius: 8px; padding: 0.75rem; margin-bottom: 1rem; } + .sim-row { display: flex; justify-content: space-between; padding: 0.25rem 0; color: var(--rs-text-primary); font-size: 0.875rem; } + .sim-value { text-align: right; } + .sim-better { color: #4ade80; font-weight: 600; font-size: 0.8125rem; } + .sim-fill { background: linear-gradient(90deg, #a855f7, #d946ef); } + + /* Commons revenue */ + .commons-section { border-top: 1px solid var(--rs-border-subtle); padding-top: 1rem; margin-top: 0.5rem; } + .commons-section h4 { color: var(--rs-text-primary); font-size: 0.9375rem; font-weight: 600; margin: 0 0 0.75rem; } + .commons-controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } + .commons-slider { flex: 1; accent-color: #4ade80; height: 6px; cursor: pointer; } + .commons-pct-label { color: #4ade80; font-weight: 600; font-size: 0.875rem; min-width: 2.5rem; text-align: right; } + .commons-stats { display: flex; align-items: center; gap: 1rem; } + .commons-stat { text-align: center; } + .commons-stat-value { color: var(--rs-text-primary); font-weight: 700; font-size: 1.125rem; } + .commons-stat-label { color: var(--rs-text-muted); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.125rem; } + .commons-stat-projected .commons-stat-value { color: #a855f7; } + .commons-stat-arrow { color: var(--rs-text-muted); font-size: 1.25rem; } + @media (max-width: 768px) { .hero { flex-direction: column; } .hero-img { width: 100%; height: auto; aspect-ratio: 1; } .main-grid { grid-template-columns: 1fr; } .pledge-panel { position: static; } + .commons-stats { flex-wrap: wrap; gap: 0.5rem; } } `; }