rspace-online/modules/rswag/mod.ts

1006 lines
33 KiB
TypeScript

/**
* 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, rm } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { getProduct, PRODUCTS, getPodProducts, type StorefrontProduct } from "./products";
import { processImage } from "./process-image";
import { ditherDesign, generateColorSeparations, ALGORITHMS, type DitherAlgorithm } from "./dither";
import { generateMockup } from "./mockup";
import { submitFulfillment, parsePrintfulWebhook, parseProdigiWebhook, getFulfillmentStatus, getTrackingInfo, type FulfillmentRequest } from "./fulfillment";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
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,
...(p.printful ? { printful: p.printful } : {}),
}));
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") || "rswag.online";
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: "rswag.online",
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: "rswag.online", 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);
}
});
// ── Design storage (filesystem-based, per-slug) ──
const DESIGNS_DIR = process.env.SWAG_DESIGNS_DIR || join(ARTIFACTS_DIR, "designs");
interface DesignMeta {
slug: string;
name: string;
description: string;
tags: string[];
category: string;
author: string;
created: string;
source: "ai-generated" | "upload" | "artifact";
status: "draft" | "active" | "paused" | "removed";
imageFile: string;
products: { type: string; provider: string; sku: string; variants: string[]; retailPrice: number }[];
}
/** In-memory design index (loaded from filesystem) */
let designIndex: Map<string, DesignMeta> = new Map();
let designIndexLoaded = false;
async function loadDesignIndex() {
designIndex.clear();
try {
await stat(DESIGNS_DIR);
} catch {
await mkdir(DESIGNS_DIR, { recursive: true });
designIndexLoaded = true;
return;
}
const categories = await readdir(DESIGNS_DIR);
for (const cat of categories) {
const catDir = join(DESIGNS_DIR, cat);
try {
const catStat = await stat(catDir);
if (!catStat.isDirectory()) continue;
} catch { continue; }
const slugs = await readdir(catDir);
for (const slug of slugs) {
const metaPath = join(catDir, slug, "meta.json");
try {
const raw = await readFile(metaPath, "utf-8");
const meta: DesignMeta = JSON.parse(raw);
designIndex.set(slug, meta);
} catch { /* skip invalid designs */ }
}
}
designIndexLoaded = true;
}
async function ensureDesignIndex() {
if (!designIndexLoaded) await loadDesignIndex();
}
async function saveDesignMeta(meta: DesignMeta) {
const dir = join(DESIGNS_DIR, meta.category, meta.slug);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
designIndex.set(meta.slug, meta);
}
function getDesignImagePath(slug: string): string | null {
const meta = designIndex.get(slug);
if (!meta) return null;
return join(DESIGNS_DIR, meta.category, meta.slug, meta.imageFile);
}
function slugify(text: string): string {
return text.toLowerCase().trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// ── POST /api/design/generate — AI-powered design generation ──
routes.post("/api/design/generate", async (c) => {
try {
const body = await c.req.json();
const { concept, name, tags = [], product_type = "sticker" } = body;
if (!concept || !name) {
return c.json({ error: "concept and name are required" }, 400);
}
const geminiKey = process.env.GEMINI_API_KEY;
if (!geminiKey) {
return c.json({ error: "AI generation not configured. Set GEMINI_API_KEY." }, 503);
}
let slug = slugify(name);
if (!slug) slug = `design-${randomUUID().slice(0, 8)}`;
await ensureDesignIndex();
if (designIndex.has(slug)) {
return c.json({ error: `Design '${slug}' already exists` }, 409);
}
// Build AI prompt
const stylePrompt = `A striking sticker design for "${name}".
${concept}
The design should have a clean, modern spatial-web aesthetic with interconnected
nodes, network patterns, and a collaborative/commons feel.
Colors: vibrant cyan, warm orange accents on dark background.
High contrast, suitable for vinyl sticker printing.
Square format, clean edges for die-cut sticker.`;
// Call Gemini API for image generation
const resp = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${geminiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: stylePrompt }] }],
generationConfig: { responseModalities: ["image", "text"] },
}),
},
);
if (!resp.ok) {
const errText = await resp.text();
return c.json({ error: `AI generation failed (${resp.status}): ${errText.slice(0, 300)}` }, 502);
}
const result = await resp.json();
let imageData: string | null = null;
for (const candidate of result.candidates || []) {
for (const part of candidate.content?.parts || []) {
if (part.inlineData?.data) {
imageData = part.inlineData.data;
break;
}
}
if (imageData) break;
}
if (!imageData) {
return c.json({ error: "AI did not return an image" }, 502);
}
// Save design
const imageBuffer = Buffer.from(imageData, "base64");
const category = product_type === "sticker" ? "stickers" : product_type === "shirt" || product_type === "tee" ? "apparel" : "prints";
const meta: DesignMeta = {
slug,
name,
description: concept,
tags: tags.length ? tags : ["rspace", "ai-generated"],
category,
author: "ai-generated",
created: new Date().toISOString().split("T")[0],
source: "ai-generated",
status: "draft",
imageFile: `${slug}.png`,
products: getPodProducts(product_type).map(p => ({
type: p.type, provider: p.provider, sku: p.sku,
variants: p.variants, retailPrice: p.retailPrice,
})),
};
await saveDesignMeta(meta);
const imgDir = join(DESIGNS_DIR, category, slug);
await writeFile(join(imgDir, `${slug}.png`), imageBuffer);
return c.json({
slug,
name,
image_url: `/api/designs/${slug}/image`,
status: "draft",
}, 201);
} catch (error) {
console.error("[rSwag] Design generation error:", error);
return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500);
}
});
// ── POST /api/design/upload — User artwork upload ──
routes.post("/api/design/upload", async (c) => {
try {
const formData = await c.req.formData();
const imageFile = formData.get("image") as File | null;
const name = (formData.get("name") as string) || "Untitled Upload";
const description = (formData.get("description") as string) || "";
const tagsRaw = formData.get("tags") as string | null;
const tags = tagsRaw ? tagsRaw.split(",").map(t => t.trim()) : [];
const productType = (formData.get("product_type") as string) || "sticker";
if (!imageFile) return c.json({ error: "image file is required" }, 400);
// Validate format
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(imageFile.type)) {
return c.json({ error: `Invalid format. Accepted: PNG, JPEG, WebP` }, 400);
}
// Validate size (max 10MB)
if (imageFile.size > 10 * 1024 * 1024) {
return c.json({ error: "Image too large (max 10MB)" }, 400);
}
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
// Validate dimensions (min 500x500)
const metadata = await sharp(inputBuffer).metadata();
if (!metadata.width || !metadata.height || metadata.width < 500 || metadata.height < 500) {
return c.json({ error: "Image too small (minimum 500x500 pixels)" }, 400);
}
let slug = slugify(name);
if (!slug) slug = `upload-${randomUUID().slice(0, 8)}`;
await ensureDesignIndex();
// Deduplicate slug
let finalSlug = slug;
let counter = 1;
while (designIndex.has(finalSlug)) {
finalSlug = `${slug}-${counter++}`;
}
slug = finalSlug;
const category = productType === "sticker" ? "stickers" : productType === "tee" || productType === "shirt" ? "apparel" : "prints";
// Process to standard 1024x1024 PNG
const processed = await sharp(inputBuffer)
.resize(1024, 1024, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toBuffer();
const meta: DesignMeta = {
slug,
name,
description,
tags,
category,
author: "upload",
created: new Date().toISOString().split("T")[0],
source: "upload",
status: "draft",
imageFile: `${slug}.png`,
products: getPodProducts(productType).map(p => ({
type: p.type, provider: p.provider, sku: p.sku,
variants: p.variants, retailPrice: p.retailPrice,
})),
};
await saveDesignMeta(meta);
const imgDir = join(DESIGNS_DIR, category, slug);
await writeFile(join(imgDir, `${slug}.png`), processed);
return c.json({
slug,
name,
image_url: `/api/designs/${slug}/image`,
status: "draft",
}, 201);
} catch (error) {
console.error("[rSwag] Upload error:", error);
return c.json({ error: error instanceof Error ? error.message : "Upload failed" }, 500);
}
});
// ── POST /api/design/:slug/activate — Publish draft design ──
routes.post("/api/design/:slug/activate", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
const meta = designIndex.get(slug);
if (!meta) return c.json({ error: "Design not found" }, 404);
meta.status = "active";
await saveDesignMeta(meta);
return c.json({ status: "activated", slug });
});
// ── DELETE /api/design/:slug — Delete draft design ──
routes.delete("/api/design/:slug", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
const meta = designIndex.get(slug);
if (!meta) return c.json({ error: "Design not found" }, 404);
if (meta.status === "active") {
return c.json({ error: "Cannot delete active designs. Set to draft first." }, 400);
}
const designDir = join(DESIGNS_DIR, meta.category, slug);
try { await rm(designDir, { recursive: true }); } catch { /* ok */ }
designIndex.delete(slug);
return c.json({ status: "deleted", slug });
});
// ── GET /api/designs — List designs with filtering ──
routes.get("/api/designs", async (c) => {
await ensureDesignIndex();
const statusFilter = c.req.query("status");
const categoryFilter = c.req.query("category");
const search = c.req.query("q")?.toLowerCase();
let designs = Array.from(designIndex.values());
if (statusFilter) designs = designs.filter(d => d.status === statusFilter);
if (categoryFilter) designs = designs.filter(d => d.category === categoryFilter);
if (search) {
designs = designs.filter(d =>
d.name.toLowerCase().includes(search) ||
d.description.toLowerCase().includes(search) ||
d.tags.some(t => t.toLowerCase().includes(search)),
);
}
return c.json({
designs: designs.map(d => ({
slug: d.slug,
name: d.name,
description: d.description,
category: d.category,
tags: d.tags,
status: d.status,
source: d.source,
image_url: `/api/designs/${d.slug}/image`,
products: d.products,
created: d.created,
})),
});
});
// ── GET /api/designs/:slug — Single design metadata ──
routes.get("/api/designs/:slug", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
const meta = designIndex.get(slug);
if (!meta) return c.json({ error: "Design not found" }, 404);
return c.json({
slug: meta.slug,
name: meta.name,
description: meta.description,
category: meta.category,
tags: meta.tags,
status: meta.status,
source: meta.source,
image_url: `/api/designs/${slug}/image`,
products: meta.products,
created: meta.created,
});
});
// ── GET /api/designs/:slug/image — Serve design PNG ──
routes.get("/api/designs/:slug/image", async (c) => {
await ensureDesignIndex();
const imagePath = getDesignImagePath(c.req.param("slug"));
if (!imagePath) return c.json({ error: "Design not found" }, 404);
try {
const buffer = await readFile(imagePath);
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
"Content-Length": String(buffer.length),
},
});
} catch {
return c.json({ error: "Image file not found" }, 404);
}
});
// ── GET /api/designs/:slug/mockup — Photorealistic mockup ──
routes.get("/api/designs/:slug/mockup", async (c) => {
const slug = c.req.param("slug");
const type = c.req.query("type") || "shirt";
const fresh = c.req.query("fresh") === "true";
await ensureDesignIndex();
const imagePath = getDesignImagePath(slug);
if (!imagePath) return c.json({ error: "Design not found" }, 404);
try {
const designBuffer = await readFile(imagePath);
const meta = designIndex.get(slug);
// Find Printful SKU if available
const printfulProduct = meta?.products?.find(p => p.provider === "printful");
const printfulSku = printfulProduct ? parseInt(printfulProduct.sku, 10) : undefined;
// Build public URL for Printful API (needs accessible URL)
const proto = c.req.header("x-forwarded-proto") || "https";
const host = c.req.header("host") || "rswag.online";
const designImageUrl = printfulSku ? `${proto}://${host}/api/designs/${slug}/image` : undefined;
const mockup = await generateMockup(designBuffer, type, {
printfulSku,
designImageUrl,
fresh,
});
return new Response(new Uint8Array(mockup), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
"Content-Length": String(mockup.length),
},
});
} catch (error) {
console.error("[rSwag] Mockup error:", error);
return c.json({ error: "Mockup generation failed" }, 500);
}
});
// ── GET /api/designs/:slug/dither — Dithered PNG ──
routes.get("/api/designs/:slug/dither", async (c) => {
const slug = c.req.param("slug");
const algorithm = (c.req.query("algorithm") || "floyd-steinberg") as DitherAlgorithm;
const numColors = parseInt(c.req.query("num_colors") || "8", 10);
const customColors = c.req.query("colors")?.split(",").filter(Boolean);
const threshold = parseInt(c.req.query("threshold") || "64", 10);
const order = parseInt(c.req.query("order") || "3", 10);
const format = c.req.query("format") || "image";
if (!ALGORITHMS.includes(algorithm)) {
return c.json({ error: `Unknown algorithm. Available: ${ALGORITHMS.join(", ")}` }, 400);
}
await ensureDesignIndex();
const imagePath = getDesignImagePath(slug);
if (!imagePath) return c.json({ error: "Design not found" }, 404);
try {
const imageBuffer = await readFile(imagePath);
const result = await ditherDesign(imageBuffer, slug, {
algorithm,
numColors: Math.min(Math.max(numColors, 2), 32),
customColors,
threshold,
order,
});
if (format === "json") {
return c.json({
slug,
algorithm: result.algorithm,
palette_mode: result.paletteMode,
num_colors: result.numColors,
colors_used: result.colorsUsed,
cached: result.cached,
image_url: `/api/designs/${slug}/dither?algorithm=${algorithm}&num_colors=${numColors}`,
});
}
return new Response(new Uint8Array(result.buffer), {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="${slug}-${algorithm}.png"`,
"X-Colors-Used": result.colorsUsed.join(","),
"X-Algorithm": result.algorithm,
"X-Cached": String(result.cached),
},
});
} catch (error) {
console.error("[rSwag] Dither error:", error);
return c.json({ error: "Dithering failed" }, 500);
}
});
// ── POST /api/designs/:slug/screen-print — Color separation PNGs ──
routes.post("/api/designs/:slug/screen-print", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
const imagePath = getDesignImagePath(slug);
if (!imagePath) return c.json({ error: "Design not found" }, 404);
try {
const body = await c.req.json();
const numColors = Math.min(Math.max(body.num_colors || 4, 2), 16);
const algorithm = (body.algorithm || "floyd-steinberg") as DitherAlgorithm;
const spotColors = body.spot_colors as string[] | undefined;
const imageBuffer = await readFile(imagePath);
const result = await generateColorSeparations(
imageBuffer, slug, numColors, algorithm, spotColors,
);
const separationUrls: Record<string, string> = {};
for (const color of result.colors) {
separationUrls[color] = `/api/designs/${slug}/screen-print/${color}`;
}
// Cache separations for the GET endpoint
screenPrintCache.set(slug, result);
return c.json({
slug,
num_colors: result.colors.length,
algorithm,
colors: result.colors,
composite_url: `/api/designs/${slug}/screen-print/composite`,
separation_urls: separationUrls,
});
} catch (error) {
console.error("[rSwag] Screen-print error:", error);
return c.json({ error: "Color separation failed" }, 500);
}
});
// Screen-print separation cache (slug → result)
const screenPrintCache = new Map<string, { composite: Buffer; separations: Map<string, Buffer>; colors: string[] }>();
// ── GET /api/designs/:slug/screen-print/:channel — Serve separation channel ──
routes.get("/api/designs/:slug/screen-print/:channel", async (c) => {
const slug = c.req.param("slug");
const channel = c.req.param("channel");
const cached = screenPrintCache.get(slug);
if (!cached) return c.json({ error: "Run POST screen-print first" }, 404);
let buffer: Buffer;
if (channel === "composite") {
buffer = cached.composite;
} else {
const sep = cached.separations.get(channel.toUpperCase());
if (!sep) return c.json({ error: `Channel ${channel} not found` }, 404);
buffer = sep;
}
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="${slug}-${channel}.png"`,
},
});
});
// ── GET /api/storefront — Storefront product listing ──
routes.get("/api/storefront", async (c) => {
await ensureDesignIndex();
const products: StorefrontProduct[] = [];
for (const meta of designIndex.values()) {
if (meta.status !== "active") continue;
for (const prod of meta.products) {
products.push({
slug: `${meta.slug}-${prod.type}`,
designSlug: meta.slug,
name: `${meta.name} ${prod.type}`,
description: meta.description,
category: meta.category,
productType: prod.type,
imageUrl: `/api/designs/${meta.slug}/image`,
basePrice: prod.retailPrice,
variants: prod.variants,
provider: prod.provider,
sku: prod.sku,
isActive: true,
});
}
}
return c.json({ products });
});
// ── GET /api/storefront/:slug — Single storefront product ──
routes.get("/api/storefront/:slug", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
// Parse "designSlug-productType" format
for (const meta of designIndex.values()) {
for (const prod of meta.products) {
const prodSlug = `${meta.slug}-${prod.type}`;
if (prodSlug === slug) {
return c.json({
slug: prodSlug,
designSlug: meta.slug,
name: `${meta.name} ${prod.type}`,
description: meta.description,
category: meta.category,
productType: prod.type,
imageUrl: `/api/designs/${meta.slug}/image`,
mockupUrl: `/api/designs/${meta.slug}/mockup?type=${prod.type}`,
basePrice: prod.retailPrice,
variants: prod.variants,
provider: prod.provider,
sku: prod.sku,
isActive: meta.status === "active",
});
}
}
}
return c.json({ error: "Product not found" }, 404);
});
// ── POST /api/fulfill/submit — Submit rCart order to POD provider ──
routes.post("/api/fulfill/submit", async (c) => {
try {
const body = await c.req.json() as FulfillmentRequest;
if (!body.orderId || !body.items?.length || !body.recipient) {
return c.json({ error: "orderId, items, and recipient are required" }, 400);
}
const results = await submitFulfillment(body);
return c.json({ results }, 201);
} catch (error) {
console.error("[rSwag] Fulfillment error:", error);
return c.json({ error: error instanceof Error ? error.message : "Fulfillment failed" }, 500);
}
});
// ── POST /api/webhooks/printful — Printful shipment webhook ──
routes.post("/api/webhooks/printful", async (c) => {
try {
const body = await c.req.json();
const event = parsePrintfulWebhook(body);
if (!event) return c.json({ error: "Invalid webhook" }, 400);
console.log(`[rSwag] Printful webhook: ${event.type} for order ${event.orderId}`);
// TODO: push tracking update to rCart order doc
return c.json({ received: true, type: event.type });
} catch {
return c.json({ error: "Webhook processing failed" }, 500);
}
});
// ── POST /api/webhooks/prodigi — Prodigi shipment webhook ──
routes.post("/api/webhooks/prodigi", async (c) => {
try {
const body = await c.req.json();
const event = parseProdigiWebhook(body);
if (!event) return c.json({ error: "Invalid webhook" }, 400);
console.log(`[rSwag] Prodigi webhook: ${event.type} for order ${event.orderId}`);
// TODO: push tracking update to rCart order doc
return c.json({ received: true, type: event.type });
} catch {
return c.json({ error: "Webhook processing failed" }, 500);
}
});
// ── GET /api/orders/:id/tracking — Tracking info lookup ──
routes.get("/api/orders/:id/tracking", async (c) => {
const orderId = c.req.param("id");
const fulfillments = getFulfillmentStatus(orderId);
if (!fulfillments) return c.json({ error: "Order not found" }, 404);
const tracking = [];
for (const f of fulfillments) {
const info = await getTrackingInfo(f.provider, f.providerOrderId);
tracking.push({
provider: f.provider,
providerOrderId: f.providerOrderId,
status: f.status,
tracking: info,
});
}
return c.json({ orderId, fulfillments: tracking });
});
// ── GET /api/dither/algorithms — List available dithering algorithms ──
routes.get("/api/dither/algorithms", (c) => {
return c.json({ algorithms: ALGORITHMS });
});
// ── POST /api/admin/designs/sync — Reload designs from storage ──
routes.post("/api/admin/designs/sync", async (c) => {
designIndexLoaded = false;
await loadDesignIndex();
return c.json({ synced: true, count: designIndex.size });
});
// ── PUT /api/admin/products/:slug/override — Price/visibility override ──
routes.put("/api/admin/products/:slug/override", async (c) => {
const slug = c.req.param("slug");
await ensureDesignIndex();
const meta = designIndex.get(slug);
if (!meta) return c.json({ error: "Design not found" }, 404);
const body = await c.req.json();
if (body.status) meta.status = body.status;
if (body.products) meta.products = body.products;
await saveDesignMeta(meta);
return c.json({ updated: true, slug });
});
// ── GET /api/admin/analytics/summary — Sales metrics ──
routes.get("/api/admin/analytics/summary", async (c) => {
await ensureDesignIndex();
const totalDesigns = designIndex.size;
const activeDesigns = Array.from(designIndex.values()).filter(d => d.status === "active").length;
const draftDesigns = Array.from(designIndex.values()).filter(d => d.status === "draft").length;
// Count artifacts
let totalArtifacts = 0;
try {
const dirs = await readdir(ARTIFACTS_DIR);
totalArtifacts = dirs.filter(d => d.match(/^[0-9a-f-]{36}$/)).length;
} catch { /* ok */ }
return c.json({
totalDesigns,
activeDesigns,
draftDesigns,
totalArtifacts,
designsByCategory: Array.from(designIndex.values()).reduce((acc, d) => {
acc[d.category] = (acc[d.category] || 0) + 1;
return acc;
}, {} as Record<string, number>),
designsBySource: Array.from(designIndex.values()).reduce((acc, d) => {
acc[d.source] = (acc[d.source] || 0) + 1;
return acc;
}, {} as Record<string, number>),
});
});
// ── Page route: swag designer ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
return c.html(renderShell({
title: `Swag Designer | rSpace`,
moduleId: "rswag",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=3"></script>
<script type="module" src="/modules/rswag/folk-revenue-sankey.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
}));
});
export const swagModule: RSpaceModule = {
id: "rswag",
name: "rSwag",
icon: "🎨",
description: "Design print-ready swag: stickers, posters, tees",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rswag.online",
feeds: [
{
id: "designs",
name: "Designs",
kind: "resource",
description: "Print-ready design artifacts for stickers, posters, and tees",
filterable: true,
},
{
id: "merch-orders",
name: "Merch Orders",
kind: "economic",
description: "Merchandise order events and fulfillment tracking",
},
],
acceptsFeeds: ["economic", "resource"],
outputPaths: [
{ path: "merchandise", name: "Merchandise", icon: "👕", description: "Print-ready swag designs" },
],
};