From 12d3e86e13b1707a0d5d6ca47280ee270adf5f60 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 01:21:38 -0700 Subject: [PATCH] feat(rcart): merge group shopping into rCart module Add shared shopping carts with URL product extraction, pooled funding, and extension support alongside existing cosmolocal catalog/orders. - Shopping cart schemas (ShoppingCartDoc, ShoppingCartIndexDoc) - Server-side product extraction (JSON-LD, meta tags, Amazon/Shopify) - 13 new API routes (cart CRUD, items, contributions, extension endpoints) - 3-tab UI (Carts/Catalog/Orders) with cart detail, funding progress - Local-first client subscriptions for real-time cart sync - Updated landing page to reflect group shopping workflow Co-Authored-By: Claude Opus 4.6 --- modules/rcart/components/folk-cart-shop.ts | 669 +++++++++++++++------ modules/rcart/extract.ts | 276 +++++++++ modules/rcart/landing.ts | 12 +- modules/rcart/local-first-client.ts | 53 +- modules/rcart/mod.ts | 504 +++++++++++++++- modules/rcart/schemas.ts | 146 +++++ 6 files changed, 1470 insertions(+), 190 deletions(-) create mode 100644 modules/rcart/extract.ts diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index 2163b01..bf33a26 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -1,9 +1,14 @@ /** - * — browse catalog, view orders, trigger fulfillment. - * Shows catalog items, order creation flow, and order status tracking. + * — group shopping carts, cosmolocal catalog, and orders. + * Three-tab view: Carts (default) | Catalog | Orders */ -import { catalogSchema, catalogDocId, type CatalogDoc, orderSchema, type OrderDoc } from "../schemas"; +import { + catalogSchema, catalogDocId, type CatalogDoc, + orderSchema, type OrderDoc, + shoppingCartIndexSchema, shoppingCartIndexDocId, type ShoppingCartIndexDoc, + shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc, +} from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; class FolkCartShop extends HTMLElement { @@ -11,8 +16,13 @@ class FolkCartShop extends HTMLElement { private space = "default"; private catalog: any[] = []; private orders: any[] = []; - private view: "catalog" | "orders" = "catalog"; + private carts: any[] = []; + private view: "carts" | "cart-detail" | "catalog" | "orders" = "carts"; + private selectedCartId: string | null = null; + private selectedCart: any = null; private loading = true; + private addingUrl = false; + private contributingAmount = false; private _offlineUnsubs: (() => void)[] = []; constructor() { @@ -21,7 +31,6 @@ class FolkCartShop extends HTMLElement { } connectedCallback() { - // Resolve space from attribute or URL path const attr = this.getAttribute("space"); if (attr) { this.space = attr; @@ -50,7 +59,7 @@ class FolkCartShop extends HTMLElement { if (!runtime?.isInitialized) return; try { - // Subscribe to catalog (single doc per space) + // Subscribe to catalog const catDocId = catalogDocId(this.space) as DocumentId; const catDoc = await runtime.subscribe(catDocId, catalogSchema); if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) { @@ -75,11 +84,25 @@ class FolkCartShop extends HTMLElement { } })); - // Subscribe to orders (multi-doc) + // Subscribe to shopping cart index + const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId; + const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema); + if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) { + this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c })); + this.render(); + } + this._offlineUnsubs.push(runtime.onChange(indexDocId, (doc: ShoppingCartIndexDoc) => { + if (doc?.carts) { + this.carts = Object.entries(doc.carts).map(([id, c]) => ({ id, ...c })); + this.render(); + } + })); + + // Subscribe to orders const orderDocs = await runtime.subscribeModule('cart', 'orders', orderSchema); if (orderDocs.size > 0 && this.orders.length === 0) { const fromDocs: any[] = []; - for (const [docId, doc] of orderDocs) { + for (const [, doc] of orderDocs) { const d = doc as OrderDoc; if (!d?.order) continue; fromDocs.push({ @@ -99,145 +122,90 @@ class FolkCartShop extends HTMLElement { private loadDemoData() { const now = Date.now(); - this.catalog = [ + + // Demo shopping carts + this.carts = [ { - id: "demo-cat-1", - title: "The Commons", - description: "A pocket book exploring shared resources and collective stewardship.", - price: 12, + id: "demo-cart-1", + name: "Community Garden Project", + status: "FUNDING", + itemCount: 8, + totalAmount: 247.83, + fundedAmount: 165.00, currency: "USD", - tags: ["books"], - product_type: "pocket book", - status: "active", - created_at: new Date(now - 30 * 86400000).toISOString(), + createdAt: new Date(now - 7 * 86400000).toISOString(), + updatedAt: new Date(now - 1 * 86400000).toISOString(), }, { - id: "demo-cat-2", - title: "Mycelium Networks", - description: "Illustrated poster mapping underground fungal communication pathways.", - price: 18, + id: "demo-cart-2", + name: "Office Supplies", + status: "OPEN", + itemCount: 3, + totalAmount: 45.97, + fundedAmount: 0, currency: "USD", - tags: ["prints"], - product_type: "poster", - status: "active", - created_at: new Date(now - 25 * 86400000).toISOString(), + createdAt: new Date(now - 2 * 86400000).toISOString(), + updatedAt: new Date(now - 2 * 86400000).toISOString(), }, { - id: "demo-cat-3", - title: "#DefectFi", - description: "Organic cotton tee shirt with the #DefectFi campaign logo.", - price: 25, + id: "demo-cart-3", + name: "Hackathon Merch Order", + status: "ORDERED", + itemCount: 5, + totalAmount: 189.50, + fundedAmount: 189.50, currency: "USD", - tags: ["apparel"], - product_type: "tee shirt", - status: "active", - created_at: new Date(now - 20 * 86400000).toISOString(), - }, - { - id: "demo-cat-4", - title: "Cosmolocal Sticker Sheet", - description: "Die-cut sticker sheet with cosmolocal design motifs.", - price: 5, - currency: "USD", - tags: ["stickers"], - product_type: "sticker sheet", - status: "active", - created_at: new Date(now - 15 * 86400000).toISOString(), - }, - { - id: "demo-cat-5", - title: "Doughnut Economics", - description: "A zine breaking down Kate Raworth's doughnut economics framework.", - price: 8, - currency: "USD", - tags: ["books"], - product_type: "zine", - status: "active", - created_at: new Date(now - 10 * 86400000).toISOString(), - }, - { - id: "demo-cat-6", - title: "rSpace Logo", - description: "Embroidered patch featuring the rSpace logo on twill backing.", - price: 6, - currency: "USD", - tags: ["accessories"], - product_type: "embroidered patch", - status: "active", - created_at: new Date(now - 5 * 86400000).toISOString(), - }, - { - id: "demo-cat-7", - title: "Cosmolocal Network Tee", - description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design. DTG printed by local providers or Printful.", - price: 25, - currency: "USD", - tags: ["apparel", "cosmolocal"], - product_type: "tee", - required_capabilities: ["dtg-print"], - status: "active", - created_at: new Date(now - 3 * 86400000).toISOString(), - }, - { - id: "demo-cat-8", - title: "Cosmolocal Sticker Sheet", - description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs. Weatherproof and UV-resistant.", - price: 5, - currency: "USD", - tags: ["stickers", "cosmolocal"], - product_type: "sticker-sheet", - required_capabilities: ["vinyl-cut"], - status: "active", - created_at: new Date(now - 1 * 86400000).toISOString(), + createdAt: new Date(now - 14 * 86400000).toISOString(), + updatedAt: new Date(now - 3 * 86400000).toISOString(), }, ]; + // Demo cart detail (Community Garden Project) + this.selectedCart = { + id: "demo-cart-1", + name: "Community Garden Project", + description: "Supplies for the community garden build day", + status: "FUNDING", + targetAmount: 300, + fundedAmount: 165.00, + currency: "USD", + createdAt: new Date(now - 7 * 86400000).toISOString(), + updatedAt: new Date(now - 1 * 86400000).toISOString(), + items: [ + { id: "i1", name: "Raised Garden Bed Kit (4x8 ft)", price: 79.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08ABC1234", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 6 * 86400000).toISOString() }, + { id: "i2", name: "Organic Potting Soil (40 qt)", price: 12.99, currency: "USD", quantity: 3, sourceUrl: "https://amazon.com/dp/B08DEF5678", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 6 * 86400000).toISOString() }, + { id: "i3", name: "Heirloom Seed Variety Pack", price: 24.95, currency: "USD", quantity: 2, sourceUrl: "https://etsy.com/listing/123456", imageUrl: null, vendor: { name: "GardenSeedCo", domain: "etsy.com", platform: "etsy" }, addedAt: new Date(now - 5 * 86400000).toISOString() }, + { id: "i4", name: "Garden Trowel Set", price: 15.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08GHI9012", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 5 * 86400000).toISOString() }, + { id: "i5", name: "Drip Irrigation Kit", price: 29.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08JKL3456", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 4 * 86400000).toISOString() }, + { id: "i6", name: "Plant Labels (100 pack)", price: 8.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08MNO7890", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 4 * 86400000).toISOString() }, + { id: "i7", name: "Gardening Gloves (3 pair)", price: 12.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08PQR1234", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 3 * 86400000).toISOString() }, + { id: "i8", name: "Compost Bin (80 gal)", price: 34.99, currency: "USD", quantity: 1, sourceUrl: "https://homedepot.com/p/12345", imageUrl: null, vendor: { name: "Home Depot", domain: "homedepot.com", platform: null }, addedAt: new Date(now - 2 * 86400000).toISOString() }, + ], + contributions: [ + { id: "c1", username: "Alice", amount: 50, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 5 * 86400000).toISOString() }, + { id: "c2", username: "Bob", amount: 40, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 4 * 86400000).toISOString() }, + { id: "c3", username: "Carol", amount: 50, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 3 * 86400000).toISOString() }, + { id: "c4", username: "Dave", amount: 25, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 1 * 86400000).toISOString() }, + ], + events: [], + }; + + // Existing catalog demo data + this.catalog = [ + { id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", created_at: new Date(now - 30 * 86400000).toISOString() }, + { id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", created_at: new Date(now - 25 * 86400000).toISOString() }, + { id: "demo-cat-3", title: "#DefectFi", description: "Organic cotton tee shirt with the #DefectFi campaign logo.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", created_at: new Date(now - 20 * 86400000).toISOString() }, + { id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker sheet with cosmolocal design motifs.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", created_at: new Date(now - 15 * 86400000).toISOString() }, + { id: "demo-cat-5", title: "Doughnut Economics", description: "A zine breaking down Kate Raworth's doughnut economics framework.", price: 8, currency: "USD", tags: ["books"], product_type: "zine", status: "active", created_at: new Date(now - 10 * 86400000).toISOString() }, + { id: "demo-cat-6", title: "rSpace Logo", description: "Embroidered patch featuring the rSpace logo on twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", created_at: new Date(now - 5 * 86400000).toISOString() }, + { id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", created_at: new Date(now - 3 * 86400000).toISOString() }, + { id: "demo-cat-8", title: "Cosmolocal Sticker Sheet", description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", created_at: new Date(now - 1 * 86400000).toISOString() }, + ]; + this.orders = [ - { - id: "demo-ord-1001", - items: [ - { title: "The Commons", qty: 1, price: 12 }, - { title: "Mycelium Networks", qty: 1, price: 18 }, - ], - total: 30, - total_price: "30.00", - currency: "USD", - status: "paid", - created_at: new Date(now - 2 * 86400000).toISOString(), - customer_email: "reader@example.com", - artifact_title: "Order #1001", - quantity: 2, - }, - { - id: "demo-ord-1002", - items: [ - { title: "#DefectFi", qty: 1, price: 25 }, - ], - total: 25, - total_price: "25.00", - currency: "USD", - status: "pending", - created_at: new Date(now - 1 * 86400000).toISOString(), - customer_email: "activist@example.com", - artifact_title: "Order #1002", - quantity: 1, - }, - { - id: "demo-ord-1003", - items: [ - { title: "Cosmolocal Sticker Sheet", qty: 1, price: 5 }, - { title: "Doughnut Economics", qty: 1, price: 8 }, - { title: "rSpace Logo", qty: 1, price: 6 }, - ], - total: 23, - total_price: "23.00", - currency: "USD", - status: "shipped", - created_at: new Date(now - 5 * 86400000).toISOString(), - customer_email: "maker@example.com", - artifact_title: "Order #1003", - quantity: 3, - }, + { id: "demo-ord-1001", total_price: "30.00", currency: "USD", status: "paid", created_at: new Date(now - 2 * 86400000).toISOString(), artifact_title: "Order #1001", quantity: 2 }, + { id: "demo-ord-1002", total_price: "25.00", currency: "USD", status: "pending", created_at: new Date(now - 1 * 86400000).toISOString(), artifact_title: "Order #1002", quantity: 1 }, + { id: "demo-ord-1003", total_price: "23.00", currency: "USD", status: "shipped", created_at: new Date(now - 5 * 86400000).toISOString(), artifact_title: "Order #1003", quantity: 3 }, ]; this.loading = false; @@ -254,14 +222,17 @@ class FolkCartShop extends HTMLElement { this.loading = true; this.render(); try { - const [catRes, ordRes] = await Promise.all([ + const [catRes, ordRes, cartRes] = await Promise.all([ fetch(`${this.getApiBase()}/api/catalog?limit=50`), fetch(`${this.getApiBase()}/api/orders?limit=20`), + fetch(`${this.getApiBase()}/api/shopping-carts`), ]); const catData = await catRes.json(); const ordData = await ordRes.json(); + const cartData = await cartRes.json(); this.catalog = catData.entries || []; this.orders = ordData.orders || []; + this.carts = cartData.carts || []; } catch (e) { console.error("Failed to load cart data:", e); } @@ -269,63 +240,314 @@ class FolkCartShop extends HTMLElement { this.render(); } - private render() { - this.shadow.innerHTML = ` - + private async loadCartDetail(cartId: string) { + if (this.space === "demo") { + this.selectedCartId = cartId; + this.view = "cart-detail"; + this.render(); + return; + } + try { + const res = await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}`); + this.selectedCart = await res.json(); + this.selectedCartId = cartId; + this.view = "cart-detail"; + } catch (e) { + console.error("Failed to load cart:", e); + } + this.render(); + } + private async createCart(name: string, targetAmount: number) { + if (this.space === "demo") return; + try { + await fetch(`${this.getApiBase()}/api/shopping-carts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, targetAmount }), + }); + await this.loadData(); + } catch (e) { + console.error("Failed to create cart:", e); + } + } + + private async addItemByUrl(cartId: string, url: string) { + this.addingUrl = true; + this.render(); + try { + await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + await this.loadCartDetail(cartId); + } catch (e) { + console.error("Failed to add item:", e); + } + this.addingUrl = false; + } + + private async removeItem(cartId: string, itemId: string) { + try { + await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/items/${itemId}`, { method: 'DELETE' }); + await this.loadCartDetail(cartId); + } catch (e) { + console.error("Failed to remove item:", e); + } + } + + private async contribute(cartId: string, amount: number, username: string) { + try { + await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/contribute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount, username }), + }); + await this.loadCartDetail(cartId); + } catch (e) { + console.error("Failed to contribute:", e); + } + } + + // ── Main render ── + + private render() { + const styles = this.getStyles(); + let content = ''; + + if (this.loading) { + content = `
Loading...
`; + } else if (this.view === "carts") { + content = this.renderCarts(); + } else if (this.view === "cart-detail") { + content = this.renderCartDetail(); + } else if (this.view === "catalog") { + content = this.renderCatalog(); + } else { + content = this.renderOrders(); + } + + this.shadow.innerHTML = ` +
Shop
+
- - ${this.loading ? `
⏳ Loading...
` : - this.view === "catalog" ? this.renderCatalog() : this.renderOrders()} + ${content} `; + this.bindEvents(); + } + + private bindEvents() { + // Tab switching this.shadow.querySelectorAll(".tab").forEach((el) => { el.addEventListener("click", () => { - this.view = ((el as HTMLElement).dataset.view || "catalog") as "catalog" | "orders"; + const v = (el as HTMLElement).dataset.view as any; + this.view = v; + if (v === 'carts') this.selectedCartId = null; this.render(); }); }); + + // Cart card clicks + this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => { + el.addEventListener("click", () => { + this.loadCartDetail((el as HTMLElement).dataset.cartId!); + }); + }); + + // Back button + this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => { + this.view = "carts"; + this.selectedCartId = null; + this.render(); + }); + + // New cart form + const newCartBtn = this.shadow.querySelector("[data-action='new-cart']"); + newCartBtn?.addEventListener("click", () => { + const form = this.shadow.querySelector(".new-cart-form") as HTMLElement; + if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none'; + }); + this.shadow.querySelector("[data-action='create-cart']")?.addEventListener("click", () => { + const nameInput = this.shadow.querySelector("[data-field='cart-name']") as HTMLInputElement; + const amountInput = this.shadow.querySelector("[data-field='cart-target']") as HTMLInputElement; + if (nameInput?.value) this.createCart(nameInput.value, parseFloat(amountInput?.value) || 0); + }); + + // Add item by URL + this.shadow.querySelector("[data-action='add-url']")?.addEventListener("click", () => { + const input = this.shadow.querySelector("[data-field='item-url']") as HTMLInputElement; + if (input?.value && this.selectedCartId) { + this.addItemByUrl(this.selectedCartId, input.value); + } + }); + + // Remove item + this.shadow.querySelectorAll("[data-remove-item]").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.selectedCartId) this.removeItem(this.selectedCartId, (el as HTMLElement).dataset.removeItem!); + }); + }); + + // Contribute toggle + this.shadow.querySelector("[data-action='show-contribute']")?.addEventListener("click", () => { + const form = this.shadow.querySelector(".contribute-form") as HTMLElement; + if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none'; + }); + this.shadow.querySelector("[data-action='submit-contribute']")?.addEventListener("click", () => { + const amtInput = this.shadow.querySelector("[data-field='contrib-amount']") as HTMLInputElement; + const nameInput = this.shadow.querySelector("[data-field='contrib-name']") as HTMLInputElement; + if (amtInput?.value && this.selectedCartId) { + this.contribute(this.selectedCartId, parseFloat(amtInput.value), nameInput?.value || 'Anonymous'); + } + }); } + // ── Cart list view ── + + private renderCarts(): string { + const newCartForm = ` + `; + + if (this.carts.length === 0) { + return ` +
+

No shopping carts yet. Create one to start group shopping.

+ + ${newCartForm} +
`; + } + + return ` +
+ + ${newCartForm} +
+
+ ${this.carts.map((cart) => { + const pct = cart.totalAmount > 0 ? Math.min(100, Math.round((cart.fundedAmount / cart.totalAmount) * 100)) : 0; + return ` +
+
+

${this.esc(cart.name)}

+ ${cart.status} +
+
${cart.itemCount} item${cart.itemCount !== 1 ? 's' : ''} • $${cart.totalAmount.toFixed(2)}
+
+
+
+
$${cart.fundedAmount.toFixed(2)} / $${cart.totalAmount.toFixed(2)} funded (${pct}%)
+
`; + }).join("")} +
`; + } + + // ── Cart detail view ── + + private renderCartDetail(): string { + const cart = this.selectedCart; + if (!cart) return `
Loading cart...
`; + + const items = cart.items || []; + const contributions = cart.contributions || []; + const totalItemsCost = items.reduce((s: number, i: any) => s + (i.price || 0) * (i.quantity || 1), 0); + const targetDisplay = cart.targetAmount > 0 ? cart.targetAmount : totalItemsCost; + const pct = targetDisplay > 0 ? Math.min(100, Math.round((cart.fundedAmount / targetDisplay) * 100)) : 0; + const remaining = Math.max(0, targetDisplay - cart.fundedAmount); + + // Group items by vendor domain + const vendorGroups: Record = {}; + for (const item of items) { + const key = item.vendor?.domain || 'other'; + if (!vendorGroups[key]) vendorGroups[key] = []; + vendorGroups[key].push(item); + } + + const itemsHtml = Object.entries(vendorGroups).map(([domain, vendorItems]) => ` +
+
${this.esc(domain)}
+ ${vendorItems.map((item: any) => ` +
+ ${item.imageUrl ? `` : `
📦
`} +
+ ${this.esc(item.name)} +
Qty: ${item.quantity}${item.price != null ? ` • $${(item.price * item.quantity).toFixed(2)}` : ''}
+
+ +
+ `).join("")} +
+ `).join(""); + + const contribHtml = contributions.length > 0 ? contributions.map((c: any) => ` +
+ ${this.esc(c.username)} + $${c.amount.toFixed(2)} + ${c.status} +
+ `).join("") : `
No contributions yet.
`; + + return ` +
+ +
+ +
+
+

${this.esc(cart.name)}

+ ${cart.description ? `

${this.esc(cart.description)}

` : ''} + +
+ + +
+ + ${items.length === 0 + ? `
No items yet. Paste a URL above to add products.
` + : itemsHtml} +
+ +
+
+

Summary

+
Items cost$${totalItemsCost.toFixed(2)}
+
Funded$${cart.fundedAmount.toFixed(2)}
+
Remaining$${remaining.toFixed(2)}
+
+
+
+
${pct}% funded
+ + + +
+ +
+

Contributions

+ ${contribHtml} +
+
+
`; + } + + // ── Catalog view ── + private renderCatalog(): string { if (this.catalog.length === 0) { return `
No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.
`; @@ -349,6 +571,8 @@ class FolkCartShop extends HTMLElement { `; } + // ── Orders view ── + private renderOrders(): string { if (this.orders.length === 0) { return `
No orders yet.
`; @@ -362,7 +586,7 @@ class FolkCartShop extends HTMLElement {

${this.esc(order.artifact_title || "Order")}

${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""} - ${order.quantity > 1 ? ` • Qty: ${order.quantity}` : ""} + ${order.quantity > 1 ? ` • Qty: ${order.quantity}` : ""}
${order.status} @@ -373,6 +597,97 @@ class FolkCartShop extends HTMLElement { `; } + // ── Styles ── + + private getStyles(): string { + return ` + :host { display: block; padding: 1.5rem; } + * { box-sizing: border-box; } + .rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; flex-wrap: wrap; } + .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; } + .tabs { display: flex; gap: 0.5rem; } + .tab { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; } + .tab:hover { border-color: #475569; color: #f1f5f9; } + .tab.active { background: #4f46e5; border-color: #6366f1; color: #fff; } + + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; } + .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; } + .card:hover { border-color: #475569; } + .card-clickable { cursor: pointer; transition: border-color 0.15s, transform 0.1s; } + .card-clickable:hover { border-color: #6366f1; transform: translateY(-1px); } + .card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.5rem; } + .card-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.5rem; } + .section-title { color: #f1f5f9; font-weight: 700; font-size: 1.25rem; margin: 0 0 0.5rem; } + + .tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; margin-right: 0.25rem; } + .tag-type { background: rgba(99,102,241,0.1); color: #818cf8; } + .tag-cap { background: rgba(34,197,94,0.1); color: #4ade80; } + .dims { color: #64748b; font-size: 0.75rem; margin-top: 0.5rem; } + + .status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; white-space: nowrap; } + .status-pending, .status-open { background: rgba(251,191,36,0.15); color: #fbbf24; } + .status-paid, .status-active, .status-confirmed, .status-funded { background: rgba(34,197,94,0.15); color: #4ade80; } + .status-funding { background: rgba(99,102,241,0.15); color: #a5b4fc; } + .status-completed, .status-ordered, .status-checking_out { background: rgba(99,102,241,0.15); color: #a5b4fc; } + .status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; } + .status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; } + + .price { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin-top: 0.5rem; } + .text-green { color: #4ade80; } + + .progress-bar { background: #334155; border-radius: 999px; height: 8px; overflow: hidden; } + .progress-fill { background: linear-gradient(90deg, #6366f1, #8b5cf6); height: 100%; border-radius: 999px; transition: width 0.3s; } + + .btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #e2e8f0; cursor: pointer; font-size: 0.875rem; } + .btn:hover { border-color: #475569; } + .btn-primary { background: #4f46e5; border-color: #6366f1; color: #fff; } + .btn-primary:hover { background: #4338ca; } + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } + .btn-icon { background: none; border: none; color: #64748b; cursor: pointer; padding: 4px; font-size: 0.875rem; border-radius: 4px; } + .btn-icon:hover { color: #f87171; background: rgba(239,68,68,0.1); } + + .input { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 0.875rem; } + .input:focus { outline: none; border-color: #6366f1; } + .input-sm { max-width: 160px; } + + .new-cart-form, .contribute-form { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap; } + + .url-input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; } + + .detail-layout { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; } + @media (max-width: 768px) { .detail-layout { grid-template-columns: 1fr; } } + + .vendor-group { margin-bottom: 1rem; } + .vendor-header { color: #94a3b8; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.5rem 0; border-bottom: 1px solid #1e293b; margin-bottom: 0.5rem; } + .item-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #1e293b; } + .item-thumb { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: #334155; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; } + .item-thumb-placeholder { width: 40px; height: 40px; border-radius: 6px; background: #334155; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; } + .item-info { flex: 1; min-width: 0; } + .item-name { color: #e2e8f0; font-size: 0.875rem; text-decoration: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .item-name:hover { color: #818cf8; } + .item-meta { color: #64748b; font-size: 0.75rem; } + + .summary-row { display: flex; justify-content: space-between; padding: 0.375rem 0; color: #cbd5e1; font-size: 0.875rem; } + + .contrib-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.8125rem; } + .contrib-name { color: #e2e8f0; flex: 1; } + .contrib-amount { color: #4ade80; font-weight: 600; } + + .order-card { display: flex; justify-content: space-between; align-items: center; } + .order-info { flex: 1; } + .order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; } + + .empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; } + .loading { text-align: center; padding: 3rem; color: #94a3b8; } + + @media (max-width: 480px) { + .grid { grid-template-columns: 1fr; } + .url-input-row { flex-direction: column; } + } + `; + } + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; diff --git a/modules/rcart/extract.ts b/modules/rcart/extract.ts new file mode 100644 index 0000000..1305eb9 --- /dev/null +++ b/modules/rcart/extract.ts @@ -0,0 +1,276 @@ +/** + * Server-side product extraction from URLs. + * + * Ported from UniCart extension's content.ts ProductDetector, + * adapted for server-side HTML parsing (no DOM — regex-based). + * Reuses the fetch pattern from /api/link-preview in server/index.ts. + */ + +export interface ExtractedProduct { + name: string; + price: number | null; + currency: string; + description: string | null; + imageUrl: string | null; + sourceUrl: string; + sku: string | null; + vendor: { + name: string; + domain: string; + platform: string | null; + }; +} + +const FETCH_TIMEOUT = 5000; +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +/** Fetch URL HTML with browser-like headers and timeout. */ +async function fetchHtml(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + try { + const resp = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': USER_AGENT, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + }, + redirect: 'follow', + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return await resp.text(); + } finally { + clearTimeout(timer); + } +} + +/** Extract domain from URL, stripping www. */ +function extractDomain(url: string): string { + try { + const host = new URL(url).hostname; + return host.replace(/^www\./, ''); + } catch { + return url; + } +} + +/** Detect platform from URL hostname. */ +function detectPlatform(url: string, html: string): string | null { + const domain = extractDomain(url); + if (domain.includes('amazon.')) return 'amazon'; + if (domain.includes('etsy.com')) return 'etsy'; + if (html.includes('Shopify.') || html.includes('cdn.shopify.com')) return 'shopify'; + if (html.includes('woocommerce')) return 'woocommerce'; + return null; +} + +// ── Extractors ── + +/** Extract product data from JSON-LD structured data. */ +function extractJsonLd(html: string): Partial | null { + const scriptRegex = /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let match: RegExpExecArray | null; + + while ((match = scriptRegex.exec(html)) !== null) { + try { + const data = JSON.parse(match[1]); + + // Handle @graph arrays + const products = data['@graph']?.filter((item: any) => item['@type'] === 'Product') || []; + const product = data['@type'] === 'Product' ? data : products[0]; + + if (product) { + const offer = Array.isArray(product.offers) ? product.offers[0] : product.offers; + const price = parseFloat(offer?.price || offer?.lowPrice || '0'); + + return { + name: product.name || null, + price: price > 0 ? price : null, + currency: offer?.priceCurrency || 'USD', + description: product.description || null, + imageUrl: Array.isArray(product.image) ? product.image[0] : product.image || null, + sku: product.sku || null, + }; + } + } catch { + // Invalid JSON, continue + } + } + return null; +} + +/** Extract product data from Open Graph / product meta tags. */ +function extractMetaTags(html: string): Partial { + const result: Partial = {}; + + const getMetaContent = (property: string): string | null => { + const re = new RegExp(`]*property=["']${property}["'][^>]*content=["']([^"']*)["']`, 'i'); + const alt = new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']${property}["']`, 'i'); + const m = html.match(re) || html.match(alt); + return m ? m[1] : null; + }; + + const title = getMetaContent('og:title'); + if (title) result.name = title; + + const priceAmount = getMetaContent('product:price:amount') || getMetaContent('og:price:amount'); + if (priceAmount) { + const p = parseFloat(priceAmount); + if (p > 0) result.price = p; + } + + const priceCurrency = getMetaContent('product:price:currency'); + if (priceCurrency) result.currency = priceCurrency; + + const image = getMetaContent('og:image'); + if (image) result.imageUrl = image; + + const description = getMetaContent('og:description'); + if (description) result.description = description; + + return result; +} + +/** Amazon-specific extraction via regex on HTML. */ +function extractAmazon(html: string, url: string): Partial | null { + const result: Partial = {}; + + // Title + const titleMatch = html.match(/id=["']productTitle["'][^>]*>([^<]+)]*>([£€$¥₹]?)\s*([\d,]+\.?\d*)/, + /id=["']priceblock_ourprice["'][^>]*>([£€$¥₹]?)\s*([\d,]+\.?\d*)/, + /id=["']priceblock_dealprice["'][^>]*>([£€$¥₹]?)\s*([\d,]+\.?\d*)/, + /"price":\s*"?([\d.]+)"?/, + ]; + for (const re of pricePatterns) { + const m = html.match(re); + if (m) { + const priceStr = m[2] || m[1]; + const price = parseFloat(priceStr.replace(/,/g, '')); + if (price > 0) { + result.price = price; + break; + } + } + } + + // ASIN from URL + const asinMatch = url.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/); + if (asinMatch) result.sku = asinMatch[1]; + + // Image + const imgMatch = html.match(/id=["']landingImage["'][^>]*src=["']([^"']+)/i); + if (imgMatch) result.imageUrl = imgMatch[1]; + + // Description — feature bullets + const bulletMatches = html.match(/class=["']a-list-item["'][^>]*>([^<]+)/g); + if (bulletMatches && bulletMatches.length > 0) { + result.description = bulletMatches + .slice(0, 3) + .map(b => { + const m = b.match(/>([^<]+)/); + return m ? m[1].trim() : ''; + }) + .filter(Boolean) + .join(' • '); + } + + return result; +} + +/** Shopify extraction — look for `var meta = {...}` in scripts. */ +function extractShopify(html: string): Partial | null { + const metaMatch = html.match(/var\s+meta\s*=\s*(\{[\s\S]*?\});/); + if (!metaMatch) return null; + + try { + const meta = JSON.parse(metaMatch[1]); + if (meta.product) { + return { + name: meta.product.title, + price: typeof meta.product.price === 'number' ? meta.product.price / 100 : null, + currency: 'USD', + description: meta.product.description || null, + sku: meta.product.variants?.[0]?.sku || null, + }; + } + } catch { /* parse failure */ } + return null; +} + +/** Get HTML as last-resort name fallback. */ +function extractTitle(html: string): string | null { + const m = html.match(/<title[^>]*>([^<]+)<\/title>/i); + return m ? m[1].trim() : null; +} + +// ── Public API ── + +/** + * Extract product information from a URL. + * Cascading strategy: JSON-LD → platform-specific → meta tags → fallback. + */ +export async function extractProductFromUrl(url: string): Promise<ExtractedProduct> { + const html = await fetchHtml(url); + const domain = extractDomain(url); + const platform = detectPlatform(url, html); + + // Layer 1: JSON-LD (most reliable) + const jsonLd = extractJsonLd(html); + + // Layer 2: Platform-specific + let platformData: Partial<ExtractedProduct> | null = null; + if (platform === 'amazon') platformData = extractAmazon(html, url); + else if (platform === 'shopify') platformData = extractShopify(html); + // Etsy and WooCommerce rely on JSON-LD / meta tags + + // Layer 3: Meta tags + const metaTags = extractMetaTags(html); + + // Merge layers (earlier = higher priority) + const merged: Partial<ExtractedProduct> = { + ...metaTags, + ...platformData, + ...jsonLd, + }; + + // Fallback name from <title> + if (!merged.name) { + merged.name = extractTitle(html) || domain; + } + + // Resolve relative image URLs + if (merged.imageUrl && !merged.imageUrl.startsWith('http')) { + try { + merged.imageUrl = new URL(merged.imageUrl, url).href; + } catch { /* leave as-is */ } + } + + // Vendor name: try to find site name from og:site_name + let vendorName = domain; + const siteNameMatch = html.match(/<meta[^>]*property=["']og:site_name["'][^>]*content=["']([^"']*)["']/i) + || html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:site_name["']/i); + if (siteNameMatch) vendorName = siteNameMatch[1]; + + return { + name: merged.name!, + price: merged.price ?? null, + currency: merged.currency || 'USD', + description: merged.description ?? null, + imageUrl: merged.imageUrl ?? null, + sourceUrl: url, + sku: merged.sku ?? null, + vendor: { + name: vendorName, + domain, + platform, + }, + }; +} diff --git a/modules/rcart/landing.ts b/modules/rcart/landing.ts index f669534..1da28c8 100644 --- a/modules/rcart/landing.ts +++ b/modules/rcart/landing.ts @@ -54,18 +54,18 @@ export function renderLanding(): string { <div class="rl-grid-3"> <div class="rl-step"> <span class="rl-step__num">1</span> - <h3>Create a Space</h3> - <p>Your space is your shared shopping context. Members see the same catalog and cart.</p> + <h3>Create a Cart</h3> + <p>Start a shared shopping cart. Paste product URLs from any store, or browse the cosmolocal catalog.</p> </div> <div class="rl-step"> <span class="rl-step__num">2</span> - <h3>Add Products</h3> - <p>List print-ready artifacts from rPubs, or browse what others have published.</p> + <h3>Add Items Together</h3> + <p>Members paste links from Amazon, Etsy, Shopify — anywhere. Products are auto-detected and added to the cart. Or list print-ready artifacts from rPubs.</p> </div> <div class="rl-step"> <span class="rl-step__num">3</span> - <h3>Pay Together</h3> - <p>Pool orders to hit bulk pricing tiers. Pay with crypto or card. Revenue splits automatically.</p> + <h3>Pool Funds & Checkout</h3> + <p>Everyone contributes what they can. Once funded, check out together. Revenue splits flow automatically for cosmolocal items.</p> </div> </div> </div> diff --git a/modules/rcart/local-first-client.ts b/modules/rcart/local-first-client.ts index e80d17e..43048e2 100644 --- a/modules/rcart/local-first-client.ts +++ b/modules/rcart/local-first-client.ts @@ -11,8 +11,11 @@ import type { DocumentId } from '../../shared/local-first/document'; import { EncryptedDocStore } from '../../shared/local-first/storage'; import { DocSyncManager } from '../../shared/local-first/sync'; import { DocCrypto } from '../../shared/local-first/crypto'; -import { catalogSchema, orderSchema, catalogDocId, orderDocId } from './schemas'; -import type { CatalogDoc, CatalogEntry, OrderDoc } from './schemas'; +import { + catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema, + catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId, +} from './schemas'; +import type { CatalogDoc, CatalogEntry, OrderDoc, ShoppingCartDoc, ShoppingCartIndexDoc } from './schemas'; export class CartLocalFirstClient { #space: string; @@ -31,6 +34,8 @@ export class CartLocalFirstClient { }); this.#documents.registerSchema(catalogSchema); this.#documents.registerSchema(orderSchema); + this.#documents.registerSchema(shoppingCartSchema); + this.#documents.registerSchema(shoppingCartIndexSchema); } get isConnected(): boolean { return this.#sync.isConnected; } @@ -38,15 +43,21 @@ export class CartLocalFirstClient { async init(): Promise<void> { if (this.#initialized) return; await this.#store.open(); - const [catalogIds, orderIds] = await Promise.all([ + const [catalogIds, orderIds, shoppingIds, shoppingIndexIds] = await Promise.all([ this.#store.listByModule('cart', 'catalog'), this.#store.listByModule('cart', 'orders'), + this.#store.listByModule('cart', 'shopping'), + this.#store.listByModule('cart', 'shopping-index'), ]); - const allIds = [...catalogIds, ...orderIds]; + const allIds = [...catalogIds, ...orderIds, ...shoppingIds, ...shoppingIndexIds]; const cached = await this.#store.loadMany(allIds); for (const [docId, binary] of cached) { if (catalogIds.includes(docId)) { this.#documents.open<CatalogDoc>(docId, catalogSchema, binary); + } else if (shoppingIds.includes(docId)) { + this.#documents.open<ShoppingCartDoc>(docId, shoppingCartSchema, binary); + } else if (shoppingIndexIds.includes(docId)) { + this.#documents.open<ShoppingCartIndexDoc>(docId, shoppingCartIndexSchema, binary); } else { this.#documents.open<OrderDoc>(docId, orderSchema, binary); } @@ -92,6 +103,40 @@ export class CartLocalFirstClient { return this.#sync.onChange(catalogDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + async subscribeShoppingCartIndex(): Promise<ShoppingCartIndexDoc | null> { + const docId = shoppingCartIndexDocId(this.#space) as DocumentId; + let doc = this.#documents.get<ShoppingCartIndexDoc>(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open<ShoppingCartIndexDoc>(docId, shoppingCartIndexSchema, binary) + : this.#documents.open<ShoppingCartIndexDoc>(docId, shoppingCartIndexSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + async subscribeShoppingCart(cartId: string): Promise<ShoppingCartDoc | null> { + const docId = shoppingCartDocId(this.#space, cartId) as DocumentId; + let doc = this.#documents.get<ShoppingCartDoc>(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open<ShoppingCartDoc>(docId, shoppingCartSchema, binary) + : this.#documents.open<ShoppingCartDoc>(docId, shoppingCartSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + onCartIndexChange(cb: (doc: ShoppingCartIndexDoc) => void): () => void { + return this.#sync.onChange(shoppingCartIndexDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onCartChange(cartId: string, cb: (doc: ShoppingCartDoc) => void): () => void { + return this.#sync.onChange(shoppingCartDocId(this.#space, cartId) as DocumentId, cb as (doc: any) => void); + } + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 027095d..a1a3a39 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -18,11 +18,14 @@ import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { - catalogSchema, orderSchema, - catalogDocId, orderDocId, + 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; @@ -605,6 +608,491 @@ routes.post("/api/fulfill/resolve", async (c) => { 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<ShoppingCartIndexDoc> { + const docId = shoppingCartIndexDocId(space); + let doc = _syncServer!.getDoc<ShoppingCartIndexDoc>(docId); + if (!doc) { + doc = Automerge.change(Automerge.init<ShoppingCartIndexDoc>(), '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<ShoppingCartDoc>(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<ShoppingCartIndexDoc>(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<ShoppingCartDoc>(), '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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(docId); + if (!doc) return c.json({ error: "Cart not found" }, 404); + + // Remove from index + const indexDocId = shoppingCartIndexDocId(space); + ensureShoppingCartIndex(space); + _syncServer!.changeDoc<ShoppingCartIndexDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(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<ShoppingCartDoc>(), '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<ShoppingCartDoc>(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<ShoppingCartDoc>(cartDocId); + if (cartDoc) { + const byVendor: Record<string, typeof vendorGroups[0]> = {}; + 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"; @@ -669,11 +1157,13 @@ export const cartModule: RSpaceModule = { id: "rcart", name: "rCart", icon: "🛒", - description: "Cosmolocal print-on-demand shop", + 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", @@ -697,9 +1187,17 @@ export const cartModule: RSpaceModule = { 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" }, ], diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index f2a2513..01b11c2 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -136,6 +136,144 @@ export const orderSchema: DocSchema<OrderDoc> = { }), }; +// ── Shopping Cart types ── + +export type CartStatus = 'OPEN' | 'FUNDING' | 'FUNDED' | 'CHECKING_OUT' | 'ORDERED' | 'CLOSED'; + +export interface CartItemVendor { + name: string; + domain: string; + platform: string | null; // 'amazon' | 'shopify' | 'etsy' | null +} + +export interface CartItem { + name: string; + price: number | null; + currency: string; + quantity: number; + sourceUrl: string; + imageUrl: string | null; + description: string | null; + vendor: CartItemVendor; + addedBy: string | null; // DID + addedAt: number; + sku: string | null; +} + +export interface CartContribution { + userId: string | null; + username: string; + amount: number; + currency: string; + paymentMethod: string; // 'MANUAL' for MVP + status: string; // 'pending' | 'confirmed' + txHash: string | null; + createdAt: number; + updatedAt: number; +} + +export interface CartEvent { + type: string; // 'item_added' | 'item_removed' | 'contribution' | 'status_change' + actor: string; + detail: string; + timestamp: number; +} + +export interface ShoppingCartDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + cart: { + id: string; + name: string; + description: string; + status: CartStatus; + createdBy: string | null; + targetAmount: number; + fundedAmount: number; + currency: string; + createdAt: number; + updatedAt: number; + }; + items: Record<string, CartItem>; + contributions: Record<string, CartContribution>; + events: CartEvent[]; +} + +export interface ShoppingCartIndexEntry { + name: string; + status: CartStatus; + itemCount: number; + totalAmount: number; + fundedAmount: number; + currency: string; + createdAt: number; + updatedAt: number; +} + +export interface ShoppingCartIndexDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + carts: Record<string, ShoppingCartIndexEntry>; +} + +// ── Shopping Cart schema registration ── + +export const shoppingCartSchema: DocSchema<ShoppingCartDoc> = { + module: 'cart', + collection: 'shopping', + version: 1, + init: (): ShoppingCartDoc => ({ + meta: { + module: 'cart', + collection: 'shopping', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + cart: { + id: '', + name: '', + description: '', + status: 'OPEN', + createdBy: null, + targetAmount: 0, + fundedAmount: 0, + currency: 'USD', + createdAt: Date.now(), + updatedAt: Date.now(), + }, + items: {}, + contributions: {}, + events: [], + }), +}; + +export const shoppingCartIndexSchema: DocSchema<ShoppingCartIndexDoc> = { + module: 'cart', + collection: 'shopping-index', + version: 1, + init: (): ShoppingCartIndexDoc => ({ + meta: { + module: 'cart', + collection: 'shopping-index', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + carts: {}, + }), +}; + // ── Helpers ── export function catalogDocId(space: string) { @@ -145,3 +283,11 @@ export function catalogDocId(space: string) { export function orderDocId(space: string, orderId: string) { return `${space}:cart:orders:${orderId}` as const; } + +export function shoppingCartDocId(space: string, cartId: string) { + return `${space}:cart:shopping:${cartId}` as const; +} + +export function shoppingCartIndexDocId(space: string) { + return `${space}:cart:shopping-index` as const; +}