/** * Cart module — cosmolocal print-on-demand shop. * * Ported from /opt/apps/rcart/ (Express → Hono). * Handles catalog (artifact listings), orders, fulfillment resolution. * Integrates with provider-registry for provider matching and flow-service for revenue splits. * * Storage: Automerge documents via SyncServer (no PostgreSQL). */ import * as Automerge from "@automerge/automerge"; import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { depositOrderRevenue } from "./flow"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema, catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId, type CatalogDoc, type CatalogEntry, type OrderDoc, type OrderMeta, type ShoppingCartDoc, type ShoppingCartIndexDoc, type CartItem, type CartStatus, } from './schemas'; import { extractProductFromUrl } from './extract'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // Provider registry URL (for fulfillment resolution) const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || ""; function getProviderUrl(): string { // In unified mode, providers module is co-located — call its routes directly via internal URL // In standalone mode, use PROVIDER_REGISTRY_URL env return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers"; } // ── Automerge helpers ── /** Lazily create (or retrieve) the catalog doc for a space. */ function ensureCatalogDoc(space: string): Automerge.Doc { const docId = catalogDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init catalog', (d) => { const init = catalogSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Get all order docs for a space by scanning known doc IDs. */ function getSpaceOrderDocs(space: string): Array<{ docId: string; doc: Automerge.Doc }> { const prefix = `${space}:cart:orders:`; const results: Array<{ docId: string; doc: Automerge.Doc }> = []; for (const id of _syncServer!.listDocs()) { if (id.startsWith(prefix)) { const doc = _syncServer!.getDoc(id); if (doc) results.push({ docId: id, doc }); } } return results; } // ── CATALOG ROUTES ── // POST /api/catalog/ingest — Add artifact to catalog routes.post("/api/catalog/ingest", async (c) => { const space = c.req.param("space") || "demo"; const artifact = await c.req.json(); if (!artifact.id || !artifact.schema_version || !artifact.type) { return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400); } if (artifact.type !== "print-ready") { return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400); } if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) { return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400); } const doc = ensureCatalogDoc(space); // Check for duplicate artifact_id for (const [, entry] of Object.entries(doc.items)) { if (entry.artifactId === artifact.id) { return c.json({ error: "Artifact already listed", catalog_entry_id: entry.id }, 409); } } const entryId = crypto.randomUUID(); const now = Date.now(); const docId = catalogDocId(space); _syncServer!.changeDoc(docId, 'ingest catalog entry', (d) => { d.items[entryId] = { id: entryId, artifactId: artifact.id, artifact: artifact, title: artifact.payload?.title || "Untitled", productType: artifact.spec?.product_type || null, requiredCapabilities: artifact.spec?.required_capabilities || [], substrates: artifact.spec?.substrates || [], creatorId: artifact.creator?.id || null, sourceSpace: artifact.source_space || space, tags: artifact.payload?.tags || [], status: "active", createdAt: now, updatedAt: now, }; }); return c.json({ id: entryId, artifact_id: artifact.id, title: artifact.payload?.title || "Untitled", product_type: artifact.spec?.product_type || null, status: "active", created_at: new Date(now).toISOString(), }, 201); }); // GET /api/catalog — Browse catalog routes.get("/api/catalog", async (c) => { const space = c.req.param("space") || "demo"; const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query(); const doc = ensureCatalogDoc(space); let entries = Object.values(doc.items); // Apply filters entries = entries.filter((e) => e.status === "active"); if (product_type) entries = entries.filter((e) => e.productType === product_type); if (capability) { const caps = capability.split(","); entries = entries.filter((e) => caps.some((cap) => e.requiredCapabilities.includes(cap))); } if (tag) entries = entries.filter((e) => e.tags.includes(tag)); if (source_space) entries = entries.filter((e) => e.sourceSpace === source_space); if (q) { const lower = q.toLowerCase(); entries = entries.filter((e) => e.title.toLowerCase().includes(lower)); } // Sort by createdAt descending entries.sort((a, b) => b.createdAt - a.createdAt); const limitNum = Math.min(parseInt(limit) || 50, 100); const offsetNum = parseInt(offset) || 0; const total = entries.length; const paged = entries.slice(offsetNum, offsetNum + limitNum); // Map to response shape matching the original SQL response const result = paged.map((e) => { const art = e.artifact as Record | undefined; return { id: e.id, artifact_id: e.artifactId, title: e.title, product_type: e.productType, required_capabilities: e.requiredCapabilities, tags: e.tags, source_space: e.sourceSpace, description: art?.payload?.description || null, pricing: art?.pricing || null, dimensions: art?.spec?.dimensions || null, status: e.status, created_at: new Date(e.createdAt).toISOString(), }; }); return c.json({ entries: result, total, limit: limitNum, offset: offsetNum }); }); // GET /api/catalog/:id — Single catalog entry routes.get("/api/catalog/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = ensureCatalogDoc(space); // Look up by entry id or artifact id let entry: CatalogEntry | undefined; if (doc.items[id]) { entry = doc.items[id]; } else { entry = Object.values(doc.items).find((e) => e.artifactId === id); } if (!entry) return c.json({ error: "Catalog entry not found" }, 404); return c.json({ id: entry.id, artifact: entry.artifact, status: entry.status, created_at: new Date(entry.createdAt).toISOString(), updated_at: new Date(entry.updatedAt).toISOString(), }); }); // PATCH /api/catalog/:id — Update listing status routes.patch("/api/catalog/:id", async (c) => { const space = c.req.param("space") || "demo"; const { status } = await c.req.json(); const valid = ["active", "paused", "sold_out", "removed"]; if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400); const doc = ensureCatalogDoc(space); const entryId = c.req.param("id"); if (!doc.items[entryId]) return c.json({ error: "Catalog entry not found" }, 404); const docId = catalogDocId(space); _syncServer!.changeDoc(docId, `update catalog status → ${status}`, (d) => { d.items[entryId].status = status; d.items[entryId].updatedAt = Date.now(); }); return c.json({ id: entryId, status }); }); // ── ORDER ROUTES ── // POST /api/orders — Create an order routes.post("/api/orders", async (c) => { const space = c.req.param("space") || "demo"; // Optional auth — set buyer_did from claims if authenticated const token = extractToken(c.req.raw.headers); let buyerDid: string | null = null; if (token) { try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {} } const body = await c.req.json(); const { catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact, provider_id, provider_name, provider_distance_km, quantity = 1, production_cost, creator_payout, community_payout, total_price, currency = "USD", payment_method = "manual", payment_tx, payment_network, } = body; if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400); if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400); // Look up catalog entry const catalogDoc = ensureCatalogDoc(space); const lookupId = catalog_entry_id || artifact_id; let entry: CatalogEntry | undefined; if (catalogDoc.items[lookupId]) { entry = catalogDoc.items[lookupId]; } else { entry = Object.values(catalogDoc.items).find((e) => e.artifactId === lookupId || e.id === lookupId); } if (!entry) return c.json({ error: "Catalog entry not found" }, 404); // x402 detection const x402Header = c.req.header("x-payment"); const effectiveMethod = x402Header ? "x402" : payment_method; const initialStatus = x402Header ? "paid" : "pending"; const orderId = crypto.randomUUID(); const now = Date.now(); // Create order doc const oDocId = orderDocId(space, orderId); let orderDoc = Automerge.change(Automerge.init(), 'create order', (d) => { const init = orderSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.order.id = orderId; d.order.catalogEntryId = entry!.id; d.order.artifactId = entry!.artifactId; d.order.buyerId = buyerDid || buyer_id || null; d.order.buyerLocation = buyer_location ? JSON.stringify(buyer_location) : null; d.order.buyerContact = buyer_contact ? JSON.stringify(buyer_contact) : null; d.order.providerId = provider_id; d.order.providerName = provider_name || null; d.order.providerDistanceKm = provider_distance_km || null; d.order.quantity = quantity; d.order.productionCost = production_cost || null; d.order.creatorPayout = creator_payout || null; d.order.communityPayout = community_payout || null; d.order.totalPrice = total_price; d.order.currency = currency; d.order.status = initialStatus; d.order.paymentMethod = effectiveMethod; d.order.paymentTx = payment_tx || null; d.order.paymentNetwork = payment_network || null; d.order.createdAt = now; d.order.updatedAt = now; if (initialStatus === "paid") d.order.paidAt = now; }); _syncServer!.setDoc(oDocId, orderDoc); const order = orderDoc.order; if (initialStatus === "paid") { depositOrderRevenue(total_price, orderId); } // Return response matching original shape return c.json(orderToResponse(order, entry), 201); }); // GET /api/orders — List orders routes.get("/api/orders", async (c) => { const space = c.req.param("space") || "demo"; // Optional auth — filter by buyer if authenticated const token = extractToken(c.req.raw.headers); let authedBuyer: string | null = null; if (token) { try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {} } const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query(); const orderDocs = getSpaceOrderDocs(space); // Build enriched order list with catalog info const catalogDoc = ensureCatalogDoc(space); let orders = orderDocs.map(({ doc }) => { const o = doc.order; const catEntry = catalogDoc.items[o.catalogEntryId]; const resp = orderToResponse(o); resp.artifact_title = catEntry?.title || null; resp.product_type = catEntry?.productType || null; return resp; }); // Apply filters if (status) orders = orders.filter((o) => o.status === status); if (provider_id) orders = orders.filter((o) => o.provider_id === provider_id); const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null); if (effectiveBuyerId) orders = orders.filter((o) => o.buyer_id === effectiveBuyerId); // Sort by created_at descending orders.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); const limitNum = Math.min(parseInt(limit) || 50, 100); const offsetNum = parseInt(offset) || 0; const paged = orders.slice(offsetNum, offsetNum + limitNum); return c.json({ orders: paged }); }); // GET /api/orders/:id — Single order routes.get("/api/orders/:id", async (c) => { const space = c.req.param("space") || "demo"; const orderId = c.req.param("id"); const oDocId = orderDocId(space, orderId); const doc = _syncServer!.getDoc(oDocId); if (!doc) return c.json({ error: "Order not found" }, 404); const catalogDoc = ensureCatalogDoc(space); const catEntry = catalogDoc.items[doc.order.catalogEntryId]; const resp = orderToResponse(doc.order); resp.artifact_envelope = catEntry?.artifact || null; resp.artifact_title = catEntry?.title || null; return c.json(resp); }); // PATCH /api/orders/:id/status — Update order status routes.patch("/api/orders/:id/status", async (c) => { const space = c.req.param("space") || "demo"; const body = await c.req.json(); const { status, payment_tx, payment_network } = body; const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"]; if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400); const orderId = c.req.param("id"); const oDocId = orderDocId(space, orderId); const doc = _syncServer!.getDoc(oDocId); if (!doc) return c.json({ error: "Order not found" }, 404); const now = Date.now(); const updated = _syncServer!.changeDoc(oDocId, `order status → ${status}`, (d) => { d.order.status = status; d.order.updatedAt = now; if (status === "paid") d.order.paidAt = now; if (status === "accepted") d.order.acceptedAt = now; if (status === "completed") d.order.completedAt = now; if (status === "paid" && payment_tx) { d.order.paymentTx = payment_tx; d.order.paymentNetwork = payment_network || null; } }); if (!updated) return c.json({ error: "Order not found" }, 404); if (status === "paid" && updated.order.totalPrice) { depositOrderRevenue(updated.order.totalPrice, orderId); } return c.json(orderToResponse(updated.order)); }); // ── Response helpers ── interface OrderResponse { id: string; catalog_entry_id: string; artifact_id: string; buyer_id: string | null; buyer_location: unknown; buyer_contact: unknown; provider_id: string | null; provider_name: string | null; provider_distance_km: number | null; quantity: number; production_cost: number | null; creator_payout: number | null; community_payout: number | null; total_price: number | null; currency: string; status: string; payment_method: string | null; payment_tx: string | null; payment_network: string | null; created_at: string; paid_at: string | null; accepted_at: string | null; completed_at: string | null; updated_at: string; artifact_title?: string | null; product_type?: string | null; artifact_envelope?: unknown; } /** Convert an OrderMeta to the flat response shape matching the original SQL rows. */ function orderToResponse(o: OrderMeta, catEntry?: CatalogEntry): OrderResponse { return { id: o.id, catalog_entry_id: o.catalogEntryId, artifact_id: o.artifactId, buyer_id: o.buyerId, buyer_location: o.buyerLocation ? tryParse(o.buyerLocation) : null, buyer_contact: o.buyerContact ? tryParse(o.buyerContact) : null, provider_id: o.providerId, provider_name: o.providerName, provider_distance_km: o.providerDistanceKm, quantity: o.quantity, production_cost: o.productionCost, creator_payout: o.creatorPayout, community_payout: o.communityPayout, total_price: o.totalPrice, currency: o.currency, status: o.status, payment_method: o.paymentMethod, payment_tx: o.paymentTx, payment_network: o.paymentNetwork, created_at: new Date(o.createdAt).toISOString(), paid_at: o.paidAt ? new Date(o.paidAt).toISOString() : null, accepted_at: o.acceptedAt ? new Date(o.acceptedAt).toISOString() : null, completed_at: o.completedAt ? new Date(o.completedAt).toISOString() : null, updated_at: new Date(o.updatedAt).toISOString(), ...(catEntry ? { artifact_title: catEntry.title, product_type: catEntry.productType } : {}), }; } function tryParse(s: string): unknown { try { return JSON.parse(s); } catch { return s; } } // ── FULFILLMENT ROUTES ── function round2(n: number): number { return Math.round(n * 100) / 100; } interface ProviderMatch { id: string; name: string; distance_km: number; location: { city: string; region: string; country: string }; turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number }; pricing: Record; wallet: string; } function composeCost(artifact: Record, provider: ProviderMatch, quantity: number) { const spec = artifact.spec as Record | undefined; const capabilities = (spec?.required_capabilities as string[]) || []; const pages = (spec?.pages as number) || 1; const breakdown: { label: string; amount: number }[] = []; for (const cap of capabilities) { const capPricing = provider.pricing?.[cap]; if (!capPricing) continue; const basePrice = capPricing.base_price || 0; let perUnit = capPricing.per_unit || 0; const unitLabel = capPricing.per_unit_label || "per unit"; if (capPricing.volume_breaks) { for (const vb of capPricing.volume_breaks) { if (quantity >= vb.min_qty) perUnit = vb.per_unit; } } let units = quantity; if (unitLabel.includes("page")) units = pages * quantity; if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) }); breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) }); } if (breakdown.length === 0) { breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) }); } return breakdown; } // POST /api/fulfill/resolve — Find fulfillment options routes.post("/api/fulfill/resolve", async (c) => { const space = c.req.param("space") || "demo"; const body = await c.req.json(); const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body; if (!buyer_location?.lat || !buyer_location?.lng) { return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400); } if (!artifact_id && !catalog_entry_id) { return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400); } const catalogDoc = ensureCatalogDoc(space); const lookupId = artifact_id || catalog_entry_id; // Find entry by id or artifact_id, must be active let entry: CatalogEntry | undefined; if (catalogDoc.items[lookupId] && catalogDoc.items[lookupId].status === "active") { entry = catalogDoc.items[lookupId]; } else { entry = Object.values(catalogDoc.items).find( (e) => (e.artifactId === lookupId || e.id === lookupId) && e.status === "active" ); } if (!entry) return c.json({ error: "Artifact not found in catalog" }, 404); const artifact = entry.artifact as Record; const capabilities = artifact.spec?.required_capabilities || []; const substrates = artifact.spec?.substrates || []; if (capabilities.length === 0) { return c.json({ error: "Artifact has no required_capabilities" }, 400); } // Query provider registry (internal module or external service) const providerUrl = getProviderUrl(); const params = new URLSearchParams({ capabilities: capabilities.join(","), lat: String(buyer_location.lat), lng: String(buyer_location.lng), }); if (substrates.length > 0) params.set("substrates", substrates.join(",")); let providers: ProviderMatch[]; try { const resp = await fetch(`${providerUrl}/api/providers/match?${params}`); if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`); const data = await resp.json() as { matches?: ProviderMatch[] }; providers = data.matches || []; } catch (err) { return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502); } if (providers.length === 0) { return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id }); } const options = providers.map((provider) => { const costBreakdown = composeCost(artifact, provider, quantity); const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0); const pricing = artifact.pricing || {}; const creatorPct = (pricing.creator_share_pct || 30) / 100; const communityPct = (pricing.community_share_pct || 0) / 100; const markupMultiplier = 1 / (1 - creatorPct - communityPct); const totalPrice = productionCost * markupMultiplier; const creatorPayout = totalPrice * creatorPct; const communityPayout = totalPrice * communityPct; return { provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" }, production_cost: round2(productionCost), creator_payout: round2(creatorPayout), community_payout: round2(communityPayout), total_price: round2(totalPrice), currency: "USD", turnaround_days: provider.turnaround?.standard_days || 5, cost_breakdown: costBreakdown, }; }); options.sort((a, b) => a.total_price - b.total_price); return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options }); }); // ── SHOPPING CART helpers ── /** Lazily create (or retrieve) the shopping cart index doc for a space. */ function ensureShoppingCartIndex(space: string): Automerge.Doc { const docId = shoppingCartIndexDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init shopping cart index', (d) => { const init = shoppingCartIndexSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Recompute index entry from a shopping cart doc. */ function reindexCart(space: string, cartId: string) { const cartDocId = shoppingCartDocId(space, cartId); const cartDoc = _syncServer!.getDoc(cartDocId); if (!cartDoc) return; const indexDocId = shoppingCartIndexDocId(space); ensureShoppingCartIndex(space); const items = cartDoc.items ? Object.values(cartDoc.items) : []; const totalAmount = items.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0); _syncServer!.changeDoc(indexDocId, 'reindex cart', (d) => { d.carts[cartId] = { name: cartDoc.cart.name, status: cartDoc.cart.status as CartStatus, itemCount: items.length, totalAmount: Math.round(totalAmount * 100) / 100, fundedAmount: cartDoc.cart.fundedAmount, currency: cartDoc.cart.currency, createdAt: cartDoc.cart.createdAt, updatedAt: Date.now(), }; }); } // ── SHOPPING CART ROUTES ── // POST /api/extract — Extract product from URL routes.post("/api/extract", async (c) => { const { url } = await c.req.json(); if (!url) return c.json({ error: "Required: url" }, 400); try { const product = await extractProductFromUrl(url); return c.json(product); } catch (err) { return c.json({ error: "Failed to extract product", detail: err instanceof Error ? err.message : String(err), }, 502); } }); // GET /api/shopping-carts — List carts from index routes.get("/api/shopping-carts", async (c) => { const space = c.req.param("space") || "demo"; const indexDoc = ensureShoppingCartIndex(space); const carts = Object.entries(indexDoc.carts || {}).map(([id, entry]) => ({ id, ...entry, createdAt: new Date(entry.createdAt).toISOString(), updatedAt: new Date(entry.updatedAt).toISOString(), })); carts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return c.json({ carts }); }); // POST /api/shopping-carts — Create cart routes.post("/api/shopping-carts", async (c) => { const space = c.req.param("space") || "demo"; const { name, description = "", targetAmount = 0, currency = "USD" } = await c.req.json(); if (!name) return c.json({ error: "Required: name" }, 400); const cartId = crypto.randomUUID(); const now = Date.now(); const docId = shoppingCartDocId(space, cartId); const cartDoc = Automerge.change(Automerge.init(), 'create shopping cart', (d) => { const init = shoppingCartSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.cart.id = cartId; d.cart.name = name; d.cart.description = description; d.cart.status = 'OPEN'; d.cart.targetAmount = targetAmount; d.cart.fundedAmount = 0; d.cart.currency = currency; d.cart.createdAt = now; d.cart.updatedAt = now; }); _syncServer!.setDoc(docId, cartDoc); reindexCart(space, cartId); return c.json({ id: cartId, name, status: 'OPEN', created_at: new Date(now).toISOString() }, 201); }); // GET /api/shopping-carts/:cartId — Full cart with items + contributions routes.get("/api/shopping-carts/:cartId", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); const items = Object.entries(doc.items || {}).map(([id, item]) => ({ id, ...item, addedAt: new Date(item.addedAt).toISOString(), })); const contributions = Object.entries(doc.contributions || {}).map(([id, contrib]) => ({ id, ...contrib, createdAt: new Date(contrib.createdAt).toISOString(), updatedAt: new Date(contrib.updatedAt).toISOString(), })); return c.json({ id: doc.cart.id, name: doc.cart.name, description: doc.cart.description, status: doc.cart.status, targetAmount: doc.cart.targetAmount, fundedAmount: doc.cart.fundedAmount, currency: doc.cart.currency, createdAt: new Date(doc.cart.createdAt).toISOString(), updatedAt: new Date(doc.cart.updatedAt).toISOString(), items, contributions, events: doc.events || [], }); }); // PUT /api/shopping-carts/:cartId — Update cart routes.put("/api/shopping-carts/:cartId", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); const body = await c.req.json(); const { name, description, status } = body; const validStatuses: CartStatus[] = ['OPEN', 'FUNDING', 'FUNDED', 'CHECKING_OUT', 'ORDERED', 'CLOSED']; if (status && !validStatuses.includes(status)) { return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400); } _syncServer!.changeDoc(docId, 'update cart', (d) => { if (name !== undefined) d.cart.name = name; if (description !== undefined) d.cart.description = description; if (status) d.cart.status = status; d.cart.updatedAt = Date.now(); }); reindexCart(space, cartId); return c.json({ id: cartId, status: status || doc.cart.status }); }); // DELETE /api/shopping-carts/:cartId — Delete cart + remove from index routes.delete("/api/shopping-carts/:cartId", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); // Remove from index const indexDocId = shoppingCartIndexDocId(space); ensureShoppingCartIndex(space); _syncServer!.changeDoc(indexDocId, 'remove cart from index', (d) => { delete d.carts[cartId]; }); // Note: Automerge docs aren't truly deleted, but removing from index effectively hides it _syncServer!.changeDoc(docId, 'close cart', (d) => { d.cart.status = 'CLOSED'; d.cart.updatedAt = Date.now(); }); return c.json({ deleted: true, id: cartId }); }); // POST /api/shopping-carts/:cartId/items — Add item routes.post("/api/shopping-carts/:cartId/items", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); const body = await c.req.json(); const { url, product: preExtracted } = body; let productData: any; if (preExtracted) { // Extension sent pre-extracted product data productData = preExtracted; productData.sourceUrl = url || preExtracted.sourceUrl; } else if (url) { // Extract from URL server-side try { productData = await extractProductFromUrl(url); } catch (err) { return c.json({ error: "Failed to extract product", detail: err instanceof Error ? err.message : String(err) }, 502); } } else { return c.json({ error: "Required: url or product" }, 400); } const itemId = crypto.randomUUID(); const now = Date.now(); const domain = productData.vendor?.domain || productData.sourceUrl ? (() => { try { return new URL(productData.sourceUrl).hostname.replace(/^www\./, ''); } catch { return 'unknown'; } })() : 'unknown'; _syncServer!.changeDoc(docId, 'add item to cart', (d) => { d.items[itemId] = { name: productData.name || 'Unknown Product', price: productData.price ?? null, currency: productData.currency || 'USD', quantity: body.quantity || 1, sourceUrl: productData.sourceUrl || url || '', imageUrl: productData.imageUrl || null, description: productData.description || null, vendor: { name: productData.vendor?.name || domain, domain: productData.vendor?.domain || domain, platform: productData.vendor?.platform || null, }, addedBy: null, addedAt: now, sku: productData.sku || null, }; d.cart.updatedAt = now; d.events.push({ type: 'item_added', actor: 'anonymous', detail: `Added ${productData.name || 'item'}`, timestamp: now, }); }); reindexCart(space, cartId); return c.json({ id: itemId, name: productData.name, price: productData.price, sourceUrl: productData.sourceUrl || url, imageUrl: productData.imageUrl, }, 201); }); // PUT /api/shopping-carts/:cartId/items/:itemId — Update item quantity routes.put("/api/shopping-carts/:cartId/items/:itemId", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const itemId = c.req.param("itemId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404); const { quantity } = await c.req.json(); if (typeof quantity !== 'number' || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400); _syncServer!.changeDoc(docId, 'update item quantity', (d) => { d.items[itemId].quantity = quantity; d.cart.updatedAt = Date.now(); }); reindexCart(space, cartId); return c.json({ id: itemId, quantity }); }); // DELETE /api/shopping-carts/:cartId/items/:itemId — Remove item routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const itemId = c.req.param("itemId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404); const itemName = doc.items[itemId].name; _syncServer!.changeDoc(docId, 'remove item from cart', (d) => { delete d.items[itemId]; d.cart.updatedAt = Date.now(); d.events.push({ type: 'item_removed', actor: 'anonymous', detail: `Removed ${itemName}`, timestamp: Date.now(), }); }); reindexCart(space, cartId); return c.json({ deleted: true, id: itemId }); }); // POST /api/shopping-carts/:cartId/contribute — Add contribution routes.post("/api/shopping-carts/:cartId/contribute", async (c) => { const space = c.req.param("space") || "demo"; const cartId = c.req.param("cartId"); const docId = shoppingCartDocId(space, cartId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Cart not found" }, 404); const { amount, username = "Anonymous", paymentMethod = "MANUAL" } = await c.req.json(); if (typeof amount !== 'number' || amount <= 0) return c.json({ error: "amount must be > 0" }, 400); const contribId = crypto.randomUUID(); const now = Date.now(); _syncServer!.changeDoc(docId, 'add contribution', (d) => { d.contributions[contribId] = { userId: null, username, amount, currency: d.cart.currency, paymentMethod, status: 'confirmed', txHash: null, createdAt: now, updatedAt: now, }; d.cart.fundedAmount = Math.round((d.cart.fundedAmount + amount) * 100) / 100; d.cart.updatedAt = now; d.events.push({ type: 'contribution', actor: username, detail: `Contributed $${amount.toFixed(2)}`, timestamp: now, }); }); reindexCart(space, cartId); return c.json({ id: contribId, amount, fundedAmount: doc.cart.fundedAmount + amount }, 201); }); // ── Extension shortcut routes ── // POST /api/cart/quick-add — Simplified endpoint for extension routes.post("/api/cart/quick-add", async (c) => { const space = c.req.param("space") || "demo"; const { url, product, space: targetSpace } = await c.req.json(); if (!url) return c.json({ error: "Required: url" }, 400); const effectiveSpace = targetSpace || space; // Find or create a default OPEN cart const indexDoc = ensureShoppingCartIndex(effectiveSpace); let activeCartId: string | null = null; for (const [id, entry] of Object.entries(indexDoc.carts || {})) { if (entry.status === 'OPEN') { activeCartId = id; break; } } if (!activeCartId) { // Create a default cart activeCartId = crypto.randomUUID(); const now = Date.now(); const docId = shoppingCartDocId(effectiveSpace, activeCartId); const cartDoc = Automerge.change(Automerge.init(), 'create default cart', (d) => { const init = shoppingCartSchema.init(); Object.assign(d, init); d.meta.spaceSlug = effectiveSpace; d.cart.id = activeCartId!; d.cart.name = 'My Cart'; d.cart.description = 'Default shopping cart'; d.cart.status = 'OPEN'; d.cart.createdAt = now; d.cart.updatedAt = now; }); _syncServer!.setDoc(docId, cartDoc); reindexCart(effectiveSpace, activeCartId); } // Extract product data let productData: any; if (product) { productData = product; productData.sourceUrl = url; } else { try { productData = await extractProductFromUrl(url); } catch { productData = { name: url, sourceUrl: url }; } } const itemId = crypto.randomUUID(); const now = Date.now(); const docId = shoppingCartDocId(effectiveSpace, activeCartId); const domain = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'unknown'; } })(); _syncServer!.changeDoc(docId, 'quick-add item', (d) => { d.items[itemId] = { name: productData.name || 'Unknown Product', price: productData.price ?? null, currency: productData.currency || 'USD', quantity: 1, sourceUrl: url, imageUrl: productData.imageUrl || null, description: productData.description || null, vendor: { name: productData.vendor?.name || domain, domain: productData.vendor?.domain || domain, platform: productData.vendor?.platform || null, }, addedBy: null, addedAt: now, sku: productData.sku || null, }; d.cart.updatedAt = now; }); reindexCart(effectiveSpace, activeCartId); return c.json({ success: true, data: { name: productData.name || url, cartId: activeCartId, itemId, }, }, 201); }); // GET /api/cart/summary — Badge count for extension popup routes.get("/api/cart/summary", async (c) => { const space = c.req.param("space") || "demo"; const indexDoc = ensureShoppingCartIndex(space); let totalItems = 0; let totalAmount = 0; const vendorGroups: Array<{ vendor: { name: string; domain: string }; items: Array<{ name: string; price: number; quantity: number }>; subtotal: number }> = []; for (const [cartId, entry] of Object.entries(indexDoc.carts || {})) { if (entry.status === 'OPEN' || entry.status === 'FUNDING') { totalItems += entry.itemCount; totalAmount += entry.totalAmount; // Get full cart doc for vendor grouping const cartDocId = shoppingCartDocId(space, cartId); const cartDoc = _syncServer!.getDoc(cartDocId); if (cartDoc) { const byVendor: Record = {}; for (const item of Object.values(cartDoc.items || {})) { const key = item.vendor.domain; if (!byVendor[key]) { byVendor[key] = { vendor: { name: item.vendor.name, domain: item.vendor.domain }, items: [], subtotal: 0 }; } byVendor[key].items.push({ name: item.name, price: item.price || 0, quantity: item.quantity }); byVendor[key].subtotal += (item.price || 0) * item.quantity; } vendorGroups.push(...Object.values(byVendor)); } } } return c.json({ success: true, data: { totalItems, totalAmount: Math.round(totalAmount * 100) / 100, currency: 'USD', vendorGroups, }, }); }); // ── Page route: shop ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Shop | rSpace`, moduleId: "rcart", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── Seed template data ── function seedTemplateCart(space: string) { if (!_syncServer) return; const doc = ensureCatalogDoc(space); if (Object.keys(doc.items).length > 0) return; const docId = catalogDocId(space); const now = Date.now(); const items: Array<{ title: string; type: string; caps: string[]; subs: string[]; tags: string[] }> = [ { title: 'Commons Community Sticker Pack', type: 'sticker', caps: ['laser-print'], subs: ['vinyl-matte'], tags: ['merch', 'stickers'], }, { title: 'Cosmolocal Network Poster (A2)', type: 'poster', caps: ['risograph'], subs: ['paper-heavyweight'], tags: ['merch', 'poster', 'cosmolocal'], }, { title: 'rSpace Contributor Tee', type: 'apparel', caps: ['screen-print'], subs: ['cotton-organic'], tags: ['merch', 'apparel'], }, ]; _syncServer.changeDoc(docId, 'seed template catalog', (d) => { for (const item of items) { const id = crypto.randomUUID(); d.items[id] = { id, artifactId: crypto.randomUUID(), artifact: null, title: item.title, productType: item.type, requiredCapabilities: item.caps, substrates: item.subs, creatorId: 'did:demo:seed', sourceSpace: space, tags: item.tags, status: 'active', createdAt: now, updatedAt: now, }; } }); console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`); } export const cartModule: RSpaceModule = { id: "rcart", name: "rCart", icon: "🛒", description: "Group shopping & cosmolocal print-on-demand shop", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [ { pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init }, { pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init }, { pattern: '{space}:cart:shopping:{cartId}', description: 'Shopping cart', init: shoppingCartSchema.init }, { pattern: '{space}:cart:shopping-index', description: 'Shopping cart index', init: shoppingCartIndexSchema.init }, ], routes, standaloneDomain: "rcart.online", landingPage: renderLanding, seedTemplate: seedTemplateCart, async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "orders", name: "Orders", kind: "economic", description: "Order stream with pricing, fulfillment status, and revenue splits", filterable: true, }, { id: "catalog", name: "Catalog", kind: "data", description: "Active catalog listings with product details and pricing", filterable: true, }, { id: "shopping", name: "Shopping Carts", kind: "data", description: "Group shopping carts with pooled items and contributions", filterable: true, }, ], acceptsFeeds: ["economic", "data"], outputPaths: [ { path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" }, { path: "products", name: "Products", icon: "🛍️", description: "Print-on-demand product catalog" }, { path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" }, ], };