From d9cc86637c7a07ae614615c1744f7ceb13ab9345 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Feb 2026 22:59:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20cart,=20providers,=20swag=20modul?= =?UTF-8?q?es=20=E2=80=94=20Phase=204=20cosmolocal=20commerce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile | 8 +- db/init.sql | 2 + docker-compose.yml | 10 + modules/cart/components/cart.css | 6 + modules/cart/components/folk-cart-shop.ts | 152 ++++++ modules/cart/db/schema.sql | 58 +++ modules/cart/flow.ts | 41 ++ modules/cart/mod.ts | 447 ++++++++++++++++++ modules/cart/standalone.ts | 58 +++ .../components/folk-provider-directory.ts | 182 +++++++ modules/providers/components/providers.css | 6 + modules/providers/db/schema.sql | 70 +++ modules/providers/mod.ts | 361 ++++++++++++++ modules/providers/standalone.ts | 54 +++ modules/swag/components/folk-swag-designer.ts | 197 ++++++++ modules/swag/components/swag.css | 6 + modules/swag/mod.ts | 250 ++++++++++ modules/swag/process-image.ts | 42 ++ modules/swag/products.ts | 74 +++ modules/swag/standalone.ts | 54 +++ package.json | 1 + server/index.ts | 6 + vite.config.ts | 81 ++++ 23 files changed, 2163 insertions(+), 3 deletions(-) create mode 100644 modules/cart/components/cart.css create mode 100644 modules/cart/components/folk-cart-shop.ts create mode 100644 modules/cart/db/schema.sql create mode 100644 modules/cart/flow.ts create mode 100644 modules/cart/mod.ts create mode 100644 modules/cart/standalone.ts create mode 100644 modules/providers/components/folk-provider-directory.ts create mode 100644 modules/providers/components/providers.css create mode 100644 modules/providers/db/schema.sql create mode 100644 modules/providers/mod.ts create mode 100644 modules/providers/standalone.ts create mode 100644 modules/swag/components/folk-swag-designer.ts create mode 100644 modules/swag/components/swag.css create mode 100644 modules/swag/mod.ts create mode 100644 modules/swag/process-image.ts create mode 100644 modules/swag/products.ts create mode 100644 modules/swag/standalone.ts diff --git a/Dockerfile b/Dockerfile index 646a4ca..b6d09e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY package.json bun.lock* ./ # Copy local SDK dependency (package.json references file:../encryptid-sdk) COPY --from=encryptid-sdk . /encryptid-sdk/ -RUN bun install --frozen-lockfile +RUN bun install # Copy source COPY . . @@ -43,20 +43,22 @@ COPY --from=build /app/package.json . COPY --from=build /encryptid-sdk /encryptid-sdk # Install production dependencies only -RUN bun install --production --frozen-lockfile +RUN bun install --production # Create data directories -RUN mkdir -p /data/communities /data/books +RUN mkdir -p /data/communities /data/books /data/swag-artifacts # Set environment ENV NODE_ENV=production ENV STORAGE_DIR=/data/communities ENV BOOKS_DIR=/data/books +ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts ENV PORT=3000 # Data volumes for persistence VOLUME /data/communities VOLUME /data/books +VOLUME /data/swag-artifacts EXPOSE 3000 diff --git a/db/init.sql b/db/init.sql index c3b03ad..32c22de 100644 --- a/db/init.sql +++ b/db/init.sql @@ -3,6 +3,8 @@ -- Extensions available to all schemas CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "cube"; +CREATE EXTENSION IF NOT EXISTS "earthdistance"; -- Module schemas (created on init, populated by module migrations) CREATE SCHEMA IF NOT EXISTS rbooks; diff --git a/docker-compose.yml b/docker-compose.yml index b2535e9..0b9f76e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,13 +9,18 @@ services: volumes: - rspace-data:/data/communities - rspace-books:/data/books + - rspace-swag:/data/swag-artifacts environment: - NODE_ENV=production - STORAGE_DIR=/data/communities - BOOKS_DIR=/data/books + - SWAG_ARTIFACTS_DIR=/data/swag-artifacts - PORT=3000 - INTERNAL_API_KEY=${INTERNAL_API_KEY} - DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace + - FLOW_SERVICE_URL=http://payment-flow:3010 + - FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d + - FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f depends_on: rspace-db: condition: service_healthy @@ -35,6 +40,7 @@ services: networks: - traefik-public - rspace-internal + - payment-network rspace-db: image: postgres:16-alpine @@ -58,9 +64,13 @@ services: volumes: rspace-data: rspace-books: + rspace-swag: rspace-pgdata: networks: traefik-public: external: true + payment-network: + name: payment-infra_payment-network + external: true rspace-internal: diff --git a/modules/cart/components/cart.css b/modules/cart/components/cart.css new file mode 100644 index 0000000..8808749 --- /dev/null +++ b/modules/cart/components/cart.css @@ -0,0 +1,6 @@ +/* Cart module theme */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); + padding: 0; +} diff --git a/modules/cart/components/folk-cart-shop.ts b/modules/cart/components/folk-cart-shop.ts new file mode 100644 index 0000000..f5b6b2a --- /dev/null +++ b/modules/cart/components/folk-cart-shop.ts @@ -0,0 +1,152 @@ +/** + * — 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 = ` + + +
+

