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)}
+
+ ${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}
` : ``}
+
+
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; }
}
`;
}