feat(rcart): catalog detail view, order queue, and group buy

- Add catalog-detail view with quantity selector and order queue
- Add group buy creation flow
- Add ?tab=catalog URL param support
- Expand catalog item schema with inventory fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 17:45:46 -07:00
parent ac08fb74c8
commit 13f331d72c
3 changed files with 563 additions and 5 deletions

View File

@ -20,17 +20,22 @@ class FolkCartShop extends HTMLElement {
private orders: any[] = []; private orders: any[] = [];
private carts: any[] = []; private carts: any[] = [];
private payments: any[] = []; private payments: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "orders" | "payments" = "carts"; private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments" = "carts";
private selectedCartId: string | null = null; private selectedCartId: string | null = null;
private selectedCart: any = null; private selectedCart: any = null;
private selectedCatalogItem: any = null;
private detailQuantity = 1;
private orderQueue: any[] = [];
private orderQueueOpen = false;
private loading = true; private loading = true;
private addingUrl = false; private addingUrl = false;
private contributingAmount = false; private contributingAmount = false;
private extensionInstalled = false; private extensionInstalled = false;
private bannerDismissed = false; private bannerDismissed = false;
private creatingPayment = false; private creatingPayment = false;
private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders" | "payments">("carts"); private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "payments">("carts");
// Guided tour // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
@ -65,6 +70,10 @@ class FolkCartShop extends HTMLElement {
this.space = parts.length >= 1 ? parts[0] : "default"; this.space = parts.length >= 1 ? parts[0] : "default";
} }
// Check URL params for initial tab
const params = new URLSearchParams(window.location.search);
if (params.get('tab') === 'catalog') this.view = 'catalog';
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
} else { } else {
@ -367,6 +376,8 @@ class FolkCartShop extends HTMLElement {
content = this.renderCartDetail(); content = this.renderCartDetail();
} else if (this.view === "catalog") { } else if (this.view === "catalog") {
content = this.renderCatalog(); content = this.renderCatalog();
} else if (this.view === "catalog-detail") {
content = this.renderCatalogDetail();
} else if (this.view === "payments") { } else if (this.view === "payments") {
content = this.renderPayments(); content = this.renderPayments();
} else { } else {
@ -384,7 +395,9 @@ class FolkCartShop extends HTMLElement {
<button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button> <button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button>
</div> </div>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button> <button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
${this.orderQueue.length > 0 ? `<button class="queue-badge" data-action="toggle-queue">Queue (${this.orderQueue.reduce((s: number, i: any) => s + i.quantity, 0)})</button>` : ''}
</div> </div>
${this.orderQueueOpen && this.orderQueue.length > 0 ? this.renderOrderQueue() : ''}
${content} ${content}
`; `;
@ -500,6 +513,54 @@ class FolkCartShop extends HTMLElement {
setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000); setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000);
}); });
}); });
// Catalog card clicks → detail view
this.shadow.querySelectorAll("[data-catalog-id]").forEach((el) => {
el.addEventListener("click", () => {
this.loadCatalogDetail((el as HTMLElement).dataset.catalogId!);
});
});
// Catalog detail quantity controls
this.shadow.querySelector("[data-action='qty-dec']")?.addEventListener("click", () => {
if (this.detailQuantity > 1) { this.detailQuantity--; this.render(); }
});
this.shadow.querySelector("[data-action='qty-inc']")?.addEventListener("click", () => {
this.detailQuantity++; this.render();
});
this.shadow.querySelector("[data-field='detail-qty']")?.addEventListener("change", (e) => {
const val = parseInt((e.target as HTMLInputElement).value) || 1;
this.detailQuantity = Math.max(1, val);
this.render();
});
// Add to queue
this.shadow.querySelector("[data-action='add-to-queue']")?.addEventListener("click", () => {
this.addToQueue();
});
// Start group buy
this.shadow.querySelector("[data-action='start-group-buy']")?.addEventListener("click", () => {
this.startGroupBuy();
});
// Order queue toggle
this.shadow.querySelectorAll("[data-action='toggle-queue']").forEach((el) => {
el.addEventListener("click", () => {
this.orderQueueOpen = !this.orderQueueOpen;
this.render();
});
});
// Remove from queue
this.shadow.querySelectorAll("[data-remove-queue]").forEach((el) => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.removeQueue!);
this.orderQueue.splice(idx, 1);
if (this.orderQueue.length === 0) this.orderQueueOpen = false;
this.render();
});
});
} }
// ── Extension install banner ── // ── Extension install banner ──
@ -659,7 +720,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid catalog-grid"> return `<div class="grid catalog-grid">
${this.catalog.map((entry) => ` ${this.catalog.map((entry) => `
<div class="card catalog-card" data-collab-id="product:${entry.id || entry.title}"> <div class="card catalog-card card-clickable" data-catalog-id="${entry.id}" data-collab-id="product:${entry.id || entry.title}">
${entry.image_url ? `<div class="catalog-img"><img src="${this.esc(entry.image_url)}" alt="${this.esc(entry.title || '')}" loading="lazy" /></div>` : ""} ${entry.image_url ? `<div class="catalog-img"><img src="${this.esc(entry.image_url)}" alt="${this.esc(entry.title || '')}" loading="lazy" /></div>` : ""}
<div class="catalog-body"> <div class="catalog-body">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3> <h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
@ -680,6 +741,201 @@ class FolkCartShop extends HTMLElement {
</div>`; </div>`;
} }
// ── Catalog detail view ──
private loadCatalogDetail(id: string) {
const item = this.catalog.find((e: any) => e.id === id);
if (!item) return;
this.selectedCatalogItem = item;
this.detailQuantity = 1;
this._history.push(this.view);
this.view = "catalog-detail";
this._history.push("catalog-detail");
this.render();
}
private buildDemoFulfillOptions(entry: any) {
const basePrice = entry.price || 10;
return {
provider: {
name: "Community Print Co-op",
city: "Portland, OR",
turnaround: "5-7 business days",
capabilities: entry.required_capabilities || ["dtg-print"],
},
tiers: [
{ min_qty: 1, per_unit: basePrice, currency: entry.currency || "USD" },
{ min_qty: 10, per_unit: +(basePrice * 0.85).toFixed(2), currency: entry.currency || "USD" },
{ min_qty: 25, per_unit: +(basePrice * 0.72).toFixed(2), currency: entry.currency || "USD" },
{ min_qty: 50, per_unit: +(basePrice * 0.60).toFixed(2), currency: entry.currency || "USD" },
],
};
}
private getCurrentTierPrice(): number {
if (!this.selectedCatalogItem) return 0;
const opts = this.buildDemoFulfillOptions(this.selectedCatalogItem);
let price = opts.tiers[0].per_unit;
for (const tier of opts.tiers) {
if (this.detailQuantity >= tier.min_qty) price = tier.per_unit;
}
return price;
}
private renderCatalogDetail(): string {
const item = this.selectedCatalogItem;
if (!item) return `<div class="empty">Item not found.</div>`;
const opts = this.buildDemoFulfillOptions(item);
const unitPrice = this.getCurrentTierPrice();
const totalPrice = (unitPrice * this.detailQuantity).toFixed(2);
const basePrice = opts.tiers[0].per_unit;
return `
<div class="detail-back">
<button class="btn btn-sm" data-action="back">&larr; Back to catalog</button>
</div>
<div class="catalog-detail-layout">
<div class="detail-image">
${item.image_url
? `<img src="${this.esc(item.image_url)}" alt="${this.esc(item.title)}" />`
: `<div class="detail-image-placeholder">No image</div>`}
</div>
<div class="detail-panel">
<h2 class="detail-title">${this.esc(item.title)}</h2>
${item.product_type ? `<span class="tag tag-type" style="margin-bottom:0.5rem;display:inline-block">${this.esc(item.product_type)}</span>` : ''}
${(item.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join(' ')}
${item.description ? `<p class="detail-desc">${this.esc(item.description)}</p>` : ''}
<div class="tier-table">
<div class="tier-header">Volume Pricing</div>
${opts.tiers.map((t: any, i: number) => {
const active = this.detailQuantity >= t.min_qty && (i === opts.tiers.length - 1 || this.detailQuantity < opts.tiers[i + 1].min_qty);
const savings = i > 0 ? Math.round((1 - t.per_unit / basePrice) * 100) : 0;
return `<div class="tier-row ${active ? 'tier-active' : ''}">
<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>`}
</div>`;
}).join('')}
</div>
<div class="qty-row">
<label>Quantity</label>
<div class="qty-controls">
<button class="btn btn-sm" data-action="qty-dec">-</button>
<input type="number" class="qty-input" data-field="detail-qty" value="${this.detailQuantity}" min="1" />
<button class="btn btn-sm" data-action="qty-inc">+</button>
</div>
</div>
<div class="detail-total">
<span>Total</span>
<span class="price">$${totalPrice} ${item.currency || 'USD'}</span>
</div>
<div class="provider-info">
<div class="provider-name">${this.esc(opts.provider.name)}</div>
<div class="provider-meta">${this.esc(opts.provider.city)} &bull; ${this.esc(opts.provider.turnaround)}</div>
</div>
<div class="detail-actions">
<button class="btn btn-primary" data-action="add-to-queue">Add to Queue</button>
<button class="btn" data-action="start-group-buy" ${this.creatingGroupBuy ? 'disabled' : ''}>${this.creatingGroupBuy ? 'Creating...' : 'Start Group Buy'}</button>
</div>
</div>
</div>`;
}
// ── Order queue drawer ──
private renderOrderQueue(): string {
const total = this.orderQueue.reduce((s: number, i: any) => s + i.unitPrice * i.quantity, 0);
return `
<div class="queue-drawer">
<div class="queue-header">
<h3>Order Queue (${this.orderQueue.length})</h3>
<button class="btn-icon" data-action="toggle-queue">&times;</button>
</div>
${this.orderQueue.map((item: any, idx: number) => `
<div class="queue-item">
<div class="queue-item-info">
<span class="queue-item-title">${this.esc(item.title)}</span>
<span class="queue-item-meta">Qty: ${item.quantity} &bull; $${(item.unitPrice * item.quantity).toFixed(2)}</span>
</div>
<button class="btn-icon" data-remove-queue="${idx}">&times;</button>
</div>
`).join('')}
<div class="queue-total">
<span>Total</span>
<span class="price">$${total.toFixed(2)}</span>
</div>
</div>`;
}
private addToQueue() {
if (!this.selectedCatalogItem) return;
const item = this.selectedCatalogItem;
const unitPrice = this.getCurrentTierPrice();
const existing = this.orderQueue.find((q: any) => q.catalogId === item.id);
if (existing) {
existing.quantity += this.detailQuantity;
existing.unitPrice = unitPrice;
} else {
this.orderQueue.push({
catalogId: item.id,
title: item.title,
productType: item.product_type,
imageUrl: item.image_url,
quantity: this.detailQuantity,
unitPrice,
currency: item.currency || 'USD',
});
}
this.render();
}
private async startGroupBuy() {
if (!this.selectedCatalogItem || this.creatingGroupBuy) return;
const item = this.selectedCatalogItem;
const opts = this.buildDemoFulfillOptions(item);
if (this.space === 'demo') {
const demoId = `demo-gb-${Date.now()}`;
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}`);
return;
}
this.creatingGroupBuy = true;
this.render();
try {
const res = await fetch(`${this.getApiBase()}/api/group-buys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(localStorage.getItem('encryptid-token') ? { 'Authorization': `Bearer ${localStorage.getItem('encryptid-token')}` } : {}),
},
body: JSON.stringify({
catalogEntryId: item.id,
tiers: opts.tiers,
description: item.description || '',
}),
});
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}`);
}
} catch (e) {
console.error("Failed to create group buy:", e);
}
this.creatingGroupBuy = false;
this.render();
}
// ── Orders view ── // ── Orders view ──
private renderOrders(): string { private renderOrders(): string {
@ -894,10 +1150,57 @@ class FolkCartShop extends HTMLElement {
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; } .empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); } .loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); }
/* Catalog detail view */
.detail-back { margin-bottom: 1rem; }
.catalog-detail-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
.detail-image img { width: 100%; border-radius: 12px; aspect-ratio: 1; object-fit: cover; }
.detail-image-placeholder { width: 100%; aspect-ratio: 1; background: var(--rs-bg-surface-raised); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: var(--rs-text-muted); }
.detail-title { color: var(--rs-text-primary); font-size: 1.5rem; font-weight: 700; margin: 0 0 0.5rem; }
.detail-desc { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0.75rem 0; }
.tier-table { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; margin: 1rem 0; }
.tier-header { padding: 0.625rem 1rem; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-secondary); border-bottom: 1px solid var(--rs-border); }
.tier-row { display: flex; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--rs-border-subtle); gap: 1rem; transition: background 0.15s; }
.tier-row:last-child { border-bottom: none; }
.tier-active { background: rgba(99,102,241,0.08); }
.tier-qty { color: var(--rs-text-primary); font-weight: 600; min-width: 3rem; }
.tier-price { color: var(--rs-text-primary); flex: 1; }
.tier-savings { color: #4ade80; font-size: 0.8125rem; font-weight: 500; min-width: 3rem; text-align: right; }
.qty-row { display: flex; align-items: center; justify-content: space-between; margin: 1rem 0; }
.qty-row label { color: var(--rs-text-primary); font-weight: 600; }
.qty-controls { display: flex; align-items: center; gap: 0.5rem; }
.qty-input { width: 60px; text-align: center; padding: 0.375rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; }
.detail-total { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-top: 1px solid var(--rs-border); border-bottom: 1px solid var(--rs-border); }
.detail-total span:first-child { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; }
.provider-info { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.75rem 1rem; margin: 1rem 0; }
.provider-name { color: var(--rs-text-primary); font-weight: 600; font-size: 0.875rem; }
.provider-meta { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
.detail-actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
.detail-actions .btn { flex: 1; text-align: center; }
/* Order queue */
.queue-badge { background: var(--rs-primary-hover); color: #fff; border: none; border-radius: 999px; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 600; cursor: pointer; }
.queue-badge:hover { background: #4338ca; }
.queue-drawer { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; margin-bottom: 1rem; }
.queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.queue-header h3 { margin: 0; color: var(--rs-text-primary); font-size: 1rem; }
.queue-item { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--rs-border-subtle); }
.queue-item:last-of-type { border-bottom: none; }
.queue-item-info { flex: 1; }
.queue-item-title { color: var(--rs-text-primary); font-size: 0.875rem; display: block; }
.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); }
@media (max-width: 768px) { .catalog-detail-layout { grid-template-columns: 1fr; } }
@media (max-width: 600px) { @media (max-width: 600px) {
.grid { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; }
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } .catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.url-input-row { flex-direction: column; } .url-input-row { flex-direction: column; }
.detail-actions { flex-direction: column; }
} }
`; `;
} }

