214 lines
14 KiB
JavaScript
214 lines
14 KiB
JavaScript
// 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.");
|
||
})();
|
||
})();
|