feat: add cart, providers, swag modules — Phase 4 cosmolocal commerce

Port the three cosmolocal print-on-demand services (rCart, Provider Registry,
Swag Designer) into the unified rSpace module system. Each module includes
Hono API routes, folk-* web components, DB schemas, and standalone servers.

- Cart: catalog ingest, orders with state machine, x402 detection, rFunds flow deposit
- Providers: 6 seeded providers, earthdistance proximity queries, capability matching
- Swag: product templates (sticker/poster/tee), Sharp image processing, artifact envelopes
- DB: cube + earthdistance extensions, rcart + providers schemas
- Docker: swag-artifacts volume, payment-network for flow service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 22:59:19 +00:00
parent f313d9395d
commit d9cc86637c
23 changed files with 2163 additions and 3 deletions

View File

@ -8,7 +8,7 @@ COPY package.json bun.lock* ./
# Copy local SDK dependency (package.json references file:../encryptid-sdk)
COPY --from=encryptid-sdk . /encryptid-sdk/
RUN bun install --frozen-lockfile
RUN bun install
# Copy source
COPY . .
@ -43,20 +43,22 @@ COPY --from=build /app/package.json .
COPY --from=build /encryptid-sdk /encryptid-sdk
# Install production dependencies only
RUN bun install --production --frozen-lockfile
RUN bun install --production
# Create data directories
RUN mkdir -p /data/communities /data/books
RUN mkdir -p /data/communities /data/books /data/swag-artifacts
# Set environment
ENV NODE_ENV=production
ENV STORAGE_DIR=/data/communities
ENV BOOKS_DIR=/data/books
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
ENV PORT=3000
# Data volumes for persistence
VOLUME /data/communities
VOLUME /data/books
VOLUME /data/swag-artifacts
EXPOSE 3000

View File

@ -3,6 +3,8 @@
-- Extensions available to all schemas
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "cube";
CREATE EXTENSION IF NOT EXISTS "earthdistance";
-- Module schemas (created on init, populated by module migrations)
CREATE SCHEMA IF NOT EXISTS rbooks;

View File

@ -9,13 +9,18 @@ services:
volumes:
- rspace-data:/data/communities
- rspace-books:/data/books
- rspace-swag:/data/swag-artifacts
environment:
- NODE_ENV=production
- STORAGE_DIR=/data/communities
- BOOKS_DIR=/data/books
- SWAG_ARTIFACTS_DIR=/data/swag-artifacts
- PORT=3000
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
- FLOW_SERVICE_URL=http://payment-flow:3010
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
depends_on:
rspace-db:
condition: service_healthy
@ -35,6 +40,7 @@ services:
networks:
- traefik-public
- rspace-internal
- payment-network
rspace-db:
image: postgres:16-alpine
@ -58,9 +64,13 @@ services:
volumes:
rspace-data:
rspace-books:
rspace-swag:
rspace-pgdata:
networks:
traefik-public:
external: true
payment-network:
name: payment-infra_payment-network
external: true
rspace-internal:

View File

@ -0,0 +1,6 @@
/* Cart module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}

View File

@ -0,0 +1,152 @@
/**
* <folk-cart-shop> browse catalog, view orders, trigger fulfillment.
* Shows catalog items, order creation flow, and order status tracking.
*/
class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot;
private catalog: any[] = [];
private orders: any[] = [];
private view: "catalog" | "orders" = "catalog";
private loading = true;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadData();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/cart` : "/demo/cart";
}
private async loadData() {
this.loading = true;
this.render();
try {
const [catRes, ordRes] = await Promise.all([
fetch(`${this.getApiBase()}/api/catalog?limit=50`),
fetch(`${this.getApiBase()}/api/orders?limit=20`),
]);
const catData = await catRes.json();
const ordData = await ordRes.json();
this.catalog = catData.entries || [];
this.orders = ordData.orders || [];
} catch (e) {
console.error("Failed to load cart data:", e);
}
this.loading = false;
this.render();
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.header h2 { margin: 0; color: #f1f5f9; font-size: 1.5rem; }
.tabs { display: flex; gap: 0.5rem; }
.tab { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; }
.tab:hover { border-color: #475569; color: #f1f5f9; }
.tab.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.5rem; }
.card-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.5rem; }
.tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; margin-right: 0.25rem; }
.tag-type { background: rgba(99,102,241,0.1); color: #818cf8; }
.tag-cap { background: rgba(34,197,94,0.1); color: #4ade80; }
.dims { color: #64748b; font-size: 0.75rem; margin-top: 0.5rem; }
.status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.status-paid { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; }
.order-card { display: flex; justify-content: space-between; align-items: center; }
.order-info { flex: 1; }
.order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; }
.empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
</style>
<div class="header">
<h2>\u{1F6D2} Community Shop</h2>
<div class="tabs">
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">\u{1F4E6} Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">\u{1F4CB} Orders (${this.orders.length})</button>
</div>
</div>
${this.loading ? `<div class="loading">\u23F3 Loading...</div>` :
this.view === "catalog" ? this.renderCatalog() : this.renderOrders()}
`;
this.shadow.querySelectorAll(".tab").forEach((el) => {
el.addEventListener("click", () => {
this.view = ((el as HTMLElement).dataset.view || "catalog") as "catalog" | "orders";
this.render();
});
});
}
private renderCatalog(): string {
if (this.catalog.length === 0) {
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
}
return `<div class="grid">
${this.catalog.map((entry) => `
<div class="card">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
<div class="card-meta">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
</div>
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
</div>
`).join("")}
</div>`;
}
private renderOrders(): string {
if (this.orders.length === 0) {
return `<div class="empty">No orders yet.</div>`;
}
return `<div class="grid">
${this.orders.map((order) => `
<div class="card">
<div class="order-card">
<div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
<div class="card-meta">
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
${order.quantity > 1 ? ` \u2022 Qty: ${order.quantity}` : ""}
</div>
<span class="status status-${order.status}">${order.status}</span>
</div>
<div class="order-price">$${parseFloat(order.total_price || 0).toFixed(2)}</div>
</div>
</div>
`).join("")}
</div>`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-cart-shop", FolkCartShop);

View File

@ -0,0 +1,58 @@
-- rCart schema — catalog entries, orders, payment splits
-- Inside rSpace shared DB, schema: rcart
CREATE TABLE IF NOT EXISTS rcart.catalog_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artifact_id UUID NOT NULL UNIQUE,
artifact JSONB NOT NULL,
title TEXT NOT NULL,
product_type TEXT,
required_capabilities TEXT[] DEFAULT '{}',
substrates TEXT[] DEFAULT '{}',
creator_id TEXT,
source_space TEXT,
tags TEXT[] DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'sold_out', 'removed')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_catalog_status ON rcart.catalog_entries (status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_catalog_capabilities ON rcart.catalog_entries USING gin (required_capabilities);
CREATE INDEX IF NOT EXISTS idx_catalog_tags ON rcart.catalog_entries USING gin (tags);
CREATE INDEX IF NOT EXISTS idx_catalog_source_space ON rcart.catalog_entries (source_space);
CREATE INDEX IF NOT EXISTS idx_catalog_product_type ON rcart.catalog_entries (product_type);
CREATE TABLE IF NOT EXISTS rcart.orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
catalog_entry_id UUID NOT NULL REFERENCES rcart.catalog_entries(id),
artifact_id UUID NOT NULL,
buyer_id TEXT,
buyer_location JSONB,
buyer_contact JSONB,
provider_id UUID,
provider_name TEXT,
provider_distance_km DOUBLE PRECISION,
quantity INTEGER NOT NULL DEFAULT 1,
production_cost NUMERIC(10,2),
creator_payout NUMERIC(10,2),
community_payout NUMERIC(10,2),
total_price NUMERIC(10,2),
currency TEXT DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'paid', 'accepted', 'in_production', 'ready', 'shipped', 'completed', 'cancelled'
)),
payment_method TEXT DEFAULT 'manual',
payment_tx TEXT,
payment_network TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
paid_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON rcart.orders (status);
CREATE INDEX IF NOT EXISTS idx_orders_provider ON rcart.orders (provider_id);
CREATE INDEX IF NOT EXISTS idx_orders_buyer ON rcart.orders (buyer_id);
CREATE INDEX IF NOT EXISTS idx_orders_catalog ON rcart.orders (catalog_entry_id);

41
modules/cart/flow.ts Normal file
View File

@ -0,0 +1,41 @@
/**
* Flow Service integration deposits order revenue into the TBFF flow
* for automatic threshold-based splits (provider / creator / community).
*/
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
const FLOW_ID = process.env.FLOW_ID || "";
const FUNNEL_ID = process.env.FUNNEL_ID || "";
function toUsdcUnits(dollars: number | string): string {
const d = typeof dollars === "string" ? parseFloat(dollars) : dollars;
return Math.round(d * 1e6).toString();
}
export async function depositOrderRevenue(
totalPrice: number | string,
orderId: string
): Promise<void> {
if (!FLOW_ID || !FUNNEL_ID) return;
const amount = toUsdcUnits(totalPrice);
const url = `${FLOW_SERVICE_URL}/api/flows/${FLOW_ID}/deposit`;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ funnelId: FUNNEL_ID, amount, source: "wallet" }),
});
if (resp.ok) {
const data = await resp.json() as { transaction?: { id?: string } };
console.log(`[Cart] Flow deposit OK: order=${orderId} amount=${amount} tx=${data.transaction?.id}`);
} else {
const text = await resp.text();
console.error(`[Cart] Flow deposit failed (${resp.status}): ${text}`);
}
} catch (err) {
console.error("[Cart] Flow deposit error:", err instanceof Error ? err.message : err);
}
}

447
modules/cart/mod.ts Normal file
View File

