322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* <folk-cart-shop> — browse catalog, view orders, trigger fulfillment.
|
|
* Shows catalog items, order creation flow, and order status tracking.
|
|
*/
|
|
|
|
class FolkCartShop extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "default";
|
|
private catalog: any[] = [];
|
|
private orders: any[] = [];
|
|
private view: "catalog" | "orders" = "catalog";
|
|
private loading = true;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
// Resolve space from attribute or URL path
|
|
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.loadData();
|
|
}
|
|
|
|
private loadDemoData() {
|
|
const now = Date.now();
|
|
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. DTG printed by local providers or Printful.",
|
|
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. Weatherproof and UV-resistant.",
|
|
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",
|
|
items: [
|
|
{ title: "The Commons", qty: 1, price: 12 },
|
|
{ title: "Mycelium Networks", qty: 1, price: 18 },
|
|
],
|
|
total: 30,
|
|
total_price: "30.00",
|
|
currency: "USD",
|
|
status: "paid",
|
|
created_at: new Date(now - 2 * 86400000).toISOString(),
|
|
customer_email: "reader@example.com",
|
|
artifact_title: "Order #1001",
|
|
quantity: 2,
|
|
},
|
|
{
|
|
id: "demo-ord-1002",
|
|
items: [
|
|
{ title: "#DefectFi", qty: 1, price: 25 },
|
|
],
|
|
total: 25,
|
|
total_price: "25.00",
|
|
currency: "USD",
|
|
status: "pending",
|
|
created_at: new Date(now - 1 * 86400000).toISOString(),
|
|
customer_email: "activist@example.com",
|
|
artifact_title: "Order #1002",
|
|
quantity: 1,
|
|
},
|
|
{
|
|
id: "demo-ord-1003",
|
|
items: [
|
|
{ title: "Cosmolocal Sticker Sheet", qty: 1, price: 5 },
|
|
{ title: "Doughnut Economics", qty: 1, price: 8 },
|
|
{ title: "rSpace Logo", qty: 1, price: 6 },
|
|
],
|
|
total: 23,
|
|
total_price: "23.00",
|
|
currency: "USD",
|
|
status: "shipped",
|
|
created_at: new Date(now - 5 * 86400000).toISOString(),
|
|
customer_email: "maker@example.com",
|
|
artifact_title: "Order #1003",
|
|
quantity: 3,
|
|
},
|
|
];
|
|
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const parts = path.split("/").filter(Boolean);
|
|
return parts.length >= 2 ? `/${parts[0]}/cart` : "/demo/cart";
|
|
}
|
|
|
|
private async loadData() {
|
|
this.loading = true;
|
|
this.render();
|
|
try {
|
|
const [catRes, ordRes] = await Promise.all([
|
|
fetch(`${this.getApiBase()}/api/catalog?limit=50`),
|
|
fetch(`${this.getApiBase()}/api/orders?limit=20`),
|
|
]);
|
|
const catData = await catRes.json();
|
|
const ordData = await ordRes.json();
|
|
this.catalog = catData.entries || [];
|
|
this.orders = ordData.orders || [];
|
|
} catch (e) {
|
|
console.error("Failed to load cart data:", e);
|
|
}
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; padding: 1.5rem; }
|
|
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
|
|
.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-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; }
|
|
.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; }
|
|
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
|
.status-paid { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
|
.status-cancelled { 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; }
|
|
.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; }
|
|
}
|
|
</style>
|
|
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Shop</span>
|
|
<div class="tabs">
|
|
<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>
|
|
|
|
${this.loading ? `<div class="loading">⏳ Loading...</div>` :
|
|
this.view === "catalog" ? this.renderCatalog() : this.renderOrders()}
|
|
`;
|
|
|
|
this.shadow.querySelectorAll(".tab").forEach((el) => {
|
|
el.addEventListener("click", () => {
|
|
this.view = ((el as HTMLElement).dataset.view || "catalog") as "catalog" | "orders";
|
|
this.render();
|
|
});
|
|
});
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-cart-shop", FolkCartShop);
|