View File

@ -19,13 +19,14 @@ import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { import {
catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema, catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
paymentRequestSchema, paymentRequestSchema, groupBuySchema,
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId, catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
paymentRequestDocId, paymentRequestDocId, groupBuyDocId,
type CatalogDoc, type CatalogEntry, type CatalogDoc, type CatalogEntry,
type OrderDoc, type OrderMeta, type OrderDoc, type OrderMeta,
type ShoppingCartDoc, type ShoppingCartIndexDoc, type ShoppingCartDoc, type ShoppingCartIndexDoc,
type PaymentRequestDoc, type PaymentRequestMeta, type PaymentRequestDoc, type PaymentRequestMeta,
type GroupBuyDoc,
type CartItem, type CartStatus, type CartItem, type CartStatus,
} from './schemas'; } from './schemas';
import { extractProductFromUrl } from './extract'; import { extractProductFromUrl } from './extract';
@ -1416,6 +1417,179 @@ function paymentToResponse(p: PaymentRequestMeta) {
}; };
} }
// ── GROUP BUY ROUTES ──
// POST /api/group-buys — Create a group buy from a catalog entry
routes.post("/api/group-buys", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { catalogEntryId, tiers, description, closesInDays } = body;
if (!catalogEntryId || !tiers?.length) return c.json({ error: "catalogEntryId and tiers[] required" }, 400);
const catDoc = _syncServer!.getDoc<CatalogDoc>(catalogDocId(space));
const entry = catDoc?.items?.[catalogEntryId];
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
const buyId = crypto.randomUUID();
const now = Date.now();
const docId = groupBuyDocId(space, buyId);
const doc = Automerge.change(Automerge.init<GroupBuyDoc>(), 'create group buy', (d) => {
const init = groupBuySchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.meta.createdAt = now;
d.buy.id = buyId;
d.buy.catalogEntryId = catalogEntryId;
d.buy.artifactId = entry.artifactId;
d.buy.title = entry.title;
d.buy.productType = entry.productType;
d.buy.imageUrl = (entry as any).imageUrl || null;
d.buy.description = description || '';
d.buy.status = 'OPEN';
d.buy.totalPledged = 0;
d.buy.createdBy = claims.sub || null;
d.buy.createdAt = now;
d.buy.updatedAt = now;
d.buy.closesAt = closesInDays ? now + closesInDays * 86400000 : now + 30 * 86400000;
for (const tier of tiers) {
d.buy.tiers.push({ min_qty: tier.min_qty, per_unit: tier.per_unit, currency: tier.currency || 'USD' });
}
});
_syncServer!.setDoc(docId, doc);
const host = c.req.header('host') || 'rspace.online';
const shareUrl = `https://${host}/${space}/rcart/buy/${buyId}`;
return c.json({ id: buyId, shareUrl }, 201);
});
// GET /api/group-buys — List open group buys for a space
routes.get("/api/group-buys", (c) => {
const space = c.req.param("space") || "demo";
const prefix = `${space}:cart:group-buys:`;
const buys: any[] = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<GroupBuyDoc>(id);
if (doc?.buy) {
buys.push({
id: doc.buy.id,
title: doc.buy.title,
productType: doc.buy.productType,
imageUrl: doc.buy.imageUrl,
status: doc.buy.status,
totalPledged: doc.buy.totalPledged,
tiers: doc.buy.tiers,
closesAt: new Date(doc.buy.closesAt).toISOString(),
createdAt: new Date(doc.buy.createdAt).toISOString(),
});
}
}
}
return c.json({ groupBuys: buys });
});
// GET /api/group-buys/:id — Get group buy details
routes.get("/api/group-buys/:id", (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
const docId = groupBuyDocId(space, buyId);
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
if (!doc) return c.json({ error: "Group buy not found" }, 404);
const pledges = Object.values(doc.pledges || {}).map(p => ({
id: p.id,
displayName: p.displayName,
quantity: p.quantity,
pledgedAt: new Date(p.pledgedAt).toISOString(),
}));
const currentTier = [...doc.buy.tiers].reverse().find(t => doc.buy.totalPledged >= t.min_qty) || doc.buy.tiers[0] || null;
return c.json({
id: doc.buy.id,
catalogEntryId: doc.buy.catalogEntryId,
title: doc.buy.title,
productType: doc.buy.productType,
imageUrl: doc.buy.imageUrl,
description: doc.buy.description,
tiers: doc.buy.tiers,
status: doc.buy.status,
totalPledged: doc.buy.totalPledged,
currentTier,
pledges,
closesAt: new Date(doc.buy.closesAt).toISOString(),
createdAt: new Date(doc.buy.createdAt).toISOString(),
updatedAt: new Date(doc.buy.updatedAt).toISOString(),
});
});
// POST /api/group-buys/:id/pledge — Add a pledge
routes.post("/api/group-buys/:id/pledge", async (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
const docId = groupBuyDocId(space, buyId);
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
if (!doc) return c.json({ error: "Group buy not found" }, 404);
if (doc.buy.status !== 'OPEN') return c.json({ error: "Group buy is no longer open" }, 400);
const body = await c.req.json();
const { quantity, displayName } = body;
if (!quantity || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);
let buyerId: string | null = null;
const token = extractToken(c.req.raw.headers);
if (token) {
try { const claims = await verifyEncryptIDToken(token); buyerId = claims.sub || null; } catch { /* public pledge */ }
}
const pledgeId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<GroupBuyDoc>(docId, 'add pledge', (d) => {
d.pledges[pledgeId] = {
id: pledgeId,
buyerId,
displayName: displayName || 'Anonymous',
quantity,
pledgedAt: now,
};
d.buy.totalPledged += quantity;
d.buy.updatedAt = now;
});
const updated = _syncServer!.getDoc<GroupBuyDoc>(docId)!;
const currentTier = [...updated.buy.tiers].reverse().find(t => updated.buy.totalPledged >= t.min_qty) || updated.buy.tiers[0] || null;
return c.json({
pledgeId,
totalPledged: updated.buy.totalPledged,
currentTier,
}, 201);
});
// ── Page route: group buy page ──
routes.get("/buy/:id", (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
return c.html(renderShell({
title: `Group Buy | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-group-buy-page space="${space}" buy-id="${buyId}"></folk-group-buy-page>`,
scripts: `<script type="module" src="/modules/rcart/folk-group-buy-page.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: request payment (self-service QR generator) ── // ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => { routes.get("/request", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";

View File

@ -361,6 +361,83 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
}), }),
}; };
// ── Group Buy types ──
export type GroupBuyStatus = 'OPEN' | 'LOCKED' | 'ORDERED' | 'CANCELLED';
export interface GroupBuyTier {
min_qty: number;
per_unit: number;
currency: string;
}
export interface GroupBuyPledge {
id: string;
buyerId: string | null;
displayName: string;
quantity: number;
pledgedAt: number;
}
export interface GroupBuyDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
buy: {
id: string;
catalogEntryId: string;
artifactId: string;
title: string;
productType: string | null;
imageUrl: string | null;
description: string;
tiers: GroupBuyTier[];
status: GroupBuyStatus;
totalPledged: number;
createdBy: string | null;
createdAt: number;
updatedAt: number;
closesAt: number;
};
pledges: Record<string, GroupBuyPledge>;
}
export const groupBuySchema: DocSchema<GroupBuyDoc> = {
module: 'cart',
collection: 'group-buys',
version: 1,
init: (): GroupBuyDoc => ({
meta: {
module: 'cart',
collection: 'group-buys',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
buy: {
id: '',
catalogEntryId: '',
artifactId: '',
title: '',
productType: null,
imageUrl: null,
description: '',
tiers: [],
status: 'OPEN',
totalPledged: 0,
createdBy: null,
createdAt: Date.now(),
updatedAt: Date.now(),
closesAt: 0,
},
pledges: {},
}),
};
// ── Helpers ── // ── Helpers ──
export function catalogDocId(space: string) { export function catalogDocId(space: string) {
@ -382,3 +459,7 @@ export function shoppingCartIndexDocId(space: string) {
export function paymentRequestDocId(space: string, paymentId: string) { export function paymentRequestDocId(space: string, paymentId: string) {
return `${space}:cart:payments:${paymentId}` as const; return `${space}:cart:payments:${paymentId}` as const;
} }
export function groupBuyDocId(space: string, buyId: string) {
return `${space}:cart:group-buys:${buyId}` as const;
}