/** * — 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 = `
Loading...
`; } 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 = `
Shop
${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 = ` `; if (this.carts.length === 0) { return `

No shopping carts yet. Create one to start group shopping.

${newCartForm}
`; } return `
${newCartForm}
${this.carts.map((cart) => { const pct = cart.totalAmount > 0 ? Math.min(100, Math.round((cart.fundedAmount / cart.totalAmount) * 100)) : 0; return `

${this.esc(cart.name)}

${cart.status}
${cart.itemCount} item${cart.itemCount !== 1 ? 's' : ''} • $${cart.totalAmount.toFixed(2)}
$${cart.fundedAmount.toFixed(2)} / $${cart.totalAmount.toFixed(2)} funded (${pct}%)
`; }).join("")}
`; } // ── Cart detail view ── private renderCartDetail(): string { const cart = this.selectedCart; if (!cart) return `
Loading cart...
`; 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 = {}; 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]) => `
${this.esc(domain)}
${vendorItems.map((item: any) => `
${item.imageUrl ? `` : `
📦
`}
${this.esc(item.name)}
Qty: ${item.quantity}${item.price != null ? ` • $${(item.price * item.quantity).toFixed(2)}` : ''}
`).join("")}
`).join(""); const contribHtml = contributions.length > 0 ? contributions.map((c: any) => `
${this.esc(c.username)} $${c.amount.toFixed(2)} ${c.status}
`).join("") : `
No contributions yet.
`; return `

${this.esc(cart.name)}

${cart.description ? `

${this.esc(cart.description)}

` : ''}
${items.length === 0 ? `
No items yet. Paste a URL above to add products.
` : itemsHtml}

Summary

Items cost$${totalItemsCost.toFixed(2)}
Funded$${cart.fundedAmount.toFixed(2)}
Remaining$${remaining.toFixed(2)}
${pct}% funded

Contributions

${contribHtml}
`; } // ── Catalog view ── private renderCatalog(): string { if (this.catalog.length === 0) { return `
No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.
`; } return `
${this.catalog.map((entry) => `

${this.esc(entry.title || "Untitled")}

${entry.product_type ? `${this.esc(entry.product_type)}` : ""} ${(entry.required_capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} ${(entry.tags || []).map((t: string) => `${this.esc(t)}`).join("")}
${entry.description ? `
${this.esc(entry.description)}
` : ""} ${entry.dimensions ? `
${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm
` : ""} ${entry.price != null ? `
$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}
` : ""}
${entry.status}
`).join("")}
`; } // ── Orders view ── private renderOrders(): string { if (this.orders.length === 0) { return `
No orders yet.
`; } return `
${this.orders.map((order) => `

${this.esc(order.artifact_title || "Order")}

${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""} ${order.quantity > 1 ? ` • Qty: ${order.quantity}` : ""}
${order.status}
$${parseFloat(order.total_price || 0).toFixed(2)}
`).join("")}
`; } // ── 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);