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 carts: 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 selectedCart: any = null;
private selectedCatalogItem: any = null;
private detailQuantity = 1;
private orderQueue: any[] = [];
private orderQueueOpen = false;
private loading = true;
private addingUrl = false;
private contributingAmount = false;
private extensionInstalled = false;
private bannerDismissed = false;
private creatingPayment = false;
private creatingGroupBuy = false;
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
private _tour!: TourEngine;
@ -65,6 +70,10 @@ class FolkCartShop extends HTMLElement {
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") {
this.loadDemoData();
} else {
@ -367,6 +376,8 @@ class FolkCartShop extends HTMLElement {
content = this.renderCartDetail();
} else if (this.view === "catalog") {
content = this.renderCatalog();
} else if (this.view === "catalog-detail") {
content = this.renderCatalogDetail();
} else if (this.view === "payments") {
content = this.renderPayments();
} else {
@ -384,7 +395,9 @@ class FolkCartShop extends HTMLElement {
<button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button>
</div>
<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>
${this.orderQueueOpen && this.orderQueue.length > 0 ? this.renderOrderQueue() : ''}
${content}
`;
@ -500,6 +513,54 @@ class FolkCartShop extends HTMLElement {
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 ──
@ -659,7 +720,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid catalog-grid">
${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>` : ""}
<div class="catalog-body">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
@ -680,6 +741,201 @@ class FolkCartShop extends HTMLElement {
</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 ──
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; }
.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) {
.grid { grid-template-columns: 1fr; }
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.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 {
catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
paymentRequestSchema,
paymentRequestSchema, groupBuySchema,
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
paymentRequestDocId,
paymentRequestDocId, groupBuyDocId,
type CatalogDoc, type CatalogEntry,
type OrderDoc, type OrderMeta,
type ShoppingCartDoc, type ShoppingCartIndexDoc,
type PaymentRequestDoc, type PaymentRequestMeta,
type GroupBuyDoc,
type CartItem, type CartStatus,
} from './schemas';
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) ──
routes.get("/request", (c) => {
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 ──
export function catalogDocId(space: string) {
@ -382,3 +459,7 @@ export function shoppingCartIndexDocId(space: string) {
export function paymentRequestDocId(space: string, paymentId: string) {
return `${space}:cart:payments:${paymentId}` as const;
}
export function groupBuyDocId(space: string, buyId: string) {
return `${space}:cart:group-buys:${buyId}` as const;
}