| 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"); |
|
|