239 lines
17 KiB
JavaScript
239 lines
17 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();
|
||
}
|
||
|
||
function setModel(model, ms) {
|
||
const short = (model || "").replace(/:free$/, "");
|
||
$("#agentBadge").textContent = "AI: " + short;
|
||
$("#agentBadge").title = (model || "") + " via OpenRouter (free tier)";
|
||
$("#chatMode").textContent = `AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`;
|
||
}
|
||
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(-6), actor, theme: AMTheme.get() });
|
||
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)");
|
||
if (out.agent === "llm") setModel(out.model, out.latency_ms);
|
||
else if (out.agent === "fallback") {
|
||
$("#chatMode").textContent = "built-in agent answered (AI model unavailable)";
|
||
$("#agentBadge").textContent = "agent: built-in (AI unavailable)"; $("#agentBadge").title = "The hosted model is rate-limited or out of free quota";
|
||
}
|
||
else if (out.agent === "rate-limit") toast(out.reply, "warn");
|
||
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 payOpts = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
|
||
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: payOpts.includes("online") ? "" : null }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)),
|
||
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: payOpts.includes("cash") ? "" : null }), " 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}`));
|
||
},
|
||
set_theme(p) { AMTheme.applyAction(p); toast(p.reset && !p.base && !Object.keys(p.patch || {}).length ? "Look reset to default" : "Look updated", "ok"); return null; },
|
||
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; if (AMTheme.ensureResultsVisible()) toast("Results section shown again so you can see this", "info"); }
|
||
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 });
|
||
});
|
||
|
||
const copyDefaults = (bps) => ({
|
||
logo: "Agent Market", headline: "A marketplace you can just talk to.",
|
||
subhead: "Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.",
|
||
chips: [{ label: "🚲 Show me bikes", say: "show me bikes" }, { label: "📐 Tutoring", say: "find tutoring services" },
|
||
{ label: "⚖️ Compare tutoring", say: "compare the two tutoring options" }, { label: "➕ Sell something", event: "sell" },
|
||
{ label: "🎨 Make it look retro", say: "make it look retro" }],
|
||
bullets: [`${(bps / 100).toFixed(bps % 100 ? 1 : 0)}% facilitation fee when you pay online (test mode)`, "$0 fee when you meet and pay cash in person",
|
||
"Agents welcome: other AI agents buy and sell through the open API (see For agents)"],
|
||
});
|
||
AMTheme.init(copyDefaults(feeBps));
|
||
$("#resetLook").addEventListener("click", () => { AMTheme.reset(); toast("Look reset to default", "ok"); });
|
||
api("/api/status").then((s) => {
|
||
feeBps = s.fee_bps; AMTheme.init(copyDefaults(feeBps));
|
||
if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc) { $("#agentBadge").textContent = "agent: built-in (AI quota used up)"; $("#agentBadge").title = "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc; }
|
||
else if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model);
|
||
else { $("#agentBadge").textContent = "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; }
|
||
});
|
||
// Landing: show featured listings via the agent.
|
||
(async () => {
|
||
const out = await api("/api/chat", { event: { type: "landing" }, actor });
|
||
render(out.actions || []);
|
||
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
|
||
})();
|
||
})();
|