707 lines
24 KiB
TypeScript
707 lines
24 KiB
TypeScript
/**
|
|
* Cart module — cosmolocal print-on-demand shop.
|
|
*
|
|
* Ported from /opt/apps/rcart/ (Express → Hono).
|
|
* Handles catalog (artifact listings), orders, fulfillment resolution.
|
|
* Integrates with provider-registry for provider matching and flow-service for revenue splits.
|
|
*
|
|
* Storage: Automerge documents via SyncServer (no PostgreSQL).
|
|
*/
|
|
|
|
import * as Automerge from "@automerge/automerge";
|
|
import { Hono } from "hono";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import { depositOrderRevenue } from "./flow";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
import { renderLanding } from "./landing";
|
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
|
import {
|
|
catalogSchema, orderSchema,
|
|
catalogDocId, orderDocId,
|
|
type CatalogDoc, type CatalogEntry,
|
|
type OrderDoc, type OrderMeta,
|
|
} from './schemas';
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
const routes = new Hono();
|
|
|
|
// Provider registry URL (for fulfillment resolution)
|
|
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";
|
|
|
|
function getProviderUrl(): string {
|
|
// In unified mode, providers module is co-located — call its routes directly via internal URL
|
|
// In standalone mode, use PROVIDER_REGISTRY_URL env
|
|
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
|
|
}
|
|
|
|
// ── Automerge helpers ──
|
|
|
|
/** Lazily create (or retrieve) the catalog doc for a space. */
|
|
function ensureCatalogDoc(space: string): Automerge.Doc<CatalogDoc> {
|
|
const docId = catalogDocId(space);
|
|
let doc = _syncServer!.getDoc<CatalogDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<CatalogDoc>(), 'init catalog', (d) => {
|
|
const init = catalogSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
/** Get all order docs for a space by scanning known doc IDs. */
|
|
function getSpaceOrderDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> {
|
|
const prefix = `${space}:cart:orders:`;
|
|
const results: Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> = [];
|
|
for (const id of _syncServer!.listDocs()) {
|
|
if (id.startsWith(prefix)) {
|
|
const doc = _syncServer!.getDoc<OrderDoc>(id);
|
|
if (doc) results.push({ docId: id, doc });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ── CATALOG ROUTES ──
|
|
|
|
// POST /api/catalog/ingest — Add artifact to catalog
|
|
routes.post("/api/catalog/ingest", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const artifact = await c.req.json();
|
|
|
|
if (!artifact.id || !artifact.schema_version || !artifact.type) {
|
|
return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
|
|
}
|
|
if (artifact.type !== "print-ready") {
|
|
return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
|
|
}
|
|
if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
|
|
return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
|
|
}
|
|
|
|
const doc = ensureCatalogDoc(space);
|
|
|
|
// Check for duplicate artifact_id
|
|
for (const [, entry] of Object.entries(doc.items)) {
|
|
if (entry.artifactId === artifact.id) {
|
|
return c.json({ error: "Artifact already listed", catalog_entry_id: entry.id }, 409);
|
|
}
|
|
}
|
|
|
|
const entryId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
|
|
const docId = catalogDocId(space);
|
|
_syncServer!.changeDoc<CatalogDoc>(docId, 'ingest catalog entry', (d) => {
|
|
d.items[entryId] = {
|
|
id: entryId,
|
|
artifactId: artifact.id,
|
|
artifact: artifact,
|
|
title: artifact.payload?.title || "Untitled",
|
|
productType: artifact.spec?.product_type || null,
|
|
requiredCapabilities: artifact.spec?.required_capabilities || [],
|
|
substrates: artifact.spec?.substrates || [],
|
|
creatorId: artifact.creator?.id || null,
|
|
sourceSpace: artifact.source_space || space,
|
|
tags: artifact.payload?.tags || [],
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
|
|
return c.json({
|
|
id: entryId,
|
|
artifact_id: artifact.id,
|
|
title: artifact.payload?.title || "Untitled",
|
|
product_type: artifact.spec?.product_type || null,
|
|
status: "active",
|
|
created_at: new Date(now).toISOString(),
|
|
}, 201);
|
|
});
|
|
|
|
// GET /api/catalog — Browse catalog
|
|
routes.get("/api/catalog", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
|
|
|
|
const doc = ensureCatalogDoc(space);
|
|
let entries = Object.values(doc.items);
|
|
|
|
// Apply filters
|
|
entries = entries.filter((e) => e.status === "active");
|
|
if (product_type) entries = entries.filter((e) => e.productType === product_type);
|
|
if (capability) {
|
|
const caps = capability.split(",");
|
|
entries = entries.filter((e) => caps.some((cap) => e.requiredCapabilities.includes(cap)));
|
|
}
|
|
if (tag) entries = entries.filter((e) => e.tags.includes(tag));
|
|
if (source_space) entries = entries.filter((e) => e.sourceSpace === source_space);
|
|
if (q) {
|
|
const lower = q.toLowerCase();
|
|
entries = entries.filter((e) => e.title.toLowerCase().includes(lower));
|
|
}
|
|
|
|
// Sort by createdAt descending
|
|
entries.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
|
const offsetNum = parseInt(offset) || 0;
|
|
const total = entries.length;
|
|
const paged = entries.slice(offsetNum, offsetNum + limitNum);
|
|
|
|
// Map to response shape matching the original SQL response
|
|
const result = paged.map((e) => {
|
|
const art = e.artifact as Record<string, any> | undefined;
|
|
return {
|
|
id: e.id,
|
|
artifact_id: e.artifactId,
|
|
title: e.title,
|
|
product_type: e.productType,
|
|
required_capabilities: e.requiredCapabilities,
|
|
tags: e.tags,
|
|
source_space: e.sourceSpace,
|
|
description: art?.payload?.description || null,
|
|
pricing: art?.pricing || null,
|
|
dimensions: art?.spec?.dimensions || null,
|
|
status: e.status,
|
|
created_at: new Date(e.createdAt).toISOString(),
|
|
};
|
|
});
|
|
|
|
return c.json({ entries: result, total, limit: limitNum, offset: offsetNum });
|
|
});
|
|
|
|
// GET /api/catalog/:id — Single catalog entry
|
|
routes.get("/api/catalog/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
const doc = ensureCatalogDoc(space);
|
|
|
|
// Look up by entry id or artifact id
|
|
let entry: CatalogEntry | undefined;
|
|
if (doc.items[id]) {
|
|
entry = doc.items[id];
|
|
} else {
|
|
entry = Object.values(doc.items).find((e) => e.artifactId === id);
|
|
}
|
|
|
|
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
|
|
|
|
return c.json({
|
|
id: entry.id,
|
|
artifact: entry.artifact,
|
|
status: entry.status,
|
|
created_at: new Date(entry.createdAt).toISOString(),
|
|
updated_at: new Date(entry.updatedAt).toISOString(),
|
|
});
|
|
});
|
|
|
|
// PATCH /api/catalog/:id — Update listing status
|
|
routes.patch("/api/catalog/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const { status } = await c.req.json();
|
|
const valid = ["active", "paused", "sold_out", "removed"];
|
|
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
|
|
|
const doc = ensureCatalogDoc(space);
|
|
const entryId = c.req.param("id");
|
|
|
|
if (!doc.items[entryId]) return c.json({ error: "Catalog entry not found" }, 404);
|
|
|
|
const docId = catalogDocId(space);
|
|
_syncServer!.changeDoc<CatalogDoc>(docId, `update catalog status → ${status}`, (d) => {
|
|
d.items[entryId].status = status;
|
|
d.items[entryId].updatedAt = Date.now();
|
|
});
|
|
|
|
return c.json({ id: entryId, status });
|
|
});
|
|
|
|
// ── ORDER ROUTES ──
|
|
|
|
// POST /api/orders — Create an order
|
|
routes.post("/api/orders", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
|
|
// Optional auth — set buyer_did from claims if authenticated
|
|
const token = extractToken(c.req.raw.headers);
|
|
let buyerDid: string | null = null;
|
|
if (token) {
|
|
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
|
|
}
|
|
|
|
const body = await c.req.json();
|
|
const {
|
|
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
|
provider_id, provider_name, provider_distance_km,
|
|
quantity = 1, production_cost, creator_payout, community_payout,
|
|
total_price, currency = "USD", payment_method = "manual",
|
|
payment_tx, payment_network,
|
|
} = body;
|
|
|
|
if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
|
|
if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);
|
|
|
|
// Look up catalog entry
|
|
const catalogDoc = ensureCatalogDoc(space);
|
|
const lookupId = catalog_entry_id || artifact_id;
|
|
let entry: CatalogEntry | undefined;
|
|
if (catalogDoc.items[lookupId]) {
|
|
entry = catalogDoc.items[lookupId];
|
|
} else {
|
|
entry = Object.values(catalogDoc.items).find((e) => e.artifactId === lookupId || e.id === lookupId);
|
|
}
|
|
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
|
|
|
|
// x402 detection
|
|
const x402Header = c.req.header("x-payment");
|
|
const effectiveMethod = x402Header ? "x402" : payment_method;
|
|
const initialStatus = x402Header ? "paid" : "pending";
|
|
|
|
const orderId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
|
|
// Create order doc
|
|
const oDocId = orderDocId(space, orderId);
|
|
let orderDoc = Automerge.change(Automerge.init<OrderDoc>(), 'create order', (d) => {
|
|
const init = orderSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
d.order.id = orderId;
|
|
d.order.catalogEntryId = entry!.id;
|
|
d.order.artifactId = entry!.artifactId;
|
|
d.order.buyerId = buyerDid || buyer_id || null;
|
|
d.order.buyerLocation = buyer_location ? JSON.stringify(buyer_location) : null;
|
|
d.order.buyerContact = buyer_contact ? JSON.stringify(buyer_contact) : null;
|
|
d.order.providerId = provider_id;
|
|
d.order.providerName = provider_name || null;
|
|
d.order.providerDistanceKm = provider_distance_km || null;
|
|
d.order.quantity = quantity;
|
|
d.order.productionCost = production_cost || null;
|
|
d.order.creatorPayout = creator_payout || null;
|
|
d.order.communityPayout = community_payout || null;
|
|
d.order.totalPrice = total_price;
|
|
d.order.currency = currency;
|
|
d.order.status = initialStatus;
|
|
d.order.paymentMethod = effectiveMethod;
|
|
d.order.paymentTx = payment_tx || null;
|
|
d.order.paymentNetwork = payment_network || null;
|
|
d.order.createdAt = now;
|
|
d.order.updatedAt = now;
|
|
if (initialStatus === "paid") d.order.paidAt = now;
|
|
});
|
|
_syncServer!.setDoc(oDocId, orderDoc);
|
|
|
|
const order = orderDoc.order;
|
|
|
|
if (initialStatus === "paid") {
|
|
depositOrderRevenue(total_price, orderId);
|
|
}
|
|
|
|
// Return response matching original shape
|
|
return c.json(orderToResponse(order, entry), 201);
|
|
});
|
|
|
|
// GET /api/orders — List orders
|
|
routes.get("/api/orders", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
|
|
// Optional auth — filter by buyer if authenticated
|
|
const token = extractToken(c.req.raw.headers);
|
|
let authedBuyer: string | null = null;
|
|
if (token) {
|
|
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
|
|
}
|
|
|
|
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
|
|
|
|
const orderDocs = getSpaceOrderDocs(space);
|
|
|
|
// Build enriched order list with catalog info
|
|
const catalogDoc = ensureCatalogDoc(space);
|
|
|
|
let orders = orderDocs.map(({ doc }) => {
|
|
const o = doc.order;
|
|
const catEntry = catalogDoc.items[o.catalogEntryId];
|
|
const resp = orderToResponse(o);
|
|
resp.artifact_title = catEntry?.title || null;
|
|
resp.product_type = catEntry?.productType || null;
|
|
return resp;
|
|
});
|
|
|
|
// Apply filters
|
|
if (status) orders = orders.filter((o) => o.status === status);
|
|
if (provider_id) orders = orders.filter((o) => o.provider_id === provider_id);
|
|
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
|
|
if (effectiveBuyerId) orders = orders.filter((o) => o.buyer_id === effectiveBuyerId);
|
|
|
|
// Sort by created_at descending
|
|
orders.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
|
|
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
|
const offsetNum = parseInt(offset) || 0;
|
|
const paged = orders.slice(offsetNum, offsetNum + limitNum);
|
|
|
|
return c.json({ orders: paged });
|
|
});
|
|
|
|
// GET /api/orders/:id — Single order
|
|
routes.get("/api/orders/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const orderId = c.req.param("id");
|
|
const oDocId = orderDocId(space, orderId);
|
|
const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
|
|
if (!doc) return c.json({ error: "Order not found" }, 404);
|
|
|
|
const catalogDoc = ensureCatalogDoc(space);
|
|
const catEntry = catalogDoc.items[doc.order.catalogEntryId];
|
|
|
|
const resp = orderToResponse(doc.order);
|
|
resp.artifact_envelope = catEntry?.artifact || null;
|
|
resp.artifact_title = catEntry?.title || null;
|
|
return c.json(resp);
|
|
});
|
|
|
|
// PATCH /api/orders/:id/status — Update order status
|
|
routes.patch("/api/orders/:id/status", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const body = await c.req.json();
|
|
const { status, payment_tx, payment_network } = body;
|
|
const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
|
|
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
|
|
|
const orderId = c.req.param("id");
|
|
const oDocId = orderDocId(space, orderId);
|
|
const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
|
|
if (!doc) return c.json({ error: "Order not found" }, 404);
|
|
|
|
const now = Date.now();
|
|
|
|
const updated = _syncServer!.changeDoc<OrderDoc>(oDocId, `order status → ${status}`, (d) => {
|
|
d.order.status = status;
|
|
d.order.updatedAt = now;
|
|
if (status === "paid") d.order.paidAt = now;
|
|
if (status === "accepted") d.order.acceptedAt = now;
|
|
if (status === "completed") d.order.completedAt = now;
|
|
if (status === "paid" && payment_tx) {
|
|
d.order.paymentTx = payment_tx;
|
|
d.order.paymentNetwork = payment_network || null;
|
|
}
|
|
});
|
|
|
|
if (!updated) return c.json({ error: "Order not found" }, 404);
|
|
|
|
if (status === "paid" && updated.order.totalPrice) {
|
|
depositOrderRevenue(updated.order.totalPrice, orderId);
|
|
}
|
|
|
|
return c.json(orderToResponse(updated.order));
|
|
});
|
|
|
|
// ── Response helpers ──
|
|
|
|
interface OrderResponse {
|
|
id: string;
|
|
catalog_entry_id: string;
|
|
artifact_id: string;
|
|
buyer_id: string | null;
|
|
buyer_location: unknown;
|
|
buyer_contact: unknown;
|
|
provider_id: string | null;
|
|
provider_name: string | null;
|
|
provider_distance_km: number | null;
|
|
quantity: number;
|
|
production_cost: number | null;
|
|
creator_payout: number | null;
|
|
community_payout: number | null;
|
|
total_price: number | null;
|
|
currency: string;
|
|
status: string;
|
|
payment_method: string | null;
|
|
payment_tx: string | null;
|
|
payment_network: string | null;
|
|
created_at: string;
|
|
paid_at: string | null;
|
|
accepted_at: string | null;
|
|
completed_at: string | null;
|
|
updated_at: string;
|
|
artifact_title?: string | null;
|
|
product_type?: string | null;
|
|
artifact_envelope?: unknown;
|
|
}
|
|
|
|
/** Convert an OrderMeta to the flat response shape matching the original SQL rows. */
|
|
function orderToResponse(o: OrderMeta, catEntry?: CatalogEntry): OrderResponse {
|
|
return {
|
|
id: o.id,
|
|
catalog_entry_id: o.catalogEntryId,
|
|
artifact_id: o.artifactId,
|
|
buyer_id: o.buyerId,
|
|
buyer_location: o.buyerLocation ? tryParse(o.buyerLocation) : null,
|
|
buyer_contact: o.buyerContact ? tryParse(o.buyerContact) : null,
|
|
provider_id: o.providerId,
|
|
provider_name: o.providerName,
|
|
provider_distance_km: o.providerDistanceKm,
|
|
quantity: o.quantity,
|
|
production_cost: o.productionCost,
|
|
creator_payout: o.creatorPayout,
|
|
community_payout: o.communityPayout,
|
|
total_price: o.totalPrice,
|
|
currency: o.currency,
|
|
status: o.status,
|
|
payment_method: o.paymentMethod,
|
|
payment_tx: o.paymentTx,
|
|
payment_network: o.paymentNetwork,
|
|
created_at: new Date(o.createdAt).toISOString(),
|
|
paid_at: o.paidAt ? new Date(o.paidAt).toISOString() : null,
|
|
accepted_at: o.acceptedAt ? new Date(o.acceptedAt).toISOString() : null,
|
|
completed_at: o.completedAt ? new Date(o.completedAt).toISOString() : null,
|
|
updated_at: new Date(o.updatedAt).toISOString(),
|
|
...(catEntry ? { artifact_title: catEntry.title, product_type: catEntry.productType } : {}),
|
|
};
|
|
}
|
|
|
|
function tryParse(s: string): unknown {
|
|
try { return JSON.parse(s); } catch { return s; }
|
|
}
|
|
|
|
// ── FULFILLMENT ROUTES ──
|
|
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100;
|
|
}
|
|
|
|
interface ProviderMatch {
|
|
id: string; name: string; distance_km: number;
|
|
location: { city: string; region: string; country: string };
|
|
turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
|
|
pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
|
|
wallet: string;
|
|
}
|
|
|
|
function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
|
|
const spec = artifact.spec as Record<string, unknown> | undefined;
|
|
const capabilities = (spec?.required_capabilities as string[]) || [];
|
|
const pages = (spec?.pages as number) || 1;
|
|
const breakdown: { label: string; amount: number }[] = [];
|
|
|
|
for (const cap of capabilities) {
|
|
const capPricing = provider.pricing?.[cap];
|
|
if (!capPricing) continue;
|
|
|
|
const basePrice = capPricing.base_price || 0;
|
|
let perUnit = capPricing.per_unit || 0;
|
|
const unitLabel = capPricing.per_unit_label || "per unit";
|
|
|
|
if (capPricing.volume_breaks) {
|
|
for (const vb of capPricing.volume_breaks) {
|
|
if (quantity >= vb.min_qty) perUnit = vb.per_unit;
|
|
}
|
|
}
|
|
|
|
let units = quantity;
|
|
if (unitLabel.includes("page")) units = pages * quantity;
|
|
|
|
if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
|
|
breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
|
|
}
|
|
|
|
if (breakdown.length === 0) {
|
|
breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
|
|
}
|
|
|
|
return breakdown;
|
|
}
|
|
|
|
// POST /api/fulfill/resolve — Find fulfillment options
|
|
routes.post("/api/fulfill/resolve", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const body = await c.req.json();
|
|
const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;
|
|
|
|
if (!buyer_location?.lat || !buyer_location?.lng) {
|
|
return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
|
|
}
|
|
if (!artifact_id && !catalog_entry_id) {
|
|
return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
|
|
}
|
|
|
|
const catalogDoc = ensureCatalogDoc(space);
|
|
const lookupId = artifact_id || catalog_entry_id;
|
|
|
|
// Find entry by id or artifact_id, must be active
|
|
let entry: CatalogEntry | undefined;
|
|
if (catalogDoc.items[lookupId] && catalogDoc.items[lookupId].status === "active") {
|
|
entry = catalogDoc.items[lookupId];
|
|
} else {
|
|
entry = Object.values(catalogDoc.items).find(
|
|
(e) => (e.artifactId === lookupId || e.id === lookupId) && e.status === "active"
|
|
);
|
|
}
|
|
if (!entry) return c.json({ error: "Artifact not found in catalog" }, 404);
|
|
|
|
const artifact = entry.artifact as Record<string, any>;
|
|
const capabilities = artifact.spec?.required_capabilities || [];
|
|
const substrates = artifact.spec?.substrates || [];
|
|
|
|
if (capabilities.length === 0) {
|
|
return c.json({ error: "Artifact has no required_capabilities" }, 400);
|
|
}
|
|
|
|
// Query provider registry (internal module or external service)
|
|
const providerUrl = getProviderUrl();
|
|
const params = new URLSearchParams({
|
|
capabilities: capabilities.join(","),
|
|
lat: String(buyer_location.lat),
|
|
lng: String(buyer_location.lng),
|
|
});
|
|
if (substrates.length > 0) params.set("substrates", substrates.join(","));
|
|
|
|
let providers: ProviderMatch[];
|
|
try {
|
|
const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
|
|
if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
|
|
const data = await resp.json() as { matches?: ProviderMatch[] };
|
|
providers = data.matches || [];
|
|
} catch (err) {
|
|
return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
|
|
}
|
|
|
|
if (providers.length === 0) {
|
|
return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
|
|
}
|
|
|
|
const options = providers.map((provider) => {
|
|
const costBreakdown = composeCost(artifact, provider, quantity);
|
|
const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
|
|
const pricing = artifact.pricing || {};
|
|
const creatorPct = (pricing.creator_share_pct || 30) / 100;
|
|
const communityPct = (pricing.community_share_pct || 0) / 100;
|
|
const markupMultiplier = 1 / (1 - creatorPct - communityPct);
|
|
const totalPrice = productionCost * markupMultiplier;
|
|
const creatorPayout = totalPrice * creatorPct;
|
|
const communityPayout = totalPrice * communityPct;
|
|
|
|
return {
|
|
provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
|
|
production_cost: round2(productionCost),
|
|
creator_payout: round2(creatorPayout),
|
|
community_payout: round2(communityPayout),
|
|
total_price: round2(totalPrice),
|
|
currency: "USD",
|
|
turnaround_days: provider.turnaround?.standard_days || 5,
|
|
cost_breakdown: costBreakdown,
|
|
};
|
|
});
|
|
|
|
options.sort((a, b) => a.total_price - b.total_price);
|
|
|
|
return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
|
|
});
|
|
|
|
// ── Page route: shop ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${space} — Shop | rSpace`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── Seed template data ──
|
|
|
|
function seedTemplateCart(space: string) {
|
|
if (!_syncServer) return;
|
|
const doc = ensureCatalogDoc(space);
|
|
if (Object.keys(doc.items).length > 0) return;
|
|
|
|
const docId = catalogDocId(space);
|
|
const now = Date.now();
|
|
|
|
const items: Array<{ title: string; type: string; caps: string[]; subs: string[]; tags: string[] }> = [
|
|
{
|
|
title: 'Commons Community Sticker Pack', type: 'sticker',
|
|
caps: ['laser-print'], subs: ['vinyl-matte'],
|
|
tags: ['merch', 'stickers'],
|
|
},
|
|
{
|
|
title: 'Cosmolocal Network Poster (A2)', type: 'poster',
|
|
caps: ['risograph'], subs: ['paper-heavyweight'],
|
|
tags: ['merch', 'poster', 'cosmolocal'],
|
|
},
|
|
{
|
|
title: 'rSpace Contributor Tee', type: 'apparel',
|
|
caps: ['screen-print'], subs: ['cotton-organic'],
|
|
tags: ['merch', 'apparel'],
|
|
},
|
|
];
|
|
|
|
_syncServer.changeDoc<CatalogDoc>(docId, 'seed template catalog', (d) => {
|
|
for (const item of items) {
|
|
const id = crypto.randomUUID();
|
|
d.items[id] = {
|
|
id, artifactId: crypto.randomUUID(), artifact: null,
|
|
title: item.title, productType: item.type,
|
|
requiredCapabilities: item.caps, substrates: item.subs,
|
|
creatorId: 'did:demo:seed', sourceSpace: space,
|
|
tags: item.tags, status: 'active',
|
|
createdAt: now, updatedAt: now,
|
|
};
|
|
}
|
|
});
|
|
|
|
console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`);
|
|
}
|
|
|
|
export const cartModule: RSpaceModule = {
|
|
id: "rcart",
|
|
name: "rCart",
|
|
icon: "🛒",
|
|
description: "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 },
|
|
],
|
|
routes,
|
|
standaloneDomain: "rcart.online",
|
|
landingPage: renderLanding,
|
|
seedTemplate: seedTemplateCart,
|
|
async onInit(ctx) {
|
|
_syncServer = ctx.syncServer;
|
|
},
|
|
feeds: [
|
|
{
|
|
id: "orders",
|
|
name: "Orders",
|
|
kind: "economic",
|
|
description: "Order stream with pricing, fulfillment status, and revenue splits",
|
|
filterable: true,
|
|
},
|
|
{
|
|
id: "catalog",
|
|
name: "Catalog",
|
|
kind: "data",
|
|
description: "Active catalog listings with product details and pricing",
|
|
filterable: true,
|
|
},
|
|
],
|
|
acceptsFeeds: ["economic", "data"],
|
|
outputPaths: [
|
|
{ path: "products", name: "Products", icon: "🛍️", description: "Print-on-demand product catalog" },
|
|
{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
|
|
],
|
|
};
|