\u{1F6D2} Community Shop

+
+ + +
+
+ + ${this.loading ? `
\u23F3 Loading...
` : + 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 `
No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.
`; + } + + return `
+ ${this.catalog.map((entry) => ` +
+

${this.esc(entry.title || "Untitled")}

+
+ ${entry.product_type ? `${this.esc(entry.product_type)}` : ""} + ${(entry.required_capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} +
+ ${entry.description ? `
${this.esc(entry.description)}
` : ""} + ${entry.dimensions ? `
${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm
` : ""} +
${entry.status}
+
+ `).join("")} +
`; + } + + private renderOrders(): string { + if (this.orders.length === 0) { + return `
No orders yet.
`; + } + + return `
+ ${this.orders.map((order) => ` +
+
+
+

${this.esc(order.artifact_title || "Order")}

+
+ ${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""} + ${order.quantity > 1 ? ` \u2022 Qty: ${order.quantity}` : ""} +
+ ${order.status} +
+
$${parseFloat(order.total_price || 0).toFixed(2)}
+
+
+ `).join("")} +
`; + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } +} + +customElements.define("folk-cart-shop", FolkCartShop); diff --git a/modules/cart/db/schema.sql b/modules/cart/db/schema.sql new file mode 100644 index 0000000..0f6e993 --- /dev/null +++ b/modules/cart/db/schema.sql @@ -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); diff --git a/modules/cart/flow.ts b/modules/cart/flow.ts new file mode 100644 index 0000000..733f69a --- /dev/null +++ b/modules/cart/flow.ts @@ -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 { + 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); + } +} diff --git a/modules/cart/mod.ts b/modules/cart/mod.ts new file mode 100644 index 0000000..0ba0aac --- /dev/null +++ b/modules/cart/mod.ts @@ -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 = { 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; + wallet: string; +} + +function composeCost(artifact: Record, provider: ProviderMatch, quantity: number) { + const spec = artifact.spec as Record | 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: ``, + body: ``, + scripts: ``, + })); +}); + +export const cartModule: RSpaceModule = { + id: "cart", + name: "rCart", + icon: "\u{1F6D2}", + description: "Cosmolocal print-on-demand shop", + routes, + standaloneDomain: "rcart.online", +}; diff --git a/modules/cart/standalone.ts b/modules/cart/standalone.ts new file mode 100644 index 0000000..518903f --- /dev/null +++ b/modules/cart/standalone.ts @@ -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}`); diff --git a/modules/providers/components/folk-provider-directory.ts b/modules/providers/components/folk-provider-directory.ts new file mode 100644 index 0000000..5e85087 --- /dev/null +++ b/modules/providers/components/folk-provider-directory.ts @@ -0,0 +1,182 @@ +/** + * — 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(); + 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 = ` + + +
+

