AIDA / test_chat_ui.html
destinyebuka's picture
fyp
8c9362b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIDA Agent Test Console</title>
<style>
:root {
--primary: #2563eb;
--bg: #f8fafc;
--chat-bg: #ffffff;
--user-msg: #eff6ff;
--ai-msg: #f1f5f9;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: var(--bg);
color: #1e293b;
height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
height: 100%;
}
.sidebar {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.main-chat {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
h2,
h3 {
margin-top: 0;
}
/* Forms */
.form-group {
margin-bottom: 12px;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 4px;
}
input,
select,
textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.875rem;
box-sizing: border-box;
}
button {
background-color: var(--primary);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
width: 100%;
}
button:hover {
opacity: 0.9;
}
button.secondary {
background-color: #64748b;
}
/* Chat Area */
#chat-history {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
position: relative;
}
.message.user {
align-self: flex-end;
background-color: var(--user-msg);
color: #1e3a8a;
border-bottom-right-radius: 4px;
}
.message.ai {
align-self: flex-start;
background-color: var(--ai-msg);
color: #334155;
border-bottom-left-radius: 4px;
}
.message .meta {
font-size: 0.75rem;
opacity: 0.7;
margin-bottom: 4px;
}
.message pre {
background: rgba(0, 0, 0, 0.05);
padding: 8px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
/* Input Area */
.input-area {
padding: 20px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 10px;
}
/* Status & Debug */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.success {
background: #dcfce7;
color: #166534;
}
.status-badge.error {
background: #fee2e2;
color: #991b1b;
}
#json-output {
font-family: monospace;
font-size: 0.75rem;
white-space: pre-wrap;
background: #1e293b;
color: #a5b4fc;
padding: 10px;
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
}
.hidden {
display: none;
}
/* Typing Indicator */
.typing-indicator {
padding: 12px 16px;
background-color: var(--ai-msg);
color: #64748b;
border-bottom-left-radius: 4px;
border-radius: 12px;
align-self: flex-start;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
}
.typing-dot {
width: 6px;
height: 6px;
background-color: #64748b;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
/* Listing Card Unit (Message + Card together) */
.listing-card-unit {
margin-top: 10px;
border-radius: 12px;
overflow: hidden;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.listing-card-unit .card-message {
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 14px;
line-height: 1.5;
}
.listing-card-unit .card-message p {
margin: 0;
}
.listing-card-unit .card-message strong {
font-weight: 600;
}
/* Listing Draft Preview */
.listing-draft {
margin-top: 0;
border: none;
border-radius: 0 0 8px 8px;
overflow: hidden;
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.listing-draft img.hero {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
}
.listing-draft .content {
padding: 16px;
}
.listing-draft h3 {
margin: 0 0 8px 0;
font-size: 1.1rem;
color: #1e293b;
}
.listing-draft p {
margin: 0 0 12px 0;
color: #64748b;
font-size: 0.9rem;
line-height: 1.5;
}
.listing-draft .details {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.listing-draft .pill {
background: #f1f5f9;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.listing-draft .amenities {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
/* Search Results Cards - With Hero Images */
.search-results-container {
margin-top: 12px;
width: 100%;
}
.search-results-scroll {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 8px 4px 16px 4px;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.search-results-scroll::-webkit-scrollbar {
height: 8px;
}
.search-results-scroll::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.search-results-scroll::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 4px;
}
.search-results-single {
display: flex;
justify-content: flex-start;
}
.search-result-card {
min-width: 300px;
max-width: 340px;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
scroll-snap-align: start;
flex-shrink: 0;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.search-result-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.15);
}
.search-card-image-container {
position: relative;
height: 160px;
overflow: hidden;
}
.search-card-hero {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.search-result-card:hover .search-card-hero {
transform: scale(1.05);
}
.search-card-type {
position: absolute;
top: 12px;
left: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
}
.search-relevance-badge {
position: absolute;
top: 12px;
right: 12px;
background: rgba(255, 255, 255, 0.95);
color: #f59e0b;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.search-card-content {
padding: 16px;
}
.search-card-title {
margin: 0 0 4px 0;
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-card-location {
margin: 0 0 8px 0;
font-size: 0.85rem;
color: #64748b;
}
.search-card-desc {
margin: 0 0 12px 0;
font-size: 0.875rem;
color: #475569;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.search-card-details {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.search-detail {
font-size: 0.875rem;
color: #64748b;
}
.search-card-amenities {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.amenity-pill {
background: #f1f5f9;
color: #475569;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.72rem;
}
.search-card-footer {
display: flex;
align-items: baseline;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.search-card-price {
font-size: 1.25rem;
font-weight: 700;
color: #059669;
}
.search-card-price-type {
font-size: 0.85rem;
color: #94a3b8;
margin-left: 2px;
}
/* ============================================================
MY LISTINGS CARDS - With Edit/Delete Buttons
============================================================ */
.my-listings-container {
margin-top: 12px;
}
.my-listings-scroll {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 8px 0;
scroll-snap-type: x mandatory;
}
.my-listing-card {
min-width: 280px;
max-width: 320px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
scroll-snap-align: start;
border: 1px solid #e2e8f0;
}
.my-listing-card-image {
position: relative;
height: 140px;
overflow: hidden;
}
.my-listing-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-listing-card-content {
padding: 14px;
}
.my-listing-card-title {
margin: 0 0 6px 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
.my-listing-card-location {
margin: 0 0 10px 0;
font-size: 0.85rem;
color: #64748b;
}
.my-listing-card-price {
font-size: 1.1rem;
font-weight: 700;
color: #059669;
margin-bottom: 14px;
}
.my-listing-card-actions {
display: flex;
gap: 10px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.my-listing-btn {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
}
.my-listing-btn-edit {
background: #f0f9ff;
color: #0369a1;
border: 1px solid #bae6fd;
}
.my-listing-btn-edit:hover {
background: #e0f2fe;
}
.my-listing-btn-delete {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.my-listing-btn-delete:hover {
background: #fee2e2;
}
.my-listing-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<div class="sidebar">
<div>
<h2>Authentication</h2>
<!-- Login Form -->
<div id="login-form">
<div class="form-group">
<label>Identifier (Email/Phone)</label>
<input type="text" id="auth-identifier" placeholder="e.g. user@example.com"
value="test@example.com">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="auth-password" value="Password123!">
</div>
<button onclick="login()">Login</button>
</div>
<!-- Authenticated State -->
<div id="auth-success" class="hidden">
<div class="status-badge success"
style="margin-bottom: 10px; width: 100%; box-sizing: border-box; text-align: center;">Logged In
</div>
<div class="form-group">
<label>User ID</label>
<input type="text" id="user-id" readonly>
</div>
<div class="form-group">
<label>Access Token</label>
<input type="text" id="access-token" readonly style="text-overflow: ellipsis;">
</div>
<button class="secondary" onclick="logout()">Logout</button>
</div>
<!-- Fallback Token Input -->
<div class="form-group" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 20px;">
<label>Manual Token Override</label>
<textarea id="manual-token" rows="2" placeholder="Paste JWT here if login fails"></textarea>
</div>
</div>
<div>
<h3>Session Options</h3>
<div class="form-group">
<label>Session ID</label>
<input type="text" id="session-id" placeholder="Auto-generated">
</div>
<div class="form-group">
<label>User Role</label>
<select id="user-role">
<option value="renter">Renter</option>
<option value="landlord">Landlord</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="secondary" onclick="newConversation()">Start New Conversation</button>
</div>
<div style="flex: 1; display: flex; flex-direction: column;">
<h3>Debug Output</h3>
<div id="json-output">Ready...</div>
</div>
</div>
<div class="main-chat">
<div
style="padding: 15px 20px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center;">
<h2 style="margin:0; font-size: 1.25rem;">AIDA Chat</h2>
<div id="connection-status" class="status-badge">Disconnected</div>
</div>
<div id="chat-history">
<!-- Messages will appear here -->
<div class="message ai">
<div class="meta">System</div>
Hello! I am AIDA. Please log in relative to the sidebar to start chatting.
</div>
</div>
<div class="input-area">
<input type="text" id="message-input" placeholder="Type your message..."
onkeypress="if(event.key === 'Enter') sendMessage()">
<button onclick="sendMessage()" style="width: auto;">Send</button>
<button onclick="uploadImage()" class="secondary" style="width: auto; background-color: #f59e0b;">📷
Upload Image</button>
</div>
<!-- Typing Indicator (Hidden by default) -->
<div id="typing-indicator" class="typing-indicator hidden" style="margin: 0 20px 20px 20px;">
<span>Thinking</span>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
</div>
<script>
// State
let authToken = '';
let currentSessionId = '';
// Use 127.0.0.1 to avoid localhost DNS/Av issues
const API_BASE = 'http://127.0.0.1:8000';
// Init
window.onload = function () {
currentSessionId = generateUUID();
document.getElementById('session-id').value = currentSessionId;
checkHealth();
};
// Global user personalization data
let userName = '';
let userLocation = '';
// Utils
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Image URL Regex
const IMG_URL_REGEX = /(https?:\/\/.*\.(?:png|jpg|jpeg|gif|webp|svg))/i;
// Markdown to HTML formatter (Browser-compatible, no lookbehind)
function formatMarkdown(text) {
if (!text) return '';
// Escape HTML first to prevent XSS
let formatted = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Convert markdown to HTML
// Bold: **text** (handle first to avoid conflict with single *)
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
formatted = formatted.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic: *text* (simple version - works after bold is processed)
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
formatted = formatted.replace(/_([^_]+)_/g, '<em>$1</em>');
// Headers: # Header
formatted = formatted.replace(/^### (.+)$/gm, '<h4>$1</h4>');
formatted = formatted.replace(/^## (.+)$/gm, '<h3>$1</h3>');
formatted = formatted.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Line breaks
formatted = formatted.replace(/\n/g, '<br>');
// Horizontal rule: ---
formatted = formatted.replace(/---/g, '<hr style="border:none; border-top:1px solid #e2e8f0; margin:10px 0;">');
return formatted;
}
function updateDebug(data) {
const el = document.getElementById('json-output');
el.textContent = JSON.stringify(data, null, 2);
}
function addMessage(role, text, debugInfo = null) {
const history = document.getElementById('chat-history');
const div = document.createElement('div');
div.className = `message ${role}`;
let html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`;
// Check for image URL to render it
const imgMatch = text.match(IMG_URL_REGEX);
if (imgMatch) {
// If text contains an image URL, render the image + the text (if it's not JUST the url)
const url = imgMatch[0];
const textWithoutUrl = text.replace(url, '').trim();
html += `<div style="margin-bottom:8px;">
<img src="${url}" style="max-width:100%; border-radius:8px; display:block;" alt="Uploaded Image" onerror="this.style.display='none'">
</div>`;
if (textWithoutUrl) {
html += `<div>${formatMarkdown(textWithoutUrl)}</div>`;
}
} else {
// Always show the actual text from the response (personalized by LLM)
html += `<div>${formatMarkdown(text)}</div>`;
}
// Handle draft_ui: Message + Card appear as ONE UNIT
// When updating, the entire unit moves to bottom with new message
if (debugInfo && debugInfo.draft_ui) {
const isUpdate = debugInfo.replace_last_message === true ||
(debugInfo.metadata && debugInfo.metadata.replace_last_message === true);
// Find and remove the ENTIRE message div that contains the card unit
// This prevents empty leftover divs in chat history
const existingCardUnit = document.querySelector('.listing-card-unit');
if (existingCardUnit) {
// Find the parent message div (the one with class 'message')
const parentMessageDiv = existingCardUnit.closest('.message');
if (parentMessageDiv) {
parentMessageDiv.remove();
console.log('[UI] Entire old message div with card removed');
} else {
existingCardUnit.remove();
console.log('[UI] Old card unit removed (no parent found)');
}
}
// For draft with card: Don't add text separately, it will be part of the card unit
// Remove the text we just added - it will be inside the card unit instead
html = `<div class="meta">${role === 'user' ? 'You' : 'AIDA'}</div>`;
// Create combined Message + Card unit
html += renderDraftWithMessage(debugInfo.draft_ui, text);
console.log('[UI] Card unit created with message:', isUpdate ? 'UPDATE' : 'INITIAL');
}
// Handle search results: Render as cards
if (debugInfo && debugInfo.action === 'search_results' && debugInfo.search_results && debugInfo.search_results.length > 0) {
html += renderSearchResults(debugInfo.search_results);
console.log('[UI] Search results cards rendered:', debugInfo.search_results.length);
}
// Handle my listings: Render as cards with Edit/Delete buttons
if (debugInfo && debugInfo.action === 'my_listings' && debugInfo.my_listings && debugInfo.my_listings.length > 0) {
html += renderMyListings(debugInfo.my_listings);
console.log('[UI] My listings cards rendered:', debugInfo.my_listings.length);
}
div.innerHTML = html;
history.appendChild(div);
history.scrollTop = history.scrollHeight;
}
// NEW: Render card WITH message as ONE unit
function renderDraftWithMessage(draft, message) {
const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image';
let amenitiesHtml = '';
if (draft.amenities && draft.amenities.length > 0) {
amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>';
}
return `
<div class="listing-card-unit">
<div class="card-message">${formatMarkdown(message)}</div>
<div class="listing-draft" id="listing-draft-preview">
<img src="${heroImage}" class="hero" alt="Listing Image">
<div class="content">
<h3>${draft.title}</h3>
<p>${draft.description}</p>
<div class="details">
<span class="pill">📍 ${draft.details.location}</span>
<span class="pill">💰 ${draft.details.price}</span>
<span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span>
<span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span>
<span class="pill">🏷️ ${draft.details.listing_type}</span>
</div>
${amenitiesHtml}
</div>
</div>
</div>`;
}
// Update an existing draft card in-place
function updateDraftCard(cardElement, draft) {
const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image';
// Update hero image
const heroImg = cardElement.querySelector('img.hero');
if (heroImg) heroImg.src = heroImage;
// Update title
const titleEl = cardElement.querySelector('h3');
if (titleEl) titleEl.textContent = draft.title;
// Update description
const descEl = cardElement.querySelector('p');
if (descEl) descEl.textContent = draft.description;
// Update details pills
const detailsDiv = cardElement.querySelector('.details');
if (detailsDiv && draft.details) {
detailsDiv.innerHTML = `
<span class="pill">📍 ${draft.details.location}</span>
<span class="pill">💰 ${draft.details.price}</span>
<span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span>
<span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span>
<span class="pill">🏷️ ${draft.details.listing_type}</span>
${draft.status === 'published' ? '<span class="pill" style="background:#dcfce7; color:#166534; border:1px solid #86efac;">✅ Published</span>' : ''}
`;
}
// Update amenities
const amenitiesDiv = cardElement.querySelector('.amenities');
if (amenitiesDiv && draft.amenities) {
amenitiesDiv.innerHTML = draft.amenities.map(a => `<span class="pill">${a}</span>`).join('');
}
// Add a subtle highlight animation to show the update
cardElement.style.transition = 'box-shadow 0.3s ease';
cardElement.style.boxShadow = '0 0 0 3px rgba(37, 99, 235, 0.5)';
setTimeout(() => {
cardElement.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
}, 500);
}
function renderDraft(draft) {
const heroImage = (draft.images && draft.images.length > 0) ? draft.images[0] : 'https://via.placeholder.com/400x200?text=No+Image';
let amenitiesHtml = '';
if (draft.amenities && draft.amenities.length > 0) {
amenitiesHtml = '<div class="amenities">' + draft.amenities.map(a => `<span class="pill">${a}</span>`).join('') + '</div>';
}
return `
<div class="listing-draft" id="listing-draft-preview">
<img src="${heroImage}" class="hero" alt="Listing Image">
<div class="content">
<h3>${draft.title}</h3>
<p>${draft.description}</p>
<div class="details">
<span class="pill">📍 ${draft.details.location}</span>
<span class="pill">💰 ${draft.details.price}</span>
<span class="pill">🛏️ ${draft.details.bedrooms} Bedroom</span>
<span class="pill">🚿 ${draft.details.bathrooms} Bathroom</span>
<span class="pill">🏷️ ${draft.details.listing_type}</span>
</div>
${amenitiesHtml}
</div>
</div>`;
}
// Render search results as cards (with hero images like draft UI)
function renderSearchResults(results) {
if (!results || results.length === 0) return '';
const cards = results.map(listing => {
const title = listing.title || 'Untitled Property';
const description = listing.description || 'A beautiful property waiting to be explored.';
const location = listing.location || 'Unknown';
const price = listing.price ? listing.price.toLocaleString() : 'N/A';
const currency = listing.currency || 'XOF';
const priceType = listing.price_type || 'monthly';
const bedrooms = listing.bedrooms || '?';
const bathrooms = listing.bathrooms || '?';
const listingType = listing.listing_type || 'rent';
const relevance = listing._relevance_score;
const amenities = listing.amenities || [];
// Get hero image (first image or placeholder)
const images = listing.images || [];
const heroImage = images.length > 0
? images[0]
: 'https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=400&h=200&fit=crop';
// Truncate description to 2 lines
const shortDesc = description.length > 100
? description.substring(0, 100) + '...'
: description;
// Build amenities HTML (max 4)
let amenitiesHtml = '';
if (amenities.length > 0) {
const displayAmenities = amenities.slice(0, 4);
amenitiesHtml = '<div class="search-card-amenities">' +
displayAmenities.map(a => `<span class="amenity-pill">${a}</span>`).join('') +
(amenities.length > 4 ? `<span class="amenity-pill">+${amenities.length - 4}</span>` : '') +
'</div>';
}
// Relevance badge for hybrid search results
let relevanceBadge = '';
if (relevance) {
const percent = Math.round(relevance * 100);
relevanceBadge = `<span class="search-relevance-badge">⭐ ${percent}% match</span>`;
}
return `
<div class="search-result-card">
<div class="search-card-image-container">
<img src="${heroImage}" class="search-card-hero" alt="${title}" onerror="this.src='https://via.placeholder.com/400x180?text=Property'">
<span class="search-card-type">${listingType}</span>
${relevanceBadge}
</div>
<div class="search-card-content">
<h3 class="search-card-title">${title}</h3>
<p class="search-card-location">📍 ${location}</p>
<p class="search-card-desc">${shortDesc}</p>
<div class="search-card-details">
<span class="search-detail">🛏️ ${bedrooms} bed</span>
<span class="search-detail">🚿 ${bathrooms} bath</span>
</div>
${amenitiesHtml}
<div class="search-card-footer">
<span class="search-card-price">${currency} ${price}</span>
<span class="search-card-price-type">/${priceType}</span>
</div>
</div>
</div>`;
}).join('');
// Use scroll container for multiple, simple flex for single
const containerClass = results.length === 1 ? 'search-results-single' : 'search-results-scroll';
return `
<div class="search-results-container">
<div class="${containerClass}">
${cards}
</div>
</div>`;
}
// Render My Listings Cards with Edit/Delete buttons
function renderMyListings(listings) {
if (!listings || listings.length === 0) {
return '';
}
const cards = listings.map(listing => {
const id = listing._id || listing.id || '';
const title = listing.title || 'Untitled';
const location = listing.location || 'Unknown Location';
const price = (listing.price || 0).toLocaleString();
const currency = listing.currency || 'XOF';
const priceType = listing.price_type || 'monthly';
const images = listing.images || [];
const heroImage = images.length > 0 ? images[0] : 'https://via.placeholder.com/400x140?text=Property';
return `
<div class="my-listing-card" data-listing-id="${id}">
<div class="my-listing-card-image">
<img src="${heroImage}" alt="${title}" onerror="this.src='https://via.placeholder.com/400x140?text=Property'">
</div>
<div class="my-listing-card-content">
<h4 class="my-listing-card-title">${title}</h4>
<p class="my-listing-card-location">📍 ${location}</p>
<p class="my-listing-card-price">${currency} ${price}/${priceType}</p>
<div class="my-listing-card-actions">
<button class="my-listing-btn my-listing-btn-edit" onclick="editListing('${id}')">✏️ Edit</button>
<button class="my-listing-btn my-listing-btn-delete" onclick="deleteListing('${id}', this)">🗑️ Delete</button>
</div>
</div>
</div>`;
}).join('');
return `
<div class="my-listings-container">
<div class="my-listings-scroll">
${cards}
</div>
</div>`;
}
// Delete listing function
async function deleteListing(listingId, buttonElement) {
if (!confirm('Are you sure you want to delete this listing?')) {
return;
}
buttonElement.disabled = true;
buttonElement.innerText = 'Deleting...';
try {
const res = await fetch(`${API_BASE}/listings/${listingId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Remove the card from UI
const card = buttonElement.closest('.my-listing-card');
if (card) {
card.style.opacity = '0.5';
card.style.transform = 'scale(0.95)';
setTimeout(() => card.remove(), 300);
}
addMessage('assistant', '✅ Listing deleted successfully!');
} else {
const error = await res.json();
addMessage('assistant', `❌ Failed to delete: ${error.detail || 'Unknown error'}`);
buttonElement.disabled = false;
buttonElement.innerText = '🗑️ Delete';
}
} catch (error) {
addMessage('assistant', `❌ Error: ${error.message}`);
buttonElement.disabled = false;
buttonElement.innerText = '🗑️ Delete';
}
}
// Edit listing function - triggers edit flow via chat
async function editListing(listingId) {
console.log('editListing called with ID:', listingId);
console.log('authToken present:', !!authToken, 'length:', authToken?.length);
if (!authToken) {
addMessage('assistant', '🔒 Please log in to edit listings.');
return;
}
// Send the edit command to the chat API
const editMessage = `edit listing ${listingId}`;
console.log('Sending edit request with token:', authToken.substring(0, 20) + '...');
// Get user details from DOM inputs (set during login)
const currentUserId = document.getElementById('user-id').value;
const currentUserRole = document.getElementById('user-role').value;
// Show user message
addMessage('user', `✏️ Edit listing`);
try {
const res = await fetch(`${API_BASE}/ai/ask`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({
message: editMessage,
session_id: currentSessionId,
user_id: currentUserId,
user_role: currentUserRole,
user_name: userName,
user_location: userLocation
})
});
const data = await res.json();
if (data.text) {
addMessage('assistant', data.text, data);
} else {
addMessage('assistant', '❌ Failed to load listing for editing.');
}
} catch (error) {
addMessage('assistant', `❌ Error: ${error.message}`);
}
}
// Auth Functions
async function login() {
// Visual feedback that the function started
const loginBtn = document.querySelector('button[onclick="login()"]');
const originalText = loginBtn.innerText;
loginBtn.innerText = "Logging in...";
loginBtn.disabled = true;
const identifier = document.getElementById('auth-identifier').value;
const password = document.getElementById('auth-password').value;
updateDebug({ status: "Attempting login...", identifier: identifier });
try {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password })
});
const data = await res.json();
updateDebug(data);
if (res.ok && data.success && data.data && data.data.token) {
const token = data.data.token;
const user = data.data.user;
const userId = user.id || user._id || 'unknown';
const userRole = user.role || 'renter';
// Extract personalization data
const name = user.firstName || user.name || user.fullName || '';
const location = user.location || user.city || user.address?.city || '';
setAuth(token, userId, userRole, name, location);
// alert("Login Successful!");
} else {
alert('Login failed: ' + (data.message || 'Unknown error'));
}
} catch (e) {
console.error("Login Error:", e);
updateDebug({ error: e.message, hint: "Is the backend running on port 8000?" });
alert('Login error: ' + e.message + "\n\nIs the backend server running?");
} finally {
loginBtn.innerText = originalText;
loginBtn.disabled = false;
}
}
function setAuth(token, userId, userRole, name = '', location = '') {
authToken = token;
userName = name;
userLocation = location;
document.getElementById('access-token').value = token;
document.getElementById('user-id').value = userId;
// Auto-fill the user role dropdown
if (userRole) {
document.getElementById('user-role').value = userRole;
}
// Log personalization data
console.log('[Auth] User personalization:', { name: userName, location: userLocation });
document.getElementById('login-form').classList.add('hidden');
document.getElementById('auth-success').classList.remove('hidden');
}
function logout() {
authToken = '';
document.getElementById('login-form').classList.remove('hidden');
document.getElementById('auth-success').classList.add('hidden');
document.getElementById('access-token').value = '';
document.getElementById('user-id').value = '';
}
// API Functions
async function checkHealth() {
try {
const res = await fetch(`${API_BASE}/health`);
const data = await res.json();
const statusEl = document.getElementById('connection-status');
if (res.ok) {
statusEl.textContent = 'Connected';
statusEl.classList.add('success');
} else {
statusEl.textContent = 'Error';
statusEl.classList.add('error');
}
} catch (e) {
document.getElementById('connection-status').textContent = 'Offline';
}
}
async function sendMessage() {
const input = document.getElementById('message-input');
const text = input.value.trim();
if (!text) return;
// Use manual token if provided
const manualToken = document.getElementById('manual-token').value.trim();
const effectiveToken = manualToken || authToken;
input.value = '';
addMessage('user', text);
const payload = {
message: text,
session_id: document.getElementById('session-id').value,
user_id: document.getElementById('user-id').value || undefined,
user_role: document.getElementById('user-role').value,
user_name: userName || undefined,
user_location: userLocation || undefined
};
const headers = {
'Content-Type': 'application/json'
};
if (effectiveToken) {
headers['Authorization'] = `Bearer ${effectiveToken}`;
}
updateDebug({ status: 'Sending...', payload });
// Show typing indicator
const typingEl = document.getElementById('typing-indicator');
const chatHistory = document.getElementById('chat-history');
// Move typing indicator to inside chat history temporarily or just toggle it
// Better to append a temporary element
const tempTypingId = 'temp-typing-' + Date.now();
const typingHtml = `
<div id="${tempTypingId}" class="message ai" style="display:flex; align-items:center; gap:8px;">
<div class="meta">AIDA</div>
<div style="display:flex; gap:4px; align-items:center;">
<span>Thinking</span>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>`;
// Append temporary typing indicator
const tempDiv = document.createElement('div');
tempDiv.innerHTML = typingHtml;
chatHistory.appendChild(tempDiv.firstElementChild);
chatHistory.scrollTop = chatHistory.scrollHeight;
try {
const res = await fetch(`${API_BASE}/ai/ask`, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
});
const data = await res.json();
updateDebug(data);
// Remove typing indicator
const typingNode = document.getElementById(tempTypingId);
if (typingNode) typingNode.remove();
if (data.metadata && data.metadata.replace_last_message) {
console.log('[UI] Replace last message signal received');
// For edit flow: Remove the old card-message unit entirely
// Then use addMessage to create a new one at the bottom
// This ensures both message AND card update correctly
if (data.draft_ui) {
// Find and remove the entire old card-message unit
const existingCardUnit = document.querySelector('.listing-card-unit');
if (existingCardUnit) {
const parentMessageDiv = existingCardUnit.closest('.message');
if (parentMessageDiv) {
parentMessageDiv.remove();
console.log('[UI] Old card-message unit removed');
} else {
existingCardUnit.remove();
}
}
}
// Now use addMessage to create the proper response (with card if present)
const text = data.text || data.message || '';
addMessage('ai', text, data);
console.log('[UI] New message (with card if present) added at bottom');
} else {
// Standard append
if (data.text) {
addMessage('ai', data.text, data);
} else if (data.message) {
addMessage('ai', data.message, data);
} else {
addMessage('ai', JSON.stringify(data), data);
}
}
} catch (e) {
// Remove typing indicator on error
const typingNode = document.getElementById(tempTypingId);
if (typingNode) typingNode.remove();
updateDebug({ error: e.message });
addMessage('ai', 'Error: ' + e.message);
}
}
async function uploadImage() {
// Trigger hidden file input
let fileInput = document.getElementById('hidden-file-input');
if (!fileInput) {
fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'hidden-file-input';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.addEventListener('change', handleFileSelect);
}
fileInput.click();
}
async function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const history = document.getElementById('chat-history');
// Create local preview URL immediately
const localPreviewUrl = URL.createObjectURL(file);
// Create a unique ID for this upload message
const uploadMsgId = 'upload-' + Date.now();
// Show user message with image preview + loading overlay
const previewHtml = `
<div id="${uploadMsgId}" class="message user">
<div class="meta">You</div>
<div style="position: relative; display: inline-block; max-width: 300px;">
<img id="${uploadMsgId}-img" src="${localPreviewUrl}"
style="max-width: 100%; border-radius: 8px; display: block; opacity: 0.7;"
alt="Uploading...">
<div id="${uploadMsgId}-overlay" style="
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
border-radius: 8px;
color: white; font-weight: 500;
">
<span>⏳ Uploading...</span>
</div>
</div>
<div style="font-size: 0.85rem; color: #64748b; margin-top: 4px;">
📷 Property image
</div>
</div>`;
history.insertAdjacentHTML('beforeend', previewHtml);
history.scrollTop = history.scrollHeight;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('https://lojiz-worker.lojiz-uploadapi.workers.dev/', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
const result = await response.json();
console.log("Upload result:", result);
let imageUrl = result.url || result.secure_url || result.data?.url || result.imageUrl;
if (!imageUrl && typeof result === 'string' && result.startsWith('http')) {
imageUrl = result;
}
if (imageUrl) {
// Update the preview: remove overlay, set final image, full opacity
const imgEl = document.getElementById(`${uploadMsgId}-img`);
const overlayEl = document.getElementById(`${uploadMsgId}-overlay`);
if (imgEl) {
imgEl.src = imageUrl;
imgEl.style.opacity = '1';
}
if (overlayEl) {
overlayEl.innerHTML = '✅';
setTimeout(() => overlayEl.remove(), 1000);
}
// Revoke the local URL to free memory
URL.revokeObjectURL(localPreviewUrl);
// Send the image URL to AIDA (hidden from user display since image is already shown)
const message = `Here is the image of the property: ${imageUrl}`;
// Don't show another user message, just send to backend
await sendImageToAi(message);
} else {
throw new Error("Could not extract image URL from response");
}
} catch (e) {
console.error("Upload failed:", e);
// Update overlay to show error
const overlayEl = document.getElementById(`${uploadMsgId}-overlay`);
if (overlayEl) {
overlayEl.innerHTML = '❌ Failed';
overlayEl.style.background = 'rgba(220, 38, 38, 0.7)';
}
URL.revokeObjectURL(localPreviewUrl);
}
// Clear file input
event.target.value = '';
}
// Send image URL to AI without showing duplicate user message
async function sendImageToAi(message) {
const manualToken = document.getElementById('manual-token').value.trim();
const effectiveToken = manualToken || authToken;
const payload = {
message: message,
session_id: document.getElementById('session-id').value,
user_id: document.getElementById('user-id').value || undefined,
user_role: document.getElementById('user-role').value
};
const headers = { 'Content-Type': 'application/json' };
if (effectiveToken) {
headers['Authorization'] = `Bearer ${effectiveToken}`;
}
// Show typing indicator
const history = document.getElementById('chat-history');
const tempTypingId = 'temp-typing-' + Date.now();
const typingHtml = `
<div id="${tempTypingId}" class="message ai" style="display:flex; align-items:center; gap:8px;">
<div class="meta">AIDA</div>
<div style="display:flex; gap:4px; align-items:center;">
<span>Processing image</span>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>`;
history.insertAdjacentHTML('beforeend', typingHtml);
history.scrollTop = history.scrollHeight;
try {
const res = await fetch(`${API_BASE}/ai/ask`, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
});
const data = await res.json();
// Remove typing indicator
const typingNode = document.getElementById(tempTypingId);
if (typingNode) typingNode.remove();
if (data.text) {
addMessage('ai', data.text, data);
} else if (data.message) {
addMessage('ai', data.message, data);
}
} catch (e) {
const typingNode = document.getElementById(tempTypingId);
if (typingNode) typingNode.remove();
addMessage('ai', 'Error processing image: ' + e.message);
}
}
function newConversation() {
currentSessionId = generateUUID();
document.getElementById('session-id').value = currentSessionId;
// Clear chat history
const history = document.getElementById('chat-history');
history.innerHTML = `
<div class="message ai">
<div class="meta">System</div>
New conversation started! Session ID: ${currentSessionId}
</div>`;
updateDebug({ status: 'New conversation started', session_id: currentSessionId });
}
</script>
</body>
</html>