214 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.");
})();
})();