@ -0,0 +1,447 @@
/**
* 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.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Cart] DB schema initialized");
} catch (e) {
console.error("[Cart] DB init error:", e);
}
}
initDB();
// 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";
}
// ── CATALOG ROUTES ──
// POST /api/catalog/ingest — Add artifact to catalog
routes.post("/api/catalog/ingest", async (c) => {
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 existing = await sql.unsafe("SELECT id FROM rcart.catalog_entries WHERE artifact_id = $1", [artifact.id]);
if (existing.length > 0) {
return c.json({ error: "Artifact already listed", catalog_entry_id: existing[0].id }, 409);
}
const result = await sql.unsafe(
`INSERT INTO rcart.catalog_entries (
artifact_id, artifact, title, product_type,
required_capabilities, substrates, creator_id,
source_space, tags
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, artifact_id, title, product_type, status, created_at`,
[
artifact.id, JSON.stringify(artifact),
artifact.payload?.title || "Untitled",
artifact.spec?.product_type || null,
artifact.spec?.required_capabilities || [],
artifact.spec?.substrates || [],
artifact.creator?.id || null,
artifact.source_space || null,
artifact.payload?.tags || [],
]
);
return c.json(result[0], 201);
});
// GET /api/catalog — Browse catalog
routes.get("/api/catalog", async (c) => {
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = ["status = 'active'"];
const params: unknown[] = [];
let paramIdx = 1;
if (product_type) {
conditions.push(`product_type = $${paramIdx}`);
params.push(product_type);
paramIdx++;
}
if (capability) {
conditions.push(`required_capabilities && $${paramIdx}`);
params.push(capability.split(","));
paramIdx++;
}
if (tag) {
conditions.push(`$${paramIdx} = ANY(tags)`);
params.push(tag);
paramIdx++;
}
if (source_space) {
conditions.push(`source_space = $${paramIdx}`);
params.push(source_space);
paramIdx++;
}
if (q) {
conditions.push(`title ILIKE $${paramIdx}`);
params.push(`%${q}%`);
paramIdx++;
}
const where = conditions.join(" AND ");
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const [result, countResult] = await Promise.all([
sql.unsafe(
`SELECT id, artifact_id, title, product_type,
required_capabilities, tags, source_space,
artifact->'payload'->>'description' as description,
artifact->'pricing' as pricing,
artifact->'spec'->'dimensions' as dimensions,
status, created_at
FROM rcart.catalog_entries
WHERE ${where}
ORDER BY created_at DESC
LIMIT ${limitNum} OFFSET ${offsetNum}`,
params
),
sql.unsafe(`SELECT count(*) FROM rcart.catalog_entries WHERE ${where}`, params),
]);
return c.json({ entries: result, total: parseInt(countResult[0].count as string), limit: limitNum, offset: offsetNum });
});
// GET /api/catalog/:id — Single catalog entry
routes.get("/api/catalog/:id", async (c) => {
const id = c.req.param("id");
const result = await sql.unsafe(
"SELECT * FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
[id]
);
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
const row = result[0];
return c.json({ id: row.id, artifact: row.artifact, status: row.status, created_at: row.created_at, updated_at: row.updated_at });
});
// PATCH /api/catalog/:id — Update listing status
routes.patch("/api/catalog/:id", async (c) => {
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 result = await sql.unsafe(
"UPDATE rcart.catalog_entries SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, status",
[status, c.req.param("id")]
);
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
return c.json(result[0]);
});
// ── ORDER ROUTES ──
// POST /api/orders — Create an order
routes.post("/api/orders", async (c) => {
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);
const entryResult = await sql.unsafe(
"SELECT id, artifact_id FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
[catalog_entry_id || artifact_id]
);
if (entryResult.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
const entry = entryResult[0];
// x402 detection
const x402Header = c.req.header("x-payment");
const effectiveMethod = x402Header ? "x402" : payment_method;
const initialStatus = x402Header ? "paid" : "pending";
const result = await sql.unsafe(
`INSERT INTO rcart.orders (
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
provider_id, provider_name, provider_distance_km,
quantity, production_cost, creator_payout, community_payout,
total_price, currency, status, payment_method, payment_tx, payment_network
${initialStatus === "paid" ? ", paid_at" : ""}
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18
${initialStatus === "paid" ? ", NOW()" : ""})
RETURNING *`,
[
entry.id, entry.artifact_id,
buyer_id || null,
buyer_location ? JSON.stringify(buyer_location) : null,
buyer_contact ? JSON.stringify(buyer_contact) : null,
provider_id, provider_name || null, provider_distance_km || null,
quantity, production_cost || null, creator_payout || null, community_payout || null,
total_price, currency, initialStatus, effectiveMethod,
payment_tx || null, payment_network || null,
]
);
const order = result[0];
if (initialStatus === "paid") {
depositOrderRevenue(total_price, order.id);
}
return c.json(order, 201);
});
// GET /api/orders — List orders
routes.get("/api/orders", async (c) => {
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
if (buyer_id) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(buyer_id); paramIdx++; }
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const result = await sql.unsafe(
`SELECT o.*, c.title as artifact_title, c.product_type
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
${where} ORDER BY o.created_at DESC LIMIT ${limitNum} OFFSET ${offsetNum}`,
params
);
return c.json({ orders: result });
});
// GET /api/orders/:id — Single order
routes.get("/api/orders/:id", async (c) => {
const result = await sql.unsafe(
`SELECT o.*, c.artifact as artifact_envelope, c.title as artifact_title
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
WHERE o.id = $1`,
[c.req.param("id")]
);
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
return c.json(result[0]);
});
// PATCH /api/orders/:id/status — Update order status
routes.patch("/api/orders/:id/status", async (c) => {
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 timestampField: Record<string, string> = { paid: "paid_at", accepted: "accepted_at", completed: "completed_at" };
const extraSet = timestampField[status] ? `, ${timestampField[status]} = NOW()` : "";
// Use parameterized query for payment info
let paymentSet = "";
const params: unknown[] = [status, c.req.param("id")];
if (status === "paid" && payment_tx) {
paymentSet = `, payment_tx = $3, payment_network = $4`;
params.push(payment_tx, payment_network || null);
}
const result = await sql.unsafe(
`UPDATE rcart.orders SET status = $1, updated_at = NOW()${extraSet}${paymentSet} WHERE id = $2 RETURNING *`,
params
);
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
const updated = result[0];
if (status === "paid" && updated.total_price) {
depositOrderRevenue(updated.total_price, c.req.param("id"));
}
return c.json(updated);
});
// ── 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 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 entryResult = await sql.unsafe(
"SELECT * FROM rcart.catalog_entries WHERE (artifact_id = $1 OR id = $1) AND status = 'active'",
[artifact_id || catalog_entry_id]
);
if (entryResult.length === 0) return c.json({ error: "Artifact not found in catalog" }, 404);
const entry = entryResult[0];
const artifact = entry.artifact;
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: `Shop | rSpace`,
moduleId: "cart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
body: `<folk-cart-shop></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
}));
});
export const cartModule: RSpaceModule = {
id: "cart",
name: "rCart",
icon: "\u{1F6D2}",
description: "Cosmolocal print-on-demand shop",
routes,
standaloneDomain: "rcart.online",
};

View File

@ -0,0 +1,58 @@
/**
* Cart standalone server independent deployment at rcart.online.
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { resolve } from "node:path";
import { cartModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.use("/api/*", cors({
origin: "*",
exposeHeaders: ["X-PAYMENT-REQUIRED", "X-PAYMENT-RESPONSE"],
allowHeaders: ["Content-Type", "Authorization", "X-PAYMENT", "X-PAYMENT-RESPONSE"],
}));
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", cartModule.routes);
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(assetPath) } });
}
}
}
return app.fetch(req);
},
});
console.log(`rCart standalone server running on http://localhost:${PORT}`);

View File

@ -0,0 +1,182 @@
/**
* <folk-provider-directory> browseable provider directory.
* Shows a grid of provider cards with search, capability filter, and proximity sorting.
*/
class FolkProviderDirectory extends HTMLElement {
private shadow: ShadowRoot;
private providers: any[] = [];
private capabilities: string[] = [];
private selectedCap = "";
private searchQuery = "";
private userLat: number | null = null;
private userLng: number | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadProviders();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/providers` : "/demo/providers";
}
private async loadProviders() {
try {
const params = new URLSearchParams();
if (this.selectedCap) params.set("capability", this.selectedCap);
if (this.userLat && this.userLng) {
params.set("lat", String(this.userLat));
params.set("lng", String(this.userLng));
}
params.set("limit", "100");
const res = await fetch(`${this.getApiBase()}/api/providers?${params}`);
const data = await res.json();
this.providers = data.providers || [];
// Collect unique capabilities
const capSet = new Set<string>();
for (const p of this.providers) {
for (const cap of (p.capabilities || [])) capSet.add(cap);
}
this.capabilities = Array.from(capSet).sort();
this.render();
} catch (e) {
console.error("Failed to load providers:", e);
}
}
private render() {
const filtered = this.providers.filter((p) => {
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
const name = (p.name || "").toLowerCase();
const city = (p.location?.city || "").toLowerCase();
const country = (p.location?.country || "").toLowerCase();
if (!name.includes(q) && !city.includes(q) && !country.includes(q)) return false;
}
return true;
});
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1.5rem; }
.header h2 { margin: 0; font-size: 1.5rem; color: #f1f5f9; flex: 1; }
.search { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; width: 240px; }
.search:focus { outline: none; border-color: #6366f1; }
.locate-btn { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.75rem; }
.locate-btn:hover { background: #334155; color: #f1f5f9; }
.locate-btn.active { border-color: #6366f1; color: #a5b4fc; }
.caps { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 1.5rem; }
.cap { padding: 0.25rem 0.625rem; border-radius: 999px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; font-size: 0.75rem; cursor: pointer; }
.cap:hover { border-color: #6366f1; color: #c7d2fe; }
.cap.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; }
.card-name { font-size: 1.125rem; font-weight: 600; color: #f1f5f9; margin: 0; }
.card-location { font-size: 0.8125rem; color: #94a3b8; margin-top: 0.25rem; }
.badge { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
.badge-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-distance { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.caps-list { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0.75rem 0; }
.cap-tag { padding: 0.125rem 0.5rem; border-radius: 4px; background: rgba(99,102,241,0.1); color: #818cf8; font-size: 0.6875rem; }
.card-footer { display: flex; gap: 1rem; font-size: 0.75rem; color: #64748b; border-top: 1px solid #334155; padding-top: 0.75rem; margin-top: 0.75rem; }
.card-footer a { color: #818cf8; text-decoration: none; }
.card-footer a:hover { text-decoration: underline; }
.turnaround { font-size: 0.75rem; color: #94a3b8; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
</style>
<div class="header">
<h2>\u{1F3ED} Provider Directory</h2>
<input class="search" type="text" placeholder="Search providers..." value="${this.searchQuery}">
<button class="locate-btn ${this.userLat ? 'active' : ''}">\u{1F4CD} ${this.userLat ? 'Nearby' : 'Use location'}</button>
</div>
${this.capabilities.length > 0 ? `
<div class="caps">
<span class="cap ${!this.selectedCap ? 'active' : ''}" data-cap="">All</span>
${this.capabilities.map((cap) => `
<span class="cap ${this.selectedCap === cap ? 'active' : ''}" data-cap="${cap}">${cap}</span>
`).join("")}
</div>` : ""}
${filtered.length === 0 ? `<div class="empty">No providers found</div>` : `
<div class="grid">
${filtered.map((p) => `
<div class="card">
<div class="card-header">
<div>
<h3 class="card-name">${this.esc(p.name)}</h3>
<div class="card-location">${this.esc(p.location?.city || "")}${p.location?.region ? `, ${this.esc(p.location.region)}` : ""} ${this.esc(p.location?.country || "")}</div>
</div>
<div style="display:flex;gap:0.375rem;flex-direction:column;align-items:flex-end">
<span class="badge badge-active">\u2713 Active</span>
${p.distance_km !== undefined ? `<span class="badge badge-distance">${p.distance_km} km</span>` : ""}
</div>
</div>
<div class="caps-list">
${(p.capabilities || []).map((cap: string) => `<span class="cap-tag">${this.esc(cap)}</span>`).join("")}
</div>
${p.turnaround?.standard_days ? `<div class="turnaround">\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}</div>` : ""}
<div class="card-footer">
${p.contact?.email ? `<a href="mailto:${this.esc(p.contact.email)}">\u2709 Email</a>` : ""}
${p.contact?.website ? `<a href="${this.esc(p.contact.website)}" target="_blank">\u{1F310} Website</a>` : ""}
${p.location?.offers_shipping ? `<span>\u{1F4E6} Ships</span>` : ""}
</div>
</div>
`).join("")}
</div>`}
`;
// Event listeners
this.shadow.querySelector(".search")?.addEventListener("input", (e) => {
this.searchQuery = (e.target as HTMLInputElement).value;
this.render();
});
this.shadow.querySelector(".locate-btn")?.addEventListener("click", () => {
if (this.userLat) {
this.userLat = null;
this.userLng = null;
this.loadProviders();
} else {
navigator.geolocation?.getCurrentPosition(
(pos) => {
this.userLat = pos.coords.latitude;
this.userLng = pos.coords.longitude;
this.loadProviders();
},
() => { console.warn("Geolocation denied"); }
);
}
});
this.shadow.querySelectorAll(".cap[data-cap]").forEach((el) => {
el.addEventListener("click", () => {
this.selectedCap = (el as HTMLElement).dataset.cap || "";
this.loadProviders();
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-provider-directory", FolkProviderDirectory);

View File

@ -0,0 +1,6 @@
/* Providers module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}

View File

@ -0,0 +1,70 @@
-- Provider Registry schema (inside rSpace shared DB, schema: providers)
-- Uses earth_distance extension for proximity queries (lighter than PostGIS)
CREATE EXTENSION IF NOT EXISTS "cube";
CREATE EXTENSION IF NOT EXISTS "earthdistance";
CREATE TABLE IF NOT EXISTS providers.providers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
description TEXT,
-- Location
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
address TEXT,
city VARCHAR(100),
region VARCHAR(100),
country CHAR(2),
service_radius_km DOUBLE PRECISION DEFAULT 0,
offers_shipping BOOLEAN DEFAULT FALSE,
-- Capabilities & substrates (arrays for containment queries)
capabilities TEXT[] NOT NULL DEFAULT '{}',
substrates TEXT[] NOT NULL DEFAULT '{}',
-- Turnaround
standard_days INTEGER,
rush_days INTEGER,
rush_surcharge_pct DOUBLE PRECISION DEFAULT 0,
-- Pricing (JSONB -- keyed by capability)
pricing JSONB DEFAULT '{}',
-- Community membership
communities TEXT[] NOT NULL DEFAULT '{}',
-- Contact
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
contact_website VARCHAR(500),
-- Payment
wallet VARCHAR(255),
-- Reputation
jobs_completed INTEGER DEFAULT 0,
avg_rating DOUBLE PRECISION,
member_since TIMESTAMPTZ DEFAULT NOW(),
-- Status
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_providers_location
ON providers.providers USING gist (ll_to_earth(lat, lng));
CREATE INDEX IF NOT EXISTS idx_providers_capabilities
ON providers.providers USING gin (capabilities);
CREATE INDEX IF NOT EXISTS idx_providers_substrates
ON providers.providers USING gin (substrates);
CREATE INDEX IF NOT EXISTS idx_providers_communities
ON providers.providers USING gin (communities);
CREATE INDEX IF NOT EXISTS idx_providers_active
ON providers.providers (active) WHERE active = TRUE;

361
modules/providers/mod.ts Normal file
View File

@ -0,0 +1,361 @@
/**
* Providers module local provider directory.
*
* Ported from /opt/apps/provider-registry/ (Express + pg Hono + postgres.js).
* Uses earthdistance extension for proximity queries.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Providers] DB schema initialized");
} catch (e) {
console.error("[Providers] DB init error:", e);
}
}
initDB();
// ── Seed data (if empty) ──
async function seedIfEmpty() {
const count = await sql.unsafe("SELECT count(*) FROM providers.providers");
if (parseInt(count[0].count) > 0) return;
const providers = [
{ name: "Radiant Hall Press", lat: 40.4732, lng: -79.9535, city: "Pittsburgh", region: "PA", country: "US", caps: ["risograph","saddle-stitch","perfect-bind","laser-print","fold"], subs: ["paper-80gsm","paper-100gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 25, shipping: true, days: 3, rush: 1, rushPct: 50, email: "hello@radianthallpress.com", website: "https://radianthallpress.com", community: "pittsburgh.mycofi.earth" },
{ name: "Tiny Splendor", lat: 37.7799, lng: -122.2822, city: "Oakland", region: "CA", country: "US", caps: ["risograph","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 40, email: "print@tinysplendor.com", website: "https://tinysplendor.com", community: "oakland.mycofi.earth" },
{ name: "People's Print Shop", lat: 40.7282, lng: -73.7949, city: "New York", region: "NY", country: "US", caps: ["risograph","screen-print","saddle-stitch"], subs: ["paper-80gsm","paper-100gsm","fabric-cotton"], radius: 15, shipping: true, days: 4, rush: 2, rushPct: 50, email: "hello@peoplesprintshop.com", website: "https://peoplesprintshop.com", community: "nyc.mycofi.earth" },
{ name: "Colour Code Press", lat: 51.5402, lng: -0.1449, city: "London", region: "England", country: "GB", caps: ["risograph","perfect-bind","fold","laser-print"], subs: ["paper-80gsm","paper-100gsm","paper-160gsm-cover"], radius: 20, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@colourcodepress.com", website: "https://colourcodepress.com", community: "london.mycofi.earth" },
{ name: "Druckwerkstatt Berlin", lat: 52.5200, lng: 13.4050, city: "Berlin", region: "Berlin", country: "DE", caps: ["risograph","screen-print","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 20, shipping: true, days: 4, rush: 1, rushPct: 60, email: "hallo@druckwerkstatt.de", website: "https://druckwerkstatt.de", community: "berlin.mycofi.earth" },
{ name: "Kinko Printing Collective", lat: 35.6762, lng: 139.6503, city: "Tokyo", region: "Tokyo", country: "JP", caps: ["risograph","saddle-stitch","fold","perfect-bind"], subs: ["paper-80gsm","paper-100gsm","washi-paper"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@kinkoprint.jp", website: "https://kinkoprint.jp", community: "tokyo.mycofi.earth" },
];
for (const p of providers) {
await sql.unsafe(
`INSERT INTO providers.providers (
name, lat, lng, city, region, country,
capabilities, substrates, service_radius_km, offers_shipping,
standard_days, rush_days, rush_surcharge_pct,
contact_email, contact_website, communities
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
[p.name, p.lat, p.lng, p.city, p.region, p.country,
p.caps, p.subs, p.radius, p.shipping,
p.days, p.rush, p.rushPct, p.email, p.website, [p.community]]
);
}
console.log("[Providers] Seeded 6 providers");
}
initDB().then(seedIfEmpty).catch(() => {});
// ── Transform DB row → API response ──
function toProviderResponse(row: Record<string, unknown>) {
return {
id: row.id,
name: row.name,
description: row.description,
location: {
lat: row.lat,
lng: row.lng,
address: row.address,
city: row.city,
region: row.region,
country: row.country,
service_radius_km: row.service_radius_km,
offers_shipping: row.offers_shipping,
},
capabilities: row.capabilities,
substrates: row.substrates,
turnaround: {
standard_days: row.standard_days,
rush_days: row.rush_days,
rush_surcharge_pct: row.rush_surcharge_pct,
},
pricing: row.pricing,
communities: row.communities,
contact: {
email: row.contact_email,
phone: row.contact_phone,
website: row.contact_website,
},
wallet: row.wallet,
reputation: {
jobs_completed: row.jobs_completed,
avg_rating: row.avg_rating,
member_since: row.member_since,
},
active: row.active,
...(row.distance_km !== undefined && { distance_km: parseFloat(row.distance_km as string) }),
};
}
// ── GET /api/providers — List/search providers ──
routes.get("/api/providers", async (c) => {
const { capability, substrate, community, lat, lng, radius_km, active, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
if (active !== "false") {
conditions.push("active = TRUE");
}
if (capability) {
const caps = capability.split(",");
conditions.push(`capabilities @> $${paramIdx}`);
params.push(caps);
paramIdx++;
}
if (substrate) {
const subs = substrate.split(",");
conditions.push(`substrates && $${paramIdx}`);
params.push(subs);
paramIdx++;
}
if (community) {
conditions.push(`$${paramIdx} = ANY(communities)`);
params.push(community);
paramIdx++;
}
let distanceSelect = "";
let orderBy = "ORDER BY name";
if (lat && lng) {
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
distanceSelect = `, round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) / 1000)::numeric, 1) as distance_km`;
params.push(latNum, lngNum);
if (radius_km) {
conditions.push(`earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) <= $${paramIdx + 2} * 1000`);
params.push(parseFloat(radius_km));
paramIdx += 3;
} else {
paramIdx += 2;
}
orderBy = "ORDER BY distance_km ASC";
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const [result, countResult] = await Promise.all([
sql.unsafe(`SELECT *${distanceSelect} FROM providers.providers ${where} ${orderBy} LIMIT ${limitNum} OFFSET ${offsetNum}`, params),
sql.unsafe(`SELECT count(*) FROM providers.providers ${where}`, params),
]);
return c.json({
providers: result.map(toProviderResponse),
total: parseInt(countResult[0].count as string),
limit: limitNum,
offset: offsetNum,
});
});
// ── GET /api/providers/match — Match providers for an artifact ──
routes.get("/api/providers/match", async (c) => {
const { capabilities, substrates, lat, lng, community } = c.req.query();
if (!capabilities || !lat || !lng) {
return c.json({ error: "Required query params: capabilities (comma-separated), lat, lng" }, 400);
}
const caps = capabilities.split(",");
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const conditions = ["active = TRUE", "capabilities @> $1"];
const params: unknown[] = [caps, latNum, lngNum];
let paramIdx = 4;
if (substrates) {
const subs = substrates.split(",");
conditions.push(`substrates && $${paramIdx}`);
params.push(subs);
paramIdx++;
}
if (community) {
conditions.push(`$${paramIdx} = ANY(communities)`);
params.push(community);
paramIdx++;
}
const result = await sql.unsafe(
`SELECT *,
round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) / 1000)::numeric, 1) as distance_km
FROM providers.providers
WHERE ${conditions.join(" AND ")}
AND (service_radius_km = 0 OR offers_shipping = TRUE
OR earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) <= service_radius_km * 1000)
ORDER BY earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) ASC
LIMIT 20`,
params
);
return c.json({
matches: result.map(toProviderResponse),
query: { capabilities: caps, location: { lat: latNum, lng: lngNum } },
});
});
// ── GET /api/providers/:id — Single provider ──
routes.get("/api/providers/:id", async (c) => {
const id = c.req.param("id");
const result = await sql.unsafe("SELECT * FROM providers.providers WHERE id = $1", [id]);
if (result.length === 0) return c.json({ error: "Provider not found" }, 404);
return c.json(toProviderResponse(result[0]));
});
// ── POST /api/providers — Register a new provider ──
routes.post("/api/providers", async (c) => {
const body = await c.req.json();
const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body;
if (!name || !location?.lat || !location?.lng || !capabilities?.length) {
return c.json({ error: "Required: name, location.lat, location.lng, capabilities (non-empty array)" }, 400);
}
const result = await sql.unsafe(
`INSERT INTO providers.providers (
name, description, lat, lng, address, city, region, country,
service_radius_km, offers_shipping, capabilities, substrates,
standard_days, rush_days, rush_surcharge_pct, pricing, communities,
contact_email, contact_phone, contact_website, wallet
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
RETURNING *`,
[
name, description || null,
location.lat, location.lng, location.address || null,
location.city || null, location.region || null, location.country || null,
location.service_radius_km || 0, location.offers_shipping || false,
capabilities, substrates || [],
turnaround?.standard_days || null, turnaround?.rush_days || null, turnaround?.rush_surcharge_pct || 0,
JSON.stringify(pricing || {}), communities || [],
contact?.email || null, contact?.phone || null, contact?.website || null,
wallet || null,
]
);
return c.json(toProviderResponse(result[0]), 201);
});
// ── PUT /api/providers/:id — Update provider ──
routes.put("/api/providers/:id", async (c) => {
const id = c.req.param("id");
const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]);
if (existing.length === 0) return c.json({ error: "Provider not found" }, 404);
const body = await c.req.json();
const fields: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
const settable = ["name", "description", "capabilities", "substrates", "communities", "wallet", "active"];
for (const key of settable) {
if (body[key] !== undefined) {
fields.push(`${key} = $${paramIdx}`);
params.push(body[key]);
paramIdx++;
}
}
if (body.location) {
for (const [key, col] of Object.entries({ lat: "lat", lng: "lng", address: "address", city: "city", region: "region", country: "country", service_radius_km: "service_radius_km", offers_shipping: "offers_shipping" })) {
if (body.location[key] !== undefined) {
fields.push(`${col} = $${paramIdx}`);
params.push(body.location[key]);
paramIdx++;
}
}
}
if (body.turnaround) {
for (const [key, col] of Object.entries({ standard_days: "standard_days", rush_days: "rush_days", rush_surcharge_pct: "rush_surcharge_pct" })) {
if (body.turnaround[key] !== undefined) {
fields.push(`${col} = $${paramIdx}`);
params.push(body.turnaround[key]);
paramIdx++;
}
}
}
if (body.pricing !== undefined) {
fields.push(`pricing = $${paramIdx}`);
params.push(JSON.stringify(body.pricing));
paramIdx++;
}
if (body.contact) {
for (const [key, col] of Object.entries({ email: "contact_email", phone: "contact_phone", website: "contact_website" })) {
if (body.contact[key] !== undefined) {
fields.push(`${col} = $${paramIdx}`);
params.push(body.contact[key]);
paramIdx++;
}
}
}
if (fields.length === 0) return c.json({ error: "No fields to update" }, 400);
fields.push("updated_at = NOW()");
params.push(id);
const result = await sql.unsafe(
`UPDATE providers.providers SET ${fields.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
params
);
return c.json(toProviderResponse(result[0]));
});
// ── DELETE /api/providers/:id — Deactivate provider ──
routes.delete("/api/providers/:id", async (c) => {
const result = await sql.unsafe(
"UPDATE providers.providers SET active = FALSE, updated_at = NOW() WHERE id = $1 RETURNING *",
[c.req.param("id")]
);
if (result.length === 0) return c.json({ error: "Provider not found" }, 404);
return c.json({ message: "Provider deactivated", provider: toProviderResponse(result[0]) });
});
// ── Page route: browse providers ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Providers | rSpace`,
moduleId: "providers",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/providers/providers.css">`,
body: `<folk-provider-directory></folk-provider-directory>`,
scripts: `<script type="module" src="/modules/providers/folk-provider-directory.js"></script>`,
}));
});
export const providersModule: RSpaceModule = {
id: "providers",
name: "Providers",
icon: "\u{1F3ED}",
description: "Local provider directory for cosmolocal production",
routes,
standaloneDomain: "providers.mycofi.earth",
};

View File

@ -0,0 +1,54 @@
/**
* Providers standalone server independent deployment at providers.mycofi.earth.
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { resolve } from "node:path";
import { providersModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.use("/api/*", cors());
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", providersModule.routes);
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(assetPath) } });
}
}
}
return app.fetch(req);
},
});
console.log(`Providers standalone server running on http://localhost:${PORT}`);

View File

@ -0,0 +1,197 @@
/**
* <folk-swag-designer> upload artwork generate print-ready files.
* Product selector (sticker, poster, tee), image upload with preview,
* generate button, artifact result display with download link.
*/
class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot;
private selectedProduct = "sticker";
private imageFile: File | null = null;
private imagePreview = "";
private title = "";
private generating = false;
private artifact: any = null;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/swag` : "/demo/swag";
}
private async generate() {
if (!this.imageFile || this.generating) return;
this.generating = true;
this.error = "";
this.artifact = null;
this.render();
try {
const formData = new FormData();
formData.append("image", this.imageFile);
formData.append("product", this.selectedProduct);
formData.append("title", this.title || "Untitled Design");
const res = await fetch(`${this.getApiBase()}/api/artifact`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `Failed: ${res.status}`);
}
this.artifact = await res.json();
} catch (e) {
this.error = e instanceof Error ? e.message : "Generation failed";
} finally {
this.generating = false;
this.render();
}
}
private render() {
const products = [
{ id: "sticker", name: "Sticker Sheet", icon: "\u{1F4CB}", desc: "A4 vinyl stickers" },
{ id: "poster", name: "Poster (A3)", icon: "\u{1F5BC}", desc: "A3 art print" },
{ id: "tee", name: "T-Shirt", icon: "\u{1F455}", desc: "12x16\" DTG print" },
];
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
h2 { color: #f1f5f9; margin: 0 0 1.5rem; font-size: 1.5rem; }
.products { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
.product { padding: 1rem; border-radius: 12px; border: 2px solid #334155; background: #1e293b; cursor: pointer; text-align: center; transition: all 0.15s; }
.product:hover { border-color: #475569; }
.product.active { border-color: #6366f1; background: rgba(99,102,241,0.1); }
.product-icon { font-size: 2rem; margin-bottom: 0.375rem; }
.product-name { color: #f1f5f9; font-weight: 600; font-size: 0.875rem; }
.product-desc { color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }
.upload-area { border: 2px dashed #334155; border-radius: 12px; padding: 2rem; text-align: center; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.15s; background: #1e293b; }
.upload-area:hover { border-color: #6366f1; }
.upload-area.has-image { border-style: solid; border-color: #475569; }
.upload-label { color: #94a3b8; font-size: 0.875rem; }
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
.title-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
.title-input:focus { outline: none; border-color: #6366f1; }
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: #4f46e5; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; }
.generate-btn:hover { background: #4338ca; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error { background: rgba(239,68,68,0.1); border: 1px solid #ef4444; border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
.result { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.result-title { color: #f1f5f9; font-weight: 600; margin: 0 0 0.5rem; }
.result-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 1rem; }
.result-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.result-btn { padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.875rem; text-decoration: none; font-weight: 500; cursor: pointer; border: none; }
.result-btn-primary { background: #4f46e5; color: #fff; }
.result-btn-primary:hover { background: #4338ca; }
.result-btn-secondary { background: #334155; color: #f1f5f9; }
.result-btn-secondary:hover { background: #475569; }
.json-toggle { color: #818cf8; cursor: pointer; font-size: 0.75rem; margin-top: 0.75rem; display: inline-block; }
.json-pre { background: #0f172a; border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: #94a3b8; margin-top: 0.5rem; max-height: 300px; display: none; }
.json-pre.visible { display: block; }
input[type="file"] { display: none; }
</style>
<h2>\u{1F3A8} Swag Designer</h2>
<div class="products">
${products.map((p) => `
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
<div class="product-icon">${p.icon}</div>
<div class="product-name">${p.name}</div>
<div class="product-desc">${p.desc}</div>
</div>
`).join("")}
</div>
<div class="upload-area ${this.imagePreview ? 'has-image' : ''}">
${this.imagePreview
? `<img class="preview-img" src="${this.imagePreview}" alt="Preview">`
: `<div class="upload-label">\u{1F4C1} Click or drag to upload artwork (PNG, JPG, SVG)</div>`}
<input type="file" accept="image/*">
</div>
<input class="title-input" type="text" placeholder="Design title" value="${this.esc(this.title)}">
<button class="generate-btn" ${!this.imageFile || this.generating ? 'disabled' : ''}>
${this.generating ? '\u23F3 Generating...' : '\u{1F680} Generate Print-Ready Files'}
</button>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.artifact ? `
<div class="result">
<h3 class="result-title">\u2705 ${this.esc(this.artifact.payload?.title || "Artifact")}</h3>
<div class="result-meta">
${this.esc(this.artifact.spec?.product_type || "")} \u2022
${this.artifact.spec?.dimensions?.width_mm}x${this.artifact.spec?.dimensions?.height_mm}mm \u2022
${this.artifact.spec?.dpi}dpi
</div>
<div class="result-actions">
${Object.entries(this.artifact.render_targets || {}).map(([key, target]: [string, any]) => `
<a class="result-btn result-btn-primary" href="${target.url}" target="_blank">\u{2B07} Download ${target.format.toUpperCase()}</a>
`).join("")}
<button class="result-btn result-btn-secondary" data-action="copy-json">\u{1F4CB} Copy Artifact JSON</button>
</div>
<span class="json-toggle">Show artifact envelope \u25BC</span>
<pre class="json-pre">${this.esc(JSON.stringify(this.artifact, null, 2))}</pre>
</div>` : ""}
`;
// Event listeners
this.shadow.querySelectorAll(".product").forEach((el) => {
el.addEventListener("click", () => {
this.selectedProduct = (el as HTMLElement).dataset.product || "sticker";
this.render();
});
});
const uploadArea = this.shadow.querySelector(".upload-area");
const fileInput = this.shadow.querySelector('input[type="file"]') as HTMLInputElement;
uploadArea?.addEventListener("click", () => fileInput?.click());
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
this.imageFile = file;
this.imagePreview = URL.createObjectURL(file);
this.render();
}
});
this.shadow.querySelector(".title-input")?.addEventListener("input", (e) => {
this.title = (e.target as HTMLInputElement).value;
});
this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.generate());
this.shadow.querySelector(".json-toggle")?.addEventListener("click", () => {
const pre = this.shadow.querySelector(".json-pre");
pre?.classList.toggle("visible");
});
this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2));
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-swag-designer", FolkSwagDesigner);

View File

@ -0,0 +1,6 @@
/* Swag module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}

250
modules/swag/mod.ts Normal file
View File

@ -0,0 +1,250 @@
/**
* Swag module design tool for print-ready swag (stickers, posters, tees).
*
* Ported from /opt/apps/swag-designer/ (Next.js Hono).
* Uses Sharp for image processing. No database needed (filesystem storage).
*/
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { getProduct, PRODUCTS } from "./products";
import { processImage } from "./process-image";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
const ARTIFACTS_DIR = process.env.SWAG_ARTIFACTS_DIR || "/tmp/swag-artifacts";
// ── GET /api/products — List available product templates ──
routes.get("/api/products", (c) => {
const products = Object.values(PRODUCTS).map((p) => ({
id: p.id,
name: p.name,
description: p.description,
printArea: p.printArea,
dpi: p.dpi,
bleedMm: p.bleedMm,
widthPx: p.widthPx,
heightPx: p.heightPx,
productType: p.productType,
substrates: p.substrates,
requiredCapabilities: p.requiredCapabilities,
finish: p.finish,
}));
return c.json({ products });
});
// ── POST /api/generate — Preview without persistence ──
routes.post("/api/generate", async (c) => {
try {
const formData = await c.req.formData();
const imageFile = formData.get("image") as File | null;
const productId = formData.get("product") as string | null;
if (!imageFile) return c.json({ error: "image file is required" }, 400);
if (!productId) return c.json({ error: "product is required (sticker, poster, tee)" }, 400);
const product = getProduct(productId);
if (!product) return c.json({ error: `Unknown product: ${productId}` }, 400);
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
const result = await processImage(inputBuffer, product);
return new Response(new Uint8Array(result.buffer), {
status: 200,
headers: {
"Content-Type": "image/png",
"Content-Disposition": `attachment; filename="${productId}-print-ready.png"`,
"Content-Length": String(result.sizeBytes),
"X-Width-Px": String(result.widthPx),
"X-Height-Px": String(result.heightPx),
"X-DPI": String(result.dpi),
},
});
} catch (error) {
console.error("Generate error:", error);
return c.json({ error: error instanceof Error ? error.message : "Processing failed" }, 500);
}
});
// ── POST /api/artifact — Create artifact with persistence ──
routes.post("/api/artifact", async (c) => {
try {
const formData = await c.req.formData();
const imageFile = formData.get("image") as File | null;
const productId = formData.get("product") as string | null;
const title = (formData.get("title") as string) || "Untitled Design";
const description = formData.get("description") as string | null;
const creatorId = formData.get("creator_id") as string | null;
const creatorName = formData.get("creator_name") as string | null;
const creatorWallet = formData.get("creator_wallet") as string | null;
const sourceSpace = formData.get("source_space") as string | null;
const license = formData.get("license") as string | null;
const tagsRaw = formData.get("tags") as string | null;
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()) : [];
const creatorSharePct = parseFloat((formData.get("creator_share_pct") as string) || "25");
const communitySharePct = parseFloat((formData.get("community_share_pct") as string) || "10");
if (!imageFile) return c.json({ error: "image file is required" }, 400);
if (!productId || !getProduct(productId)) {
return c.json({ error: `product is required. Available: ${Object.keys(PRODUCTS).join(", ")}` }, 400);
}
const product = getProduct(productId)!;
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
const result = await processImage(inputBuffer, product);
// Store artifact
const artifactId = randomUUID();
const artifactDir = join(ARTIFACTS_DIR, artifactId);
await mkdir(artifactDir, { recursive: true });
const filename = `${productId}-print-ready.png`;
await writeFile(join(artifactDir, filename), result.buffer);
await writeFile(join(artifactDir, `original-${imageFile.name}`), inputBuffer);
// Build artifact envelope
const proto = c.req.header("x-forwarded-proto") || "https";
const host = c.req.header("host") || "swag.mycofi.earth";
const baseUrl = `${proto}://${host}`;
const renderTargetKey = `${product.id}-${result.format}`;
// Build the URL — in unified mode, we're mounted under /:space/swag
const path = c.req.path.replace("/api/artifact", "");
const mountBase = c.req.url.includes("/swag/") ? c.req.url.split("/swag/")[0] + "/swag" : "";
const fileUrl = mountBase ? `${baseUrl}${mountBase}/api/artifact/${artifactId}/file` : `${baseUrl}/api/artifact/${artifactId}/file`;
const artifact = {
id: artifactId,
schema_version: "1.0",
type: "print-ready",
origin: "swag.mycofi.earth",
source_space: sourceSpace || null,
creator: {
id: creatorId || "anonymous",
...(creatorName ? { name: creatorName } : {}),
...(creatorWallet ? { wallet: creatorWallet } : {}),
},
created_at: new Date().toISOString(),
...(license ? { license } : {}),
payload: {
title,
...(description ? { description } : {
description: `${product.name} design — ${result.widthPx}x${result.heightPx}px at ${result.dpi}dpi`,
}),
...(tags.length > 0 ? { tags } : {}),
},
spec: {
product_type: product.productType,
dimensions: {
width_mm: product.printArea.widthMm,
height_mm: product.printArea.heightMm,
bleed_mm: product.bleedMm,
},
color_space: "CMYK",
dpi: product.dpi,
binding: "none",
finish: product.finish,
substrates: product.substrates,
required_capabilities: product.requiredCapabilities,
},
render_targets: {
[renderTargetKey]: {
url: fileUrl,
format: result.format,
dpi: result.dpi,
file_size_bytes: result.sizeBytes,
notes: `${product.name} print-ready file. ${result.widthPx}x${result.heightPx}px.`,
},
},
pricing: {
creator_share_pct: creatorSharePct,
community_share_pct: communitySharePct,
},
next_actions: [
{ tool: "rcart.online", action: "list-for-sale", label: "Sell in community shop", endpoint: "/api/catalog/ingest", method: "POST" },
{ tool: "swag.mycofi.earth", action: "edit-design", label: "Edit design", endpoint: "/editor", method: "GET" },
{ tool: "rfiles.online", action: "archive", label: "Save to files", endpoint: "/api/v1/files/import", method: "POST" },
],
};
await writeFile(join(artifactDir, "artifact.json"), JSON.stringify(artifact, null, 2));
return c.json(artifact, 201);
} catch (error) {
console.error("Artifact generation error:", error);
return c.json({ error: error instanceof Error ? error.message : "Artifact generation failed" }, 500);
}
});
// ── GET /api/artifact/:id/file — Download render target ──
routes.get("/api/artifact/:id/file", async (c) => {
const id = c.req.param("id");
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return c.json({ error: "Invalid artifact ID" }, 400);
}
const artifactDir = join(ARTIFACTS_DIR, id);
try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); }
const files = await readdir(artifactDir);
const printFile = files.find((f) => f.includes("print-ready") && (f.endsWith(".png") || f.endsWith(".tiff")));
if (!printFile) return c.json({ error: "Print file not found" }, 404);
const buffer = await readFile(join(artifactDir, printFile));
const contentType = printFile.endsWith(".tiff") ? "image/tiff" : "image/png";
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${printFile}"`,
"Content-Length": String(buffer.length),
},
});
});
// ── GET /api/artifact/:id — Get artifact metadata ──
routes.get("/api/artifact/:id", async (c) => {
const id = c.req.param("id");
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return c.json({ error: "Invalid artifact ID" }, 400);
}
const artifactPath = join(ARTIFACTS_DIR, id, "artifact.json");
try {
const data = await readFile(artifactPath, "utf-8");
return c.json(JSON.parse(data));
} catch {
return c.json({ error: "Artifact not found" }, 404);
}
});
// ── Page route: swag designer ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Swag Designer | rSpace`,
moduleId: "swag",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/swag/swag.css">`,
body: `<folk-swag-designer></folk-swag-designer>`,
scripts: `<script type="module" src="/modules/swag/folk-swag-designer.js"></script>`,
}));
});
export const swagModule: RSpaceModule = {
id: "swag",
name: "Swag",
icon: "\u{1F3A8}",
description: "Design print-ready swag: stickers, posters, tees",
routes,
standaloneDomain: "swag.mycofi.earth",
};

View File

@ -0,0 +1,42 @@
import sharp from "sharp";
import type { ProductTemplate } from "./products";
export interface ProcessedImage {
buffer: Buffer;
format: "png" | "tiff";
widthPx: number;
heightPx: number;
dpi: number;
sizeBytes: number;
}
/**
* Process an uploaded image for a given product template.
* Resizes to fit the print area at the correct DPI, converts to PNG with
* proper density metadata.
*/
export async function processImage(
inputBuffer: Buffer,
product: ProductTemplate
): Promise<ProcessedImage> {
const targetWidth = product.widthPx;
const targetHeight = product.heightPx;
const result = await sharp(inputBuffer)
.resize(targetWidth, targetHeight, {
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 0 },
})
.withMetadata({ density: product.dpi })
.png()
.toBuffer();
return {
buffer: result,
format: "png",
widthPx: targetWidth,
heightPx: targetHeight,
dpi: product.dpi,
sizeBytes: result.length,
};
}

74
modules/swag/products.ts Normal file
View File

@ -0,0 +1,74 @@
export interface ProductTemplate {
id: string;
name: string;
description: string;
// Print area dimensions in mm
printArea: { widthMm: number; heightMm: number };
// Output DPI
dpi: number;
// Bleed in mm
bleedMm: number;
// What artifact spec fields to use
productType: string;
substrates: string[];
requiredCapabilities: string[];
finish: string;
// Computed pixel dimensions (at DPI)
get widthPx(): number;
get heightPx(): number;
}
function makeTemplate(opts: Omit<ProductTemplate, "widthPx" | "heightPx">): ProductTemplate {
return {
...opts,
get widthPx() {
return Math.round(((this.printArea.widthMm + this.bleedMm * 2) / 25.4) * this.dpi);
},
get heightPx() {
return Math.round(((this.printArea.heightMm + this.bleedMm * 2) / 25.4) * this.dpi);
},
};
}
export const PRODUCTS: Record<string, ProductTemplate> = {
sticker: makeTemplate({
id: "sticker",
name: "Sticker Sheet",
description: "A4 sheet of die-cut vinyl stickers",
printArea: { widthMm: 210, heightMm: 297 },
dpi: 300,
bleedMm: 2,
productType: "sticker-sheet",
substrates: ["vinyl-matte", "vinyl-gloss", "sticker-paper-matte"],
requiredCapabilities: ["vinyl-cut"],
finish: "matte",
}),
poster: makeTemplate({
id: "poster",
name: "Poster (A3)",
description: "A3 art print / poster",
printArea: { widthMm: 297, heightMm: 420 },
dpi: 300,
bleedMm: 3,
productType: "poster",
substrates: ["paper-160gsm-cover", "paper-100gsm"],
requiredCapabilities: ["inkjet-print"],
finish: "matte",
}),
tee: makeTemplate({
id: "tee",
name: "T-Shirt",
description: "Front print on cotton tee (12x16 inch print area)",
printArea: { widthMm: 305, heightMm: 406 },
dpi: 300,
bleedMm: 0,
productType: "tee",
substrates: ["cotton-standard", "cotton-organic"],
requiredCapabilities: ["dtg-print"],
finish: "none",
}),
};
export function getProduct(id: string): ProductTemplate | undefined {
return PRODUCTS[id];
}

