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:
parent
f313d9395d
commit
d9cc86637c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/* Cart module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Providers module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Swag module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 52px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue