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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 18:29:32 -07:00
parent eb00579183
commit cbf1ae0b2c
2 changed files with 302 additions and 6 deletions

View File

@ -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 {
<div class="tabs">
<button class="tab ${this.view === 'carts' || this.view === 'cart-detail' ? 'active' : ''}" data-view="carts">🛒 Carts (${this.carts.length})</button>
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'group-buys' ? 'active' : ''}" data-view="group-buys">🤝 Group Buys${this.groupBuys.length > 0 ? ` (${this.groupBuys.filter((g: any) => g.status === 'OPEN').length})` : ''}</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
<button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button>
</div>
@ -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 `<div class="empty">
<p>No group buys yet. Start one from any catalog item to unlock volume pricing together.</p>
<button class="btn btn-primary" data-view="catalog">Browse Catalog</button>
</div>`;
}
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 `
<div class="card card-clickable gb-card" data-group-buy-id="${gb.id}">
<div class="gb-card-top">
${gb.imageUrl ? `<img class="gb-card-img" src="${this.esc(gb.imageUrl)}" alt="${this.esc(gb.title)}" />` : `<div class="gb-card-img gb-card-img-placeholder">🤝</div>`}
<div class="gb-card-info">
<h3 class="card-title" style="margin:0">${this.esc(gb.title)}</h3>
<div class="card-meta" style="margin-top:0.25rem">
${gb.productType ? `<span class="tag tag-type">${this.esc(gb.productType)}</span>` : ''}
<span class="status status-${gb.status.toLowerCase()}">${gb.status}</span>
</div>
<div class="gb-card-stats">
<span>${gb.totalPledged} pledged</span>
<span>${daysLeft}d left</span>
<span>$${currentTier.per_unit.toFixed(2)}/ea${savings > 0 ? ` (-${savings}%)` : ''}</span>
</div>
</div>
</div>
<div class="gb-card-progress">
<div class="progress-bar"><div class="progress-fill" style="width:${progressPct}%"></div></div>
<div class="gb-card-progress-label">
${nextTier
? `${remaining} more to unlock $${nextTier.per_unit.toFixed(2)}/ea`
: `Best tier unlocked — $${bestTier.per_unit.toFixed(2)}/ea`}
</div>
</div>
<div class="gb-card-tiers">
${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 `<span class="gb-tier-chip ${isCurrent ? 'gb-tier-current' : ''} ${reached ? 'gb-tier-reached' : ''}">${t.min_qty}+ $${t.per_unit.toFixed(2)}${tierSavings > 0 ? ` (-${tierSavings}%)` : ''}</span>`;
}).join('')}
</div>
</div>`;
};
return `
${open.length > 0 ? `
<h3 class="section-title" style="margin-bottom:0.75rem">Open Group Buys</h3>
<div class="grid gb-grid">${open.map(renderCard).join('')}</div>
` : ''}
${closed.length > 0 ? `
<h3 class="section-title" style="margin-top:1.5rem;margin-bottom:0.75rem;color:var(--rs-text-secondary)">Past Group Buys</h3>
<div class="grid gb-grid">${closed.map(renderCard).join('')}</div>
` : ''}
`;
}
// ── 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; }

View File

@ -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 = `
<style>${styles}</style>
<div class="page">
@ -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 `<div class="tier-row ${active ? 'tier-current' : ''} ${reached ? 'tier-reached' : ''}">
const tierRevenue = t.min_qty * t.per_unit;
const tierCommons = tierRevenue * (this.commonsSharePct / 100);
return `<div class="tier-row ${active ? 'tier-current' : ''} ${reached ? 'tier-reached' : ''} ${this.simQty > 0 && simActive ? 'tier-sim-active' : ''} ${this.simQty > 0 && simReached && !reached ? 'tier-sim-reached' : ''}">
<span class="tier-qty">${t.min_qty}+</span>
<span class="tier-price">$${t.per_unit.toFixed(2)}/ea</span>
${savings > 0 ? `<span class="tier-savings">-${savings}%</span>` : `<span class="tier-savings"></span>`}
${reached ? `<span class="tier-check">&#10003;</span>` : ''}
<span class="tier-commons" title="Commons revenue at this tier">$${tierCommons.toFixed(0)} commons</span>
${reached ? `<span class="tier-check">&#10003;</span>` : this.simQty > 0 && simReached ? `<span class="tier-check tier-check-sim">&#10003;</span>` : ''}
</div>`;
}).join('')}
</div>
@ -215,6 +235,64 @@ class FolkGroupBuyPage extends HTMLElement {
<div class="progress-meta">${d.totalPledged} / ${nextTierQty}</div>
</div>` : `<div class="progress-section"><div class="progress-label text-green">Best tier unlocked!</div></div>`}
<div class="sim-section">
<h3>What If...?</h3>
<p class="sim-desc">Explore how more pledges unlock better pricing and grow the commons.</p>
<div class="sim-controls">
<label class="sim-label">Add pledges</label>
<input type="range" class="sim-slider" data-field="sim-qty" min="0" max="${Math.max(maxTierQty * 1.5, 100)}" value="${this.simQty}" />
<input type="number" class="qty-input sim-qty-input" data-field="sim-qty-num" value="${this.simQty}" min="0" />
</div>
${this.simQty > 0 ? `
<div class="sim-results">
<div class="sim-row">
<span>Total pledged</span>
<span class="sim-value">${d.totalPledged} + ${this.simQty} = <strong>${simTotal}</strong></span>
</div>
<div class="sim-row">
<span>Tier unlocked</span>
<span class="sim-value"><strong>$${simTier.per_unit.toFixed(2)}/ea</strong>${simTier.per_unit < (d.currentTier?.per_unit || d.tiers[0].per_unit) ? ` <span class="sim-better">(better!)</span>` : ''}</span>
</div>
<div class="sim-row">
<span>Total revenue</span>
<span class="sim-value">$${simRevenue.toFixed(2)}</span>
</div>
${simNextTier ? `
<div class="sim-row">
<span>Next unlock</span>
<span class="sim-value">${simNextTier.min_qty - simTotal} more for $${simNextTier.per_unit.toFixed(2)}/ea</span>
</div>
<div class="progress-bar"><div class="progress-fill sim-fill" style="width:${simProgressPct}%"></div></div>
` : `<div class="sim-row"><span class="text-green" style="font-weight:600">Best tier unlocked!</span></div>`}
</div>
` : ''}
<div class="commons-section">
<h4>Commons Revenue</h4>
<div class="commons-controls">
<label class="sim-label">Commons share</label>
<input type="range" class="sim-slider commons-slider" data-field="commons-pct" min="0" max="50" value="${this.commonsSharePct}" />
<span class="commons-pct-label">${this.commonsSharePct}%</span>
</div>
<div class="commons-stats">
<div class="commons-stat">
<div class="commons-stat-value">$${currentCommons.toFixed(2)}</div>
<div class="commons-stat-label">Current</div>
</div>
<div class="commons-stat-arrow">${this.simQty > 0 ? '&#8594;' : ''}</div>
${this.simQty > 0 ? `
<div class="commons-stat commons-stat-projected">
<div class="commons-stat-value">$${commonsRevenue.toFixed(2)}</div>
<div class="commons-stat-label">Projected</div>
</div>
<div class="commons-stat">
<div class="commons-stat-value text-green">+$${(commonsRevenue - currentCommons).toFixed(2)}</div>
<div class="commons-stat-label">Growth</div>
</div>` : ''}
</div>
</div>
</div>
<h3>Pledges (${d.pledges.length})</h3>
<div class="pledges-list">
${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; }
}
`;
}