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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 01:21:38 -07:00
parent 62a96c164a
commit 12d3e86e13
6 changed files with 1470 additions and 190 deletions

View File

@ -1,9 +1,14 @@
/**
* <folk-cart-shop> browse catalog, view orders, trigger fulfillment.
* Shows catalog items, order creation flow, and order status tracking.
* <folk-cart-shop> 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 = `
<style>
:host { display: block; padding: 1.5rem; }
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
.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-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; }
.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; }
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.status-paid { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.status-cancelled { 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; }
.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; }
}
</style>
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 = `<div class="loading">Loading...</div>`;
} 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 = `
<style>${styles}</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Shop</span>
<div class="tabs">
<button class="tab ${this.view === 'carts' || this.view === 'cart-detail' ? 'active' : ''}" data-view="carts">🛒 Carts (${this.carts.length})</button>
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
</div>
</div>
${this.loading ? `<div class="loading">⏳ Loading...</div>` :
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 = `
<div class="new-cart-form" style="display:none">
<input data-field="cart-name" type="text" placeholder="Cart name" class="input" />
<input data-field="cart-target" type="number" placeholder="Target amount (optional)" class="input input-sm" />
<button data-action="create-cart" class="btn btn-primary btn-sm">Create</button>
</div>`;
if (this.carts.length === 0) {
return `
<div class="empty">
<p>No shopping carts yet. Create one to start group shopping.</p>
<button data-action="new-cart" class="btn btn-primary">+ New Cart</button>
${newCartForm}
</div>`;
}
return `
<div style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<button data-action="new-cart" class="btn btn-primary btn-sm">+ New Cart</button>
${newCartForm}
</div>
<div class="grid">
${this.carts.map((cart) => {
const pct = cart.totalAmount > 0 ? Math.min(100, Math.round((cart.fundedAmount / cart.totalAmount) * 100)) : 0;
return `
<div class="card card-clickable" data-cart-id="${cart.id}">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.5rem">
<h3 class="card-title" style="margin:0">${this.esc(cart.name)}</h3>
<span class="status status-${cart.status.toLowerCase()}">${cart.status}</span>
</div>
<div class="card-meta">${cart.itemCount} item${cart.itemCount !== 1 ? 's' : ''} &bull; $${cart.totalAmount.toFixed(2)}</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
<div class="card-meta" style="margin-top:0.25rem">$${cart.fundedAmount.toFixed(2)} / $${cart.totalAmount.toFixed(2)} funded (${pct}%)</div>
</div>`;
}).join("")}
</div>`;
}
// ── Cart detail view ──
private renderCartDetail(): string {
const cart = this.selectedCart;
if (!cart) return `<div class="loading">Loading cart...</div>`;
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<string, any[]> = {};
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]) => `
<div class="vendor-group">
<div class="vendor-header">${this.esc(domain)}</div>
${vendorItems.map((item: any) => `
<div class="item-row">
${item.imageUrl ? `<img class="item-thumb" src="${this.esc(item.imageUrl)}" alt="" />` : `<div class="item-thumb item-thumb-placeholder">📦</div>`}
<div class="item-info">
<a class="item-name" href="${this.esc(item.sourceUrl)}" target="_blank" rel="noopener">${this.esc(item.name)}</a>
<div class="item-meta">Qty: ${item.quantity}${item.price != null ? ` &bull; $${(item.price * item.quantity).toFixed(2)}` : ''}</div>
</div>
<button class="btn-icon" data-remove-item="${item.id}" title="Remove"></button>
</div>
`).join("")}
</div>
`).join("");
const contribHtml = contributions.length > 0 ? contributions.map((c: any) => `
<div class="contrib-row">
<span class="contrib-name">${this.esc(c.username)}</span>
<span class="contrib-amount">$${c.amount.toFixed(2)}</span>
<span class="status status-${c.status}">${c.status}</span>
</div>
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
return `
<div style="margin-bottom:1rem">
<button data-action="back" class="btn btn-sm"> Back to Carts</button>
</div>
<div class="detail-layout">
<div class="detail-left">
<h2 class="section-title">${this.esc(cart.name)}</h2>
${cart.description ? `<p class="card-meta">${this.esc(cart.description)}</p>` : ''}
<div class="url-input-row">
<input data-field="item-url" type="url" placeholder="Paste product URL from any store..." class="input" style="flex:1" />
<button data-action="add-url" class="btn btn-primary btn-sm" ${this.addingUrl ? 'disabled' : ''}>${this.addingUrl ? 'Adding...' : 'Add'}</button>
</div>
${items.length === 0
? `<div class="empty">No items yet. Paste a URL above to add products.</div>`
: itemsHtml}
</div>
<div class="detail-right">
<div class="card">
<h3 class="card-title">Summary</h3>
<div class="summary-row"><span>Items cost</span><span>$${totalItemsCost.toFixed(2)}</span></div>
<div class="summary-row"><span>Funded</span><span class="text-green">$${cart.fundedAmount.toFixed(2)}</span></div>
<div class="summary-row"><span>Remaining</span><span>$${remaining.toFixed(2)}</span></div>
<div class="progress-bar" style="margin:0.75rem 0">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
<div class="card-meta" style="text-align:center">${pct}% funded</div>
<button data-action="show-contribute" class="btn btn-primary" style="width:100%; margin-top:1rem">Contribute</button>
<div class="contribute-form" style="display:none">
<input data-field="contrib-name" type="text" placeholder="Your name" class="input" />
<input data-field="contrib-amount" type="number" placeholder="Amount ($)" class="input" step="0.01" min="0.01" />
<button data-action="submit-contribute" class="btn btn-primary btn-sm">Confirm</button>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h3 class="card-title">Contributions</h3>
${contribHtml}
</div>
</div>
</div>`;
}
// ── Catalog view ──
private renderCatalog(): string {
if (this.catalog.length === 0) {
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
@ -349,6 +571,8 @@ class FolkCartShop extends HTMLElement {
</div>`;
}
// ── Orders view ──
private renderOrders(): string {
if (this.orders.length === 0) {
return `<div class="empty">No orders yet.</div>`;
@ -362,7 +586,7 @@ class FolkCartShop extends HTMLElement {
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
<div class="card-meta">
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
${order.quantity > 1 ? ` Qty: ${order.quantity}` : ""}
${order.quantity > 1 ? ` &bull; Qty: ${order.quantity}` : ""}
</div>
<span class="status status-${order.status}">${order.status}</span>
</div>
@ -373,6 +597,97 @@ class FolkCartShop extends HTMLElement {
</div>`;
}
// ── 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;

276
modules/rcart/extract.ts Normal file
View File

@ -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<string> {
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<ExtractedProduct> | null {
const scriptRegex = /<script[^>]*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<ExtractedProduct> {
const result: Partial<ExtractedProduct> = {};
const getMetaContent = (property: string): string | null => {
const re = new RegExp(`<meta[^>]*property=["']${property}["'][^>]*content=["']([^"']*)["']`, 'i');
const alt = new RegExp(`<meta[^>]*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<ExtractedProduct> | null {
const result: Partial<ExtractedProduct> = {};
// Title
const titleMatch = html.match(/id=["']productTitle["'][^>]*>([^<]+)</i);
if (titleMatch) result.name = titleMatch[1].trim();
if (!result.name) return null;
// Price — look for common Amazon price patterns
const pricePatterns = [
/class=["']a-offscreen["'][^>]*>([£€$¥₹]?)\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<ExtractedProduct> | 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 <title> 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,
},
};
}

View File

@ -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 &mdash; 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 &amp; Checkout</h3>
<p>Everyone contributes what they can. Once funded, check out together. Revenue splits flow automatically for cosmolocal items.</p>
</div>
</div>
</div>

View File

@ -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); }

View File

@ -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" },
],

View File

@ -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;
}