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:
parent
62a96c164a
commit
12d3e86e13
|
|
@ -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' : ''} • $${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 ? ` • $${(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 ? ` • 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue