rspace-online/modules/swag/components/folk-swag-designer.ts

843 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-swag-designer> — upload artwork → generate print-ready files.
* Product selector (sticker, poster, tee, hoodie), image upload with preview,
* generate button, artifact result display with download link.
*
* Demo mode: 4-step interactive flow with inline SVG mockups,
* provider matching, revenue splits, and pipeline visualization.
*/
// --- 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: "305×406mm", 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: "210×297mm", baseCost: "$1.20$1.50",
printful: true,
},
{
id: "poster", name: "Poster (A3)", printArea: "297×420mm", baseCost: "$4.50$7.00",
printful: false,
},
{
id: "hoodie", name: "Hoodie", printArea: "356×406mm", 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 — 297×420mm — 300 DPI</text>
</svg>`;
}
// --- Component ---
class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
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;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
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();
return;
}
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/swag` : "/demo/swag";
}
private 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: "/demo/cart" },
{ 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);
}
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();
} catch (e) {
this.error = e instanceof Error ? e.message : "Generation failed";
} finally {
this.generating = false;
this.render();
}
}
private render() {
if (this.space === "demo") {
this.renderDemo();
} else {
this.renderFull();
}
}
// ---- Demo mode rendering (4-step flow) ----
private renderDemo() {
const p = this.getDemoProduct();
const isApparel = p.id === "tee" || p.id === "hoodie";
this.shadow.innerHTML = `
<style>${this.getDemoStyles()}</style>
<!-- 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 ? '&#10003;' : 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}">
<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 ${this.demoStep >= 2 ? '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>
<!-- Generate button (between step 2 and 3) -->
${this.demoStep === 2 && this.usedSampleDesign ? `
<div class="generate-row">
<button class="btn btn-primary generate-btn">Generate Print-Ready Files</button>
</div>` : ""}
<!-- Step 3: Progress + Artifact + Providers -->
${this.demoStep >= 3 ? this.renderStep3() : ""}
<!-- Step 4: Pipeline Visualization -->
${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">`;
// Progress bar (always show)
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 ? '&#10003; ' : ''}${label}</span>`).join("")}
</div>`;
// Only show artifact + providers once done
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);
// Artifact card
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}×${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>`;
// Provider match table
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>`;
// Revenue split bar
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">&#10003;</span></div>
<div class="pipe-arrow">&rarr;</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">&#10003;</span></div>
<div class="pipe-arrow">&rarr;</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">&#10003;</span></div>
<div class="pipe-arrow">&rarr;</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">&#10003;</span></div>
<div class="pipe-arrow">&rarr;</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">&#10003;</span></div>
</div>
<div class="pipeline-actions">
<a class="btn btn-primary" href="/demo/cart">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() {
// Product selection
this.shadow.querySelectorAll(".product").forEach(el => {
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));
});
// Size pills
this.shadow.querySelectorAll(".pill[data-size]").forEach(el => {
el.addEventListener("click", () => { this.selectedSize = (el as HTMLElement).dataset.size || "M"; this.render(); });
});
// Color swatches
this.shadow.querySelectorAll(".swatch[data-color]").forEach(el => {
el.addEventListener("click", () => { this.selectedColor = (el as HTMLElement).dataset.color || "black"; this.render(); });
});
// Sample design button
this.shadow.querySelector(".sample-btn")?.addEventListener("click", () => this.demoUseSample());
// Generate button
this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.demoGenerate());
// JSON toggle
this.shadow.querySelector(".json-toggle-btn")?.addEventListener("click", () => {
const pre = this.shadow.querySelector(".json-pre");
pre?.classList.toggle("visible");
});
}
// ---- Full (non-demo) rendering ----
private renderFull() {
const products = [
{ id: "sticker", name: "Sticker Sheet", icon: "📋", desc: "A4 vinyl stickers" },
{ id: "poster", name: "Poster (A3)", icon: "🖼", desc: "A3 art print" },
{ id: "tee", name: "T-Shirt", icon: "👕", desc: '12x16" DTG print' },
{ id: "hoodie", name: "Hoodie", icon: "🧥", desc: '14x16" DTG print' },
];
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.products { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
.product { padding: 1rem; border-radius: 12px; border: 2px solid #334155; background: #1e293b; cursor: pointer; text-align: center; transition: all 0.15s; }
.product:hover { border-color: #475569; }
.product.active { border-color: #6366f1; background: rgba(99,102,241,0.1); }
.product-icon { font-size: 2rem; margin-bottom: 0.375rem; }
.product-name { color: #f1f5f9; font-weight: 600; font-size: 0.875rem; }
.product-desc { color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }
.upload-area { border: 2px dashed #334155; border-radius: 12px; padding: 2rem; text-align: center; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.15s; background: #1e293b; }
.upload-area:hover { border-color: #6366f1; }
.upload-area.has-image { border-style: solid; border-color: #475569; }
.upload-label { color: #94a3b8; font-size: 0.875rem; }
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
.title-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
.title-input:focus { outline: none; border-color: #6366f1; }
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: #4f46e5; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; }
.generate-btn:hover { background: #4338ca; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error { background: rgba(239,68,68,0.1); border: 1px solid #ef4444; border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
.result { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.result-title { color: #f1f5f9; font-weight: 600; margin: 0 0 0.5rem; }
.result-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 1rem; }
.result-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.result-btn { padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.875rem; text-decoration: none; font-weight: 500; cursor: pointer; border: none; }
.result-btn-primary { background: #4f46e5; color: #fff; }
.result-btn-primary:hover { background: #4338ca; }
.result-btn-secondary { background: #334155; color: #f1f5f9; }
.result-btn-secondary:hover { background: #475569; }
.json-toggle { color: #818cf8; cursor: pointer; font-size: 0.75rem; margin-top: 0.75rem; display: inline-block; }
.json-pre { background: #0f172a; border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: #94a3b8; margin-top: 0.5rem; max-height: 300px; display: none; }
.json-pre.visible { display: block; }
input[type="file"] { display: none; }
@media (max-width: 768px) {
.products { grid-template-columns: repeat(2, 1fr); }
}
</style>
<div class="products">
${products.map((p) => `
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
<div class="product-icon">${p.icon}</div>
<div class="product-name">${p.name}</div>
<div class="product-desc">${p.desc}</div>
</div>
`).join("")}
</div>
<div class="upload-area ${this.imagePreview ? 'has-image' : ''}">
${this.imagePreview
? `<img class="preview-img" src="${this.imagePreview}" alt="Preview">`
: `<div class="upload-label">Click or drag to upload artwork (PNG, JPG, SVG)</div>`}
<input type="file" accept="image/*">
</div>
<input class="title-input" type="text" placeholder="Design title" value="${this.esc(this.designTitle)}">
<button class="generate-btn" ${!this.imageFile || this.generating ? 'disabled' : ''}>
${this.generating ? 'Generating...' : 'Generate Print-Ready Files'}
</button>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.artifact ? `
<div class="result">
<h3 class="result-title">${this.esc(this.artifact.payload?.title || this.artifact.title || "Artifact")}</h3>
<div class="result-meta">
${this.esc(this.artifact.spec?.product_type || "")} &bull;
${this.artifact.spec?.dimensions?.width_mm}x${this.artifact.spec?.dimensions?.height_mm}mm &bull;
${this.artifact.spec?.dpi}dpi
</div>
<div class="result-actions">
${Object.entries(this.artifact.render_targets || {}).map(([key, target]: [string, any]) => `
<a class="result-btn result-btn-primary" href="${target.url}" target="_blank">Download ${target.format.toUpperCase()}</a>
`).join("")}
<button class="result-btn result-btn-secondary" data-action="copy-json">Copy Artifact JSON</button>
</div>
<span class="json-toggle">Show artifact envelope</span>
<pre class="json-pre">${this.esc(JSON.stringify(this.artifact, null, 2))}</pre>
</div>` : ""}
`;
// Event listeners
this.shadow.querySelectorAll(".product").forEach((el) => {
el.addEventListener("click", () => {
this.selectedProduct = (el as HTMLElement).dataset.product || "sticker";
this.render();
});
});
const uploadArea = this.shadow.querySelector(".upload-area");
const fileInput = this.shadow.querySelector('input[type="file"]') as HTMLInputElement;
uploadArea?.addEventListener("click", () => fileInput?.click());
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
this.imageFile = file;
this.imagePreview = URL.createObjectURL(file);
this.render();
}
});
this.shadow.querySelector(".title-input")?.addEventListener("input", (e) => {
this.designTitle = (e.target as HTMLInputElement).value;
});
this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.generate());
this.shadow.querySelector(".json-toggle")?.addEventListener("click", () => {
const pre = this.shadow.querySelector(".json-pre");
pre?.classList.toggle("visible");
});
this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2));
});
}
private getDemoStyles(): string {
return `
:host { display: block; padding: 1.5rem; max-width: 960px; margin: 0 auto; }
*, *::before, *::after { box-sizing: border-box; }
/* Step bar */
.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 #334155; color: #64748b; background: #1e293b; transition: all 0.2s; }
.step-dot.active .step-num { border-color: #6366f1; color: #fff; background: #4f46e5; }
.step-dot.current .step-num { box-shadow: 0 0 0 3px rgba(99,102,241,0.3); }
.step-label { font-size: 0.6875rem; color: #64748b; }
.step-dot.active .step-label { color: #a5b4fc; }
.step-line { width: 40px; height: 2px; background: #334155; margin: 0 0.5rem; margin-bottom: 1rem; }
/* Product grid */
.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 #334155; background: #1e293b; cursor: pointer; text-align: center; transition: all 0.15s; }
.product:hover { border-color: #475569; }
.product.active { border-color: #6366f1; background: rgba(99,102,241,0.1); }
.product-icon { margin-bottom: 0.375rem; display: flex; justify-content: center; }
.product-name { color: #f1f5f9; font-weight: 600; font-size: 0.8125rem; }
.product-specs { color: #64748b; font-size: 0.6875rem; margin-top: 0.125rem; }
.product-cost { color: #94a3b8; font-size: 0.75rem; margin-top: 0.25rem; }
/* Badges */
.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; }
/* Options */
.option-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
.option-label { color: #94a3b8; 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 #334155; background: #1e293b; color: #94a3b8; font-size: 0.75rem; cursor: pointer; transition: all 0.15s; }
.pill:hover { border-color: #475569; color: #f1f5f9; }
.pill.active { border-color: #6366f1; 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 #334155; cursor: pointer; transition: all 0.15s; }
.swatch:hover { border-color: #475569; }
.swatch.active { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,0.3); }
/* Mockup */
.mockup-area { display: flex; gap: 1.5rem; align-items: center; background: #1e293b; border: 1px solid #334155; 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: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
.mockup-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 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; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-primary:hover { background: #4338ca; }
.btn-secondary { background: #334155; color: #f1f5f9; }
.btn-secondary:hover { background: #475569; }
.generate-row { text-align: center; margin-bottom: 1.5rem; }
/* Step sections */
.step-section { margin-bottom: 1.5rem; }
.step-section:not(.visible) { display: none; }
/* Progress bar */
.progress-bar { height: 6px; background: #1e293b; border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #6366f1, #818cf8); 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: #475569; transition: color 0.2s; }
.prog-label.active { color: #818cf8; font-weight: 600; }
.prog-label.done { color: #4ade80; }
/* Artifact card */
.artifact-card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
.artifact-heading { color: #f1f5f9; 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: #64748b; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; }
.af-value { color: #e2e8f0; 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: #0f172a; color: #94a3b8; padding: 0.25rem 0.625rem; border-radius: 6px; font-size: 0.6875rem; border: 1px solid #334155; }
/* Provider table */
.provider-section { margin-bottom: 1rem; }
.provider-heading { color: #f1f5f9; font-weight: 600; font-size: 0.9375rem; margin: 0 0 0.75rem; }
.buyer-loc { color: #64748b; font-weight: 400; font-size: 0.75rem; }
.provider-table { background: #1e293b; border: 1px solid #334155; 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: #0f172a; color: #64748b; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.pt-row { color: #e2e8f0; border-top: 1px solid #1e293b; }
.pt-row.nearest { background: rgba(99,102,241,0.05); border-left: 3px solid #6366f1; }
.pt-name { font-weight: 500; }
.pt-cost { color: #4ade80; font-weight: 600; }
/* Split bar */
.split-section { margin-bottom: 1.5rem; }
.split-heading { color: #94a3b8; font-size: 0.8125rem; font-weight: 500; margin: 0 0 0.5rem; }
.split-total { color: #64748b; 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: #64748b; font-size: 0.6875rem; }
/* Pipeline */
.pipeline-heading { color: #f1f5f9; 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: #1e293b; border: 1px solid #334155; 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: #e2e8f0; 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: #475569; font-size: 1.25rem; margin: 0 0.25rem; margin-bottom: 0.75rem; }
/* Pipeline actions */
.pipeline-actions { display: flex; gap: 0.75rem; justify-content: center; margin-bottom: 1rem; }
/* JSON */
.json-pre { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: #94a3b8; 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);