curieous's picture
Deploy Venue Manager Agent Modal spike
50d0387 verified
Raw
History Blame Contribute Delete
19.5 kB
const state = {
data: null,
selectedRequestId: "",
selectedSlotId: "",
selectedPrice: 0,
activeScenario: "normal",
activeSlotFilter: "morning",
mobilePanel: "phone",
variationIndex: 0,
};
const els = {
shell: document.querySelector(".app-shell"),
todayLabel: document.querySelector("#todayLabel"),
channelStatus: document.querySelector("#channelStatus"),
channelCopy: document.querySelector("#channelCopy"),
pendingCount: document.querySelector("#pendingCount"),
scenarioButtons: document.querySelector("#scenarioButtons"),
requestQueue: document.querySelector("#requestQueue"),
chatPhone: document.querySelector("#chatPhone"),
chatThread: document.querySelector("#chatThread"),
detectedIntent: document.querySelector("#detectedIntent"),
confidenceValue: document.querySelector("#confidenceValue"),
extractedSlots: document.querySelector("#extractedSlots"),
missingList: document.querySelector("#missingList"),
traceList: document.querySelector("#traceList"),
timelineState: document.querySelector("#timelineState"),
bookingId: document.querySelector("#bookingId"),
availabilityBoard: document.querySelector("#availabilityBoard"),
packetList: document.querySelector("#packetList"),
groupA: document.querySelector("#groupA"),
groupB: document.querySelector("#groupB"),
suggestedPrice: document.querySelector("#suggestedPrice"),
integrationsList: document.querySelector("#integrationsList"),
replyDraft: document.querySelector("#replyDraft"),
charCount: document.querySelector("#charCount"),
decisionState: document.querySelector("#decisionState"),
approveButton: document.querySelector("#approveButton"),
clarifyButton: document.querySelector("#clarifyButton"),
rejectButton: document.querySelector("#rejectButton"),
resetDemoButton: document.querySelector("#resetDemoButton"),
suggestButton: document.querySelector("#suggestButton"),
};
function formatPrice(value) {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
}).format(value);
}
function slotKey(venueId, slotId) {
return `${venueId}:${slotId}`;
}
function getSelectedRequest() {
return state.data?.requests.find((request) => request.id === state.selectedRequestId) || null;
}
function getSelectedSlot() {
if (!state.data || !state.selectedSlotId) return null;
const [venueId, slotId] = state.selectedSlotId.split(":");
const venue = state.data.venues.find((item) => item.id === venueId);
const slot = state.data.slots.find((item) => item.id === slotId);
const inventory = venue?.slots?.[slotId];
if (!venue || !slot || !inventory) return null;
return { venue, slot, inventory };
}
async function loadScenario(scenario = "normal") {
const response = await fetch(`/api/bootstrap?scenario=${encodeURIComponent(scenario)}`);
state.data = await response.json();
state.activeScenario = state.data.active_scenario;
state.selectedRequestId = state.data.decision.selected_request_id;
state.selectedSlotId = state.data.decision.selected_slot_id;
state.selectedPrice = state.data.decision.selected_price;
state.activeSlotFilter = state.selectedSlotId.split(":")[1] || "morning";
state.variationIndex = 0;
render();
}
function render() {
renderRuntime();
renderScenarios();
renderRequestSelect();
renderPhone();
renderAgentPanel();
renderOwnerPanel();
renderDecisionBar();
renderMobilePanel();
}
function renderRuntime() {
const runtime = state.data.runtime;
els.todayLabel.textContent = getSelectedRequest()?.date_label || "25 May 2025, Sun";
els.channelStatus.textContent = runtime.channel_label;
els.channelCopy.textContent = runtime.channel_copy;
els.pendingCount.textContent = `${runtime.pending_count} pending`;
els.shell.classList.toggle("is-provider-error", runtime.channel_status === "provider-error");
}
function renderScenarios() {
els.scenarioButtons.replaceChildren(
...state.data.scenarios.map((scenario) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = scenario.label;
button.className = scenario.id === state.activeScenario ? "is-active" : "";
button.addEventListener("click", () => loadScenario(scenario.id));
return button;
})
);
}
function renderRequestSelect() {
if (!state.data.requests.length) {
const option = new Option("No booking requests", "");
els.requestQueue.replaceChildren(option);
els.requestQueue.disabled = true;
return;
}
els.requestQueue.disabled = false;
els.requestQueue.replaceChildren(
...state.data.requests.map((request) => {
const label = `${request.customer_name} · ${request.activity} · ${stateLabel(request.status)}`;
const option = new Option(label, request.id, false, request.id === state.selectedRequestId);
return option;
})
);
}
function renderPhone() {
const request = getSelectedRequest();
els.chatPhone.textContent = request?.phone || "No active conversation";
if (!request) {
els.chatThread.replaceChildren(emptyPanel("No active booking chat. Pick a scenario or wait for a new request."));
return;
}
const selected = getSelectedSlot();
const locationText = selected?.venue.name || "North Field or South Field";
const transcript = [
{ who: "customer", text: `Hi, need a ground for ${request.activity.toLowerCase()} ${request.date_label.toLowerCase()}.`, time: "11:34 AM" },
{ who: "agent", text: "Hi! Sure, what time are you looking for?", time: "11:35 AM" },
{ who: "customer", text: `${selected?.slot.label || "8 AM - 12 PM"}. For ${request.players} players.`, time: "11:35 AM" },
{ who: "agent", text: "Which location?", time: "11:36 AM" },
{ who: "customer", text: `${locationText} works.`, time: "11:36 AM" },
{ who: "agent", text: "Any surface preference?", time: "11:37 AM" },
{ who: "customer", text: `${selected?.venue.surface || "Grass"} is fine. Budget around ${formatPrice(state.selectedPrice || selected?.inventory.rate || 6000)}.`, time: "11:38 AM" },
{ who: "agent", text: "Got it. Let me check availability and send options.", time: "11:38 AM" },
];
const day = document.createElement("div");
day.className = "chat-day";
day.textContent = "Today";
els.chatThread.replaceChildren(day, ...transcript.map(chatBubble));
}
function chatBubble(message) {
const div = document.createElement("div");
div.className = `bubble ${message.who === "agent" ? "agent" : ""}`;
div.innerHTML = `${message.text}<time>${message.time}</time>`;
return div;
}
function renderAgentPanel() {
const request = getSelectedRequest();
const selected = getSelectedSlot();
const confidence = confidenceFor(request);
els.detectedIntent.textContent = request ? "BOOK_SLOT" : "IDLE";
els.confidenceValue.textContent = confidence.toFixed(2);
document.querySelector(".confidence-meter span").style.width = `${Math.round(confidence * 100)}%`;
if (!request) {
els.extractedSlots.replaceChildren();
els.missingList.replaceChildren(emptyListItem("Waiting for the next booking request."));
els.traceList.replaceChildren(...state.data.trace.map(traceItem));
els.timelineState.textContent = "Idle";
return;
}
const extracted = [
["date", request.date_label],
["time", selected?.slot.label || "Not selected"],
["players", String(request.players)],
["activity", request.activity],
["location", selected?.venue.name || "Any venue"],
["surface", selected ? `${selected.venue.surface} · ${selected.venue.grass_type}` : "Grass"],
["budget", `${formatPrice(state.selectedPrice || selected?.inventory.rate || 0)} approx`],
];
els.extractedSlots.replaceChildren(...extracted.flatMap(([label, value]) => slotRow(label, value)));
const missing = request.missing_fields.length
? request.missing_fields
: ["Backup slot preference", "Add-ons final confirmation"];
els.missingList.replaceChildren(...missing.map((item) => listItem(item)));
els.traceList.replaceChildren(...buildTimeline(request).map(traceItem));
els.timelineState.textContent = state.data.decision.state === "sent" ? "Sent" : timelineCopy(request);
}
function renderOwnerPanel() {
const request = getSelectedRequest();
els.bookingId.textContent = request ? `Booking ID: BK-${request.id.slice(0, 2).toUpperCase()}51872` : "Booking ID: none";
renderAvailabilityBoard();
renderPacket();
renderGroups(request);
renderIntegrations();
}
function renderAvailabilityBoard() {
const slot = state.data.slots.find((item) => item.id === state.activeSlotFilter) || state.data.slots[0];
document.querySelectorAll("[data-slot-filter]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.slotFilter === slot.id);
});
const rows = state.data.venues.map((venue) => {
const inventory = venue.slots[slot.id];
const key = slotKey(venue.id, slot.id);
const button = document.createElement("button");
button.type = "button";
button.className = `venue-row ${key === state.selectedSlotId ? "is-selected" : ""}`;
button.innerHTML = `
<span>
<strong>${venue.name}</strong>
<small>${venue.surface} · ${venue.grass_type}</small>
</span>
<span class="venue-rate">${formatPrice(inventory.rate)}</span>
<span class="surface-badge">${venue.grass_type}</span>
<span class="slot-count ${inventory.status}">${inventoryLabel(inventory)}</span>
`;
button.addEventListener("click", () => {
state.selectedSlotId = key;
state.selectedPrice = inventory.rate;
render();
});
return button;
});
els.availabilityBoard.replaceChildren(...rows);
}
function renderPacket() {
const selected = getSelectedSlot();
const packet = [
["Umpire", "₹700"],
["Scorekeeper", "₹300"],
["Match ball", "₹250"],
["Water", "₹200"],
];
els.packetList.replaceChildren(
...packet.map(([label, price]) => {
const item = document.createElement("div");
item.className = "packet-item";
item.innerHTML = `<strong>${label}</strong><span>${price}</span>`;
return item;
})
);
const base = selected?.inventory.rate || 0;
els.suggestedPrice.textContent = `Slot ${formatPrice(base)} · Total add-ons: ${formatPrice(1450)}`;
}
function renderGroups(request) {
const groupA = request
? [
["Group name", request.group_type],
["Primary contact", request.phone],
["Activity", request.activity],
["Players", String(request.players)],
]
: [["State", "No request selected"]];
const groupB = request
? [
["Group name", "To be confirmed"],
["Status", request.missing_fields.length ? "Needs clarification" : "Ready"],
["Venue", getSelectedSlot()?.venue.name || "Not selected"],
["Slot", getSelectedSlot()?.slot.label || "Not selected"],
]
: [["State", "No request selected"]];
els.groupA.replaceChildren(...definitionRows(groupA));
els.groupB.replaceChildren(...definitionRows(groupB));
}
function renderIntegrations() {
els.integrationsList.replaceChildren(
...state.data.integrations.map((integration) => {
const row = document.createElement("div");
row.className = "integration-row";
row.innerHTML = `<span>${integration.label}</span><span>${integration.copy}</span>`;
return row;
})
);
}
function renderDecisionBar() {
const request = getSelectedRequest();
const providerError = state.data.runtime.channel_status === "provider-error";
if (document.activeElement !== els.replyDraft) {
els.replyDraft.value = request?.reply_draft || "Pick a request to review the reply draft.";
}
updateCharCount();
els.decisionState.textContent = decisionCopy(state.data.decision.state, request);
const disabled = !request || providerError;
els.approveButton.disabled = disabled;
els.clarifyButton.disabled = !request;
els.rejectButton.disabled = !request;
}
function slotRow(label, value) {
const dt = document.createElement("dt");
const dd = document.createElement("dd");
const ok = document.createElement("span");
dt.textContent = label;
dd.textContent = value;
ok.className = "ok-mark";
ok.textContent = "✓";
return [dt, dd, ok];
}
function definitionRows(rows) {
return rows.flatMap(([label, value]) => {
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = value;
return [dt, dd];
});
}
function listItem(text) {
const li = document.createElement("li");
li.textContent = text;
return li;
}
function emptyListItem(text) {
return listItem(text);
}
function emptyPanel(text) {
const div = document.createElement("div");
div.className = "empty-state";
div.textContent = text;
return div;
}
function traceItem(event) {
const li = document.createElement("li");
li.className = event.tone;
li.innerHTML = `<time>${event.timestamp_label || "now"}</time><span>${event.label}</span>`;
return li;
}
function buildTimeline(request) {
const selected = getSelectedSlot();
return [
{ id: "hello", label: `Message received from ${request.customer_name}`, tone: "neutral", timestamp_label: "11:34:12" },
{ id: "time", label: `Extracted ${selected?.slot.label || "requested slot"}`, tone: "success", timestamp_label: "11:35:04" },
{ id: "players", label: `User: ${request.players} players`, tone: "success", timestamp_label: "11:35:16" },
{ id: "venue", label: `Venue candidate: ${selected?.venue.name || "Any venue"}`, tone: "success", timestamp_label: "11:36:02" },
...state.data.trace,
];
}
function confidenceFor(request) {
if (!request) return 0;
if (state.data.runtime.channel_status === "provider-error") return 0.64;
if (request.status === "clarification") return 0.78;
if (request.status === "conflict") return 0.86;
return 0.92;
}
function timelineCopy(request) {
if (!request) return "Idle";
if (request.status === "conflict") return "Alternatives Needed";
if (request.missing_fields.length) return "Clarify First";
return "Ready for Reply";
}
function stateLabel(status) {
const labels = {
new: "New",
clarification: "Clarify",
conflict: "Conflict",
sent: "Sent",
rejected: "Rejected",
};
return labels[status] || status;
}
function inventoryLabel(inventory) {
if (inventory.status === "partial") return `${inventory.request_count} holds`;
if (inventory.status === "conflict") return "Conflict";
if (inventory.status === "booked") return "Booked";
return "Available";
}
function decisionCopy(decisionState, request) {
if (!request) return "Idle. No outbound reply can be sent.";
if (decisionState === "sent") return "Sent via simulator. No live WhatsApp proof claimed.";
if (decisionState === "rejected") return "Rejected by owner.";
if (decisionState === "error") return "Provider error. Retry before sending.";
if (request.missing_fields.length) return "Clarification needed before confirmation.";
if (request.status === "conflict") return "Conflict detected. Offer alternatives.";
return "Pending owner approval.";
}
function renderMobilePanel() {
document.querySelectorAll("[data-panel]").forEach((panel) => {
panel.classList.toggle("is-mobile-active", panel.dataset.panel === state.mobilePanel);
});
document.querySelectorAll("[data-mobile-panel-target]").forEach((tab) => {
tab.classList.toggle("is-active", tab.dataset.mobilePanelTarget === state.mobilePanel);
});
}
function updateCharCount() {
els.charCount.textContent = `Characters: ${els.replyDraft.value.length}`;
}
function attachEvents() {
document.querySelectorAll("[data-mobile-panel-target]").forEach((tab) => {
tab.addEventListener("click", () => {
state.mobilePanel = tab.dataset.mobilePanelTarget;
renderMobilePanel();
});
});
document.querySelectorAll("[data-slot-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.activeSlotFilter = button.dataset.slotFilter;
renderAvailabilityBoard();
});
});
els.requestQueue.addEventListener("change", () => {
state.selectedRequestId = els.requestQueue.value;
const request = getSelectedRequest();
if (request) {
state.selectedSlotId = request.requested_slot_id;
state.activeSlotFilter = state.selectedSlotId.split(":")[1] || "morning";
const selected = getSelectedSlot();
state.selectedPrice = selected?.inventory.rate || 0;
}
render();
});
els.replyDraft.addEventListener("input", updateCharCount);
els.suggestButton.addEventListener("click", () => {
const request = getSelectedRequest();
const selected = getSelectedSlot();
if (!request || !selected) return;
const variants = [
`Great, ${selected.venue.name} is available for ${request.activity.toLowerCase()} on ${request.date_label} from ${selected.slot.label}. The slot is ${formatPrice(state.selectedPrice)}. Should I hold it after owner approval?`,
`I can offer ${selected.venue.name}, ${selected.slot.label}, ${selected.venue.grass_type} surface at ${formatPrice(state.selectedPrice)}. Please confirm and we will proceed after owner approval.`,
`Available option: ${selected.venue.name} on ${request.date_label}, ${selected.slot.label}, for ${request.players} players. Estimated slot cost is ${formatPrice(state.selectedPrice)} plus selected add-ons.`,
];
state.variationIndex = (state.variationIndex + 1) % variants.length;
els.replyDraft.value = variants[state.variationIndex];
updateCharCount();
});
els.approveButton.addEventListener("click", () => {
if (!state.data || state.data.runtime.channel_status === "provider-error") return;
const request = getSelectedRequest();
if (!request) return;
state.data.decision.state = "sending";
state.data.trace.push({ id: "sending", label: "Owner approved; simulator send in progress", tone: "neutral", timestamp_label: "now" });
renderDecisionBar();
renderAgentPanel();
setTimeout(() => {
state.data.decision.state = "sent";
request.status = "sent";
request.tone = "ready";
request.reply_draft = els.replyDraft.value;
state.data.trace.push({ id: "sent", label: "Reply marked sent via simulator", tone: "success", timestamp_label: "now" });
render();
}, 650);
});
els.clarifyButton.addEventListener("click", () => {
const request = getSelectedRequest();
if (!request) return;
request.status = "clarification";
request.tone = "clarification";
request.reply_draft = els.replyDraft.value || `Hi ${request.customer_name.split(" ")[0]}, could you confirm the missing details before I ask the owner to hold this slot?`;
state.data.trace.push({ id: "edited-send", label: "Edited reply prepared for simulator send", tone: "warning", timestamp_label: "now" });
render();
});
els.rejectButton.addEventListener("click", () => {
const request = getSelectedRequest();
if (!request) return;
request.status = "rejected";
request.tone = "rejected";
state.data.decision.state = "rejected";
state.data.trace.push({ id: "rejected", label: "Owner rejected the outbound reply", tone: "error", timestamp_label: "now" });
render();
});
els.resetDemoButton.addEventListener("click", () => loadScenario("normal"));
}
attachEvents();
loadScenario("normal");