rspace-online/modules/rcart/components/folk-cart-shop.ts

699 lines
31 KiB
TypeScript

/**
* <folk-cart-shop> — group shopping carts, cosmolocal catalog, and orders.
* Three-tab view: Carts (default) | Catalog | Orders
*/
import {
catalogSchema, catalogDocId, type CatalogDoc,
orderSchema, type OrderDoc,
shoppingCartIndexSchema, shoppingCartIndexDocId, type ShoppingCartIndexDoc,
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
} from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot;
private space = "default";
private catalog: any[] = [];
private orders: any[] = [];
private carts: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "orders" = "carts";
private selectedCartId: string | null = null;
private selectedCart: any = null;
private loading = true;
private addingUrl = false;
private contributingAmount = false;
private _offlineUnsubs: (() => void)[] = [];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
const attr = this.getAttribute("space");
if (attr) {
this.space = attr;
} else {
const parts = window.location.pathname.split("/").filter(Boolean);
this.space = parts.length >= 1 ? parts[0] : "default";
}
if (this.space === "demo") {
this.loadDemoData();
return;
}
this.render();
this.subscribeOffline();
this.loadData();
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
// Subscribe to catalog
const catDocId = catalogDocId(this.space) as DocumentId;
const catDoc = await runtime.subscribe(catDocId, catalogSchema);
if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) {
this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({
id: item.id, title: item.title, description: '',
product_type: item.productType, status: item.status,
tags: item.tags || [],
created_at: new Date(item.createdAt).toISOString(),
}));
this.loading = false;
this.render();
}
this._offlineUnsubs.push(runtime.onChange(catDocId, (doc: CatalogDoc) => {
if (doc?.items) {
this.catalog = Object.values(doc.items).map(item => ({
id: item.id, title: item.title, description: '',
product_type: item.productType, status: item.status,
tags: item.tags || [],
created_at: new Date(item.createdAt).toISOString(),
}));
this.render();
}
}));
// Subscribe to shopping cart index
const indexDocId = shoppingCartIndexDocId(this.space) as DocumentId;
const indexDoc = await runtime.subscribe(indexDocId, shoppingCartIndexSchema);
if (indexDoc?.carts && Object.keys(indexDoc.carts).length > 0) {
this.carts = Object.entries((indexDoc as ShoppingCartIndexDoc).carts).map(([id, c]) => ({ id, ...c }));
this.render();
}
this._offlineUnsubs.push(runtime.onChange(indexDocId, (doc: ShoppingCartIndexDoc) => {
if (doc?.carts) {
this.carts = Object.entries(doc.carts).map(([id, c]) => ({ id, ...c }));
this.render();
}
}));
// Subscribe to orders
const orderDocs = await runtime.subscribeModule('cart', 'orders', orderSchema);
if (orderDocs.size > 0 && this.orders.length === 0) {
const fromDocs: any[] = [];
for (const [, doc] of orderDocs) {
const d = doc as OrderDoc;
if (!d?.order) continue;
fromDocs.push({
id: d.order.id, status: d.order.status,
quantity: d.order.quantity, total_price: d.order.totalPrice,
currency: d.order.currency,
created_at: new Date(d.order.createdAt).toISOString(),
});
}
if (fromDocs.length > 0) {
this.orders = fromDocs;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private loadDemoData() {
const now = Date.now();
// Demo shopping carts
this.carts = [
{
id: "demo-cart-1",
name: "Community Garden Project",
status: "FUNDING",
itemCount: 8,
totalAmount: 247.83,
fundedAmount: 165.00,
currency: "USD",
createdAt: new Date(now - 7 * 86400000).toISOString(),
updatedAt: new Date(now - 1 * 86400000).toISOString(),
},
{
id: "demo-cart-2",
name: "Office Supplies",
status: "OPEN",
itemCount: 3,
totalAmount: 45.97,
fundedAmount: 0,
currency: "USD",
createdAt: new Date(now - 2 * 86400000).toISOString(),
updatedAt: new Date(now - 2 * 86400000).toISOString(),
},
{
id: "demo-cart-3",
name: "Hackathon Merch Order",
status: "ORDERED",
itemCount: 5,
totalAmount: 189.50,
fundedAmount: 189.50,
currency: "USD",
createdAt: new Date(now - 14 * 86400000).toISOString(),
updatedAt: new Date(now - 3 * 86400000).toISOString(),
},
];
// Demo cart detail (Community Garden Project)
this.selectedCart = {
id: "demo-cart-1",
name: "Community Garden Project",
description: "Supplies for the community garden build day",
status: "FUNDING",
targetAmount: 300,
fundedAmount: 165.00,
currency: "USD",
createdAt: new Date(now - 7 * 86400000).toISOString(),
updatedAt: new Date(now - 1 * 86400000).toISOString(),
items: [
{ id: "i1", name: "Raised Garden Bed Kit (4x8 ft)", price: 79.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08ABC1234", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 6 * 86400000).toISOString() },
{ id: "i2", name: "Organic Potting Soil (40 qt)", price: 12.99, currency: "USD", quantity: 3, sourceUrl: "https://amazon.com/dp/B08DEF5678", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 6 * 86400000).toISOString() },
{ id: "i3", name: "Heirloom Seed Variety Pack", price: 24.95, currency: "USD", quantity: 2, sourceUrl: "https://etsy.com/listing/123456", imageUrl: null, vendor: { name: "GardenSeedCo", domain: "etsy.com", platform: "etsy" }, addedAt: new Date(now - 5 * 86400000).toISOString() },
{ id: "i4", name: "Garden Trowel Set", price: 15.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08GHI9012", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 5 * 86400000).toISOString() },
{ id: "i5", name: "Drip Irrigation Kit", price: 29.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08JKL3456", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 4 * 86400000).toISOString() },
{ id: "i6", name: "Plant Labels (100 pack)", price: 8.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08MNO7890", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 4 * 86400000).toISOString() },
{ id: "i7", name: "Gardening Gloves (3 pair)", price: 12.99, currency: "USD", quantity: 1, sourceUrl: "https://amazon.com/dp/B08PQR1234", imageUrl: null, vendor: { name: "Amazon", domain: "amazon.com", platform: "amazon" }, addedAt: new Date(now - 3 * 86400000).toISOString() },
{ id: "i8", name: "Compost Bin (80 gal)", price: 34.99, currency: "USD", quantity: 1, sourceUrl: "https://homedepot.com/p/12345", imageUrl: null, vendor: { name: "Home Depot", domain: "homedepot.com", platform: null }, addedAt: new Date(now - 2 * 86400000).toISOString() },
],
contributions: [
{ id: "c1", username: "Alice", amount: 50, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 5 * 86400000).toISOString() },
{ id: "c2", username: "Bob", amount: 40, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 4 * 86400000).toISOString() },
{ id: "c3", username: "Carol", amount: 50, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 3 * 86400000).toISOString() },
{ id: "c4", username: "Dave", amount: 25, currency: "USD", paymentMethod: "MANUAL", status: "confirmed", createdAt: new Date(now - 1 * 86400000).toISOString() },
],
events: [],
};
// Existing catalog demo data
this.catalog = [
{ id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", created_at: new Date(now - 30 * 86400000).toISOString() },
{ id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", created_at: new Date(now - 25 * 86400000).toISOString() },
{ id: "demo-cat-3", title: "#DefectFi", description: "Organic cotton tee shirt with the #DefectFi campaign logo.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", created_at: new Date(now - 20 * 86400000).toISOString() },
{ id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker sheet with cosmolocal design motifs.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", created_at: new Date(now - 15 * 86400000).toISOString() },
{ id: "demo-cat-5", title: "Doughnut Economics", description: "A zine breaking down Kate Raworth's doughnut economics framework.", price: 8, currency: "USD", tags: ["books"], product_type: "zine", status: "active", created_at: new Date(now - 10 * 86400000).toISOString() },
{ id: "demo-cat-6", title: "rSpace Logo", description: "Embroidered patch featuring the rSpace logo on twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", created_at: new Date(now - 5 * 86400000).toISOString() },
{ id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", created_at: new Date(now - 3 * 86400000).toISOString() },
{ id: "demo-cat-8", title: "Cosmolocal Sticker Sheet", description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", created_at: new Date(now - 1 * 86400000).toISOString() },
];
this.orders = [
{ id: "demo-ord-1001", total_price: "30.00", currency: "USD", status: "paid", created_at: new Date(now - 2 * 86400000).toISOString(), artifact_title: "Order #1001", quantity: 2 },
{ id: "demo-ord-1002", total_price: "25.00", currency: "USD", status: "pending", created_at: new Date(now - 1 * 86400000).toISOString(), artifact_title: "Order #1002", quantity: 1 },
{ id: "demo-ord-1003", total_price: "23.00", currency: "USD", status: "shipped", created_at: new Date(now - 5 * 86400000).toISOString(), artifact_title: "Order #1003", quantity: 3 },
];
this.loading = false;
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : "/rcart";
}
private async loadData() {
this.loading = true;
this.render();
try {
const [catRes, ordRes, cartRes] = await Promise.all([
fetch(`${this.getApiBase()}/api/catalog?limit=50`),
fetch(`${this.getApiBase()}/api/orders?limit=20`),
fetch(`${this.getApiBase()}/api/shopping-carts`),
]);
const catData = await catRes.json();
const ordData = await ordRes.json();
const cartData = await cartRes.json();
this.catalog = catData.entries || [];
this.orders = ordData.orders || [];
this.carts = cartData.carts || [];
} catch (e) {
console.error("Failed to load cart data:", e);
}
this.loading = false;
this.render();
}
private async loadCartDetail(cartId: string) {
if (this.space === "demo") {
this.selectedCartId = cartId;
this.view = "cart-detail";
this.render();
return;
}
try {
const res = await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}`);
this.selectedCart = await res.json();
this.selectedCartId = cartId;
this.view = "cart-detail";
} catch (e) {
console.error("Failed to load cart:", e);
}
this.render();
}
private async createCart(name: string, targetAmount: number) {
if (this.space === "demo") return;
try {
await fetch(`${this.getApiBase()}/api/shopping-carts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, targetAmount }),
});
await this.loadData();
} catch (e) {
console.error("Failed to create cart:", e);
}
}
private async addItemByUrl(cartId: string, url: string) {
this.addingUrl = true;
this.render();
try {
await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
await this.loadCartDetail(cartId);
} catch (e) {
console.error("Failed to add item:", e);
}
this.addingUrl = false;
}
private async removeItem(cartId: string, itemId: string) {
try {
await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/items/${itemId}`, { method: 'DELETE' });
await this.loadCartDetail(cartId);
} catch (e) {
console.error("Failed to remove item:", e);
}
}
private async contribute(cartId: string, amount: number, username: string) {
try {
await fetch(`${this.getApiBase()}/api/shopping-carts/${cartId}/contribute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, username }),
});
await this.loadCartDetail(cartId);
} catch (e) {
console.error("Failed to contribute:", e);
}
}
// ── Main render ──
private render() {
const styles = this.getStyles();
let content = '';
if (this.loading) {
content = `<div class="loading">Loading...</div>`;
} else if (this.view === "carts") {
content = this.renderCarts();
} else if (this.view === "cart-detail") {
content = this.renderCartDetail();
} else if (this.view === "catalog") {
content = this.renderCatalog();
} else {
content = this.renderOrders();
}
this.shadow.innerHTML = `
<style>${styles}</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Shop</span>
<div class="tabs">
<button class="tab ${this.view === 'carts' || this.view === 'cart-detail' ? 'active' : ''}" data-view="carts">🛒 Carts (${this.carts.length})</button>
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
</div>
</div>
${content}
`;
this.bindEvents();
}
private bindEvents() {
// Tab switching
this.shadow.querySelectorAll(".tab").forEach((el) => {
el.addEventListener("click", () => {
const v = (el as HTMLElement).dataset.view as any;
this.view = v;
if (v === 'carts') this.selectedCartId = null;
this.render();
});
});
// Cart card clicks
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
el.addEventListener("click", () => {
this.loadCartDetail((el as HTMLElement).dataset.cartId!);
});
});
// Back button
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
this.view = "carts";
this.selectedCartId = null;
this.render();
});
// New cart form
const newCartBtn = this.shadow.querySelector("[data-action='new-cart']");
newCartBtn?.addEventListener("click", () => {
const form = this.shadow.querySelector(".new-cart-form") as HTMLElement;
if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none';
});
this.shadow.querySelector("[data-action='create-cart']")?.addEventListener("click", () => {
const nameInput = this.shadow.querySelector("[data-field='cart-name']") as HTMLInputElement;
const amountInput = this.shadow.querySelector("[data-field='cart-target']") as HTMLInputElement;
if (nameInput?.value) this.createCart(nameInput.value, parseFloat(amountInput?.value) || 0);
});
// Add item by URL
this.shadow.querySelector("[data-action='add-url']")?.addEventListener("click", () => {
const input = this.shadow.querySelector("[data-field='item-url']") as HTMLInputElement;
if (input?.value && this.selectedCartId) {
this.addItemByUrl(this.selectedCartId, input.value);
}
});
// Remove item
this.shadow.querySelectorAll("[data-remove-item]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
if (this.selectedCartId) this.removeItem(this.selectedCartId, (el as HTMLElement).dataset.removeItem!);
});
});
// Contribute toggle
this.shadow.querySelector("[data-action='show-contribute']")?.addEventListener("click", () => {
const form = this.shadow.querySelector(".contribute-form") as HTMLElement;
if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none';
});
this.shadow.querySelector("[data-action='submit-contribute']")?.addEventListener("click", () => {
const amtInput = this.shadow.querySelector("[data-field='contrib-amount']") as HTMLInputElement;
const nameInput = this.shadow.querySelector("[data-field='contrib-name']") as HTMLInputElement;
if (amtInput?.value && this.selectedCartId) {
this.contribute(this.selectedCartId, parseFloat(amtInput.value), nameInput?.value || 'Anonymous');
}
});
}
// ── Cart list view ──
private renderCarts(): string {
const newCartForm = `
<div class="new-cart-form" style="display:none">
<input data-field="cart-name" type="text" placeholder="Cart name" class="input" />
<input data-field="cart-target" type="number" placeholder="Target amount (optional)" class="input input-sm" />
<button data-action="create-cart" class="btn btn-primary btn-sm">Create</button>
</div>`;
if (this.carts.length === 0) {
return `
<div class="empty">
<p>No shopping carts yet. Create one to start group shopping.</p>
<button data-action="new-cart" class="btn btn-primary">+ New Cart</button>
${newCartForm}
</div>`;
}
return `
<div style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<button data-action="new-cart" class="btn btn-primary btn-sm">+ New Cart</button>
${newCartForm}
</div>
<div class="grid">
${this.carts.map((cart) => {
const pct = cart.totalAmount > 0 ? Math.min(100, Math.round((cart.fundedAmount / cart.totalAmount) * 100)) : 0;
return `
<div class="card card-clickable" data-cart-id="${cart.id}">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.5rem">
<h3 class="card-title" style="margin:0">${this.esc(cart.name)}</h3>
<span class="status status-${cart.status.toLowerCase()}">${cart.status}</span>
</div>
<div class="card-meta">${cart.itemCount} item${cart.itemCount !== 1 ? 's' : ''} &bull; $${cart.totalAmount.toFixed(2)}</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
<div class="card-meta" style="margin-top:0.25rem">$${cart.fundedAmount.toFixed(2)} / $${cart.totalAmount.toFixed(2)} funded (${pct}%)</div>
</div>`;
}).join("")}
</div>`;
}
// ── Cart detail view ──
private renderCartDetail(): string {
const cart = this.selectedCart;
if (!cart) return `<div class="loading">Loading cart...</div>`;
const items = cart.items || [];
const contributions = cart.contributions || [];
const totalItemsCost = items.reduce((s: number, i: any) => s + (i.price || 0) * (i.quantity || 1), 0);
const targetDisplay = cart.targetAmount > 0 ? cart.targetAmount : totalItemsCost;
const pct = targetDisplay > 0 ? Math.min(100, Math.round((cart.fundedAmount / targetDisplay) * 100)) : 0;
const remaining = Math.max(0, targetDisplay - cart.fundedAmount);
// Group items by vendor domain
const vendorGroups: Record<string, any[]> = {};
for (const item of items) {
const key = item.vendor?.domain || 'other';
if (!vendorGroups[key]) vendorGroups[key] = [];
vendorGroups[key].push(item);
}
const itemsHtml = Object.entries(vendorGroups).map(([domain, vendorItems]) => `
<div class="vendor-group">
<div class="vendor-header">${this.esc(domain)}</div>
${vendorItems.map((item: any) => `
<div class="item-row">
${item.imageUrl ? `<img class="item-thumb" src="${this.esc(item.imageUrl)}" alt="" />` : `<div class="item-thumb item-thumb-placeholder">📦</div>`}
<div class="item-info">
<a class="item-name" href="${this.esc(item.sourceUrl)}" target="_blank" rel="noopener">${this.esc(item.name)}</a>
<div class="item-meta">Qty: ${item.quantity}${item.price != null ? ` &bull; $${(item.price * item.quantity).toFixed(2)}` : ''}</div>
</div>
<button class="btn-icon" data-remove-item="${item.id}" title="Remove">✕</button>
</div>
`).join("")}
</div>
`).join("");
const contribHtml = contributions.length > 0 ? contributions.map((c: any) => `
<div class="contrib-row">
<span class="contrib-name">${this.esc(c.username)}</span>
<span class="contrib-amount">$${c.amount.toFixed(2)}</span>
<span class="status status-${c.status}">${c.status}</span>
</div>
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
return `
<div style="margin-bottom:1rem">
<button data-action="back" class="btn btn-sm">← Back to Carts</button>
</div>
<div class="detail-layout">
<div class="detail-left">
<h2 class="section-title">${this.esc(cart.name)}</h2>
${cart.description ? `<p class="card-meta">${this.esc(cart.description)}</p>` : ''}
<div class="url-input-row">
<input data-field="item-url" type="url" placeholder="Paste product URL from any store..." class="input" style="flex:1" />
<button data-action="add-url" class="btn btn-primary btn-sm" ${this.addingUrl ? 'disabled' : ''}>${this.addingUrl ? 'Adding...' : 'Add'}</button>
</div>
${items.length === 0
? `<div class="empty">No items yet. Paste a URL above to add products.</div>`
: itemsHtml}
</div>
<div class="detail-right">
<div class="card">
<h3 class="card-title">Summary</h3>
<div class="summary-row"><span>Items cost</span><span>$${totalItemsCost.toFixed(2)}</span></div>
<div class="summary-row"><span>Funded</span><span class="text-green">$${cart.fundedAmount.toFixed(2)}</span></div>
<div class="summary-row"><span>Remaining</span><span>$${remaining.toFixed(2)}</span></div>
<div class="progress-bar" style="margin:0.75rem 0">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
<div class="card-meta" style="text-align:center">${pct}% funded</div>
<button data-action="show-contribute" class="btn btn-primary" style="width:100%; margin-top:1rem">Contribute</button>
<div class="contribute-form" style="display:none">
<input data-field="contrib-name" type="text" placeholder="Your name" class="input" />
<input data-field="contrib-amount" type="number" placeholder="Amount ($)" class="input" step="0.01" min="0.01" />
<button data-action="submit-contribute" class="btn btn-primary btn-sm">Confirm</button>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h3 class="card-title">Contributions</h3>
${contribHtml}
</div>
</div>
</div>`;
}
// ── Catalog view ──
private renderCatalog(): string {
if (this.catalog.length === 0) {
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
}
return `<div class="grid">
${this.catalog.map((entry) => `
<div class="card">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
<div class="card-meta">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
</div>
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
</div>
`).join("")}
</div>`;
}
// ── Orders view ──
private renderOrders(): string {
if (this.orders.length === 0) {
return `<div class="empty">No orders yet.</div>`;
}
return `<div class="grid">
${this.orders.map((order) => `
<div class="card">
<div class="order-card">
<div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
<div class="card-meta">
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
${order.quantity > 1 ? ` &bull; Qty: ${order.quantity}` : ""}
</div>
<span class="status status-${order.status}">${order.status}</span>
</div>
<div class="order-price">$${parseFloat(order.total_price || 0).toFixed(2)}</div>
</div>
</div>
`).join("")}
</div>`;
}
// ── Styles ──
private getStyles(): string {
return `
:host { display: block; padding: 1.5rem; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; flex-wrap: wrap; }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
.tabs { display: flex; gap: 0.5rem; }
.tab { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; }
.tab:hover { border-color: #475569; color: #f1f5f9; }
.tab.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-clickable { cursor: pointer; transition: border-color 0.15s, transform 0.1s; }
.card-clickable:hover { border-color: #6366f1; transform: translateY(-1px); }
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.5rem; }
.card-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.5rem; }
.section-title { color: #f1f5f9; font-weight: 700; font-size: 1.25rem; margin: 0 0 0.5rem; }
.tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; margin-right: 0.25rem; }
.tag-type { background: rgba(99,102,241,0.1); color: #818cf8; }
.tag-cap { background: rgba(34,197,94,0.1); color: #4ade80; }
.dims { color: #64748b; font-size: 0.75rem; margin-top: 0.5rem; }
.status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; white-space: nowrap; }
.status-pending, .status-open { background: rgba(251,191,36,0.15); color: #fbbf24; }
.status-paid, .status-active, .status-confirmed, .status-funded { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-funding { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.status-completed, .status-ordered, .status-checking_out { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; }
.status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; }
.price { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin-top: 0.5rem; }
.text-green { color: #4ade80; }
.progress-bar { background: #334155; border-radius: 999px; height: 8px; overflow: hidden; }
.progress-fill { background: linear-gradient(90deg, #6366f1, #8b5cf6); height: 100%; border-radius: 999px; transition: width 0.3s; }
.btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #e2e8f0; cursor: pointer; font-size: 0.875rem; }
.btn:hover { border-color: #475569; }
.btn-primary { background: #4f46e5; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4338ca; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
.btn-icon { background: none; border: none; color: #64748b; cursor: pointer; padding: 4px; font-size: 0.875rem; border-radius: 4px; }
.btn-icon:hover { color: #f87171; background: rgba(239,68,68,0.1); }
.input { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 0.875rem; }
.input:focus { outline: none; border-color: #6366f1; }
.input-sm { max-width: 160px; }
.new-cart-form, .contribute-form { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap; }
.url-input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.detail-layout { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; }
@media (max-width: 768px) { .detail-layout { grid-template-columns: 1fr; } }
.vendor-group { margin-bottom: 1rem; }
.vendor-header { color: #94a3b8; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.5rem 0; border-bottom: 1px solid #1e293b; margin-bottom: 0.5rem; }
.item-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #1e293b; }
.item-thumb { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: #334155; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; }
.item-thumb-placeholder { width: 40px; height: 40px; border-radius: 6px; background: #334155; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; }
.item-info { flex: 1; min-width: 0; }
.item-name { color: #e2e8f0; font-size: 0.875rem; text-decoration: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-name:hover { color: #818cf8; }
.item-meta { color: #64748b; font-size: 0.75rem; }
.summary-row { display: flex; justify-content: space-between; padding: 0.375rem 0; color: #cbd5e1; font-size: 0.875rem; }
.contrib-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.8125rem; }
.contrib-name { color: #e2e8f0; flex: 1; }
.contrib-amount { color: #4ade80; font-weight: 600; }
.order-card { display: flex; justify-content: space-between; align-items: center; }
.order-info { flex: 1; }
.order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; }
.empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
.url-input-row { flex-direction: column; }
}
`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-cart-shop", FolkCartShop);