// Agent Market frontend: renders UI actions chosen by the agent. Card data goes only to the test payment endpoint. (() => { const $ = (s) => document.querySelector(s); const stage = $("#stage"), log = $("#log"); const actor = localStorage.getItem("am_actor") || ("guest-" + Math.random().toString(36).slice(2, 7)); localStorage.setItem("am_actor", actor); const history = []; let feeBps = 500; const compareSet = new Set(); const money = (c) => "$" + (c / 100).toFixed(2); function el(tag, attrs = {}, ...kids) { const n = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") n.className = v; else if (k.startsWith("on")) n.addEventListener(k.slice(2), v); else if (v !== undefined && v !== null) n.setAttribute(k, v); } for (const k of kids.flat()) if (k !== null && k !== undefined) n.append(k.nodeType ? k : document.createTextNode(String(k))); return n; } function toast(text, level = "info") { const t = el("div", { class: "toast " + level }, text); $("#toasts").append(t); setTimeout(() => t.remove(), 3500); } function say(role, text) { log.append(el("div", { class: "msg " + role }, text)); log.scrollTop = log.scrollHeight; } function openChat() { $("#chat").classList.remove("hidden"); $("#chatLauncher").style.display = "none"; } async function api(path, body) { const r = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) } : { headers: { "X-Agent-Id": actor } }); return r.json(); } async function talk({ message, event, echo }) { openChat(); if (echo) say("user", echo); const thinking = el("div", { class: "msg sys" }, "agent is thinking…"); log.append(thinking); try { const out = await api("/api/chat", { message, event, history: history.slice(-8), actor }); thinking.remove(); if (message) history.push({ role: "user", content: message }); if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) }); history.push({ role: "assistant", content: out.reply || "" }); say("bot", out.reply || "(no reply)"); $("#chatMode").textContent = out.agent === "fallback" ? "rule-based fallback agent (no LLM key)" : `LLM agent · ${out.model || out.agent}`; render(out.actions || []); } catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); } } // ---------- component renderers ---------- const R = { show_hero(p) { return el("section", { class: "panel hero" }, el("h2", {}, p.title || "Agent Market"), el("p", {}, p.subtitle || ""), el("ul", {}, (p.bullets || []).map((b) => el("li", {}, b)))); }, show_cards(p) { return el("section", { class: "panel", "data-component": "cards" }, el("h2", {}, p.heading || "Listings"), el("div", { class: "by" }, "Rendered by the agent"), el("div", { class: "grid" }, (p.listings || []).map(card))); }, show_compare(p) { const L = p.listings || [], Q = p.quotes || []; const row = (label, f) => el("tr", {}, el("th", {}, label), L.map((x, i) => el("td", {}, f(x, Q[i] || {})))); return el("section", { class: "panel", "data-component": "compare" }, el("h2", {}, p.heading || "Compare"), el("table", { class: "cmp" }, el("tr", {}, el("th", {}, ""), L.map((x) => el("th", {}, x.emoji + " " + x.title))), row("Type", (x) => x.kind), row("Price", (x) => money(x.price)), row("Pay online (test)", (x, q) => q.online && q.online.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered"), row("Cash in person", (x, q) => q.cash_in_person && q.cash_in_person.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered"), row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : "")), row("Area", (x) => x.area), row("", (x) => buyButtons(x)))); }, show_listing_form(p) { const d = p.defaults || {}; const f = el("form", { class: "form", "data-component": "listing-form" }, el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })), el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()), el("label", {}, "Price (USD)", el("input", { name: "price", type: "number", step: "0.01", min: "0", required: "", value: d.price ? (d.price / 100).toFixed(2) : "" })), el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })), el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })), el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")), el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: "" }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)), el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: "" }), " Accept cash in person (free)")), el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing"))); f.addEventListener("submit", (e) => { e.preventDefault(); const fd = new FormData(f); const fields = { title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"), area: fd.get("area"), description: fd.get("description"), payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean) }; talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` }); }); return el("section", { class: "panel" }, el("h2", {}, "List something"), el("div", { class: "by" }, "Form opened by the agent"), f); }, show_checkout(p) { const it = p.intent, L = p.listing || {}; const status = el("div", { class: "meta" }); const card = el("input", { name: "card", value: "4242 4242 4242 4242", inputmode: "numeric", "aria-label": "Test card number" }); const exp = el("input", { value: "12/34", "aria-label": "Expiry" }), cvc = el("input", { value: "123", "aria-label": "CVC" }); const payBtn = el("button", { class: "btn", type: "submit" }, `Pay ${money(it.total_cents)} (test)`); const form = el("form", {}, el("div", { class: "testcard" }, "TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused."), el("div", { class: "cardform" }, card, exp, cvc), el("div", { style: "margin-top:10px" }, payBtn), status); form.addEventListener("submit", async (e) => { e.preventDefault(); payBtn.disabled = true; status.textContent = "Contacting test payment provider…"; const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card.value }); status.textContent = r.status === "succeeded" ? "Paid (test)." : "Declined: " + (r.error || "unknown"); status.className = "meta " + (r.status === "succeeded" ? "ok" : "warn"); payBtn.disabled = r.status === "succeeded"; talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order && r.order.id, error: r.error } }); }); return el("section", { class: "panel", "data-component": "checkout" }, el("h2", {}, "Checkout"), el("div", { class: "by" }, `${L.emoji || ""} ${it.title} · sold by ${L.seller || "?"}`), el("table", { class: "breakdown" }, el("tr", {}, el("td", {}, `Price${it.quantity > 1 ? " × " + it.quantity : ""}`), el("td", {}, money(it.subtotal_cents))), el("tr", {}, el("td", {}, `Facilitation fee (${(it.fee_bps / 100).toFixed(1)}%, online payments only)`), el("td", { "data-testid": "fee" }, money(it.fee_cents))), el("tr", { class: "total" }, el("td", {}, "You pay"), el("td", { "data-testid": "total" }, money(it.total_cents))), el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(it.subtotal_cents)))), form, "cash" in Object.fromEntries((L.payment_options || []).map((x) => [x, 1])) ? el("p", { class: "meta" }, "Prefer to skip the fee? ", el("a", { href: "#", onclick: (e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); } }, "Meet and pay cash in person ($0 fee)")) : null); }, show_cash_meetup(p) { const d = p.deal, L = p.listing || {}; const done = el("button", { class: "btn cash" }, "Mark paid in cash"); done.addEventListener("click", () => { done.disabled = true; talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); }); return el("section", { class: "panel", "data-component": "cash-meetup" }, el("h2", {}, "Cash in person: no fee"), el("div", { class: "by" }, `${L.emoji || ""} ${d.title} · seller ${d.seller}`), el("table", { class: "breakdown" }, el("tr", {}, el("td", {}, "Price (pay the seller directly)"), el("td", {}, money(d.price_cents))), el("tr", {}, el("td", {}, "Platform fee"), el("td", { "data-testid": "cash-fee" }, money(d.fee_cents))), el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(d.price_cents)))), el("p", {}, `Suggested meetup: ${d.place} · ${d.time}`), el("p", { class: "meta" }, "We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)"), el("div", { class: "meta" }, "Deal " + d.id + " · status " + d.status), el("div", { style: "margin-top:10px" }, done)); }, show_receipt(p) { const o = p.order; return el("section", { class: "panel", "data-component": "receipt" }, el("h2", { class: "ok" }, o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"), el("table", { class: "breakdown" }, el("tr", {}, el("td", {}, o.title), el("td", {}, money(o.subtotal_cents))), el("tr", {}, el("td", {}, "Facilitation fee"), el("td", { "data-testid": "receipt-fee" }, money(o.fee_cents))), el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(o.total_cents))), el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(o.seller_receives_cents)))), el("div", { class: "meta" }, `Order ${o.id} · ${o.method}${o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · ${o.status}`)); }, clear_panel() { stage.innerHTML = ""; return null; }, toast(p) { toast(p.text || "", p.level || "info"); return null; }, }; function buyButtons(x) { const opts = x.payment_options || []; if (x.status !== "active") return el("span", { class: "meta" }, "Sold"); return el("div", { class: "actions" }, opts.includes("online") ? el("button", { class: "btn", onclick: () => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` }) }, "Buy online") : null, opts.includes("cash") ? el("button", { class: "btn cash", onclick: () => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` }) }, "Cash meetup") : null); } function card(x) { const cb = el("input", { type: "checkbox", "aria-label": "compare" }); cb.checked = compareSet.has(x.id); cb.addEventListener("change", () => { cb.checked ? compareSet.add(x.id) : compareSet.delete(x.id); updateCompareBar(); }); return el("div", { class: "card" + (x.status !== "active" ? " sold" : ""), "data-listing": x.id }, el("div", { class: "emoji" }, x.emoji || "📦"), el("h3", {}, x.title), el("div", { class: "price" }, money(x.price), el("span", { class: "meta" }, x.kind === "service" ? " / session" : "")), el("div", {}, el("span", { class: "tag" }, x.kind), x.seller_type === "agent" ? el("span", { class: "tag agent-tag" }, "sold by an agent") : null, el("span", { class: "tag" }, "test listing")), el("div", { class: "meta" }, x.area + " · " + x.seller), el("label", { class: "meta" }, cb, " compare"), buyButtons(x)); } const cmpbar = el("div", { class: "cmpbar" }); document.body.append(cmpbar); function updateCompareBar() { cmpbar.innerHTML = ""; cmpbar.style.display = compareSet.size ? "flex" : "none"; cmpbar.append(`${compareSet.size} selected`, el("button", { class: "btn", onclick: () => talk({ event: { type: "compare", listing_ids: [...compareSet] }, echo: "Compare selected" }) }, "Compare")); } function render(actions) { let first = true; for (const a of actions) { const fn = R[a.type]; if (!fn) continue; const node = fn(a.payload || {}); if (!node) continue; if (first) { stage.innerHTML = ""; first = false; } stage.append(node); } if (!first) stage.scrollIntoView({ behavior: "smooth", block: "start" }); } // ---------- wiring ---------- document.addEventListener("click", (e) => { const b = e.target.closest("[data-say],[data-event]"); if (!b) return; if (b.dataset.say) talk({ message: b.dataset.say, echo: b.dataset.say }); else talk({ event: { type: b.dataset.event }, echo: b.textContent.trim() }); }); $("#chatLauncher").addEventListener("click", openChat); $("#chatClose").addEventListener("click", () => { $("#chat").classList.add("hidden"); $("#chatLauncher").style.display = ""; }); $("#chatForm").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#chatInput").value.trim(); if (!v) return; $("#chatInput").value = ""; talk({ message: v, echo: v }); }); api("/api/status").then((s) => { feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%"; $("#agentBadge").textContent = s.agent.mode === "llm" ? `LLM: ${s.agent.model}` : "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; }); // Landing: show featured listings via the agent. (async () => { const out = await api("/api/chat", { message: "show me everything", actor }); render(out.actions || []); say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only."); })(); })();