1488 lines
71 KiB
TypeScript
1488 lines
71 KiB
TypeScript
/**
|
|
* <folk-swag-designer> — Full-featured merch design tool.
|
|
*
|
|
* 4-tab layout:
|
|
* - Browse: Product catalog with mockups, filtering, add-to-cart
|
|
* - Create: AI generate, upload, manage designs
|
|
* - HitherDither: Dithering tools + screen-print separations
|
|
* - Orders: rCart order status + fulfillment tracking
|
|
*
|
|
* Demo mode: 4-step interactive flow (preserved from original).
|
|
*/
|
|
|
|
// --- Demo data (self-contained, zero API calls in demo mode) ---
|
|
|
|
interface DemoProduct {
|
|
id: string;
|
|
name: string;
|
|
printArea: string;
|
|
baseCost: string;
|
|
printful: boolean;
|
|
sizes?: string[];
|
|
colors?: { id: string; name: string; hex: string }[];
|
|
}
|
|
|
|
interface DemoProvider {
|
|
name: string;
|
|
type: "cosmolocal" | "global";
|
|
city: string;
|
|
lat: number;
|
|
lng: number;
|
|
capabilities: string[];
|
|
unitCost: number;
|
|
turnaround: string;
|
|
}
|
|
|
|
const DEMO_PRODUCTS: DemoProduct[] = [
|
|
{
|
|
id: "tee", name: "T-Shirt", printArea: "305x406mm", baseCost: "$9.25-$13.25",
|
|
printful: true,
|
|
sizes: ["S", "M", "L", "XL", "2XL", "3XL"],
|
|
colors: [
|
|
{ id: "black", name: "Black", hex: "#0a0a0a" },
|
|
{ id: "white", name: "White", hex: "#ffffff" },
|
|
{ id: "forest_green", name: "Forest Green", hex: "#2d4a3e" },
|
|
{ id: "heather_charcoal", name: "Heather Charcoal", hex: "#4a4a4a" },
|
|
{ id: "maroon", name: "Maroon", hex: "#5a2d2d" },
|
|
],
|
|
},
|
|
{
|
|
id: "sticker", name: "Sticker Sheet", printArea: "210x297mm", baseCost: "$1.20-$1.50",
|
|
printful: true,
|
|
},
|
|
{
|
|
id: "poster", name: "Poster (A3)", printArea: "297x420mm", baseCost: "$4.50-$7.00",
|
|
printful: false,
|
|
},
|
|
{
|
|
id: "hoodie", name: "Hoodie", printArea: "356x406mm", baseCost: "$23.95-$27.95",
|
|
printful: true,
|
|
sizes: ["S", "M", "L", "XL", "2XL"],
|
|
colors: [
|
|
{ id: "black", name: "Black", hex: "#0a0a0a" },
|
|
{ id: "dark_grey_heather", name: "Dark Grey Heather", hex: "#3a3a3a" },
|
|
],
|
|
},
|
|
];
|
|
|
|
const DEMO_PROVIDERS: DemoProvider[] = [
|
|
{ name: "De Drukker Collective", type: "cosmolocal", city: "Amsterdam", lat: 52.37, lng: 4.90, capabilities: ["dtg-print", "vinyl-cut", "screen-print"], unitCost: 8.50, turnaround: "3-4 days" },
|
|
{ name: "Kuona Print Collective", type: "cosmolocal", city: "Nairobi", lat: -1.29, lng: 36.82, capabilities: ["dtg-print", "screen-print", "vinyl-cut"], unitCost: 5.50, turnaround: "5-7 days" },
|
|
{ name: "Grafica Popular", type: "cosmolocal", city: "Sao Paulo", lat: -23.55, lng: -46.63, capabilities: ["screen-print", "risograph", "inkjet-print"], unitCost: 6.00, turnaround: "4-6 days" },
|
|
{ name: "Printful", type: "global", city: "Global", lat: 0, lng: 0, capabilities: ["dtg-print", "vinyl-cut", "inkjet-print"], unitCost: 9.25, turnaround: "5-7 days" },
|
|
];
|
|
|
|
const DEMO_BUYER = { lat: 52.52, lng: 13.41 }; // Berlin
|
|
|
|
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 6371;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
// --- SVG generators ---
|
|
|
|
function cosmoDesignSvg(): string {
|
|
return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="200" height="200" fill="#0f172a"/>
|
|
<circle cx="100" cy="100" r="70" fill="none" stroke="#6366f1" stroke-width="1.5" opacity="0.4"/>
|
|
<circle cx="100" cy="100" r="50" fill="none" stroke="#818cf8" stroke-width="1.5" opacity="0.6"/>
|
|
<circle cx="100" cy="100" r="30" fill="none" stroke="#a5b4fc" stroke-width="1.5" opacity="0.8"/>
|
|
<circle cx="100" cy="100" r="10" fill="#6366f1"/>
|
|
${Array.from({length: 12}, (_, i) => {
|
|
const a = i * 30 * Math.PI / 180;
|
|
return `<line x1="${100 + 15 * Math.cos(a)}" y1="${100 + 15 * Math.sin(a)}" x2="${100 + 75 * Math.cos(a)}" y2="${100 + 75 * Math.sin(a)}" stroke="#818cf8" stroke-width="1" opacity="0.3"/>`;
|
|
}).join("")}
|
|
<circle cx="100" cy="100" r="5" fill="#e2e8f0"/>
|
|
<text x="100" y="160" text-anchor="middle" fill="#94a3b8" font-size="8" font-family="system-ui">COSMOLOCAL</text>
|
|
<text x="100" y="170" text-anchor="middle" fill="#64748b" font-size="6" font-family="system-ui">NETWORK</text>
|
|
</svg>`;
|
|
}
|
|
|
|
function teeMockupSvg(color: string): string {
|
|
return `<svg viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M60,40 L30,60 L10,120 L40,130 L50,90 L50,280 L190,280 L190,90 L200,130 L230,120 L210,60 L180,40 L160,55 Q140,70 120,70 Q100,70 80,55 Z" fill="${color}" stroke="#475569" stroke-width="1.5"/>
|
|
<defs><clipPath id="tee-clip"><rect x="80" y="90" width="80" height="107" rx="4"/></clipPath></defs>
|
|
<g clip-path="url(#tee-clip)" transform="translate(80,90)">
|
|
<svg viewBox="0 0 200 200" width="80" height="80" y="13">${cosmoDesignSvg().replace(/<svg[^>]*>/, "").replace("</svg>", "")}</svg>
|
|
</g>
|
|
<rect x="80" y="90" width="80" height="107" rx="4" fill="none" stroke="#6366f1" stroke-width="0.75" stroke-dasharray="3,2" opacity="0.5"/>
|
|
</svg>`;
|
|
}
|
|
|
|
function hoodieMockupSvg(color: string): string {
|
|
return `<svg viewBox="0 0 260 310" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M65,50 L30,70 L5,140 L40,150 L55,100 L55,290 L205,290 L205,100 L220,150 L255,140 L230,70 L195,50 L175,60 Q155,80 130,85 Q105,80 85,60 Z" fill="${color}" stroke="#475569" stroke-width="1.5"/>
|
|
<path d="M85,60 Q105,30 130,25 Q155,30 175,60 Q155,80 130,85 Q105,80 85,60 Z" fill="${color}" stroke="#475569" stroke-width="1"/>
|
|
<ellipse cx="130" cy="55" rx="20" ry="15" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
|
<defs><clipPath id="hoodie-clip"><rect x="80" y="100" width="100" height="115" rx="4"/></clipPath></defs>
|
|
<g clip-path="url(#hoodie-clip)" transform="translate(80,100)">
|
|
<svg viewBox="0 0 200 200" width="100" height="100" y="8">${cosmoDesignSvg().replace(/<svg[^>]*>/, "").replace("</svg>", "")}</svg>
|
|
</g>
|
|
<rect x="80" y="100" width="100" height="115" rx="4" fill="none" stroke="#6366f1" stroke-width="0.75" stroke-dasharray="3,2" opacity="0.5"/>
|
|
</svg>`;
|
|
}
|
|
|
|
function stickerMockupSvg(): string {
|
|
return `<svg viewBox="0 0 240 240" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="20" y="20" width="200" height="200" rx="16" fill="#1e293b" stroke="#475569" stroke-width="1.5"/>
|
|
<rect x="30" y="30" width="180" height="180" rx="12" fill="none" stroke="#6366f1" stroke-width="1" stroke-dasharray="4,3" opacity="0.5"/>
|
|
<g transform="translate(40,40)">
|
|
<svg viewBox="0 0 200 200" width="160" height="160">${cosmoDesignSvg().replace(/<svg[^>]*>/, "").replace("</svg>", "")}</svg>
|
|
</g>
|
|
<text x="120" y="232" text-anchor="middle" fill="#64748b" font-size="7" font-family="system-ui">kiss-cut border</text>
|
|
</svg>`;
|
|
}
|
|
|
|
function posterMockupSvg(): string {
|
|
return `<svg viewBox="0 0 220 300" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="15" y="15" width="190" height="270" fill="#1e293b" stroke="#475569" stroke-width="2"/>
|
|
<rect x="25" y="25" width="170" height="250" fill="none" stroke="#334155" stroke-width="1"/>
|
|
<g transform="translate(40,50)">
|
|
<svg viewBox="0 0 200 200" width="140" height="140">${cosmoDesignSvg().replace(/<svg[^>]*>/, "").replace("</svg>", "")}</svg>
|
|
</g>
|
|
<text x="110" y="230" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui" font-weight="600">COSMOLOCAL NETWORK</text>
|
|
<text x="110" y="245" text-anchor="middle" fill="#64748b" font-size="7" font-family="system-ui">A3 - 297x420mm - 300 DPI</text>
|
|
</svg>`;
|
|
}
|
|
|
|
// --- Component ---
|
|
|
|
import { TourEngine } from "../../../shared/tour-engine";
|
|
import { SwagLocalFirstClient } from "../local-first-client";
|
|
import type { SwagDoc, SwagDesign } from "../schemas";
|
|
|
|
// Auth helpers
|
|
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
|
try {
|
|
const raw = localStorage.getItem("encryptid_session");
|
|
if (!raw) return null;
|
|
const s = JSON.parse(raw);
|
|
return s?.accessToken ? s : null;
|
|
} catch { return null; }
|
|
}
|
|
function getMyDid(): string | null {
|
|
const s = getSession();
|
|
if (!s) return null;
|
|
return (s.claims as any).did || s.claims.sub;
|
|
}
|
|
|
|
// Dithering algorithms for the HitherDither tab
|
|
const DITHER_ALGORITHMS = [
|
|
{ id: "floyd-steinberg", name: "Floyd-Steinberg", group: "Error Diffusion" },
|
|
{ id: "atkinson", name: "Atkinson", group: "Error Diffusion" },
|
|
{ id: "stucki", name: "Stucki", group: "Error Diffusion" },
|
|
{ id: "burkes", name: "Burkes", group: "Error Diffusion" },
|
|
{ id: "sierra", name: "Sierra", group: "Error Diffusion" },
|
|
{ id: "sierra-two-row", name: "Sierra Two-Row", group: "Error Diffusion" },
|
|
{ id: "sierra-lite", name: "Sierra Lite", group: "Error Diffusion" },
|
|
{ id: "jarvis-judice-ninke", name: "Jarvis-Judice-Ninke", group: "Error Diffusion" },
|
|
{ id: "bayer", name: "Bayer (Ordered)", group: "Ordered" },
|
|
{ id: "ordered", name: "Ordered", group: "Ordered" },
|
|
{ id: "cluster-dot", name: "Cluster Dot", group: "Ordered" },
|
|
];
|
|
|
|
type TabId = "browse" | "create" | "dither" | "orders";
|
|
|
|
class FolkSwagDesigner extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "";
|
|
|
|
// Tab state
|
|
private activeTab: TabId = "browse";
|
|
|
|
// Demo state
|
|
private selectedProduct = "tee";
|
|
private selectedSize = "M";
|
|
private selectedColor = "black";
|
|
private imageFile: File | null = null;
|
|
private imagePreview = "";
|
|
private designTitle = "";
|
|
private generating = false;
|
|
private artifact: any = null;
|
|
private error = "";
|
|
private demoStep: 1 | 2 | 3 | 4 = 1;
|
|
private progressStep = 0;
|
|
private usedSampleDesign = false;
|
|
|
|
// Browse tab state
|
|
private catalogProducts: any[] = [];
|
|
private catalogLoading = false;
|
|
private catalogSearch = "";
|
|
private catalogCategory = "";
|
|
|
|
// Create tab state
|
|
private createMode: "ai" | "upload" | "designs" = "designs";
|
|
private aiConcept = "";
|
|
private aiName = "";
|
|
private aiTags = "";
|
|
private aiGenerating = false;
|
|
private uploadFile: File | null = null;
|
|
private uploadPreview = "";
|
|
private uploadName = "";
|
|
private uploadDescription = "";
|
|
private myDesigns: any[] = [];
|
|
|
|
// Dither tab state
|
|
private ditherDesignSlug = "";
|
|
private ditherAlgorithm = "floyd-steinberg";
|
|
private ditherNumColors = 8;
|
|
private ditherPreviewUrl = "";
|
|
private ditherLoading = false;
|
|
private ditherColors: string[] = [];
|
|
private separationData: any = null;
|
|
|
|
// Orders tab state
|
|
private orders: any[] = [];
|
|
|
|
// Multiplayer state
|
|
private lfClient: SwagLocalFirstClient | null = null;
|
|
private _lfcUnsub: (() => void) | null = null;
|
|
private sharedDesigns: SwagDesign[] = [];
|
|
|
|
private _tour!: TourEngine;
|
|
private static readonly TOUR_STEPS = [
|
|
{ target: '.product', title: "Choose Product", message: "Select a product type - tee, sticker, poster, or hoodie.", advanceOnClick: true },
|
|
{ target: '.steps-bar', title: "Design Flow", message: "Follow the 4-step flow: Product, Design, Generate, Pipeline.", advanceOnClick: false },
|
|
{ target: '.sample-btn', title: "Sample Design", message: "Try the demo with a pre-made sample design to see the full pipeline.", advanceOnClick: true },
|
|
{ target: '.generate-btn', title: "Generate", message: "Generate print-ready files and see provider matching + revenue splits.", advanceOnClick: false },
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
this._tour = new TourEngine(
|
|
this.shadow,
|
|
FolkSwagDesigner.TOUR_STEPS,
|
|
"rswag_tour_done",
|
|
() => this.shadow.host as HTMLElement,
|
|
);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "";
|
|
if (this.space === "demo") {
|
|
this.selectedProduct = "tee";
|
|
this.selectedSize = "M";
|
|
this.selectedColor = "black";
|
|
this.designTitle = "Cosmolocal Network Tee";
|
|
this.demoStep = 1;
|
|
this.render();
|
|
} else {
|
|
this.initMultiplayer();
|
|
this.loadCatalog();
|
|
this.loadMyDesigns();
|
|
this.render();
|
|
}
|
|
if (!localStorage.getItem("rswag_tour_done")) {
|
|
setTimeout(() => this._tour.start(), 1200);
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._lfcUnsub?.();
|
|
this._lfcUnsub = null;
|
|
this.lfClient?.disconnect();
|
|
}
|
|
|
|
private async initMultiplayer() {
|
|
try {
|
|
this.lfClient = new SwagLocalFirstClient(this.space);
|
|
await this.lfClient.init();
|
|
await this.lfClient.subscribe();
|
|
|
|
this._lfcUnsub = this.lfClient.onChange((doc) => {
|
|
this.extractDesigns(doc);
|
|
this.render();
|
|
});
|
|
|
|
const doc = this.lfClient.getDoc();
|
|
if (doc) this.extractDesigns(doc);
|
|
this.render();
|
|
} catch (err) {
|
|
console.warn('[rSwag] Local-first init failed:', err);
|
|
}
|
|
}
|
|
|
|
private extractDesigns(doc: SwagDoc) {
|
|
this.sharedDesigns = doc.designs
|
|
? Object.values(doc.designs).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
: [];
|
|
}
|
|
|
|
private saveDesignToSync(artifactId: string) {
|
|
if (!this.lfClient) return;
|
|
const design: SwagDesign = {
|
|
id: crypto.randomUUID(),
|
|
title: this.designTitle || 'Untitled Design',
|
|
productType: this.selectedProduct as SwagDesign['productType'],
|
|
artifactId,
|
|
source: 'artifact',
|
|
status: 'active',
|
|
imageUrl: null,
|
|
products: [],
|
|
slug: null,
|
|
description: null,
|
|
tags: [],
|
|
createdBy: getMyDid(),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.lfClient.saveDesign(design);
|
|
}
|
|
|
|
private deleteSharedDesign(designId: string) {
|
|
if (!this.lfClient) return;
|
|
if (confirm('Delete this design?')) {
|
|
this.lfClient.deleteDesign(designId);
|
|
}
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/rswag/);
|
|
return match ? match[0] : "/rswag";
|
|
}
|
|
|
|
// ── Data loading ──
|
|
|
|
private async loadCatalog() {
|
|
this.catalogLoading = true;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (this.catalogSearch) params.set("q", this.catalogSearch);
|
|
if (this.catalogCategory) params.set("category", this.catalogCategory);
|
|
const resp = await fetch(`${this.getApiBase()}/api/storefront?${params}`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.catalogProducts = data.products || [];
|
|
}
|
|
} catch (e) {
|
|
console.warn("[rSwag] Failed to load catalog:", e);
|
|
}
|
|
this.catalogLoading = false;
|
|
this.render();
|
|
}
|
|
|
|
private async loadMyDesigns() {
|
|
try {
|
|
const resp = await fetch(`${this.getApiBase()}/api/designs`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.myDesigns = data.designs || [];
|
|
}
|
|
} catch (e) {
|
|
console.warn("[rSwag] Failed to load designs:", e);
|
|
}
|
|
}
|
|
|
|
// ── Demo mode methods (preserved) ──
|
|
|
|
private getDemoProduct(): DemoProduct {
|
|
return DEMO_PRODUCTS.find(p => p.id === this.selectedProduct) || DEMO_PRODUCTS[0];
|
|
}
|
|
|
|
private getAutoTitle(): string {
|
|
const p = this.getDemoProduct();
|
|
return `Cosmolocal Network ${p.name}`;
|
|
}
|
|
|
|
private demoSelectProduct(id: string) {
|
|
this.selectedProduct = id;
|
|
const p = this.getDemoProduct();
|
|
this.selectedSize = p.sizes?.[1] || "";
|
|
this.selectedColor = p.colors?.[0]?.id || "";
|
|
this.designTitle = this.getAutoTitle();
|
|
this.demoStep = 1;
|
|
this.usedSampleDesign = false;
|
|
this.artifact = null;
|
|
this.render();
|
|
}
|
|
|
|
private demoUseSample() {
|
|
this.usedSampleDesign = true;
|
|
this.demoStep = 2;
|
|
this.render();
|
|
}
|
|
|
|
private demoGenerate() {
|
|
this.demoStep = 3;
|
|
this.progressStep = 0;
|
|
this.render();
|
|
const steps = [1, 2, 3, 4];
|
|
const delays = [400, 400, 400, 300];
|
|
let elapsed = 0;
|
|
for (const s of steps) {
|
|
elapsed += delays[s - 1];
|
|
setTimeout(() => {
|
|
this.progressStep = s;
|
|
if (s === 4) {
|
|
this.buildDemoArtifact();
|
|
this.demoStep = 4;
|
|
}
|
|
this.render();
|
|
}, elapsed);
|
|
}
|
|
}
|
|
|
|
private buildDemoArtifact() {
|
|
const p = this.getDemoProduct();
|
|
const dims: Record<string, { w: number; h: number }> = {
|
|
tee: { w: 305, h: 406 }, sticker: { w: 210, h: 297 },
|
|
poster: { w: 297, h: 420 }, hoodie: { w: 356, h: 406 },
|
|
};
|
|
const d = dims[p.id] || dims.tee;
|
|
const wpx = Math.round((d.w / 25.4) * 300);
|
|
const hpx = Math.round((d.h / 25.4) * 300);
|
|
const capabilities: Record<string, string[]> = {
|
|
tee: ["dtg-print"], sticker: ["vinyl-cut"], poster: ["inkjet-print"], hoodie: ["dtg-print"],
|
|
};
|
|
const substrates: Record<string, string[]> = {
|
|
tee: ["cotton-standard", "cotton-organic"], sticker: ["vinyl-matte", "vinyl-gloss"],
|
|
poster: ["paper-160gsm-cover", "paper-100gsm"], hoodie: ["cotton-polyester-blend"],
|
|
};
|
|
this.artifact = {
|
|
title: this.designTitle || this.getAutoTitle(),
|
|
product: p.id,
|
|
spec: {
|
|
product_type: p.id === "sticker" ? "sticker-sheet" : p.id,
|
|
dimensions: { width_mm: d.w, height_mm: d.h },
|
|
dpi: 300, color_space: "sRGB",
|
|
required_capabilities: capabilities[p.id],
|
|
substrates: substrates[p.id],
|
|
},
|
|
render_targets: {
|
|
print: { format: "png", label: "Print-Ready PNG", dimensions: `${wpx}x${hpx} px`, url: "#demo-print" },
|
|
preview: { format: "png", label: "Preview PNG", dimensions: `${Math.round(wpx / 4)}x${Math.round(hpx / 4)} px`, url: "#demo-preview" },
|
|
},
|
|
pricing: { creator_share: "35%", community_share: "15%", provider_share: "50%" },
|
|
next_actions: [
|
|
{ action: "ingest_to_rcart", label: "Send to rCart", url: "/rcart?tab=catalog" },
|
|
{ action: "edit_design", label: "Edit Design" },
|
|
{ action: "save_to_rfiles", label: "Save to rFiles" },
|
|
],
|
|
...(this.selectedSize ? { size: this.selectedSize } : {}),
|
|
...(this.selectedColor ? { color: this.selectedColor } : {}),
|
|
};
|
|
}
|
|
|
|
private getMatchedProviders(): (DemoProvider & { distance: number })[] {
|
|
const p = this.getDemoProduct();
|
|
const cap = p.id === "sticker" ? "vinyl-cut" : p.id === "poster" ? "inkjet-print" : "dtg-print";
|
|
return DEMO_PROVIDERS
|
|
.filter(prov => prov.capabilities.includes(cap))
|
|
.map(prov => ({
|
|
...prov,
|
|
distance: prov.type === "global" ? Infinity : Math.round(haversineKm(DEMO_BUYER.lat, DEMO_BUYER.lng, prov.lat, prov.lng)),
|
|
}))
|
|
.sort((a, b) => a.distance - b.distance);
|
|
}
|
|
|
|
// ── Generate (full mode) ──
|
|
|
|
private async generate() {
|
|
if (this.space === "demo") { this.demoGenerate(); return; }
|
|
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.designTitle || "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();
|
|
if (this.artifact?.id) this.saveDesignToSync(this.artifact.id);
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : "Generation failed";
|
|
} finally {
|
|
this.generating = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// ── AI Generate ──
|
|
|
|
private async aiGenerate() {
|
|
if (this.aiGenerating || !this.aiConcept || !this.aiName) return;
|
|
this.aiGenerating = true;
|
|
this.error = "";
|
|
this.render();
|
|
try {
|
|
const resp = await fetch(`${this.getApiBase()}/api/design/generate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
concept: this.aiConcept,
|
|
name: this.aiName,
|
|
tags: this.aiTags ? this.aiTags.split(",").map(t => t.trim()) : [],
|
|
product_type: "sticker",
|
|
}),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || `Failed: ${resp.status}`);
|
|
}
|
|
const result = await resp.json();
|
|
this.error = "";
|
|
this.aiConcept = "";
|
|
this.aiName = "";
|
|
this.aiTags = "";
|
|
this.createMode = "designs";
|
|
await this.loadMyDesigns();
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : "AI generation failed";
|
|
} finally {
|
|
this.aiGenerating = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// ── Upload ──
|
|
|
|
private async uploadDesign() {
|
|
if (!this.uploadFile) return;
|
|
this.generating = true;
|
|
this.error = "";
|
|
this.render();
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("image", this.uploadFile);
|
|
formData.append("name", this.uploadName || "Untitled Upload");
|
|
formData.append("description", this.uploadDescription || "");
|
|
formData.append("product_type", "sticker");
|
|
const resp = await fetch(`${this.getApiBase()}/api/design/upload`, { method: "POST", body: formData });
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || `Failed: ${resp.status}`);
|
|
}
|
|
this.uploadFile = null;
|
|
this.uploadPreview = "";
|
|
this.uploadName = "";
|
|
this.uploadDescription = "";
|
|
this.createMode = "designs";
|
|
await this.loadMyDesigns();
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : "Upload failed";
|
|
} finally {
|
|
this.generating = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// ── Dither ──
|
|
|
|
private async applyDither() {
|
|
if (!this.ditherDesignSlug) return;
|
|
this.ditherLoading = true;
|
|
this.render();
|
|
try {
|
|
const params = new URLSearchParams({
|
|
algorithm: this.ditherAlgorithm,
|
|
num_colors: String(this.ditherNumColors),
|
|
format: "json",
|
|
});
|
|
const resp = await fetch(`${this.getApiBase()}/api/designs/${this.ditherDesignSlug}/dither?${params}`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.ditherPreviewUrl = data.image_url;
|
|
this.ditherColors = data.colors_used || [];
|
|
}
|
|
} catch (e) {
|
|
console.warn("[rSwag] Dither error:", e);
|
|
}
|
|
this.ditherLoading = false;
|
|
this.render();
|
|
}
|
|
|
|
private async generateSeparations() {
|
|
if (!this.ditherDesignSlug) return;
|
|
this.ditherLoading = true;
|
|
this.render();
|
|
try {
|
|
const resp = await fetch(`${this.getApiBase()}/api/designs/${this.ditherDesignSlug}/screen-print`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
num_colors: this.ditherNumColors,
|
|
algorithm: this.ditherAlgorithm,
|
|
}),
|
|
});
|
|
if (resp.ok) {
|
|
this.separationData = await resp.json();
|
|
}
|
|
} catch (e) {
|
|
console.warn("[rSwag] Separation error:", e);
|
|
}
|
|
this.ditherLoading = false;
|
|
this.render();
|
|
}
|
|
|
|
// ── Render ──
|
|
|
|
private render() {
|
|
if (this.space === "demo") {
|
|
this.renderDemo();
|
|
} else {
|
|
this.renderFull();
|
|
}
|
|
this._tour.renderOverlay();
|
|
}
|
|
|
|
startTour() { this._tour.start(); }
|
|
|
|
// ──── Full mode (4-tab layout) ────
|
|
|
|
private renderFull() {
|
|
const isLive = this.lfClient?.isConnected ?? false;
|
|
|
|
this.shadow.innerHTML = `
|
|
<style>${this.getFullStyles()}</style>
|
|
|
|
${isLive ? `<div class="live-header"><span class="live-badge"><span class="live-dot"></span>LIVE</span></div>` : ''}
|
|
|
|
<!-- Tab bar -->
|
|
<div class="tab-bar">
|
|
${(["browse", "create", "dither", "orders"] as TabId[]).map(tab => `
|
|
<button class="tab-btn ${this.activeTab === tab ? 'active' : ''}" data-tab="${tab}">
|
|
${tab === "browse" ? "Browse" : tab === "create" ? "Create" : tab === "dither" ? "HitherDither" : "Orders"}
|
|
</button>
|
|
`).join("")}
|
|
</div>
|
|
|
|
<!-- Tab content -->
|
|
<div class="tab-content">
|
|
${this.activeTab === "browse" ? this.renderBrowseTab() : ""}
|
|
${this.activeTab === "create" ? this.renderCreateTab() : ""}
|
|
${this.activeTab === "dither" ? this.renderDitherTab() : ""}
|
|
${this.activeTab === "orders" ? this.renderOrdersTab() : ""}
|
|
</div>
|
|
`;
|
|
|
|
this.bindFullEvents();
|
|
}
|
|
|
|
// ── Browse Tab ──
|
|
|
|
private renderBrowseTab(): string {
|
|
return `
|
|
<div class="browse-controls">
|
|
<input class="search-input" type="text" placeholder="Search products..." value="${this.esc(this.catalogSearch)}">
|
|
<select class="category-select">
|
|
<option value="">All Categories</option>
|
|
<option value="stickers" ${this.catalogCategory === 'stickers' ? 'selected' : ''}>Stickers</option>
|
|
<option value="apparel" ${this.catalogCategory === 'apparel' ? 'selected' : ''}>Apparel</option>
|
|
<option value="prints" ${this.catalogCategory === 'prints' ? 'selected' : ''}>Prints</option>
|
|
</select>
|
|
</div>
|
|
|
|
${this.catalogLoading ? '<div class="loading">Loading catalog...</div>' : ''}
|
|
|
|
${this.catalogProducts.length === 0 && !this.catalogLoading ? `
|
|
<div class="empty-state">
|
|
<p>No products yet. Switch to the <strong>Create</strong> tab to add designs!</p>
|
|
</div>` : ''}
|
|
|
|
<div class="product-grid">
|
|
${this.catalogProducts.map(p => `
|
|
<div class="catalog-card">
|
|
<div class="catalog-img" style="background-image:url('${this.getApiBase()}${p.imageUrl}')"></div>
|
|
<div class="catalog-body">
|
|
<div class="catalog-name">${this.esc(p.name)}</div>
|
|
<div class="catalog-meta">
|
|
<span class="badge badge-${p.category}">${p.category}</span>
|
|
<span class="catalog-price">$${p.basePrice.toFixed(2)}</span>
|
|
</div>
|
|
${p.variants?.length ? `<div class="catalog-variants">${p.variants.map((v: string) => `<span class="variant-chip">${v}</span>`).join("")}</div>` : ""}
|
|
<button class="btn btn-primary btn-sm add-to-cart-btn" data-slug="${p.slug}">Add to Cart</button>
|
|
</div>
|
|
</div>`).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
// ── Create Tab ──
|
|
|
|
private renderCreateTab(): string {
|
|
return `
|
|
<div class="create-modes">
|
|
<button class="mode-btn ${this.createMode === 'ai' ? 'active' : ''}" data-mode="ai">AI Generate</button>
|
|
<button class="mode-btn ${this.createMode === 'upload' ? 'active' : ''}" data-mode="upload">Upload</button>
|
|
<button class="mode-btn ${this.createMode === 'designs' ? 'active' : ''}" data-mode="designs">My Designs</button>
|
|
</div>
|
|
|
|
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
|
|
|
${this.createMode === "ai" ? this.renderAiCreate() : ""}
|
|
${this.createMode === "upload" ? this.renderUploadCreate() : ""}
|
|
${this.createMode === "designs" ? this.renderMyDesigns() : ""}
|
|
`;
|
|
}
|
|
|
|
private renderAiCreate(): string {
|
|
return `
|
|
<div class="create-section">
|
|
<h3 class="section-title">AI Design Generator</h3>
|
|
<p class="section-desc">Describe your design concept and let Gemini create it.</p>
|
|
<input class="input-field ai-name-input" type="text" placeholder="Design name" value="${this.esc(this.aiName)}">
|
|
<textarea class="input-field ai-concept-input" placeholder="Describe your design concept..." rows="3">${this.esc(this.aiConcept)}</textarea>
|
|
<input class="input-field ai-tags-input" type="text" placeholder="Tags (comma-separated)" value="${this.esc(this.aiTags)}">
|
|
<button class="btn btn-primary ai-generate-btn" ${this.aiGenerating || !this.aiConcept || !this.aiName ? 'disabled' : ''}>
|
|
${this.aiGenerating ? 'Generating...' : 'Generate Design'}
|
|
</button>
|
|
</div>`;
|
|
}
|
|
|
|
private renderUploadCreate(): string {
|
|
return `
|
|
<div class="create-section">
|
|
<h3 class="section-title">Upload Artwork</h3>
|
|
<p class="section-desc">Upload PNG, JPEG, or WebP (min 500x500, max 10MB).</p>
|
|
<div class="upload-area ${this.uploadPreview ? 'has-image' : ''}">
|
|
${this.uploadPreview
|
|
? `<img class="preview-img" src="${this.uploadPreview}" alt="Preview">`
|
|
: `<div class="upload-label">Click or drag to upload artwork</div>`}
|
|
<input type="file" class="upload-file-input" accept="image/png,image/jpeg,image/webp">
|
|
</div>
|
|
<input class="input-field upload-name-input" type="text" placeholder="Design name" value="${this.esc(this.uploadName)}">
|
|
<input class="input-field upload-desc-input" type="text" placeholder="Description (optional)" value="${this.esc(this.uploadDescription)}">
|
|
<button class="btn btn-primary upload-submit-btn" ${!this.uploadFile || this.generating ? 'disabled' : ''}>
|
|
${this.generating ? 'Uploading...' : 'Upload Design'}
|
|
</button>
|
|
</div>`;
|
|
}
|
|
|
|
private renderMyDesigns(): string {
|
|
return `
|
|
<div class="create-section">
|
|
<h3 class="section-title">My Designs</h3>
|
|
${this.myDesigns.length === 0 ? `<p class="section-desc">No designs yet. Use AI Generate or Upload to create one.</p>` : ""}
|
|
<div class="designs-grid">
|
|
${this.myDesigns.map(d => `
|
|
<div class="design-card">
|
|
<div class="design-img" style="background-image:url('${this.getApiBase()}${d.image_url}')"></div>
|
|
<div class="design-body">
|
|
<div class="design-name">${this.esc(d.name)}</div>
|
|
<div class="design-meta">
|
|
<span class="badge badge-${d.status}">${d.status}</span>
|
|
<span class="badge badge-source">${d.source}</span>
|
|
</div>
|
|
<div class="design-actions">
|
|
${d.status === "draft" ? `<button class="btn btn-sm btn-primary activate-btn" data-slug="${d.slug}">Activate</button>` : ""}
|
|
${d.status === "draft" ? `<button class="btn btn-sm btn-danger delete-design-btn" data-slug="${d.slug}">Delete</button>` : ""}
|
|
<button class="btn btn-sm btn-secondary dither-btn" data-slug="${d.slug}">Dither</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
${this.sharedDesigns.length > 0 ? `
|
|
<div class="create-section" style="margin-top:1.5rem">
|
|
<h3 class="section-title">Space Designs (Multiplayer)</h3>
|
|
<div class="designs-grid">
|
|
${this.sharedDesigns.map(d => `
|
|
<div class="design-card">
|
|
<div class="design-card-icon">${this.productIcon(d.productType)}</div>
|
|
<div class="design-body">
|
|
<div class="design-name">${this.esc(d.title)}</div>
|
|
<div class="design-meta">${d.productType}${d.artifactId ? ' - ready' : ''}</div>
|
|
<div class="design-actions">
|
|
${d.artifactId ? `<a class="btn btn-sm btn-primary" href="${this.getApiBase()}/api/artifact/${d.artifactId}/file" target="_blank">Download</a>` : ''}
|
|
${d.createdBy === getMyDid() ? `<button class="btn btn-sm btn-danger shared-delete-btn" data-id="${d.id}">Delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>` : ""}`;
|
|
}
|
|
|
|
// ── HitherDither Tab ──
|
|
|
|
private renderDitherTab(): string {
|
|
const designOptions = this.myDesigns.map(d => `<option value="${d.slug}" ${this.ditherDesignSlug === d.slug ? 'selected' : ''}>${d.name}</option>`).join("");
|
|
|
|
return `
|
|
<div class="dither-section">
|
|
<h3 class="section-title">HitherDither</h3>
|
|
<p class="section-desc">Apply dithering algorithms for screen printing and artistic effects.</p>
|
|
|
|
<div class="dither-controls">
|
|
<div class="control-group">
|
|
<label>Design</label>
|
|
<select class="dither-design-select">
|
|
<option value="">Select a design...</option>
|
|
${designOptions}
|
|
</select>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Algorithm</label>
|
|
<select class="dither-algo-select">
|
|
${DITHER_ALGORITHMS.map(a => `<option value="${a.id}" ${this.ditherAlgorithm === a.id ? 'selected' : ''}>${a.name} (${a.group})</option>`).join("")}
|
|
</select>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Colors (2-32)</label>
|
|
<input type="range" class="dither-colors-range" min="2" max="32" value="${this.ditherNumColors}">
|
|
<span class="range-value">${this.ditherNumColors}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dither-actions">
|
|
<button class="btn btn-primary apply-dither-btn" ${!this.ditherDesignSlug || this.ditherLoading ? 'disabled' : ''}>
|
|
${this.ditherLoading ? 'Processing...' : 'Apply Dither'}
|
|
</button>
|
|
<button class="btn btn-secondary generate-separations-btn" ${!this.ditherDesignSlug || this.ditherLoading ? 'disabled' : ''}>
|
|
Screen-Print Separations
|
|
</button>
|
|
</div>
|
|
|
|
${this.ditherPreviewUrl ? `
|
|
<div class="dither-preview">
|
|
<h4>Dithered Preview</h4>
|
|
<div class="preview-row">
|
|
<div class="preview-original">
|
|
<img src="${this.getApiBase()}/api/designs/${this.ditherDesignSlug}/image" alt="Original">
|
|
<span>Original</span>
|
|
</div>
|
|
<div class="preview-arrow">→</div>
|
|
<div class="preview-dithered">
|
|
<img src="${this.getApiBase()}${this.ditherPreviewUrl}" alt="Dithered">
|
|
<span>${this.ditherAlgorithm}</span>
|
|
</div>
|
|
</div>
|
|
${this.ditherColors.length ? `
|
|
<div class="palette-row">
|
|
<span class="palette-label">Palette:</span>
|
|
${this.ditherColors.map(c => `<span class="palette-swatch" style="background:#${c}" title="#${c}"></span>`).join("")}
|
|
</div>` : ""}
|
|
<a class="btn btn-sm btn-secondary" href="${this.getApiBase()}${this.ditherPreviewUrl}" download="${this.ditherDesignSlug}-${this.ditherAlgorithm}.png">Download Dithered PNG</a>
|
|
</div>` : ""}
|
|
|
|
${this.separationData ? `
|
|
<div class="separations-preview">
|
|
<h4>Screen-Print Separations</h4>
|
|
<div class="sep-grid">
|
|
<div class="sep-card">
|
|
<img src="${this.getApiBase()}${this.separationData.composite_url}" alt="Composite">
|
|
<span>Composite</span>
|
|
</div>
|
|
${(this.separationData.colors || []).map((color: string) => `
|
|
<div class="sep-card">
|
|
<img src="${this.getApiBase()}${this.separationData.separation_urls[color]}" alt="${color}">
|
|
<span class="sep-color"><span class="palette-swatch" style="background:#${color}"></span> #${color}</span>
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
// ── Orders Tab ──
|
|
|
|
private renderOrdersTab(): string {
|
|
return `
|
|
<div class="orders-section">
|
|
<h3 class="section-title">Orders</h3>
|
|
<p class="section-desc">Orders containing rSwag products are shown here via rCart.</p>
|
|
${this.orders.length === 0 ? `
|
|
<div class="empty-state">
|
|
<p>No orders yet. Browse products and add them to your cart to get started.</p>
|
|
<button class="btn btn-primary" data-tab="browse">Browse Products</button>
|
|
</div>` : `
|
|
<div class="orders-list">
|
|
${this.orders.map(o => `
|
|
<div class="order-card">
|
|
<div class="order-id">${o.id}</div>
|
|
<div class="order-status"><span class="badge badge-${o.status}">${o.status}</span></div>
|
|
${o.tracking ? `<a href="${o.tracking.url}" target="_blank" class="order-tracking">Track shipment</a>` : ""}
|
|
</div>`).join("")}
|
|
</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
// ── Event binding (full mode) ──
|
|
|
|
private bindFullEvents() {
|
|
// Tab switching
|
|
this.shadow.querySelectorAll<HTMLElement>(".tab-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
this.activeTab = btn.dataset.tab as TabId;
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
// Orders tab button in empty state
|
|
this.shadow.querySelectorAll<HTMLElement>("[data-tab]").forEach(btn => {
|
|
if (!btn.classList.contains("tab-btn")) {
|
|
btn.addEventListener("click", () => {
|
|
this.activeTab = btn.dataset.tab as TabId;
|
|
this.render();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Browse tab
|
|
this.shadow.querySelector(".search-input")?.addEventListener("input", (e) => {
|
|
this.catalogSearch = (e.target as HTMLInputElement).value;
|
|
clearTimeout((this as any)._searchTimeout);
|
|
(this as any)._searchTimeout = setTimeout(() => this.loadCatalog(), 300);
|
|
});
|
|
this.shadow.querySelector(".category-select")?.addEventListener("change", (e) => {
|
|
this.catalogCategory = (e.target as HTMLSelectElement).value;
|
|
this.loadCatalog();
|
|
});
|
|
this.shadow.querySelectorAll<HTMLElement>(".add-to-cart-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const slug = btn.dataset.slug;
|
|
// TODO: integrate with rCart catalog ingest
|
|
alert(`Added ${slug} to cart (rCart integration pending)`);
|
|
});
|
|
});
|
|
|
|
// Create tab
|
|
this.shadow.querySelectorAll<HTMLElement>(".mode-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
this.createMode = btn.dataset.mode as "ai" | "upload" | "designs";
|
|
this.error = "";
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
// AI Generate
|
|
this.shadow.querySelector(".ai-name-input")?.addEventListener("input", (e) => { this.aiName = (e.target as HTMLInputElement).value; });
|
|
this.shadow.querySelector(".ai-concept-input")?.addEventListener("input", (e) => { this.aiConcept = (e.target as HTMLTextAreaElement).value; });
|
|
this.shadow.querySelector(".ai-tags-input")?.addEventListener("input", (e) => { this.aiTags = (e.target as HTMLInputElement).value; });
|
|
this.shadow.querySelector(".ai-generate-btn")?.addEventListener("click", () => this.aiGenerate());
|
|
|
|
// Upload
|
|
const uploadArea = this.shadow.querySelector(".upload-area");
|
|
const uploadInput = this.shadow.querySelector(".upload-file-input") as HTMLInputElement;
|
|
uploadArea?.addEventListener("click", () => uploadInput?.click());
|
|
uploadInput?.addEventListener("change", () => {
|
|
const file = uploadInput.files?.[0];
|
|
if (file) {
|
|
this.uploadFile = file;
|
|
this.uploadPreview = URL.createObjectURL(file);
|
|
this.render();
|
|
}
|
|
});
|
|
this.shadow.querySelector(".upload-name-input")?.addEventListener("input", (e) => { this.uploadName = (e.target as HTMLInputElement).value; });
|
|
this.shadow.querySelector(".upload-desc-input")?.addEventListener("input", (e) => { this.uploadDescription = (e.target as HTMLInputElement).value; });
|
|
this.shadow.querySelector(".upload-submit-btn")?.addEventListener("click", () => this.uploadDesign());
|
|
|
|
// My designs actions
|
|
this.shadow.querySelectorAll<HTMLElement>(".activate-btn").forEach(btn => {
|
|
btn.addEventListener("click", async () => {
|
|
const slug = btn.dataset.slug;
|
|
await fetch(`${this.getApiBase()}/api/design/${slug}/activate`, { method: "POST" });
|
|
await this.loadMyDesigns();
|
|
this.render();
|
|
});
|
|
});
|
|
this.shadow.querySelectorAll<HTMLElement>(".delete-design-btn").forEach(btn => {
|
|
btn.addEventListener("click", async () => {
|
|
const slug = btn.dataset.slug;
|
|
if (!confirm(`Delete design "${slug}"?`)) return;
|
|
await fetch(`${this.getApiBase()}/api/design/${slug}`, { method: "DELETE" });
|
|
await this.loadMyDesigns();
|
|
this.render();
|
|
});
|
|
});
|
|
this.shadow.querySelectorAll<HTMLElement>(".dither-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
this.ditherDesignSlug = btn.dataset.slug || "";
|
|
this.activeTab = "dither";
|
|
this.render();
|
|
});
|
|
});
|
|
this.shadow.querySelectorAll<HTMLElement>(".shared-delete-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const id = btn.dataset.id;
|
|
if (id) this.deleteSharedDesign(id);
|
|
});
|
|
});
|
|
|
|
// Dither tab
|
|
this.shadow.querySelector(".dither-design-select")?.addEventListener("change", (e) => {
|
|
this.ditherDesignSlug = (e.target as HTMLSelectElement).value;
|
|
this.ditherPreviewUrl = "";
|
|
this.separationData = null;
|
|
this.render();
|
|
});
|
|
this.shadow.querySelector(".dither-algo-select")?.addEventListener("change", (e) => {
|
|
this.ditherAlgorithm = (e.target as HTMLSelectElement).value;
|
|
});
|
|
this.shadow.querySelector(".dither-colors-range")?.addEventListener("input", (e) => {
|
|
this.ditherNumColors = parseInt((e.target as HTMLInputElement).value, 10);
|
|
const span = this.shadow.querySelector(".range-value");
|
|
if (span) span.textContent = String(this.ditherNumColors);
|
|
});
|
|
this.shadow.querySelector(".apply-dither-btn")?.addEventListener("click", () => this.applyDither());
|
|
this.shadow.querySelector(".generate-separations-btn")?.addEventListener("click", () => this.generateSeparations());
|
|
}
|
|
|
|
// ──── Demo mode rendering (preserved from original) ────
|
|
|
|
private renderDemo() {
|
|
const p = this.getDemoProduct();
|
|
const isApparel = p.id === "tee" || p.id === "hoodie";
|
|
|
|
this.shadow.innerHTML = `
|
|
<style>${this.getDemoStyles()}</style>
|
|
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:4px"><button id="btn-tour" style="background:transparent;border:1px solid rgba(255,255,255,0.15);color:var(--rs-text-secondary,#94a3b8);border-radius:6px;padding:2px 10px;font-size:0.78rem;cursor:pointer">Tour</button></div>
|
|
|
|
<!-- Step indicators -->
|
|
<div class="steps-bar">
|
|
${[
|
|
{ n: 1, label: "Product" },
|
|
{ n: 2, label: "Design" },
|
|
{ n: 3, label: "Generate" },
|
|
{ n: 4, label: "Pipeline" },
|
|
].map(s => `<div class="step-dot ${this.demoStep >= s.n ? 'active' : ''} ${this.demoStep === s.n ? 'current' : ''}">
|
|
<span class="step-num">${this.demoStep > s.n ? '✓' : s.n}</span>
|
|
<span class="step-label">${s.label}</span>
|
|
</div>`).join('<div class="step-line"></div>')}
|
|
</div>
|
|
|
|
<!-- Step 1: Product Selection -->
|
|
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
|
|
<div class="products">
|
|
${DEMO_PRODUCTS.map(dp => `
|
|
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}" data-collab-id="product:${dp.id}">
|
|
<div class="product-icon">${this.productIcon(dp.id)}</div>
|
|
<div class="product-name">${dp.name}</div>
|
|
<div class="product-specs">${dp.printArea}</div>
|
|
<div class="product-cost">${dp.baseCost}</div>
|
|
${dp.printful ? '<span class="badge badge-printful">Printful</span>' : '<span class="badge badge-local">cosmolocal only</span>'}
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
|
|
${isApparel && p.sizes ? `
|
|
<div class="option-row">
|
|
<span class="option-label">Size</span>
|
|
<div class="pills">
|
|
${p.sizes.map(s => `<button class="pill ${this.selectedSize === s ? 'active' : ''}" data-size="${s}">${s}</button>`).join("")}
|
|
</div>
|
|
</div>` : ""}
|
|
|
|
${isApparel && p.colors ? `
|
|
<div class="option-row">
|
|
<span class="option-label">Color</span>
|
|
<div class="color-swatches">
|
|
${p.colors.map(c => `<button class="swatch ${this.selectedColor === c.id ? 'active' : ''}" data-color="${c.id}" style="background:${c.hex}" title="${c.name}"></button>`).join("")}
|
|
</div>
|
|
</div>` : ""}
|
|
</section>
|
|
|
|
<!-- Step 2: Design Mockup -->
|
|
<section class="step-section visible">
|
|
<div class="mockup-area">
|
|
<div class="mockup-svg">${this.getMockupSvg()}</div>
|
|
<div class="mockup-info">
|
|
<h3 class="mockup-title">${this.esc(this.designTitle || this.getAutoTitle())}</h3>
|
|
<div class="mockup-meta">${p.name} ${this.selectedSize ? `/ ${this.selectedSize}` : ""} ${this.selectedColor && p.colors ? `/ ${p.colors.find(c => c.id === this.selectedColor)?.name || ""}` : ""}</div>
|
|
${!this.usedSampleDesign ? `<button class="btn btn-secondary sample-btn">Use Sample Design</button>` : '<span class="badge badge-ok">Sample design loaded</span>'}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
${this.usedSampleDesign && this.demoStep < 3 ? `
|
|
<div class="generate-row">
|
|
<button class="btn btn-primary generate-btn">Generate Print-Ready Files</button>
|
|
</div>` : ""}
|
|
|
|
${this.demoStep >= 3 ? this.renderStep3() : ""}
|
|
${this.demoStep >= 4 ? this.renderStep4() : ""}
|
|
`;
|
|
|
|
this.bindDemoEvents();
|
|
}
|
|
|
|
private renderStep3(): string {
|
|
const progressLabels = ["Processing image...", "Generating artifact...", "Matching providers...", "Done!"];
|
|
let html = `<section class="step-section visible">`;
|
|
html += `<div class="progress-bar"><div class="progress-fill" style="width:${this.progressStep * 25}%"></div></div>
|
|
<div class="progress-steps">${progressLabels.map((label, i) => `<span class="prog-label ${this.progressStep > i ? 'done' : this.progressStep === i + 1 ? 'active' : ''}">${this.progressStep > i + 1 ? '✓ ' : ''}${label}</span>`).join("")}</div>`;
|
|
|
|
if (this.demoStep >= 4 && this.artifact) {
|
|
const providers = this.getMatchedProviders();
|
|
const selectedProvider = providers[0];
|
|
const unitCost = selectedProvider?.unitCost || 9.25;
|
|
const provAmt = (unitCost * 0.5).toFixed(2);
|
|
const creatorAmt = (unitCost * 0.35).toFixed(2);
|
|
const communityAmt = (unitCost * 0.15).toFixed(2);
|
|
|
|
html += `
|
|
<div class="artifact-card">
|
|
<h3 class="artifact-heading">Artifact Envelope</h3>
|
|
<div class="artifact-grid">
|
|
<div class="artifact-field"><span class="af-label">Product</span><span class="af-value">${this.esc(this.artifact.spec.product_type)}</span></div>
|
|
<div class="artifact-field"><span class="af-label">Dimensions</span><span class="af-value">${this.artifact.spec.dimensions.width_mm}x${this.artifact.spec.dimensions.height_mm}mm</span></div>
|
|
<div class="artifact-field"><span class="af-label">DPI</span><span class="af-value">${this.artifact.spec.dpi}</span></div>
|
|
<div class="artifact-field"><span class="af-label">Color Space</span><span class="af-value">${this.artifact.spec.color_space}</span></div>
|
|
<div class="artifact-field"><span class="af-label">Capabilities</span><span class="af-value">${(this.artifact.spec.required_capabilities || []).join(", ")}</span></div>
|
|
<div class="artifact-field"><span class="af-label">Substrates</span><span class="af-value">${(this.artifact.spec.substrates || []).join(", ")}</span></div>
|
|
</div>
|
|
<div class="artifact-targets">${Object.values(this.artifact.render_targets).map((t: any) => `<span class="target-chip">${t.label}: ${t.dimensions}</span>`).join("")}</div>
|
|
<div class="artifact-actions">${(this.artifact.next_actions || []).map((a: any) => `<span class="action-chip">${a.label}</span>`).join("")}</div>
|
|
</div>`;
|
|
|
|
html += `
|
|
<div class="provider-section">
|
|
<h3 class="provider-heading">Provider Matching <span class="buyer-loc">(buyer: Berlin)</span></h3>
|
|
<div class="provider-table">
|
|
<div class="pt-header"><span>Provider</span><span>Type</span><span>City</span><span>Distance</span><span>Cost</span><span>Turnaround</span></div>
|
|
${providers.map((prov, i) => `
|
|
<div class="pt-row ${i === 0 ? 'nearest' : ''}">
|
|
<span class="pt-name">${prov.name}</span>
|
|
<span><span class="badge ${prov.type === 'cosmolocal' ? 'badge-cosmo' : 'badge-global'}">${prov.type}</span></span>
|
|
<span>${prov.city}</span>
|
|
<span>${prov.distance === Infinity ? '--' : `~${prov.distance.toLocaleString()} km`}</span>
|
|
<span class="pt-cost">$${prov.unitCost.toFixed(2)}</span>
|
|
<span>${prov.turnaround}</span>
|
|
</div>`).join("")}
|
|
</div>
|
|
</div>`;
|
|
|
|
html += `
|
|
<div class="split-section">
|
|
<h4 class="split-heading">Revenue Split <span class="split-total">(from $${unitCost.toFixed(2)} unit cost)</span></h4>
|
|
<div class="split-bar">
|
|
<div class="split-seg split-provider" style="flex:50">Provider 50% <span class="split-amt">$${provAmt}</span></div>
|
|
<div class="split-seg split-creator" style="flex:35">Creator 35% <span class="split-amt">$${creatorAmt}</span></div>
|
|
<div class="split-seg split-community" style="flex:15"><span class="split-amt">$${communityAmt}</span></div>
|
|
</div>
|
|
<div class="split-legend"><span>Community 15%</span></div>
|
|
</div>`;
|
|
}
|
|
html += `</section>`;
|
|
return html;
|
|
}
|
|
|
|
private renderStep4(): string {
|
|
return `
|
|
<section class="step-section visible">
|
|
<h3 class="pipeline-heading">Print-on-Demand Pipeline</h3>
|
|
<div class="pipeline">
|
|
<div class="pipe-node done"><div class="pipe-icon">${this.productIcon("tee")}</div><div class="pipe-label">rSwag Design</div><span class="pipe-check">✓</span></div>
|
|
<div class="pipe-arrow">→</div>
|
|
<div class="pipe-node done"><div class="pipe-icon"><svg viewBox="0 0 20 20" width="24" height="24"><rect x="2" y="4" width="16" height="12" rx="2" fill="none" stroke="#818cf8" stroke-width="1.5"/><line x1="6" y1="8" x2="14" y2="8" stroke="#818cf8" stroke-width="1"/><line x1="6" y1="11" x2="12" y2="11" stroke="#818cf8" stroke-width="1"/></svg></div><div class="pipe-label">Artifact Spec</div><span class="pipe-check">✓</span></div>
|
|
<div class="pipe-arrow">→</div>
|
|
<div class="pipe-node done"><div class="pipe-icon"><svg viewBox="0 0 20 20" width="24" height="24"><rect x="3" y="3" width="14" height="14" rx="3" fill="none" stroke="#818cf8" stroke-width="1.5"/><circle cx="10" cy="10" r="3" fill="#818cf8"/></svg></div><div class="pipe-label">rCart Catalog</div><span class="pipe-check">✓</span></div>
|
|
<div class="pipe-arrow">→</div>
|
|
<div class="pipe-node done"><div class="pipe-icon"><svg viewBox="0 0 20 20" width="24" height="24"><circle cx="10" cy="8" r="4" fill="none" stroke="#818cf8" stroke-width="1.5"/><path d="M4,18 Q4,13 10,13 Q16,13 16,18" fill="none" stroke="#818cf8" stroke-width="1.5"/></svg></div><div class="pipe-label">Provider Match</div><span class="pipe-check">✓</span></div>
|
|
<div class="pipe-arrow">→</div>
|
|
<div class="pipe-node done"><div class="pipe-icon"><svg viewBox="0 0 20 20" width="24" height="24"><rect x="3" y="5" width="14" height="10" rx="2" fill="none" stroke="#818cf8" stroke-width="1.5"/><path d="M3,8 L10,12 L17,8" fill="none" stroke="#818cf8" stroke-width="1.5"/></svg></div><div class="pipe-label">Local Print</div><span class="pipe-check">✓</span></div>
|
|
</div>
|
|
<div class="pipeline-actions">
|
|
<a class="btn btn-primary" href="/rcart?tab=catalog">Send to rCart</a>
|
|
<button class="btn btn-secondary json-toggle-btn">View Artifact JSON</button>
|
|
</div>
|
|
<pre class="json-pre">${this.esc(JSON.stringify(this.artifact, null, 2))}</pre>
|
|
</section>`;
|
|
}
|
|
|
|
private productIcon(id: string): string {
|
|
const icons: Record<string, string> = {
|
|
tee: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M7,4 L4,6 L2,12 L5,13 L6,9 L6,22 L18,22 L18,9 L19,13 L22,12 L20,6 L17,4 L15,6 Q14,8 12,8 Q10,8 9,6 Z" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
|
|
sticker: `<svg viewBox="0 0 24 24" width="28" height="28"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="#818cf8" stroke-width="1.5"/><circle cx="12" cy="12" r="5" fill="none" stroke="#818cf8" stroke-width="1.5"/></svg>`,
|
|
poster: `<svg viewBox="0 0 24 24" width="28" height="28"><rect x="4" y="2" width="16" height="20" rx="2" fill="none" stroke="#818cf8" stroke-width="1.5"/><line x1="8" y1="7" x2="16" y2="7" stroke="#818cf8" stroke-width="1"/><line x1="8" y1="10" x2="16" y2="10" stroke="#818cf8" stroke-width="1"/><rect x="8" y="13" width="8" height="5" rx="1" fill="none" stroke="#818cf8" stroke-width="1"/></svg>`,
|
|
hoodie: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M7,5 L3,7 L1,14 L4,15 L5,10 L5,22 L19,22 L19,10 L20,15 L23,14 L21,7 L17,5 L15,7 Q14,9 12,9 Q10,9 9,7 Z" fill="none" stroke="#818cf8" stroke-width="1.5" stroke-linejoin="round"/><ellipse cx="12" cy="6" rx="3" ry="2" fill="none" stroke="#818cf8" stroke-width="1"/></svg>`,
|
|
};
|
|
return icons[id] || icons.tee;
|
|
}
|
|
|
|
private getMockupSvg(): string {
|
|
const colorHex = this.getDemoProduct().colors?.find(c => c.id === this.selectedColor)?.hex || "#0a0a0a";
|
|
switch (this.selectedProduct) {
|
|
case "tee": return teeMockupSvg(colorHex);
|
|
case "hoodie": return hoodieMockupSvg(colorHex);
|
|
case "sticker": return stickerMockupSvg();
|
|
case "poster": return posterMockupSvg();
|
|
default: return teeMockupSvg(colorHex);
|
|
}
|
|
}
|
|
|
|
private bindDemoEvents() {
|
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
|
this.shadow.querySelectorAll(".product").forEach(el => {
|
|
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));
|
|
});
|
|
this.shadow.querySelectorAll(".pill[data-size]").forEach(el => {
|
|
el.addEventListener("click", () => { this.selectedSize = (el as HTMLElement).dataset.size || "M"; this.render(); });
|
|
});
|
|
this.shadow.querySelectorAll(".swatch[data-color]").forEach(el => {
|
|
el.addEventListener("click", () => { this.selectedColor = (el as HTMLElement).dataset.color || "black"; this.render(); });
|
|
});
|
|
this.shadow.querySelector(".sample-btn")?.addEventListener("click", () => this.demoUseSample());
|
|
this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.demoGenerate());
|
|
this.shadow.querySelector(".json-toggle-btn")?.addEventListener("click", () => {
|
|
this.shadow.querySelector(".json-pre")?.classList.toggle("visible");
|
|
});
|
|
}
|
|
|
|
// ── Styles ──
|
|
|
|
private getFullStyles(): string {
|
|
return `
|
|
:host { display: block; padding: 1.5rem; max-width: 960px; margin: 0 auto; }
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
/* Live indicator */
|
|
.live-header { display: flex; align-items: center; gap: 8px; margin-bottom: 0.5rem; }
|
|
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse-dot 2s infinite; }
|
|
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.live-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 2px; }
|
|
|
|
/* Tab bar */
|
|
.tab-bar { display: flex; gap: 0; border-bottom: 2px solid var(--rs-border, #334155); margin-bottom: 1.5rem; }
|
|
.tab-btn { padding: 0.625rem 1.25rem; border: none; background: none; color: var(--rs-text-secondary, #94a3b8); font-size: 0.875rem; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s; font-family: inherit; }
|
|
.tab-btn:hover { color: var(--rs-text-primary, #e2e8f0); }
|
|
.tab-btn.active { color: var(--rs-primary, #6366f1); border-bottom-color: var(--rs-primary, #6366f1); font-weight: 600; }
|
|
|
|
/* Common */
|
|
.section-title { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin: 0 0 0.375rem; }
|
|
.section-desc { color: var(--rs-text-secondary); font-size: 0.8125rem; margin: 0 0 1rem; }
|
|
.loading { text-align: center; color: var(--rs-text-muted); padding: 2rem; }
|
|
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--rs-text-secondary); }
|
|
.empty-state p { margin: 0 0 1rem; }
|
|
.error { background: rgba(239,68,68,0.1); border: 1px solid var(--rs-error, #ef4444); border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
|
|
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.625rem; font-weight: 600; }
|
|
.badge-stickers { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-apparel { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
|
.badge-prints { background: rgba(249,115,22,0.15); color: #fb923c; }
|
|
.badge-draft { background: rgba(100,116,139,0.15); color: #94a3b8; }
|
|
.badge-active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-source { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
|
.badge-cosmo { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-global { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
|
.badge-printful { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
|
.badge-local { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-ok { background: rgba(34,197,94,0.15); color: #4ade80; padding: 0.25rem 0.75rem; font-size: 0.75rem; }
|
|
|
|
/* Buttons */
|
|
.btn { padding: 0.625rem 1.25rem; border-radius: 8px; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; transition: all 0.15s; font-family: inherit; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.btn-primary { background: var(--rs-primary-hover, #4f46e5); color: #fff; }
|
|
.btn-primary:hover:not(:disabled) { background: #4338ca; }
|
|
.btn-secondary { background: var(--rs-bg-surface-raised, #1e293b); color: var(--rs-text-primary, #e2e8f0); }
|
|
.btn-secondary:hover { background: var(--rs-bg-hover, #334155); }
|
|
.btn-danger { background: rgba(239,68,68,0.15); color: #fca5a5; }
|
|
.btn-danger:hover { background: rgba(239,68,68,0.25); }
|
|
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
|
|
|
/* Browse tab */
|
|
.browse-controls { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
|
|
.search-input, .category-select { padding: 0.5rem 0.75rem; border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #e2e8f0); font-size: 0.875rem; font-family: inherit; }
|
|
.search-input { flex: 1; }
|
|
.search-input:focus, .category-select:focus { outline: none; border-color: var(--rs-primary, #6366f1); }
|
|
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
|
.catalog-card { background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 12px; overflow: hidden; transition: border-color 0.15s; }
|
|
.catalog-card:hover { border-color: var(--rs-primary, #6366f1); }
|
|
.catalog-img { height: 180px; background-size: cover; background-position: center; background-color: var(--rs-bg-page, #0f172a); }
|
|
.catalog-body { padding: 0.75rem; }
|
|
.catalog-name { font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary); margin-bottom: 0.375rem; }
|
|
.catalog-meta { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.375rem; }
|
|
.catalog-price { font-weight: 600; color: #4ade80; font-size: 0.875rem; }
|
|
.catalog-variants { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
|
.variant-chip { padding: 0.125rem 0.375rem; border-radius: 4px; font-size: 0.625rem; background: var(--rs-bg-page); color: var(--rs-text-muted); border: 1px solid var(--rs-border); }
|
|
|
|
/* Create tab */
|
|
.create-modes { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.mode-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); font-size: 0.8125rem; cursor: pointer; transition: all 0.15s; font-family: inherit; }
|
|
.mode-btn:hover { border-color: var(--rs-border-strong); }
|
|
.mode-btn.active { border-color: var(--rs-primary); background: rgba(99,102,241,0.1); color: var(--rs-text-primary); }
|
|
.create-section { margin-bottom: 1.5rem; }
|
|
.input-field { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid var(--rs-input-border, #334155); border-radius: 8px; background: var(--rs-input-bg, #0f172a); color: var(--rs-input-text, #e2e8f0); font-size: 0.875rem; margin-bottom: 0.75rem; box-sizing: border-box; font-family: inherit; }
|
|
.input-field:focus { outline: none; border-color: var(--rs-primary); }
|
|
textarea.input-field { resize: vertical; min-height: 80px; }
|
|
.upload-area { border: 2px dashed var(--rs-border); border-radius: 12px; padding: 2rem; text-align: center; margin-bottom: 0.75rem; cursor: pointer; transition: border-color 0.15s; background: var(--rs-bg-surface); }
|
|
.upload-area:hover { border-color: var(--rs-primary); }
|
|
.upload-area.has-image { border-style: solid; border-color: var(--rs-border-strong); }
|
|
.upload-label { color: var(--rs-text-secondary); font-size: 0.875rem; }
|
|
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
|
|
input[type="file"] { display: none; }
|
|
|
|
/* Designs grid */
|
|
.designs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; }
|
|
.design-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; transition: border-color 0.15s; }
|
|
.design-card:hover { border-color: var(--rs-primary); }
|
|
.design-img { height: 140px; background-size: cover; background-position: center; background-color: var(--rs-bg-page); }
|
|
.design-card-icon { font-size: 1.5rem; padding: 1rem; text-align: center; }
|
|
.design-body { padding: 0.75rem; }
|
|
.design-name { font-weight: 600; font-size: 0.8125rem; color: var(--rs-text-primary); margin-bottom: 0.25rem; }
|
|
.design-meta { display: flex; gap: 0.375rem; margin-bottom: 0.5rem; }
|
|
.design-actions { display: flex; gap: 0.375rem; flex-wrap: wrap; }
|
|
|
|
/* Dither tab */
|
|
.dither-section { }
|
|
.dither-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
|
|
.control-group { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.control-group label { font-size: 0.75rem; color: var(--rs-text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.control-group select { padding: 0.5rem; border: 1px solid var(--rs-input-border); border-radius: 6px; background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.8125rem; font-family: inherit; }
|
|
.control-group select:focus { outline: none; border-color: var(--rs-primary); }
|
|
.control-group input[type="range"] { width: 100%; accent-color: var(--rs-primary); }
|
|
.range-value { font-size: 0.75rem; color: var(--rs-text-secondary); font-weight: 600; }
|
|
.dither-actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; }
|
|
.dither-preview, .separations-preview { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; margin-bottom: 1rem; }
|
|
.dither-preview h4, .separations-preview h4 { margin: 0 0 0.75rem; color: var(--rs-text-primary); font-size: 0.9375rem; }
|
|
.preview-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; }
|
|
.preview-original, .preview-dithered { text-align: center; }
|
|
.preview-original img, .preview-dithered img { max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--rs-border); }
|
|
.preview-original span, .preview-dithered span { display: block; font-size: 0.6875rem; color: var(--rs-text-muted); margin-top: 0.25rem; }
|
|
.preview-arrow { font-size: 1.5rem; color: var(--rs-text-muted); }
|
|
.palette-row { display: flex; align-items: center; gap: 0.375rem; margin-bottom: 0.75rem; }
|
|
.palette-label { font-size: 0.75rem; color: var(--rs-text-muted); }
|
|
.palette-swatch { display: inline-block; width: 20px; height: 20px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.2); }
|
|
.sep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.5rem; }
|
|
.sep-card { text-align: center; }
|
|
.sep-card img { width: 100%; border-radius: 6px; border: 1px solid var(--rs-border); }
|
|
.sep-card span { display: block; font-size: 0.6875rem; color: var(--rs-text-muted); margin-top: 0.25rem; }
|
|
.sep-color { display: flex; align-items: center; justify-content: center; gap: 0.25rem; }
|
|
|
|
/* Orders tab */
|
|
.orders-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.order-card { display: flex; align-items: center; gap: 1rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 0.75rem; }
|
|
.order-id { font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary); }
|
|
.order-tracking { font-size: 0.8125rem; color: var(--rs-primary); }
|
|
|
|
@media (max-width: 768px) {
|
|
.tab-btn { padding: 0.5rem 0.75rem; font-size: 0.75rem; }
|
|
.product-grid, .designs-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
|
.dither-controls { grid-template-columns: 1fr; }
|
|
.preview-row { flex-direction: column; }
|
|
.browse-controls { flex-direction: column; }
|
|
}
|
|
`;
|
|
}
|
|
|
|
private getDemoStyles(): string {
|
|
return `
|
|
:host { display: block; padding: 1.5rem; max-width: 960px; margin: 0 auto; }
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
.steps-bar { display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 2rem; }
|
|
.step-dot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
|
.step-num { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 600; border: 2px solid var(--rs-border); color: var(--rs-text-muted); background: var(--rs-bg-surface); transition: all 0.2s; }
|
|
.step-dot.active .step-num { border-color: var(--rs-primary); color: #fff; background: var(--rs-primary-hover); }
|
|
.step-dot.current .step-num { box-shadow: 0 0 0 3px rgba(99,102,241,0.3); }
|
|
.step-label { font-size: 0.6875rem; color: var(--rs-text-muted); }
|
|
.step-dot.active .step-label { color: #a5b4fc; }
|
|
.step-line { width: 40px; height: 2px; background: var(--rs-border); margin: 0 0.5rem; margin-bottom: 1rem; }
|
|
.products { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
|
|
.product { padding: 0.875rem 0.5rem; border-radius: 12px; border: 2px solid var(--rs-border); background: var(--rs-bg-surface); cursor: pointer; text-align: center; transition: all 0.15s; }
|
|
.product:hover { border-color: var(--rs-border-strong); }
|
|
.product.active { border-color: var(--rs-primary); background: rgba(99,102,241,0.1); }
|
|
.product-icon { margin-bottom: 0.375rem; display: flex; justify-content: center; }
|
|
.product-name { color: var(--rs-text-primary); font-weight: 600; font-size: 0.8125rem; }
|
|
.product-specs { color: var(--rs-text-muted); font-size: 0.6875rem; margin-top: 0.125rem; }
|
|
.product-cost { color: var(--rs-text-secondary); font-size: 0.75rem; margin-top: 0.25rem; }
|
|
.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.625rem; font-weight: 600; margin-top: 0.375rem; }
|
|
.badge-printful { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
|
.badge-local { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-cosmo { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-global { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
|
.badge-ok { background: rgba(34,197,94,0.15); color: #4ade80; padding: 0.25rem 0.75rem; font-size: 0.75rem; }
|
|
.option-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
|
.option-label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; min-width: 40px; }
|
|
.pills { display: flex; gap: 0.375rem; flex-wrap: wrap; }
|
|
.pill { padding: 0.375rem 0.75rem; border-radius: 999px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); font-size: 0.75rem; cursor: pointer; transition: all 0.15s; }
|
|
.pill:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
|
|
.pill.active { border-color: var(--rs-primary); background: rgba(99,102,241,0.15); color: #fff; }
|
|
.color-swatches { display: flex; gap: 0.5rem; }
|
|
.swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--rs-border); cursor: pointer; transition: all 0.15s; }
|
|
.swatch:hover { border-color: var(--rs-border-strong); }
|
|
.swatch.active { border-color: var(--rs-primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.3); }
|
|
.mockup-area { display: flex; gap: 1.5rem; align-items: center; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
.mockup-svg { flex-shrink: 0; }
|
|
.mockup-svg svg { width: 180px; height: auto; }
|
|
.mockup-info { flex: 1; }
|
|
.mockup-title { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
|
|
.mockup-meta { color: var(--rs-text-secondary); font-size: 0.8125rem; margin-bottom: 0.75rem; }
|
|
.btn { padding: 0.625rem 1.25rem; border-radius: 8px; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; transition: all 0.15s; font-family: inherit; }
|
|
.btn-primary { background: var(--rs-primary-hover); color: #fff; }
|
|
.btn-primary:hover { background: #4338ca; }
|
|
.btn-secondary { background: var(--rs-bg-surface-raised); color: var(--rs-text-primary); }
|
|
.btn-secondary:hover { background: var(--rs-bg-hover); }
|
|
.generate-row { text-align: center; margin-bottom: 1.5rem; }
|
|
.step-section { margin-bottom: 1.5rem; }
|
|
.step-section:not(.visible) { display: none; }
|
|
.progress-bar { height: 6px; background: var(--rs-bg-surface); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
|
|
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--rs-primary), var(--rs-primary-hover)); border-radius: 3px; transition: width 0.3s ease; }
|
|
.progress-steps { display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
|
|
.prog-label { font-size: 0.6875rem; color: var(--rs-text-muted); transition: color 0.2s; }
|
|
.prog-label.active { color: var(--rs-primary-hover); font-weight: 600; }
|
|
.prog-label.done { color: #4ade80; }
|
|
.artifact-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
|
|
.artifact-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 0.75rem; }
|
|
.artifact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
|
|
.artifact-field { display: flex; flex-direction: column; }
|
|
.af-label { color: var(--rs-text-muted); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.af-value { color: var(--rs-text-primary); font-size: 0.8125rem; }
|
|
.artifact-targets { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
|
.target-chip { background: rgba(99,102,241,0.1); color: #a5b4fc; padding: 0.25rem 0.625rem; border-radius: 6px; font-size: 0.6875rem; }
|
|
.artifact-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
.action-chip { background: var(--rs-bg-page); color: var(--rs-text-secondary); padding: 0.25rem 0.625rem; border-radius: 6px; font-size: 0.6875rem; border: 1px solid var(--rs-border); }
|
|
.provider-section { margin-bottom: 1rem; }
|
|
.provider-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 0.75rem; }
|
|
.buyer-loc { color: var(--rs-text-muted); font-weight: 400; font-size: 0.75rem; }
|
|
.provider-table { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; overflow: hidden; }
|
|
.pt-header, .pt-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1.2fr 0.8fr 1fr; padding: 0.625rem 1rem; font-size: 0.8125rem; align-items: center; }
|
|
.pt-header { background: var(--rs-bg-page); color: var(--rs-text-muted); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
|
.pt-row { color: var(--rs-text-primary); border-top: 1px solid var(--rs-border-subtle); }
|
|
.pt-row.nearest { background: rgba(99,102,241,0.05); border-left: 3px solid var(--rs-primary); }
|
|
.pt-name { font-weight: 500; }
|
|
.pt-cost { color: #4ade80; font-weight: 600; }
|
|
.split-section { margin-bottom: 1.5rem; }
|
|
.split-heading { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; margin: 0 0 0.5rem; }
|
|
.split-total { color: var(--rs-text-muted); font-weight: 400; }
|
|
.split-bar { display: flex; height: 32px; border-radius: 8px; overflow: hidden; font-size: 0.6875rem; font-weight: 600; }
|
|
.split-seg { display: flex; align-items: center; justify-content: center; gap: 0.25rem; color: #fff; }
|
|
.split-provider { background: #16a34a; }
|
|
.split-creator { background: #4f46e5; }
|
|
.split-community { background: #d97706; }
|
|
.split-amt { opacity: 0.85; }
|
|
.split-legend { text-align: right; margin-top: 0.25rem; }
|
|
.split-legend span { color: var(--rs-text-muted); font-size: 0.6875rem; }
|
|
.pipeline-heading { color: var(--rs-text-primary); font-weight: 600; font-size: 0.9375rem; margin: 0 0 1rem; text-align: center; }
|
|
.pipeline { display: flex; align-items: center; justify-content: center; gap: 0; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
|
.pipe-node { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; position: relative; min-width: 90px; }
|
|
.pipe-node.done { border-color: #22c55e; }
|
|
.pipe-icon { display: flex; align-items: center; justify-content: center; }
|
|
.pipe-label { color: var(--rs-text-primary); font-size: 0.6875rem; font-weight: 500; text-align: center; }
|
|
.pipe-check { position: absolute; top: -6px; right: -6px; background: #16a34a; color: #fff; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.5625rem; }
|
|
.pipe-arrow { color: var(--rs-text-muted); font-size: 1.25rem; margin: 0 0.25rem; margin-bottom: 0.75rem; }
|
|
.pipeline-actions { display: flex; gap: 0.75rem; justify-content: center; margin-bottom: 1rem; }
|
|
.json-pre { background: var(--rs-bg-page); border: 1px solid var(--rs-border); border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: var(--rs-text-secondary); max-height: 300px; display: none; white-space: pre-wrap; word-break: break-all; }
|
|
.json-pre.visible { display: block; }
|
|
@media (max-width: 768px) {
|
|
.products { grid-template-columns: repeat(2, 1fr); }
|
|
.mockup-area { flex-direction: column; text-align: center; }
|
|
.pt-header, .pt-row { grid-template-columns: 1.5fr 0.8fr 0.8fr 1fr 0.7fr 0.8fr; font-size: 0.6875rem; padding: 0.5rem; }
|
|
.artifact-grid { grid-template-columns: repeat(2, 1fr); }
|
|
.pipeline { gap: 0.25rem; }
|
|
.pipe-node { min-width: 70px; padding: 0.5rem; }
|
|
.pipe-label { font-size: 0.5625rem; }
|
|
}
|
|
`;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-swag-designer", FolkSwagDesigner);
|