699 lines
31 KiB
TypeScript
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' : ''} • $${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 ? ` • $${(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 ? ` • 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);
|