2161 lines
78 KiB
TypeScript
2161 lines
78 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, shoppingCartSchema, shoppingCartIndexSchema,
|
|
paymentRequestSchema, groupBuySchema,
|
|
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
|
|
paymentRequestDocId, groupBuyDocId,
|
|
type CatalogDoc, type CatalogEntry,
|
|
type OrderDoc, type OrderMeta,
|
|
type ShoppingCartDoc, type ShoppingCartIndexDoc,
|
|
type PaymentRequestDoc, type PaymentRequestMeta,
|
|
type GroupBuyDoc,
|
|
type CartItem, type CartStatus,
|
|
} from './schemas';
|
|
import { extractProductFromUrl } from './extract';
|
|
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
|
|
import QRCode from 'qrcode';
|
|
import { createTransport, type Transporter } from "nodemailer";
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
// ── SMTP transport (lazy init) ──
|
|
|
|
let _smtpTransport: Transporter | null = null;
|
|
|
|
function getSmtpTransport(): Transporter | null {
|
|
if (_smtpTransport) return _smtpTransport;
|
|
if (!process.env.SMTP_PASS) return null;
|
|
_smtpTransport = createTransport({
|
|
host: process.env.SMTP_HOST || "mail.rmail.online",
|
|
port: Number(process.env.SMTP_PORT) || 587,
|
|
secure: Number(process.env.SMTP_PORT) === 465,
|
|
auth: {
|
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
return _smtpTransport;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
// ── Subscription interval helpers ──
|
|
|
|
const INTERVAL_MS: Record<string, number> = {
|
|
weekly: 7 * 24 * 60 * 60 * 1000,
|
|
biweekly: 14 * 24 * 60 * 60 * 1000,
|
|
monthly: 30 * 24 * 60 * 60 * 1000,
|
|
quarterly: 91 * 24 * 60 * 60 * 1000,
|
|
yearly: 365 * 24 * 60 * 60 * 1000,
|
|
};
|
|
|
|
function computeNextDueDate(fromMs: number, interval: string): number {
|
|
return fromMs + (INTERVAL_MS[interval] || INTERVAL_MS.monthly);
|
|
}
|
|
|
|
// ── Subscription reminder scheduler ──
|
|
|
|
let _reminderTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function startSubscriptionScheduler() {
|
|
if (_reminderTimer) return;
|
|
// Check every hour for due subscriptions
|
|
_reminderTimer = setInterval(() => checkDueSubscriptions(), 60 * 60 * 1000);
|
|
// Also run once on startup (after 30s delay for init)
|
|
setTimeout(() => checkDueSubscriptions(), 30_000);
|
|
console.log('[rcart] Subscription reminder scheduler started (hourly)');
|
|
}
|
|
|
|
async function checkDueSubscriptions() {
|
|
if (!_syncServer) return;
|
|
const now = Date.now();
|
|
const transport = getSmtpTransport();
|
|
if (!transport) return;
|
|
|
|
// Scan all payment request docs for due subscriptions
|
|
const allDocIds = _syncServer.listDocs?.() || [];
|
|
for (const docId of allDocIds) {
|
|
if (!docId.includes(':payment:')) continue;
|
|
try {
|
|
const doc = _syncServer.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) continue;
|
|
const p = doc.payment;
|
|
|
|
// Only process active subscriptions with a due date in the past
|
|
if (p.status !== 'pending') continue;
|
|
if (!p.interval || !p.nextDueAt) continue;
|
|
if (p.nextDueAt > now) continue;
|
|
if (!p.subscriberEmail) continue;
|
|
|
|
// Don't send more than once per interval — check if last payment was recent
|
|
const lastPayment = p.paymentHistory?.length > 0
|
|
? p.paymentHistory[p.paymentHistory.length - 1]
|
|
: null;
|
|
const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
|
|
if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;
|
|
|
|
// Send reminder email
|
|
const space = doc.meta.spaceSlug || 'demo';
|
|
const host = 'rspace.online';
|
|
const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
|
|
const senderName = p.creatorUsername || 'Someone';
|
|
const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`;
|
|
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
|
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
|
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
|
|
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
|
|
</td></tr>
|
|
<tr><td style="background:#1a1a24;padding:32px 24px">
|
|
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
|
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
|
|
</p>
|
|
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}</p>
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr><td align="center">
|
|
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
|
|
Pay Now
|
|
</a>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
|
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body></html>`;
|
|
|
|
try {
|
|
await transport.sendMail({
|
|
from: `"${senderName} via rSpace" <${fromAddr}>`,
|
|
to: p.subscriberEmail,
|
|
subject: `Payment reminder: ${displayAmount} due to ${senderName}`,
|
|
html,
|
|
});
|
|
console.log(`[rcart] Sent subscription reminder for ${p.id} to ${p.subscriberEmail}`);
|
|
} catch (err) {
|
|
console.warn(`[rcart] Failed to send reminder for ${p.id}:`, err);
|
|
}
|
|
} catch {
|
|
// Skip docs that fail to parse
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 });
|
|
});
|
|
|
|
// ── 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,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ── PAYMENT REQUEST helpers ──
|
|
|
|
/** Get all payment request docs for a space. */
|
|
function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> {
|
|
const prefix = `${space}:cart:payments:`;
|
|
const results: Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> = [];
|
|
for (const id of _syncServer!.listDocs()) {
|
|
if (id.startsWith(prefix)) {
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(id);
|
|
if (doc) results.push({ docId: id, doc });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// USDC contract addresses
|
|
const USDC_ADDRESSES: Record<number, string> = {
|
|
8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base
|
|
84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia
|
|
1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum
|
|
};
|
|
|
|
// ── PAYMENT REQUEST ROUTES ──
|
|
|
|
// POST /api/payments — Create payment request (auth required)
|
|
routes.post("/api/payments", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const body = await c.req.json();
|
|
const {
|
|
description, amount, amountEditable = false,
|
|
token: payToken = 'USDC',
|
|
chainId = 8453, recipientAddress,
|
|
fiatAmount = null, fiatCurrency = 'USD',
|
|
expiresIn = 0, // seconds, 0 = no expiry
|
|
paymentType = 'single',
|
|
maxPayments = 0,
|
|
enabledMethods = { card: true, wallet: true, encryptid: true },
|
|
interval = null,
|
|
} = body;
|
|
|
|
if (!description || !recipientAddress) {
|
|
return c.json({ error: "Required: description, recipientAddress" }, 400);
|
|
}
|
|
if (!amountEditable && !amount) {
|
|
return c.json({ error: "Required: amount (or set amountEditable: true)" }, 400);
|
|
}
|
|
|
|
const paymentId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : 0;
|
|
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create payment request', (d) => {
|
|
const init = paymentRequestSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
d.payment.id = paymentId;
|
|
d.payment.description = description;
|
|
d.payment.amount = amount ? String(amount) : '0';
|
|
d.payment.amountEditable = !!amountEditable;
|
|
d.payment.token = payToken;
|
|
d.payment.chainId = chainId;
|
|
d.payment.recipientAddress = recipientAddress;
|
|
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
|
|
d.payment.fiatCurrency = fiatCurrency;
|
|
d.payment.creatorDid = claims.sub;
|
|
d.payment.creatorUsername = claims.username || '';
|
|
d.payment.status = 'pending';
|
|
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
|
|
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
|
|
d.payment.paymentCount = 0;
|
|
d.payment.enabledMethods = {
|
|
card: enabledMethods.card !== false,
|
|
wallet: enabledMethods.wallet !== false,
|
|
encryptid: enabledMethods.encryptid !== false,
|
|
};
|
|
// Subscription interval
|
|
const validIntervals = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
|
|
if (interval && validIntervals.includes(interval)) {
|
|
d.payment.interval = interval;
|
|
}
|
|
d.payment.createdAt = now;
|
|
d.payment.updatedAt = now;
|
|
d.payment.expiresAt = expiresAt;
|
|
});
|
|
_syncServer!.setDoc(docId, payDoc);
|
|
|
|
const host = c.req.header("host") || "rspace.online";
|
|
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
|
|
|
|
return c.json({
|
|
id: paymentId,
|
|
description,
|
|
amount: String(amount),
|
|
token: payToken,
|
|
chainId,
|
|
recipientAddress,
|
|
status: 'pending',
|
|
payUrl,
|
|
qrUrl: `https://${host}/${space}/rcart/api/payments/${paymentId}/qr`,
|
|
created_at: new Date(now).toISOString(),
|
|
}, 201);
|
|
});
|
|
|
|
// GET /api/payments — List my payment requests (auth required)
|
|
routes.get("/api/payments", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const paymentDocs = getSpacePaymentDocs(space);
|
|
const payments = paymentDocs
|
|
.map(({ doc }) => doc.payment)
|
|
.filter((p) => p.creatorDid === claims.sub)
|
|
.sort((a, b) => b.createdAt - a.createdAt)
|
|
.map((p) => ({
|
|
id: p.id,
|
|
description: p.description,
|
|
amount: p.amount,
|
|
token: p.token,
|
|
chainId: p.chainId,
|
|
recipientAddress: p.recipientAddress,
|
|
fiatAmount: p.fiatAmount,
|
|
fiatCurrency: p.fiatCurrency,
|
|
status: p.status,
|
|
paymentMethod: p.paymentMethod,
|
|
txHash: p.txHash,
|
|
created_at: new Date(p.createdAt).toISOString(),
|
|
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
|
|
}));
|
|
|
|
return c.json({ payments });
|
|
});
|
|
|
|
// GET /api/payments/:id — Get payment details (public)
|
|
routes.get("/api/payments/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
|
|
|
const p = doc.payment;
|
|
|
|
// Check expiry
|
|
if (p.expiresAt > 0 && Date.now() > p.expiresAt && p.status === 'pending') {
|
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'expire payment', (d) => {
|
|
d.payment.status = 'expired';
|
|
d.payment.updatedAt = Date.now();
|
|
});
|
|
return c.json({
|
|
...paymentToResponse(p),
|
|
status: 'expired',
|
|
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
|
});
|
|
}
|
|
|
|
// Check inventory fill
|
|
if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') {
|
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'fill payment', (d) => {
|
|
d.payment.status = 'filled';
|
|
d.payment.updatedAt = Date.now();
|
|
});
|
|
return c.json({
|
|
...paymentToResponse(p),
|
|
status: 'filled',
|
|
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
|
});
|
|
}
|
|
|
|
return c.json({
|
|
...paymentToResponse(p),
|
|
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
|
|
});
|
|
});
|
|
|
|
// PATCH /api/payments/:id/status — Update payment status
|
|
routes.patch("/api/payments/:id/status", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
|
|
|
const body = await c.req.json();
|
|
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType, payerEmail } = body;
|
|
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
|
|
if (status && !validStatuses.includes(status)) {
|
|
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
|
|
}
|
|
|
|
// Check if inventory is full before allowing payment
|
|
const p = doc.payment;
|
|
if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) {
|
|
return c.json({ error: "This payment request has reached its limit" }, 400);
|
|
}
|
|
|
|
const now = Date.now();
|
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
|
|
if (status) d.payment.status = status;
|
|
if (txHash) d.payment.txHash = txHash;
|
|
if (paymentMethod) d.payment.paymentMethod = paymentMethod;
|
|
if (payerIdentity) d.payment.payerIdentity = payerIdentity;
|
|
if (transakOrderId) d.payment.transakOrderId = transakOrderId;
|
|
// Allow amount update only if payment is editable and still pending
|
|
if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
|
|
d.payment.amount = String(amount);
|
|
}
|
|
// Store subscriber email for reminder notifications
|
|
if (payerEmail && !d.payment.subscriberEmail) {
|
|
d.payment.subscriberEmail = payerEmail;
|
|
}
|
|
d.payment.updatedAt = now;
|
|
if (status === 'paid') {
|
|
d.payment.paidAt = now;
|
|
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
|
|
|
// Record payment in history
|
|
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
|
|
(d.payment.paymentHistory as any).push({
|
|
txHash: txHash || null,
|
|
transakOrderId: transakOrderId || null,
|
|
paymentMethod: paymentMethod || null,
|
|
payerIdentity: payerIdentity || null,
|
|
payerEmail: payerEmail || null,
|
|
amount: d.payment.amount,
|
|
paidAt: now,
|
|
});
|
|
|
|
// Determine if this payment should stay open for more payments
|
|
const effectiveType = d.payment.paymentType === 'payer_choice'
|
|
? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
|
|
: d.payment.paymentType;
|
|
const isRecurring = effectiveType === 'subscription' || d.payment.maxPayments > 1;
|
|
if (isRecurring) {
|
|
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
|
|
d.payment.status = 'filled';
|
|
} else {
|
|
// Keep accepting payments — reset status after recording
|
|
d.payment.status = 'pending';
|
|
|
|
// Compute next due date based on interval
|
|
if (d.payment.interval) {
|
|
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
|
|
// Fire-and-forget payment success email
|
|
if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) {
|
|
const host = c.req.header("host") || "rspace.online";
|
|
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
|
|
.catch((err) => console.error('[rcart] payment email failed:', err));
|
|
}
|
|
|
|
return c.json(paymentToResponse(updated!.payment));
|
|
});
|
|
|
|
// GET /api/payments/:id/qr — QR code SVG
|
|
routes.get("/api/payments/:id/qr", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
|
|
|
const host = c.req.header("host") || "rspace.online";
|
|
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
|
|
|
|
const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
|
|
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
|
|
});
|
|
|
|
// POST /api/payments/:id/transak-session — Get Transak widget URL (public)
|
|
routes.post("/api/payments/:id/transak-session", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
|
|
|
const p = doc.payment;
|
|
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
|
|
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
|
|
|
|
const { email, amount: overrideAmount } = await c.req.json();
|
|
if (!email) return c.json({ error: "Required: email" }, 400);
|
|
|
|
const transakApiKey = getTransakApiKey();
|
|
if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);
|
|
|
|
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
|
|
|
|
const host = new URL(c.req.url).hostname;
|
|
|
|
// Use override amount for editable-amount payments, otherwise use preset amount
|
|
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
|
|
|
|
const widgetParams: Record<string, string> = {
|
|
apiKey: transakApiKey,
|
|
referrerDomain: extractRootDomain(host),
|
|
cryptoCurrencyCode: p.token,
|
|
network: networkMap[p.chainId] || 'base',
|
|
defaultCryptoCurrency: p.token,
|
|
walletAddress: p.recipientAddress,
|
|
disableWalletAddressForm: 'true',
|
|
cryptoAmount: effectiveAmount,
|
|
partnerOrderId: `pay-${paymentId}`,
|
|
email,
|
|
themeColor: '6366f1',
|
|
hideMenu: 'true',
|
|
};
|
|
|
|
// Pre-fill fiat amount/currency so user sees the total immediately
|
|
if (p.fiatAmount) {
|
|
widgetParams.fiatAmount = p.fiatAmount;
|
|
widgetParams.defaultFiatAmount = p.fiatAmount;
|
|
}
|
|
if (p.fiatCurrency) {
|
|
widgetParams.fiatCurrency = p.fiatCurrency;
|
|
widgetParams.defaultFiatCurrency = p.fiatCurrency;
|
|
}
|
|
|
|
const widgetUrl = createTransakWidgetUrl(widgetParams);
|
|
|
|
return c.json({ widgetUrl });
|
|
});
|
|
|
|
// POST /api/payments/:id/share-email — Email payment link to recipients
|
|
routes.post("/api/payments/:id/share-email", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
const docId = paymentRequestDocId(space, paymentId);
|
|
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
|
|
|
const p = doc.payment;
|
|
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
|
|
|
|
const { emails } = await c.req.json();
|
|
if (!Array.isArray(emails) || emails.length === 0) return c.json({ error: "Required: emails array" }, 400);
|
|
if (emails.length > 50) return c.json({ error: "Maximum 50 recipients per request" }, 400);
|
|
|
|
const transport = getSmtpTransport();
|
|
if (!transport) return c.json({ error: "Email not configured" }, 503);
|
|
|
|
const host = c.req.header("host") || "rspace.online";
|
|
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
|
|
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
|
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
|
|
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
|
|
const senderName = p.creatorUsername || 'Someone';
|
|
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online";
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
|
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
|
<h1 style="color:#fff;font-size:24px;margin:0">Payment Request</h1>
|
|
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">from ${senderName}</p>
|
|
</td></tr>
|
|
<tr><td style="background:#1a1a24;padding:32px 24px">
|
|
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
|
<strong>${senderName}</strong> has sent you a payment request${p.description ? ` for <strong>${p.description}</strong>` : ''}.
|
|
</p>
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px">
|
|
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Amount</td>
|
|
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right;font-weight:600">${displayAmount}</td></tr>
|
|
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Network</td>
|
|
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${chainName}</td></tr>
|
|
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0">Wallet</td>
|
|
<td style="color:#fff;font-size:13px;padding:8px 0;text-align:right;font-family:monospace">${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}</td></tr>
|
|
</table>
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr><td align="center">
|
|
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
|
|
Pay Now
|
|
</a>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
|
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body></html>`;
|
|
|
|
let sent = 0;
|
|
const validEmails = emails.filter((e: string) => typeof e === 'string' && e.includes('@'));
|
|
for (const email of validEmails) {
|
|
try {
|
|
await transport.sendMail({
|
|
from: `"${senderName} via rSpace" <${fromAddr}>`,
|
|
to: email.trim(),
|
|
subject: `Payment request from ${senderName}${p.description ? `: ${p.description}` : ''}`,
|
|
html,
|
|
});
|
|
sent++;
|
|
} catch (err) {
|
|
console.warn(`[rcart] Failed to send payment email to ${email}:`, err);
|
|
}
|
|
}
|
|
|
|
return c.json({ sent, total: validEmails.length });
|
|
});
|
|
|
|
// ── Payment success email ──
|
|
|
|
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
|
const CHAIN_EXPLORERS: Record<number, string> = {
|
|
8453: 'https://basescan.org/tx/',
|
|
84532: 'https://sepolia.basescan.org/tx/',
|
|
1: 'https://etherscan.io/tx/',
|
|
};
|
|
|
|
async function sendPaymentSuccessEmail(
|
|
email: string,
|
|
p: PaymentRequestMeta,
|
|
host: string,
|
|
space: string,
|
|
) {
|
|
const transport = getSmtpTransport();
|
|
if (!transport) {
|
|
console.warn('[rcart] SMTP not configured — skipping payment email');
|
|
return;
|
|
}
|
|
|
|
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
|
|
const explorer = CHAIN_EXPLORERS[p.chainId];
|
|
const txLink = explorer && p.txHash
|
|
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
|
|
: (p.txHash || 'N/A');
|
|
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
|
|
const rflowsUrl = `https://${host}/${space}/rflows`;
|
|
const dashboardUrl = `https://${host}/${space}/rcart`;
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
|
|
|
<!-- Header -->
|
|
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
|
<div style="font-size:48px;margin-bottom:8px">✓</div>
|
|
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">Payment Received</h1>
|
|
</td></tr>
|
|
|
|
<!-- Body -->
|
|
<tr><td style="background:#1a1a24;padding:28px 24px">
|
|
|
|
<!-- Details table -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
|
|
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
|
|
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
|
|
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
|
|
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
|
|
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Method</td>
|
|
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${p.paymentMethod || 'N/A'}</td></tr>
|
|
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
|
|
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
|
|
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
|
|
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
|
|
</table>
|
|
|
|
<!-- What happens next -->
|
|
<div style="background:#12121a;border:1px solid #2a2a3a;border-radius:8px;padding:20px;margin-bottom:24px">
|
|
<h2 style="margin:0 0 8px;color:#f0f0f5;font-size:16px;font-weight:600">What happens next</h2>
|
|
<p style="margin:0 0 16px;color:#9ca3af;font-size:14px;line-height:1.6">
|
|
Your contribution flows into a funding flow that distributes resources across the project.
|
|
Track how funds are allocated in real time.
|
|
</p>
|
|
<a href="${rflowsUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View rFlows</a>
|
|
</div>
|
|
|
|
<!-- Resources -->
|
|
<div style="margin-bottom:8px">
|
|
<h2 style="margin:0 0 10px;color:#f0f0f5;font-size:16px;font-weight:600">Resources</h2>
|
|
<p style="margin:0 0 6px;font-size:14px;line-height:1.6">
|
|
<a href="https://psilo-cyber.net/ics" style="color:#67e8f9;text-decoration:none">Interplanetary Coordination System</a>
|
|
</p>
|
|
<p style="margin:0;color:#6b7280;font-size:13px;line-height:1.6">
|
|
Endosymbiotic Finance (coming soon)
|
|
</p>
|
|
</div>
|
|
|
|
</td></tr>
|
|
|
|
<!-- Footer -->
|
|
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
|
|
<p style="margin:0;color:#6b7280;font-size:12px">
|
|
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
|
|
</p>
|
|
</td></tr>
|
|
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body></html>`;
|
|
|
|
const text = [
|
|
`Payment Received`,
|
|
``,
|
|
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
|
|
`Network: ${chainName}`,
|
|
`Method: ${p.paymentMethod || 'N/A'}`,
|
|
`Transaction: ${p.txHash || 'N/A'}`,
|
|
`Date: ${paidDate}`,
|
|
``,
|
|
`What happens next:`,
|
|
`Your contribution flows into a funding flow that distributes resources across the project.`,
|
|
`View rFlows: ${rflowsUrl}`,
|
|
``,
|
|
`Resources:`,
|
|
`Interplanetary Coordination System: https://psilo-cyber.net/ics`,
|
|
`Endosymbiotic Finance (coming soon)`,
|
|
``,
|
|
`Sent by rSpace — ${dashboardUrl}`,
|
|
].join('\n');
|
|
|
|
await transport.sendMail({
|
|
from: 'rSpace <noreply@rspace.online>',
|
|
to: email,
|
|
subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
function paymentToResponse(p: PaymentRequestMeta) {
|
|
return {
|
|
id: p.id,
|
|
description: p.description,
|
|
amount: p.amount,
|
|
amountEditable: p.amountEditable,
|
|
token: p.token,
|
|
chainId: p.chainId,
|
|
recipientAddress: p.recipientAddress,
|
|
fiatAmount: p.fiatAmount,
|
|
fiatCurrency: p.fiatCurrency,
|
|
status: p.status,
|
|
paymentMethod: p.paymentMethod,
|
|
txHash: p.txHash,
|
|
payerIdentity: p.payerIdentity,
|
|
transakOrderId: p.transakOrderId,
|
|
paymentType: p.paymentType || 'single',
|
|
maxPayments: p.maxPayments || 0,
|
|
paymentCount: p.paymentCount || 0,
|
|
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
|
|
creatorUsername: p.creatorUsername || '',
|
|
interval: p.interval || null,
|
|
nextDueAt: p.nextDueAt ? new Date(p.nextDueAt).toISOString() : null,
|
|
paymentHistory: (p.paymentHistory || []).map(h => ({
|
|
txHash: h.txHash,
|
|
paymentMethod: h.paymentMethod,
|
|
amount: h.amount,
|
|
paidAt: new Date(h.paidAt).toISOString(),
|
|
})),
|
|
created_at: new Date(p.createdAt).toISOString(),
|
|
updated_at: new Date(p.updatedAt).toISOString(),
|
|
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
|
|
expires_at: p.expiresAt ? new Date(p.expiresAt).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
// ── GROUP BUY ROUTES ──
|
|
|
|
// POST /api/group-buys — Create a group buy from a catalog entry
|
|
routes.post("/api/group-buys", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const body = await c.req.json();
|
|
const { catalogEntryId, tiers, description, closesInDays } = body;
|
|
if (!catalogEntryId || !tiers?.length) return c.json({ error: "catalogEntryId and tiers[] required" }, 400);
|
|
|
|
const catDoc = _syncServer!.getDoc<CatalogDoc>(catalogDocId(space));
|
|
const entry = catDoc?.items?.[catalogEntryId];
|
|
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
|
|
|
|
const buyId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
const docId = groupBuyDocId(space, buyId);
|
|
|
|
const doc = Automerge.change(Automerge.init<GroupBuyDoc>(), 'create group buy', (d) => {
|
|
const init = groupBuySchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
d.meta.createdAt = now;
|
|
d.buy.id = buyId;
|
|
d.buy.catalogEntryId = catalogEntryId;
|
|
d.buy.artifactId = entry.artifactId;
|
|
d.buy.title = entry.title;
|
|
d.buy.productType = entry.productType;
|
|
d.buy.imageUrl = (entry as any).imageUrl || null;
|
|
d.buy.description = description || '';
|
|
d.buy.status = 'OPEN';
|
|
d.buy.totalPledged = 0;
|
|
d.buy.createdBy = claims.sub || null;
|
|
d.buy.createdAt = now;
|
|
d.buy.updatedAt = now;
|
|
d.buy.closesAt = closesInDays ? now + closesInDays * 86400000 : now + 30 * 86400000;
|
|
for (const tier of tiers) {
|
|
d.buy.tiers.push({ min_qty: tier.min_qty, per_unit: tier.per_unit, currency: tier.currency || 'USD' });
|
|
}
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
|
|
const host = c.req.header('host') || 'rspace.online';
|
|
const shareUrl = `https://${host}/${space}/rcart/group-buy/${buyId}`;
|
|
return c.json({ id: buyId, shareUrl }, 201);
|
|
});
|
|
|
|
// GET /api/group-buys — List open group buys for a space
|
|
routes.get("/api/group-buys", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const prefix = `${space}:cart:group-buys:`;
|
|
const buys: any[] = [];
|
|
for (const id of _syncServer!.listDocs()) {
|
|
if (id.startsWith(prefix)) {
|
|
const doc = _syncServer!.getDoc<GroupBuyDoc>(id);
|
|
if (doc?.buy) {
|
|
buys.push({
|
|
id: doc.buy.id,
|
|
title: doc.buy.title,
|
|
productType: doc.buy.productType,
|
|
imageUrl: doc.buy.imageUrl,
|
|
status: doc.buy.status,
|
|
totalPledged: doc.buy.totalPledged,
|
|
tiers: doc.buy.tiers,
|
|
closesAt: new Date(doc.buy.closesAt).toISOString(),
|
|
createdAt: new Date(doc.buy.createdAt).toISOString(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return c.json({ groupBuys: buys });
|
|
});
|
|
|
|
// GET /api/group-buys/:id — Get group buy details
|
|
routes.get("/api/group-buys/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const buyId = c.req.param("id");
|
|
const docId = groupBuyDocId(space, buyId);
|
|
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
|
|
if (!doc) return c.json({ error: "Group buy not found" }, 404);
|
|
|
|
const pledges = Object.values(doc.pledges || {}).map(p => ({
|
|
id: p.id,
|
|
displayName: p.displayName,
|
|
quantity: p.quantity,
|
|
pledgedAt: new Date(p.pledgedAt).toISOString(),
|
|
}));
|
|
|
|
const currentTier = [...doc.buy.tiers].reverse().find(t => doc.buy.totalPledged >= t.min_qty) || doc.buy.tiers[0] || null;
|
|
|
|
return c.json({
|
|
id: doc.buy.id,
|
|
catalogEntryId: doc.buy.catalogEntryId,
|
|
title: doc.buy.title,
|
|
productType: doc.buy.productType,
|
|
imageUrl: doc.buy.imageUrl,
|
|
description: doc.buy.description,
|
|
tiers: doc.buy.tiers,
|
|
status: doc.buy.status,
|
|
totalPledged: doc.buy.totalPledged,
|
|
currentTier,
|
|
pledges,
|
|
closesAt: new Date(doc.buy.closesAt).toISOString(),
|
|
createdAt: new Date(doc.buy.createdAt).toISOString(),
|
|
updatedAt: new Date(doc.buy.updatedAt).toISOString(),
|
|
});
|
|
});
|
|
|
|
// POST /api/group-buys/:id/pledge — Add a pledge
|
|
routes.post("/api/group-buys/:id/pledge", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const buyId = c.req.param("id");
|
|
const docId = groupBuyDocId(space, buyId);
|
|
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
|
|
if (!doc) return c.json({ error: "Group buy not found" }, 404);
|
|
if (doc.buy.status !== 'OPEN') return c.json({ error: "Group buy is no longer open" }, 400);
|
|
|
|
const body = await c.req.json();
|
|
const { quantity, displayName } = body;
|
|
if (!quantity || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);
|
|
|
|
let buyerId: string | null = null;
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (token) {
|
|
try { const claims = await verifyEncryptIDToken(token); buyerId = claims.sub || null; } catch { /* public pledge */ }
|
|
}
|
|
|
|
const pledgeId = crypto.randomUUID();
|
|
const now = Date.now();
|
|
|
|
_syncServer!.changeDoc<GroupBuyDoc>(docId, 'add pledge', (d) => {
|
|
d.pledges[pledgeId] = {
|
|
id: pledgeId,
|
|
buyerId,
|
|
displayName: displayName || 'Anonymous',
|
|
quantity,
|
|
pledgedAt: now,
|
|
};
|
|
d.buy.totalPledged += quantity;
|
|
d.buy.updatedAt = now;
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<GroupBuyDoc>(docId)!;
|
|
const currentTier = [...updated.buy.tiers].reverse().find(t => updated.buy.totalPledged >= t.min_qty) || updated.buy.tiers[0] || null;
|
|
|
|
return c.json({
|
|
pledgeId,
|
|
totalPledged: updated.buy.totalPledged,
|
|
currentTier,
|
|
}, 201);
|
|
});
|
|
|
|
// ── Page route: group buy page ──
|
|
routes.get("/group-buy/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const buyId = c.req.param("id");
|
|
return c.html(renderShell({
|
|
title: `Group Buy | rCart`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-group-buy-page space="${space}" buy-id="${buyId}"></folk-group-buy-page>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-group-buy-page.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── Page route: payments dashboard ──
|
|
routes.get("/payments", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Payments | rCart`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-payments-dashboard space="${space}"></folk-payments-dashboard>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-payments-dashboard.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── Page route: request payment (self-service QR generator) ──
|
|
routes.get("/request", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Request Payment | rCart`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-payment-request space="${space}"></folk-payment-request>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-payment-request.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── Page route: payment page ──
|
|
routes.get("/pay/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const paymentId = c.req.param("id");
|
|
return c.html(renderShell({
|
|
title: `Payment | rCart`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-payment-page space="${space}" payment-id="${paymentId}"></folk-payment-page>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-payment-page.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── Page routes: shop views (subnav tab links) ──
|
|
|
|
function renderShop(space: string, view?: string) {
|
|
const viewAttr = view ? ` initial-view="${view}"` : "";
|
|
return renderShell({
|
|
title: `${space} — Shop | rSpace`,
|
|
moduleId: "rcart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`,
|
|
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
|
});
|
|
}
|
|
|
|
routes.get("/carts", (c) => c.html(renderShop(c.req.param("space") || "demo", "carts")));
|
|
routes.get("/catalog", (c) => c.html(renderShop(c.req.param("space") || "demo", "catalog")));
|
|
routes.get("/orders", (c) => c.html(renderShop(c.req.param("space") || "demo", "orders")));
|
|
routes.get("/group-buys", (c) => c.html(renderShop(c.req.param("space") || "demo", "group-buys")));
|
|
routes.get("/subscriptions", (c) => c.html(renderShop(c.req.param("space") || "demo", "subscriptions")));
|
|
|
|
routes.get("/", (c) => c.html(renderShop(c.req.param("space") || "demo")));
|
|
|
|
// ── 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: "Group shopping & cosmolocal print-on-demand shop",
|
|
publicWrite: true,
|
|
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 },
|
|
{ pattern: '{space}:cart:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init },
|
|
],
|
|
routes,
|
|
standaloneDomain: "rcart.online",
|
|
landingPage: renderLanding,
|
|
seedTemplate: seedTemplateCart,
|
|
async onInit(ctx) {
|
|
_syncServer = ctx.syncServer;
|
|
startSubscriptionScheduler();
|
|
},
|
|
feeds: [
|
|
{
|
|
id: "orders",
|
|
name: "Orders",
|
|
kind: "economic",
|
|
description: "Order stream with pricing, fulfillment status, and revenue splits",
|
|
filterable: true,
|
|
},
|
|
{
|
|
id: "catalog",
|
|
name: "Catalog",
|
|
kind: "data",
|
|
description: "Active catalog listings with product details and pricing",
|
|
filterable: true,
|
|
},
|
|
{
|
|
id: "shopping",
|
|
name: "Shopping Carts",
|
|
kind: "data",
|
|
description: "Group shopping carts with pooled items and contributions",
|
|
filterable: true,
|
|
},
|
|
],
|
|
acceptsFeeds: ["economic", "data"],
|
|
outputPaths: [
|
|
{ path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" },
|
|
{ path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" },
|
|
{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
|
|
{ path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" },
|
|
{ path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" },
|
|
{ path: "subscriptions", name: "Subscriptions", icon: "🔄", description: "Recurring subscription orders" },
|
|
],
|
|
};
|