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 local SDK dependency (package.json references file:../encryptid-sdk)
|
||||||
COPY --from=encryptid-sdk . /encryptid-sdk/
|
COPY --from=encryptid-sdk . /encryptid-sdk/
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -43,20 +43,22 @@ COPY --from=build /app/package.json .
|
||||||
COPY --from=build /encryptid-sdk /encryptid-sdk
|
COPY --from=build /encryptid-sdk /encryptid-sdk
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN bun install --production --frozen-lockfile
|
RUN bun install --production
|
||||||
|
|
||||||
# Create data directories
|
# Create data directories
|
||||||
RUN mkdir -p /data/communities /data/books
|
RUN mkdir -p /data/communities /data/books /data/swag-artifacts
|
||||||
|
|
||||||
# Set environment
|
# Set environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV STORAGE_DIR=/data/communities
|
ENV STORAGE_DIR=/data/communities
|
||||||
ENV BOOKS_DIR=/data/books
|
ENV BOOKS_DIR=/data/books
|
||||||
|
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Data volumes for persistence
|
# Data volumes for persistence
|
||||||
VOLUME /data/communities
|
VOLUME /data/communities
|
||||||
VOLUME /data/books
|
VOLUME /data/books
|
||||||
|
VOLUME /data/swag-artifacts
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
-- Extensions available to all schemas
|
-- Extensions available to all schemas
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
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)
|
-- Module schemas (created on init, populated by module migrations)
|
||||||
CREATE SCHEMA IF NOT EXISTS rbooks;
|
CREATE SCHEMA IF NOT EXISTS rbooks;
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,18 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- rspace-data:/data/communities
|
- rspace-data:/data/communities
|
||||||
- rspace-books:/data/books
|
- rspace-books:/data/books
|
||||||
|
- rspace-swag:/data/swag-artifacts
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- STORAGE_DIR=/data/communities
|
- STORAGE_DIR=/data/communities
|
||||||
- BOOKS_DIR=/data/books
|
- BOOKS_DIR=/data/books
|
||||||
|
- SWAG_ARTIFACTS_DIR=/data/swag-artifacts
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
||||||
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
|
- 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:
|
depends_on:
|
||||||
rspace-db:
|
rspace-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -35,6 +40,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
- rspace-internal
|
- rspace-internal
|
||||||
|
- payment-network
|
||||||
|
|
||||||
rspace-db:
|
rspace-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|
@ -58,9 +64,13 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
rspace-data:
|
rspace-data:
|
||||||
rspace-books:
|
rspace-books:
|
||||||
|
rspace-swag:
|
||||||
rspace-pgdata:
|
rspace-pgdata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
payment-network:
|
||||||
|
name: payment-infra_payment-network
|
||||||
|
external: true
|
||||||
rspace-internal:
|
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",
|
"hono": "^4.11.7",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
"perfect-arrows": "^0.3.7",
|
"perfect-arrows": "^0.3.7",
|
||||||
"perfect-freehand": "^1.2.2"
|
"perfect-freehand": "^1.2.2"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ import { registerModule, getAllModules, getModuleInfoList } from "../shared/modu
|
||||||
import { canvasModule } from "../modules/canvas/mod";
|
import { canvasModule } from "../modules/canvas/mod";
|
||||||
import { booksModule } from "../modules/books/mod";
|
import { booksModule } from "../modules/books/mod";
|
||||||
import { pubsModule } from "../modules/pubs/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 { spaces } from "./spaces";
|
||||||
import { renderShell } from "./shell";
|
import { renderShell } from "./shell";
|
||||||
|
|
||||||
|
|
@ -48,6 +51,9 @@ import { renderShell } from "./shell";
|
||||||
registerModule(canvasModule);
|
registerModule(canvasModule);
|
||||||
registerModule(booksModule);
|
registerModule(booksModule);
|
||||||
registerModule(pubsModule);
|
registerModule(pubsModule);
|
||||||
|
registerModule(cartModule);
|
||||||
|
registerModule(providersModule);
|
||||||
|
registerModule(swagModule);
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,87 @@ export default defineConfig({
|
||||||
resolve(__dirname, "modules/pubs/components/pubs.css"),
|
resolve(__dirname, "modules/pubs/components/pubs.css"),
|
||||||
resolve(__dirname, "dist/modules/pubs/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