View File

@ -0,0 +1,54 @@
/**
* Swag standalone server independent deployment at swag.mycofi.earth.
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { resolve } from "node:path";
import { swagModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.use("/api/*", cors());
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", swagModule.routes);
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(assetPath) } });
}
}
}
return app.fetch(req);
},
});
console.log(`Swag standalone server running on http://localhost:${PORT}`);

View File

@ -19,6 +19,7 @@
"hono": "^4.11.7",
"postgres": "^3.4.5",
"nodemailer": "^6.9.0",
"sharp": "^0.33.0",
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2"
},

View File

@ -41,6 +41,9 @@ import { registerModule, getAllModules, getModuleInfoList } from "../shared/modu
import { canvasModule } from "../modules/canvas/mod";
import { booksModule } from "../modules/books/mod";
import { pubsModule } from "../modules/pubs/mod";
import { cartModule } from "../modules/cart/mod";
import { providersModule } from "../modules/providers/mod";
import { swagModule } from "../modules/swag/mod";
import { spaces } from "./spaces";
import { renderShell } from "./shell";
@ -48,6 +51,9 @@ import { renderShell } from "./shell";
registerModule(canvasModule);
registerModule(booksModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(providersModule);
registerModule(swagModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;

View File

@ -132,6 +132,87 @@ export default defineConfig({
resolve(__dirname, "modules/pubs/components/pubs.css"),
resolve(__dirname, "dist/modules/pubs/pubs.css"),
);
// Build cart module component
await build({
configFile: false,
root: resolve(__dirname, "modules/cart/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/cart"),
lib: {
entry: resolve(__dirname, "modules/cart/components/folk-cart-shop.ts"),
formats: ["es"],
fileName: () => "folk-cart-shop.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-cart-shop.js",
},
},
},
});
// Copy cart CSS
mkdirSync(resolve(__dirname, "dist/modules/cart"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/cart/components/cart.css"),
resolve(__dirname, "dist/modules/cart/cart.css"),
);
// Build providers module component
await build({
configFile: false,
root: resolve(__dirname, "modules/providers/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/providers"),
lib: {
entry: resolve(__dirname, "modules/providers/components/folk-provider-directory.ts"),
formats: ["es"],
fileName: () => "folk-provider-directory.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-provider-directory.js",
},
},
},
});
// Copy providers CSS
mkdirSync(resolve(__dirname, "dist/modules/providers"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/providers/components/providers.css"),
resolve(__dirname, "dist/modules/providers/providers.css"),
);
// Build swag module component
await build({
configFile: false,
root: resolve(__dirname, "modules/swag/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/swag"),
lib: {
entry: resolve(__dirname, "modules/swag/components/folk-swag-designer.ts"),
formats: ["es"],
fileName: () => "folk-swag-designer.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-swag-designer.js",
},
},
},
});
// Copy swag CSS
mkdirSync(resolve(__dirname, "dist/modules/swag"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/swag/components/swag.css"),
resolve(__dirname, "dist/modules/swag/swag.css"),
);
},
},
},