From 13f331d72c8305ba6df610b90684f8cfb1dbadb4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 17:45:46 -0700 Subject: [PATCH] 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 --- modules/rcart/components/folk-cart-shop.ts | 309 ++++++++++++++++++++- modules/rcart/mod.ts | 178 +++++++++++- modules/rcart/schemas.ts | 81 ++++++ 3 files changed, 563 insertions(+), 5 deletions(-) diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index ab53647..92426c2 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -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 { + ${this.orderQueue.length > 0 ? `` : ''} + ${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 `
${this.catalog.map((entry) => ` -
+
${entry.image_url ? `
${this.esc(entry.title || '')}
` : ""}

${this.esc(entry.title || "Untitled")}

@@ -680,6 +741,201 @@ class FolkCartShop extends HTMLElement {
`; } + // ── 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 `
Item not found.
`; + + const opts = this.buildDemoFulfillOptions(item); + const unitPrice = this.getCurrentTierPrice(); + const totalPrice = (unitPrice * this.detailQuantity).toFixed(2); + const basePrice = opts.tiers[0].per_unit; + + return ` +
+ +
+
+
+ ${item.image_url + ? `${this.esc(item.title)}` + : `
No image
`} +
+
+

${this.esc(item.title)}

+ ${item.product_type ? `${this.esc(item.product_type)}` : ''} + ${(item.tags || []).map((t: string) => `${this.esc(t)}`).join(' ')} + ${item.description ? `

${this.esc(item.description)}

` : ''} + +
+
Volume Pricing
+ ${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 `
+ ${t.min_qty}+ + $${t.per_unit.toFixed(2)}/ea + ${savings > 0 ? `-${savings}%` : ``} +
`; + }).join('')} +
+ +
+ +
+ + + +
+
+ +
+ Total + $${totalPrice} ${item.currency || 'USD'} +
+ +
+
${this.esc(opts.provider.name)}
+
${this.esc(opts.provider.city)} • ${this.esc(opts.provider.turnaround)}
+
+ +
+ + +
+
+
`; + } + + // ── Order queue drawer ── + + private renderOrderQueue(): string { + const total = this.orderQueue.reduce((s: number, i: any) => s + i.unitPrice * i.quantity, 0); + return ` +
+
+

Order Queue (${this.orderQueue.length})

+ +
+ ${this.orderQueue.map((item: any, idx: number) => ` +
+
+ ${this.esc(item.title)} + Qty: ${item.quantity} • $${(item.unitPrice * item.quantity).toFixed(2)} +
+ +
+ `).join('')} +
+ Total + $${total.toFixed(2)} +
+
`; + } + + 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; } } `; } diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 07a1f47..dff08b8 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -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(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(), '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(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(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(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(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(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: ``, + scripts: ``, + styles: ``, + })); +}); + // ── Page route: request payment (self-service QR generator) ── routes.get("/request", (c) => { const space = c.req.param("space") || "demo"; diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index f46a01e..f20bcfe 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -361,6 +361,83 @@ export const paymentRequestSchema: DocSchema = { }), }; +// ── 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; +} + +export const groupBuySchema: DocSchema = { + 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; +}