/** * 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 = 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 = {}; 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; 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), designsBySource: Array.from(designIndex.values()).reduce((acc, d) => { acc[d.source] = (acc[d.source] || 0) + 1; return acc; }, {} as Record), }); }); // ── 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: ``, scripts: ` `, styles: ``, })); }); 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" }, ], };