\u{1F3ED} Provider Directory

+ + +
+ + ${this.capabilities.length > 0 ? ` +
+ All + ${this.capabilities.map((cap) => ` + ${cap} + `).join("")} +
` : ""} + + ${filtered.length === 0 ? `
No providers found
` : ` +
+ ${filtered.map((p) => ` +
+
+
+

${this.esc(p.name)}

+
${this.esc(p.location?.city || "")}${p.location?.region ? `, ${this.esc(p.location.region)}` : ""} ${this.esc(p.location?.country || "")}
+
+
+ \u2713 Active + ${p.distance_km !== undefined ? `${p.distance_km} km` : ""} +
+
+
+ ${(p.capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} +
+ ${p.turnaround?.standard_days ? `
\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}
` : ""} + +
+ `).join("")} +
`} + `; + + // 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); diff --git a/modules/providers/components/providers.css b/modules/providers/components/providers.css new file mode 100644 index 0000000..cfa1c04 --- /dev/null +++ b/modules/providers/components/providers.css @@ -0,0 +1,6 @@ +/* Providers module theme */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); + padding: 0; +} diff --git a/modules/providers/db/schema.sql b/modules/providers/db/schema.sql new file mode 100644 index 0000000..48c805f --- /dev/null +++ b/modules/providers/db/schema.sql @@ -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; diff --git a/modules/providers/mod.ts b/modules/providers/mod.ts new file mode 100644 index 0000000..e2b86c9 --- /dev/null +++ b/modules/providers/mod.ts @@ -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) { + 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: ``, + body: ``, + scripts: ``, + })); +}); + +export const providersModule: RSpaceModule = { + id: "providers", + name: "Providers", + icon: "\u{1F3ED}", + description: "Local provider directory for cosmolocal production", + routes, + standaloneDomain: "providers.mycofi.earth", +}; diff --git a/modules/providers/standalone.ts b/modules/providers/standalone.ts new file mode 100644 index 0000000..dd68bab --- /dev/null +++ b/modules/providers/standalone.ts @@ -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}`); diff --git a/modules/swag/components/folk-swag-designer.ts b/modules/swag/components/folk-swag-designer.ts new file mode 100644 index 0000000..c97b53c --- /dev/null +++ b/modules/swag/components/folk-swag-designer.ts @@ -0,0 +1,197 @@ +/** + * — 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 = ` + + +

\u{1F3A8} Swag Designer

+ +
+ ${products.map((p) => ` +
+
${p.icon}
+
${p.name}
+
${p.desc}
+
+ `).join("")} +
+ +
+ ${this.imagePreview + ? `Preview` + : `
\u{1F4C1} Click or drag to upload artwork (PNG, JPG, SVG)
`} + +
+ + + + + + ${this.error ? `
${this.esc(this.error)}
` : ""} + + ${this.artifact ? ` +
+

\u2705 ${this.esc(this.artifact.payload?.title || "Artifact")}

+
+ ${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 +
+
+ ${Object.entries(this.artifact.render_targets || {}).map(([key, target]: [string, any]) => ` + \u{2B07} Download ${target.format.toUpperCase()} + `).join("")} + +
+ Show artifact envelope \u25BC +
${this.esc(JSON.stringify(this.artifact, null, 2))}
+
` : ""} + `; + + // 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); diff --git a/modules/swag/components/swag.css b/modules/swag/components/swag.css new file mode 100644 index 0000000..3200438 --- /dev/null +++ b/modules/swag/components/swag.css @@ -0,0 +1,6 @@ +/* Swag module theme */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); + padding: 0; +} diff --git a/modules/swag/mod.ts b/modules/swag/mod.ts new file mode 100644 index 0000000..1aa7ed6 --- /dev/null +++ b/modules/swag/mod.ts @@ -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: ``, + body: ``, + scripts: ``, + })); +}); + +export const swagModule: RSpaceModule = { + id: "swag", + name: "Swag", + icon: "\u{1F3A8}", + description: "Design print-ready swag: stickers, posters, tees", + routes, + standaloneDomain: "swag.mycofi.earth", +}; diff --git a/modules/swag/process-image.ts b/modules/swag/process-image.ts new file mode 100644 index 0000000..5f03455 --- /dev/null +++ b/modules/swag/process-image.ts @@ -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 { + 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, + }; +} diff --git a/modules/swag/products.ts b/modules/swag/products.ts new file mode 100644 index 0000000..aa3a42f --- /dev/null +++ b/modules/swag/products.ts @@ -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 { + 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 = { + 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]; +} diff --git a/modules/swag/standalone.ts b/modules/swag/standalone.ts new file mode 100644 index 0000000..6c2e017 --- /dev/null +++ b/modules/swag/standalone.ts @@ -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}`); diff --git a/package.json b/package.json index 4489ea2..b6e7298 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "hono": "^4.11.7", "postgres": "^3.4.5", "nodemailer": "^6.9.0", + "sharp": "^0.33.0", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2" }, diff --git a/server/index.ts b/server/index.ts index 9e2e9da..3e7cbf8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -41,6 +41,9 @@ import { registerModule, getAllModules, getModuleInfoList } from "../shared/modu import { canvasModule } from "../modules/canvas/mod"; import { booksModule } from "../modules/books/mod"; import { pubsModule } from "../modules/pubs/mod"; +import { cartModule } from "../modules/cart/mod"; +import { providersModule } from "../modules/providers/mod"; +import { swagModule } from "../modules/swag/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; @@ -48,6 +51,9 @@ import { renderShell } from "./shell"; registerModule(canvasModule); registerModule(booksModule); registerModule(pubsModule); +registerModule(cartModule); +registerModule(providersModule); +registerModule(swagModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; diff --git a/vite.config.ts b/vite.config.ts index fe3c67e..739eb70 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -132,6 +132,87 @@ export default defineConfig({ resolve(__dirname, "modules/pubs/components/pubs.css"), resolve(__dirname, "dist/modules/pubs/pubs.css"), ); + + // Build cart module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/cart/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/cart"), + lib: { + entry: resolve(__dirname, "modules/cart/components/folk-cart-shop.ts"), + formats: ["es"], + fileName: () => "folk-cart-shop.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-cart-shop.js", + }, + }, + }, + }); + + // Copy cart CSS + mkdirSync(resolve(__dirname, "dist/modules/cart"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/cart/components/cart.css"), + resolve(__dirname, "dist/modules/cart/cart.css"), + ); + + // Build providers module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/providers/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/providers"), + lib: { + entry: resolve(__dirname, "modules/providers/components/folk-provider-directory.ts"), + formats: ["es"], + fileName: () => "folk-provider-directory.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-provider-directory.js", + }, + }, + }, + }); + + // Copy providers CSS + mkdirSync(resolve(__dirname, "dist/modules/providers"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/providers/components/providers.css"), + resolve(__dirname, "dist/modules/providers/providers.css"), + ); + + // Build swag module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/swag/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/swag"), + lib: { + entry: resolve(__dirname, "modules/swag/components/folk-swag-designer.ts"), + formats: ["es"], + fileName: () => "folk-swag-designer.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-swag-designer.js", + }, + }, + }, + }); + + // Copy swag CSS + mkdirSync(resolve(__dirname, "dist/modules/swag"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/swag/components/swag.css"), + resolve(__dirname, "dist/modules/swag/swag.css"), + ); }, }, },