/** * CareerAI — Claude-Style Frontend * Full implementation connected to FastAPI backend */ // ===== CONFIG ===== const API_BASE = window.location.origin; // Same origin (served by FastAPI) // ===== STATE ===== const state = { sidebarOpen: true, currentModel: 'llama-3.3-70b-versatile', currentModelDisplay: 'CareerAI Pro', messages: [], conversations: JSON.parse(localStorage.getItem('careerai_conversations') || '[]'), currentConversationId: null, documents: [], documentId: null, apiConfigured: false, apiKey: '', currentUser: null, authToken: localStorage.getItem('careerai_token') || null, authMode: 'login' // 'login', 'register', 'forgot', 'reset' }; // ===== ELEMENTS ===== const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const els = { sidebar: $('#sidebar'), toggleSidebar: $('#toggleSidebar'), mobileSidebarToggle: $('#mobileSidebarToggle'), newChatBtn: $('#newChatBtn'), searchInput: $('#searchInput'), conversationList: $('#conversationList'), documentList: $('#documentList'), mainContent: $('#mainContent'), welcomeScreen: $('#welcomeScreen'), chatScreen: $('#chatScreen'), chatMessages: $('#chatMessages'), welcomeInput: $('#welcomeInput'), chatInput: $('#chatInput'), sendBtn: $('#sendBtn'), chatSendBtn: $('#chatSendBtn'), attachBtn: $('#attachBtn'), chatAttachBtn: $('#chatAttachBtn'), modelSelector: $('#modelSelector'), chatModelSelector: $('#chatModelSelector'), modelDropdown: $('#modelDropdown'), uploadModal: $('#uploadModal'), uploadBackdrop: $('#uploadBackdrop'), uploadClose: $('#uploadClose'), uploadDropzone: $('#uploadDropzone'), fileInput: $('#fileInput'), notificationBar: $('#notificationBar'), }; // ===== INIT ===== document.addEventListener('DOMContentLoaded', init); async function init() { setupSidebar(); setupNavigation(); setupInput(); setupModelSelector(); setupUpload(); setupChips(); autoResizeTextarea(els.welcomeInput); autoResizeTextarea(els.chatInput); // Load API stats and Auth await checkApiStatus(); await checkAuthSession(); renderConversations(); updateSidebarUser(); // Auto-collapse sidebar on mobile devices if (window.innerWidth <= 768) { els.sidebar.classList.add('collapsed'); } } // ===== API HELPERS ===== if (!localStorage.getItem('careerai_session')) { localStorage.setItem('careerai_session', 'session_' + Math.random().toString(36).substr(2, 9)); } state.sessionId = localStorage.getItem('careerai_session'); async function apiGet(path) { const headers = { 'X-Session-ID': state.sessionId }; if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`; const res = await fetch(`${API_BASE}${path}`, { headers }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); if (res.status === 401) handleLogout(); throw new Error(err.detail || 'API Error'); } return res.json(); } async function apiPost(path, body, useUrlEncoded = false) { const headers = { 'X-Session-ID': state.sessionId }; if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`; if (!useUrlEncoded) headers['Content-Type'] = 'application/json'; else headers['Content-Type'] = 'application/x-www-form-urlencoded'; const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: useUrlEncoded ? body : JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); if (res.status === 401) handleLogout(); throw new Error(err.detail || 'API Error'); } return res.json(); } async function apiDelete(path) { const headers = { 'X-Session-ID': state.sessionId }; if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`; const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); if (res.status === 401) handleLogout(); throw new Error(err.detail || 'API Error'); } return res.json(); } // ===== STATUS CHECK ===== async function checkApiStatus() { try { const status = await apiGet('/api/status'); state.apiConfigured = status.api_configured; state.currentModel = status.model || state.currentModel; state.documents = status.documents || []; // Update model display name const modelNames = { 'llama-3.3-70b-versatile': 'CareerAI Pro', 'llama-3.1-8b-instant': 'CareerAI Flash', }; state.currentModelDisplay = modelNames[state.currentModel] || state.currentModel; $$('.model-name').forEach(n => n.textContent = state.currentModelDisplay); // Update notification bar if (state.apiConfigured) { els.notificationBar.innerHTML = ` ● Conectado · ${state.currentModelDisplay} · ${status.total_documents} docs · ${status.total_chunks} chunks `; } else { els.notificationBar.innerHTML = ` ● Sin configurar · Configurar API Key `; } // Update model selector active state $$('.model-option').forEach(opt => { opt.classList.toggle('active', opt.dataset.model === state.currentModel); }); // Render documents renderDocumentsFromList(state.documents); } catch (e) { console.warn('Could not check API status:', e.message); els.notificationBar.innerHTML = ` ● Backend no disponible · Asegúrate de ejecutar: uvicorn api:app --port 8000 `; } } // ===== API CONFIG ===== function showApiConfig() { // Create inline config modal const existing = document.getElementById('apiConfigModal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'apiConfigModal'; modal.className = 'upload-modal'; modal.innerHTML = `

🔑 Configurar API Key

Obtén tu API key gratis en console.groq.com

`; document.body.appendChild(modal); document.getElementById('apiKeyInput').focus(); } window.showApiConfig = showApiConfig; async function saveApiConfig() { const input = document.getElementById('apiKeyInput'); const statusEl = document.getElementById('apiConfigStatus'); const apiKey = input.value.trim(); if (!apiKey) { statusEl.innerHTML = '
Ingresa un API key
'; return; } statusEl.innerHTML = '
Conectando...
'; try { const result = await apiPost('/api/config', { api_key: apiKey, model: state.currentModel, }); state.apiConfigured = true; state.apiKey = apiKey; statusEl.innerHTML = '
¡Conectado exitosamente!
'; setTimeout(() => { document.getElementById('apiConfigModal')?.remove(); checkApiStatus(); }, 1000); } catch (e) { statusEl.innerHTML = `
Error: ${e.message}
`; } } window.saveApiConfig = saveApiConfig; // ===== SIDEBAR ===== function setupSidebar() { els.toggleSidebar.addEventListener('click', toggleSidebar); els.mobileSidebarToggle.addEventListener('click', () => { els.sidebar.classList.remove('collapsed'); }); document.addEventListener('click', (e) => { if (window.innerWidth <= 768) { if (!els.sidebar.contains(e.target) && !els.mobileSidebarToggle.contains(e.target)) { els.sidebar.classList.add('collapsed'); } } }); els.newChatBtn.addEventListener('click', newChat); // Login & Profile logic bindings const userMenu = document.getElementById('userMenu'); const loginModal = document.getElementById('loginModal'); const loginClose = document.getElementById('loginClose'); const loginBackdrop = document.getElementById('loginBackdrop'); const profileModal = document.getElementById('profileModal'); const profileClose = document.getElementById('profileClose'); const profileBackdrop = document.getElementById('profileBackdrop'); if (userMenu) { userMenu.addEventListener('click', () => { if (!state.currentUser) { loginModal.classList.remove('hidden'); } else { // Open Profile Modal document.getElementById('profileName').value = state.currentUser.name; document.getElementById('profileEmail').value = state.currentUser.email; document.getElementById('profilePreview').src = state.currentUser.picture; profileModal.classList.remove('hidden'); } }); } if (loginClose) loginClose.addEventListener('click', () => { loginModal.classList.add('hidden'); setAuthMode('login'); }); if (loginBackdrop) loginBackdrop.addEventListener('click', () => { loginModal.classList.add('hidden'); setAuthMode('login'); }); if (profileClose) profileClose.addEventListener('click', () => profileModal.classList.add('hidden')); if (profileBackdrop) profileBackdrop.addEventListener('click', () => profileModal.classList.add('hidden')); } async function checkAuthSession() { if (state.authToken) { try { const user = await apiGet('/api/auth/me'); state.currentUser = user; updateSidebarUser(); // Sync conversations const cloudConvs = await apiGet('/api/conversations'); if (cloudConvs && cloudConvs.length > 0) { state.conversations = cloudConvs; saveConversations(); // Sync strictly to local cache initially renderConversations(); } } catch (e) { handleLogout(); } } } window.handleAuthSubmit = async function (event) { event.preventDefault(); const btn = document.getElementById('authSubmitBtn'); const email = document.getElementById('authEmail').value.trim(); const password = document.getElementById('authPassword').value; const name = document.getElementById('authName').value.trim(); const resetCode = document.getElementById('authResetCode')?.value.trim(); const mode = state.authMode; try { btn.innerHTML = ''; btn.style.pointerEvents = 'none'; let result; if (mode === 'register') { result = await apiPost('/api/auth/register', { name, email, password }); } else if (mode === 'login') { result = await apiPost('/api/auth/login', `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`, true); } else if (mode === 'reset') { result = await apiPost('/api/auth/reset-password', { email, code: resetCode, new_password: password }); showToast('✅ ' + result.message); setAuthMode('login'); return; } state.authToken = result.access_token; localStorage.setItem('careerai_token', result.access_token); document.getElementById('loginModal').classList.add('hidden'); showToast('✅ Sesión iniciada con éxito'); await checkAuthSession(); } catch (err) { showToast('❌ Error: ' + err.message); } finally { btn.innerHTML = mode === 'register' ? 'Registrarme' : (mode === 'reset' ? 'Actualizar Contraseña' : 'Iniciar Sesión'); btn.style.pointerEvents = 'auto'; } }; window.setAuthMode = function (mode) { state.authMode = mode; const registerFields = document.getElementById('registerFields'); const resetCodeFields = document.getElementById('resetCodeFields'); const passwordFieldsGroup = document.getElementById('passwordFieldsGroup'); const forgotPassContainer = document.getElementById('forgotPassContainer'); const authToggleContainer = document.getElementById('authToggleContainer'); const backToLoginContainer = document.getElementById('backToLoginContainer'); const loginTitle = document.getElementById('loginTitle'); const authSubmitBtn = document.getElementById('authSubmitBtn'); const authSendCodeBtn = document.getElementById('authSendCodeBtn'); // Hide all uniquely conditional elements initially registerFields.style.display = 'none'; resetCodeFields.style.display = 'none'; passwordFieldsGroup.style.display = 'none'; forgotPassContainer.style.display = 'none'; authToggleContainer.style.display = 'none'; backToLoginContainer.style.display = 'none'; authSendCodeBtn.style.display = 'none'; authSubmitBtn.style.display = 'flex'; document.getElementById('authPassword').required = false; if (mode === 'login') { passwordFieldsGroup.style.display = 'block'; forgotPassContainer.style.display = 'block'; authToggleContainer.style.display = 'block'; document.getElementById('authPassword').required = true; loginTitle.innerText = 'Acceso a CareerAI'; authSubmitBtn.innerText = 'Iniciar Sesión'; document.getElementById('authToggleText').innerHTML = '¿No tienes cuenta? Regístrate'; } else if (mode === 'register') { registerFields.style.display = 'block'; passwordFieldsGroup.style.display = 'block'; authToggleContainer.style.display = 'block'; document.getElementById('authPassword').required = true; loginTitle.innerText = 'Crear cuenta'; authSubmitBtn.innerText = 'Registrarme'; document.getElementById('authToggleText').innerHTML = '¿Ya tienes cuenta? Inicia sesión'; } else if (mode === 'forgot') { backToLoginContainer.style.display = 'block'; authSubmitBtn.style.display = 'none'; authSendCodeBtn.style.display = 'flex'; loginTitle.innerText = 'Recuperar contraseña'; } else if (mode === 'reset') { resetCodeFields.style.display = 'block'; passwordFieldsGroup.style.display = 'block'; backToLoginContainer.style.display = 'block'; document.getElementById('authPassword').required = true; loginTitle.innerText = 'Nueva contraseña'; authSubmitBtn.innerText = 'Actualizar Contraseña'; // Minor QOL: focus the code input directly setTimeout(() => document.getElementById('authResetCode')?.focus(), 100); } }; window.handleSendResetCode = async function (event) { event.preventDefault(); const email = document.getElementById('authEmail').value.trim(); if (!email) { showToast('⚠️ Ingresa tu correo electrónico', 'warning'); return; } const btn = document.getElementById('authSendCodeBtn'); try { btn.innerHTML = ''; btn.style.pointerEvents = 'none'; const result = await apiPost('/api/auth/forgot-password', { email }); showToast('✅ ' + result.message); // Move to phase 2 setAuthMode('reset'); } catch (err) { showToast('❌ Error: ' + err.message); } finally { btn.innerHTML = 'Enviar código a mi correo'; btn.style.pointerEvents = 'auto'; } }; window.handleProfilePictureSelect = function (event) { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { // For now we set it as local base64 until submission document.getElementById('profilePreview').src = e.target.result; }; reader.readAsDataURL(file); } }; window.handleProfileSubmit = async function (event) { event.preventDefault(); const btn = document.getElementById('profileSubmitBtn'); const name = document.getElementById('profileName').value.trim(); // In our implementation we can send base64 image or just accept name for now // Check if user changed picture logic const imgEl = document.getElementById('profilePreview'); const pictureStr = imgEl.src.startsWith('data:image') ? imgEl.src : state.currentUser.picture; try { btn.innerHTML = ''; btn.style.pointerEvents = 'none'; const result = await apiPost('/api/auth/me', { name: name, picture: pictureStr }); state.currentUser.name = result.name; state.currentUser.picture = result.picture; updateSidebarUser(); document.getElementById('profileModal').classList.add('hidden'); showToast('✅ Perfil actualizado exitosamente'); } catch (err) { showToast('❌ Error al actualizar perfil: ' + err.message); } finally { btn.innerHTML = 'Guardar Cambios'; btn.style.pointerEvents = 'auto'; } }; function handleLogout() { state.currentUser = null; state.authToken = null; localStorage.removeItem('careerai_token'); // Clear user localized states safely state.conversations = []; state.currentConversationId = null; state.messages = []; state.documents = []; localStorage.removeItem('careerai_conversations'); // Generate a new session ID for the guest const newSession = 'session_' + Math.random().toString(36).substr(2, 9); localStorage.setItem('careerai_session', newSession); state.sessionId = newSession; updateSidebarUser(); renderConversations(); renderDocumentsFromList([]); showWelcome(); document.getElementById('profileModal')?.classList.add('hidden'); showToast('👋 Sesión cerrada'); // Refresh status with new session checkApiStatus(); } function toggleSidebar() { els.sidebar.classList.toggle('collapsed'); } function updateSidebarUser() { const userMenu = document.getElementById('userMenu'); if (!userMenu) return; if (state.currentUser) { userMenu.innerHTML = ` ${state.currentUser.name} `; } else { userMenu.innerHTML = `
Iniciar sesión `; } } // ===== NAVIGATION ===== function setupNavigation() { $$('.nav-item').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const page = item.dataset.page; $$('.nav-item').forEach(n => n.classList.remove('active')); item.classList.add('active'); if (page === 'chat') { hideDashboardPage(); if (state.messages.length === 0) showWelcome(); else showChat(); } else if (page === 'documents') { els.uploadModal.classList.remove('hidden'); } else if (page === 'dashboard') { showDashboardPage(); } else if (page === 'settings') { showApiConfig(); } // Close sidebar on mobile after clicking if (window.innerWidth <= 768) { els.sidebar.classList.add('collapsed'); } }); }); } // ===== DASHBOARD PAGE ===== function showDashboardPage() { els.welcomeScreen.classList.add('hidden'); els.welcomeScreen.style.display = 'none'; els.chatScreen.classList.add('hidden'); els.chatScreen.style.display = 'none'; let dashPage = document.getElementById('dashboardPage'); if (dashPage) dashPage.remove(); dashPage = document.createElement('div'); dashPage.id = 'dashboardPage'; dashPage.style.cssText = 'flex:1; overflow-y:auto; padding:40px 24px; animation: fadeIn 0.4s ease-out;'; dashPage.innerHTML = `

📊 Dashboard Profesional

Análisis inteligente de tus documentos — perfil, skills y experiencia.

Documentos
Chunks
Skills
Experiencias

📊 Skills por Categoría

🎯 Skills por Nivel

Analizando tus documentos con IA...

Esto puede tomar 10-20 segundos

`; els.mainContent.appendChild(dashPage); addDashboardStyles(); loadDashboardData(); } function addDashboardStyles() { if (document.getElementById('dashStyles')) return; const s = document.createElement('style'); s.id = 'dashStyles'; s.textContent = ` .dash-kpi { background:var(--bg-input); border:1px solid var(--border-light); border-radius:14px; padding:20px; text-align:center; transition:transform 0.2s,box-shadow 0.2s; } .dash-kpi:hover { transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,0.06); } .dash-kpi-value { font-size:2rem; font-weight:700; color:var(--accent-primary); line-height:1.2; } .dash-kpi-label { font-size:0.75rem; color:var(--text-tertiary); font-weight:600; text-transform:uppercase; letter-spacing:0.06em; margin-top:4px; } .dash-card { background:var(--bg-input); border:1px solid var(--border-light); border-radius:14px; padding:24px; margin-bottom:16px; } .dash-card-title { font-size:1rem; font-weight:600; margin-bottom:16px; color:var(--text-primary); } .dash-action-btn { padding:10px 20px; border:1px solid var(--border-light); border-radius:10px; background:var(--bg-input); color:var(--text-primary); font-size:0.88rem; font-weight:500; cursor:pointer; font-family:var(--font-family); transition:all 0.2s; } .dash-action-btn:hover { background:var(--bg-secondary); border-color:var(--accent-primary); color:var(--accent-primary); } .skill-badge { display:inline-flex; align-items:center; gap:4px; padding:4px 10px; border-radius:6px; font-size:0.8rem; font-weight:500; margin:3px; } .skill-badge.advanced { background:#dcfce7; color:#166534; } .skill-badge.intermediate { background:#dbeafe; color:#1e40af; } .skill-badge.basic { background:#fef3c7; color:#92400e; } .timeline-item { position:relative; padding:16px 0 16px 28px; border-left:2px solid var(--border-light); } .timeline-item:before { content:''; position:absolute; left:-5px; top:20px; width:8px; height:8px; border-radius:50%; background:var(--accent-primary); border:2px solid var(--bg-primary); } .timeline-item.current:before { background:#16a34a; box-shadow:0 0 0 3px rgba(22,163,74,0.2); } .timeline-role { font-weight:600; font-size:0.95rem; } .timeline-company { color:var(--accent-primary); font-size:0.88rem; } .timeline-dates { color:var(--text-tertiary); font-size:0.8rem; margin-top:2px; } .timeline-desc { color:var(--text-secondary); font-size:0.84rem; margin-top:4px; } .insight-section { margin-bottom:16px; } .insight-section h4 { font-size:0.88rem; font-weight:600; margin-bottom:8px; } .insight-item { padding:6px 0; font-size:0.86rem; color:var(--text-secondary); border-bottom:1px solid var(--border-light); } .insight-item:last-child { border-bottom:none; } @media (max-width:768px) { #dashKpis { grid-template-columns:repeat(2,1fr) !important; } } `; document.head.appendChild(s); } async function loadDashboardData() { const loading = document.getElementById('dashLoading'); try { const status = await apiGet('/api/status'); const el = (id) => document.getElementById(id); if (el('kpiDocs')) el('kpiDocs').textContent = status.total_documents; if (el('kpiChunks')) el('kpiChunks').textContent = status.total_chunks; } catch (e) { /* ignore */ } try { const data = await apiGet('/api/dashboard'); if (loading) loading.style.display = 'none'; if (!data.has_data) { if (loading) { loading.style.display = 'block'; loading.innerHTML = `

📭

No hay datos para analizar

${data.error || 'Sube documentos o configura tu API key.'}

`; } return; } const el = (id) => document.getElementById(id); if (el('kpiSkills')) el('kpiSkills').textContent = data.total_skills || 0; if (el('kpiExp')) el('kpiExp').textContent = data.total_experience || 0; // Profile Summary if (data.summary && (data.summary.headline || data.summary.estimated_seniority)) { const sc = el('dashSummaryCard'); if (sc) sc.style.display = 'block'; const s = data.summary; el('dashSummaryContent').innerHTML = `
Headline
${s.headline || '—'}
Seniority
${s.estimated_seniority || '—'}
Años Experiencia
${s.total_years_experience || '—'} años
`; } // Charts renderCategoryChart(data.skills_by_category || {}); renderLevelChart(data.skills_by_level || {}); // Skills Table if (data.skills && data.skills.length > 0) { el('dashSkillsCard').style.display = 'block'; const grouped = {}; data.skills.forEach(sk => { const c = sk.category || 'other'; if (!grouped[c]) grouped[c] = []; grouped[c].push(sk); }); const catL = { technical: '💻 Técnicas', soft: '🤝 Soft Skills', tools: '🔧 Herramientas', language: '🌍 Idiomas', other: '📌 Otras' }; let h = ''; for (const [cat, skills] of Object.entries(grouped)) { h += `
${catL[cat] || cat}
`; skills.forEach(sk => { h += `${sk.name}`; }); h += '
'; } el('dashSkillsTable').innerHTML = h; } // Timeline if (data.experience_timeline && data.experience_timeline.length > 0) { el('dashTimelineCard').style.display = 'block'; el('dashTimeline').innerHTML = data.experience_timeline.map(exp => `
${exp.role}
${exp.company}
${exp.start_date} → ${exp.end_date}${exp.current ? ' (Actual)' : ''}
${exp.description ? `
${exp.description}
` : ''}
`).join(''); } // Insights if (data.insights) { const ins = data.insights; if (ins.strengths?.length || ins.potential_gaps?.length || ins.role_suggestions?.length || ins.next_actions?.length) { el('dashInsightsCard').style.display = 'block'; let h = ''; if (ins.strengths?.length) h += `

💪 Fortalezas

${ins.strengths.map(s => `
✅ ${s}
`).join('')}
`; if (ins.potential_gaps?.length) h += `

📉 Áreas de mejora

${ins.potential_gaps.map(s => `
⚠️ ${s}
`).join('')}
`; if (ins.role_suggestions?.length) h += `

🎯 Roles sugeridos

${ins.role_suggestions.map(s => `
🏢 ${s}
`).join('')}
`; if (ins.next_actions?.length) h += `

🚀 Próximos pasos

${ins.next_actions.map(s => `
→ ${s}
`).join('')}
`; el('dashInsights').innerHTML = h; } } } catch (e) { console.error('Dashboard error:', e); if (loading) loading.innerHTML = `

⚠️

Error al cargar el dashboard

${e.message}

`; } } function renderCategoryChart(data) { const canvas = document.getElementById('chartCategory'); if (!canvas || !window.Chart) return; const labels = Object.keys(data), values = Object.values(data); if (!labels.length) { document.getElementById('chartCategoryWrap').innerHTML = '

Sin datos

'; return; } const catN = { technical: 'Técnicas', soft: 'Soft Skills', tools: 'Herramientas', language: 'Idiomas', other: 'Otras' }; const catC = { technical: '#c97c3e', soft: '#6366f1', tools: '#10b981', language: '#f59e0b', other: '#8b5cf6' }; new Chart(canvas, { type: 'bar', data: { labels: labels.map(l => catN[l] || l), datasets: [{ data: values, backgroundColor: labels.map(l => catC[l] || '#94a3b8'), borderRadius: 8, borderSkipped: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { ticks: { font: { size: 11 } }, grid: { display: false } } } } }); } function renderLevelChart(data) { const canvas = document.getElementById('chartLevel'); if (!canvas || !window.Chart) return; const labels = Object.keys(data), values = Object.values(data); if (values.every(v => v === 0)) { document.getElementById('chartLevelWrap').innerHTML = '

Sin datos

'; return; } const levelN = { basic: 'Básico', intermediate: 'Intermedio', advanced: 'Avanzado' }; new Chart(canvas, { type: 'doughnut', data: { labels: labels.map(l => levelN[l] || l), datasets: [{ data: values, backgroundColor: ['#fbbf24', '#3b82f6', '#22c55e'], borderWidth: 0, hoverOffset: 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '60%', plugins: { legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyleWidth: 8, font: { size: 12 } } } } } }); } function hideDashboardPage() { const dashPage = document.getElementById('dashboardPage'); if (dashPage) dashPage.remove(); } function clearAllConversations() { if (confirm('¿Estás seguro? Se borrarán todas las conversaciones guardadas.')) { state.conversations = []; state.messages = []; state.currentConversationId = null; saveConversations(); renderConversations(); showWelcome(); hideDashboardPage(); showToast('🗑️ Historial limpiado'); } } window.clearAllConversations = clearAllConversations; // ===== INPUT ===== function setupInput() { // Welcome input els.welcomeInput.addEventListener('input', () => { autoResizeTextarea(els.welcomeInput); els.sendBtn.disabled = !els.welcomeInput.value.trim(); }); els.welcomeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (els.welcomeInput.value.trim() && !state.isStreaming) { sendMessage(els.welcomeInput.value.trim()); els.welcomeInput.value = ''; autoResizeTextarea(els.welcomeInput); els.sendBtn.disabled = true; } } }); els.sendBtn.addEventListener('click', () => { if (els.welcomeInput.value.trim() && !state.isStreaming) { sendMessage(els.welcomeInput.value.trim()); els.welcomeInput.value = ''; autoResizeTextarea(els.welcomeInput); els.sendBtn.disabled = true; } }); // Chat input els.chatInput.addEventListener('input', () => { autoResizeTextarea(els.chatInput); els.chatSendBtn.disabled = !els.chatInput.value.trim(); }); els.chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (els.chatInput.value.trim() && !state.isStreaming) { sendMessage(els.chatInput.value.trim()); els.chatInput.value = ''; autoResizeTextarea(els.chatInput); els.chatSendBtn.disabled = true; } } }); els.chatSendBtn.addEventListener('click', () => { if (els.chatInput.value.trim() && !state.isStreaming) { sendMessage(els.chatInput.value.trim()); els.chatInput.value = ''; autoResizeTextarea(els.chatInput); els.chatSendBtn.disabled = true; } }); } function autoResizeTextarea(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; } // ===== MODEL SELECTOR ===== function setupModelSelector() { const selectors = [els.modelSelector, els.chatModelSelector]; selectors.forEach(sel => { sel.addEventListener('click', (e) => { e.stopPropagation(); const rect = sel.getBoundingClientRect(); const dropdown = els.modelDropdown; if (!dropdown.classList.contains('hidden')) { dropdown.classList.add('hidden'); return; } dropdown.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; dropdown.style.left = rect.left + 'px'; dropdown.classList.remove('hidden'); }); }); $$('.model-option').forEach(opt => { opt.addEventListener('click', async () => { const model = opt.dataset.model; const display = opt.dataset.display; state.currentModel = model; state.currentModelDisplay = display; $$('.model-name').forEach(n => n.textContent = display); $$('.model-option').forEach(o => o.classList.remove('active')); opt.classList.add('active'); els.modelDropdown.classList.add('hidden'); // Update on backend if (state.apiConfigured) { try { await fetch(`${API_BASE}/api/model?model=${model}`, { method: 'POST' }); showToast(`Modelo cambiado a ${display}`); } catch (e) { showToast(`Error al cambiar modelo: ${e.message}`); } } else { showToast(`Modelo: ${display} (conecta API key para usar)`); } }); }); document.addEventListener('click', (e) => { if (!els.modelDropdown.contains(e.target)) { els.modelDropdown.classList.add('hidden'); } }); } // ===== UPLOAD ===== function setupUpload() { [els.attachBtn, els.chatAttachBtn].forEach(btn => { btn.addEventListener('click', () => { els.uploadModal.classList.remove('hidden'); }); }); els.uploadClose.addEventListener('click', closeUploadModal); els.uploadBackdrop.addEventListener('click', closeUploadModal); els.uploadDropzone.addEventListener('click', () => els.fileInput.click()); els.fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) handleFileUpload(e.target.files[0]); }); els.uploadDropzone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadDropzone.classList.add('drag-over'); }); els.uploadDropzone.addEventListener('dragleave', () => { els.uploadDropzone.classList.remove('drag-over'); }); els.uploadDropzone.addEventListener('drop', (e) => { e.preventDefault(); els.uploadDropzone.classList.remove('drag-over'); if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]); }); $$('.upload-type').forEach(type => { type.addEventListener('click', () => { $$('.upload-type').forEach(t => t.classList.remove('active')); type.classList.add('active'); state.selectedDocType = type.dataset.type; }); }); } function closeUploadModal() { els.uploadModal.classList.add('hidden'); } async function handleFileUpload(file) { const validExts = ['pdf', 'txt', 'docx', 'jpg', 'jpeg', 'png', 'webp']; const ext = file.name.split('.').pop().toLowerCase(); if (!validExts.includes(ext)) { showToast('❌ Formato no soportado'); return; } const dropzone = els.uploadDropzone; const originalContent = dropzone.innerHTML; dropzone.innerHTML = `
Procesando ${file.name}...
`; try { const formData = new FormData(); formData.append('file', file); formData.append('doc_type', state.selectedDocType); const headers = { 'X-Session-ID': state.sessionId }; if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`; const res = await fetch(`${API_BASE}/api/documents/upload`, { method: 'POST', headers: headers, body: formData, }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: 'Upload failed' })); throw new Error(err.detail); } const result = await res.json(); dropzone.innerHTML = `
${file.name} — ${result.message}
`; // Refresh documents list await refreshDocuments(); setTimeout(() => { dropzone.innerHTML = originalContent; closeUploadModal(); showToast(`📄 ${file.name} indexado correctamente`); }, 1800); } catch (e) { dropzone.innerHTML = `
❌ Error: ${e.message}
`; setTimeout(() => { dropzone.innerHTML = originalContent; }, 3000); } els.fileInput.value = ''; } async function refreshDocuments() { try { const data = await apiGet('/api/documents'); state.documents = data.documents || []; renderDocumentsFromList(state.documents); checkApiStatus(); // Also refresh status bar } catch (e) { console.warn('Could not refresh documents:', e); } } function renderDocumentsFromList(docs) { if (!docs || docs.length === 0) { els.documentList.innerHTML = `
📭 Sin documentos aún
`; return; } const docIcons = { cv: '📋', job_offer: '💼', linkedin: '👤', other: '📄' }; els.documentList.innerHTML = docs.map(doc => { const icon = '📄'; // Simple icon for filenames from backend return `
${icon} ${doc}
`; }).join(''); } async function removeDocument(filename) { try { await apiDelete(`/api/documents/${encodeURIComponent(filename)}`); showToast(`🗑️ ${filename} eliminado`); await refreshDocuments(); } catch (e) { showToast(`❌ Error: ${e.message} `); } } window.removeDocument = removeDocument; // ===== CHIPS ===== function setupChips() { $$('.chip').forEach(chip => { chip.addEventListener('click', () => { const query = chip.dataset.query; if (query && !state.isStreaming) sendMessage(query); }); }); } // ===== MESSAGES ===== async function sendMessage(text) { if (state.isStreaming) return; // API is pre-configured, no need to check // Create conversation if needed if (!state.currentConversationId) { state.currentConversationId = Date.now().toString(); state.conversations.unshift({ id: state.currentConversationId, title: text.substring(0, 60) + (text.length > 60 ? '...' : ''), date: new Date().toISOString(), messages: [], }); saveConversations(); renderConversations(); } // Add user message const userMsg = { role: 'user', content: text }; state.messages.push(userMsg); showChat(); renderMessages(); scrollToBottom(); // Show typing indicator showTypingIndicator(); state.isStreaming = true; try { const headers = { 'Content-Type': 'application/json', 'X-Session-ID': state.sessionId }; if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`; // Call streaming API const response = await fetch(`${API_BASE}/api/chat/stream`, { method: 'POST', headers: headers, body: JSON.stringify({ query: text, chat_history: state.messages.slice(0, -1), // Exclude last message (the current query) mode: 'auto', }), }); if (!response.ok) { const err = await response.json().catch(() => ({ detail: 'Error de comunicación' })); throw new Error(err.detail); } // Parse SSE stream const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullResponse = ''; let detectedMode = 'general'; hideTypingIndicator(); // Add placeholder AI message const aiMsg = { role: 'assistant', content: '' }; state.messages.push(aiMsg); renderMessages(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); const lines = text.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.substring(6)); if (data.type === 'mode') { detectedMode = data.mode; } else if (data.type === 'token') { fullResponse += data.content; aiMsg.content = fullResponse; updateLastMessage(fullResponse); scrollToBottom(); } else if (data.type === 'done') { // Streaming complete } else if (data.type === 'error') { throw new Error(data.error); } } catch (parseError) { // Skip malformed SSE lines if (parseError.message !== 'Unexpected end of JSON input') { console.warn('SSE parse error:', parseError); } } } } } // Final render with full markdown aiMsg.content = fullResponse; renderMessages(); scrollToBottom(); // Save to conversation saveCurrentConversation(); } catch (e) { hideTypingIndicator(); const errorMsg = { role: 'assistant', content: `❌ **Error:** ${e.message}\n\nVerifica tu API key y conexión.` }; state.messages.push(errorMsg); renderMessages(); scrollToBottom(); } finally { state.isStreaming = false; } } function updateLastMessage(content) { const messages = els.chatMessages.querySelectorAll('.message.ai'); const lastMsg = messages[messages.length - 1]; if (lastMsg) { const contentEl = lastMsg.querySelector('.message-content'); if (contentEl) { contentEl.innerHTML = formatMarkdown(content) + ''; } } } function renderMessages() { els.chatMessages.innerHTML = state.messages.map((msg, i) => { if (msg.role === 'user') { const hasPic = state.currentUser && state.currentUser.picture; const avatarContent = hasPic ? `` : `🧑‍💻`; const avatarStyle = hasPic ? 'padding:0; overflow:hidden; background:transparent; border:none; border-radius:50%;' : 'padding:0; overflow:hidden;'; return `
${avatarContent}
${state.currentUser?.name || 'Tú'}
${escapeHtml(msg.content)}
`; } else { const modelIcon = state.currentModel === 'llama-3.1-8b-instant' ? '/static/icon-flash.png' : 'https://i.postimg.cc/tJ32Jnph/image.png'; const modelLabel = state.currentModel === 'llama-3.1-8b-instant' ? 'CareerAI Flash' : 'CareerAI Pro'; return `
${modelLabel}
${modelLabel}
${formatMarkdown(msg.content)}
`; } }).join(''); } function showTypingIndicator() { const indicator = document.createElement('div'); indicator.id = 'typingIndicator'; indicator.className = 'message ai'; const modelIcon = state.currentModel === 'llama-3.1-8b-instant' ? '/static/icon-flash.png' : 'https://i.postimg.cc/tJ32Jnph/image.png'; const modelLabel = state.currentModel === 'llama-3.1-8b-instant' ? 'CareerAI Flash' : 'CareerAI Pro'; indicator.innerHTML = `
${modelLabel}
${modelLabel}
`; els.chatMessages.appendChild(indicator); scrollToBottom(); } function hideTypingIndicator() { const indicator = document.getElementById('typingIndicator'); if (indicator) indicator.remove(); } function scrollToBottom() { requestAnimationFrame(() => { els.chatMessages.scrollTop = els.chatMessages.scrollHeight; }); } // ===== MESSAGE ACTIONS ===== function copyMessage(index) { const msg = state.messages[index]; if (msg) { navigator.clipboard.writeText(msg.content).then(() => { showToast('✅ Copiado al portapapeles'); }); } } async function exportMessage(index, format) { const msg = state.messages[index]; if (!msg) return; showToast(`📄 Exportando ${format.toUpperCase()}...`); try { const res = await fetch(`${API_BASE}/api/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: msg.content, format }), }); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') || ''; const filenameMatch = disposition.match(/filename="?(.+?)"?$/); const filename = filenameMatch ? filenameMatch[1] : `CareerAI_Export.${format} `; // Download const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); showToast(`✅ ${filename} descargado`); } catch (e) { showToast(`❌ Error al exportar: ${e.message} `); } } function likeMessage(index) { showToast('👍 ¡Gracias por tu feedback!'); } function dislikeMessage(index) { showToast('👎 Feedback registrado'); } window.copyMessage = copyMessage; window.exportMessage = exportMessage; window.likeMessage = likeMessage; window.dislikeMessage = dislikeMessage; // ===== CONVERSATIONS (Backend & Local fallback) ===== async function saveConversations() { // Save to local storage as fallback localStorage.setItem('careerai_conversations', JSON.stringify(state.conversations.slice(0, 50))); } async function saveCurrentConversation() { if (!state.currentConversationId) return; const convIndex = state.conversations.findIndex(c => c.id === state.currentConversationId); if (convIndex !== -1) { state.conversations[convIndex].messages = [...state.messages]; state.conversations[convIndex].date = new Date().toISOString(); saveConversations(); // Save to backend if logged in if (state.authToken) { try { await apiPost('/api/conversations', { id: state.currentConversationId, title: state.conversations[convIndex].title, messages: state.messages }); } catch (e) { console.error("Failed to save to cloud:", e); } } } } function renderConversations() { if (state.conversations.length === 0) { els.conversationList.innerHTML = '
Sin conversaciones
'; return; } els.conversationList.innerHTML = state.conversations.slice(0, 20).map(conv => `
${escapeHtml(conv.title)}
`).join(''); } function loadConversation(id) { const conv = state.conversations.find(c => c.id === id); if (conv) { state.currentConversationId = id; state.messages = conv.messages || []; renderConversations(); if (state.messages.length > 0) { showChat(); renderMessages(); scrollToBottom(); } else { showWelcome(); } } } window.loadConversation = loadConversation; function newChat() { state.messages = []; state.currentConversationId = null; showWelcome(); renderConversations(); } async function deleteConversation(event, id) { event.stopPropagation(); if (confirm('¿Estás seguro de que deseas eliminar esta conversación?')) { state.conversations = state.conversations.filter(c => c.id !== id); if (state.currentConversationId === id) { state.currentConversationId = null; state.messages = []; showWelcome(); } saveConversations(); renderConversations(); // Delete from backend if logged in if (state.authToken) { try { await apiDelete(`/api/conversations/${id}`); } catch (e) { console.error("Failed to delete from cloud:", e); } } showToast('🗑️ Conversación eliminada'); } } window.deleteConversation = deleteConversation; // ===== VIEW TOGGLE ===== function showWelcome() { hideDashboardPage(); els.welcomeScreen.classList.remove('hidden'); els.welcomeScreen.style.display = ''; els.chatScreen.classList.add('hidden'); els.chatScreen.style.display = 'none'; els.welcomeInput.focus(); } function showChat() { hideDashboardPage(); els.welcomeScreen.classList.add('hidden'); els.welcomeScreen.style.display = 'none'; els.chatScreen.classList.remove('hidden'); els.chatScreen.style.display = ''; els.chatInput.focus(); } // ===== UTILITIES ===== function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatMarkdown(text) { let html = escapeHtml(text); // Headers html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // Bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.+?)\*/g, '$1'); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Code blocks html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
'); // Blockquotes html = html.replace(/^> (.+)$/gm, '
$1
'); // Horizontal rule html = html.replace(/^---$/gm, '
'); // Tables const lines = html.split('\n'); let inTable = false; let tableHtml = ''; const result = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('|') && line.endsWith('|')) { if (!inTable) { inTable = true; tableHtml = ''; } if (line.match(/^\|[\s\-|]+\|$/)) continue; const cells = line.split('|').filter(c => c.trim()); const isHeader = i < lines.length - 1 && lines[i + 1] && lines[i + 1].trim().match(/^\|[\s\-|]+\|$/); const tag = isHeader ? 'th' : 'td'; tableHtml += '' + cells.map(c => `<${tag}>${c.trim()}`).join('') + ''; } else { if (inTable) { inTable = false; tableHtml += '
'; result.push(tableHtml); tableHtml = ''; } result.push(line); } } if (inTable) { tableHtml += ''; result.push(tableHtml); } html = result.join('\n'); // Unordered lists html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>\n?)+/g, ''); // Ordered lists html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); // Paragraphs html = html.replace(/^(?!<[hupoltb]|<\/|$1

    '); // Clean up html = html.replace(/\n{2,}/g, ''); html = html.replace(/\n/g, ''); return html; } // ===== TOAST ===== function showToast(message) { let toast = document.querySelector('.toast'); if (!toast) { toast = document.createElement('div'); toast.className = 'toast'; document.body.appendChild(toast); } toast.textContent = message; toast.classList.add('show'); clearTimeout(toast._timeout); toast._timeout = setTimeout(() => { toast.classList.remove('show'); }, 2800); } // ===== KEYBOARD SHORTCUTS ===== document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); els.searchInput.focus(); } if (e.key === 'Escape') { els.modelDropdown.classList.add('hidden'); closeUploadModal(); document.getElementById('apiConfigModal')?.remove(); } }); // ===== CSS for cursor blink ===== const style = document.createElement('style'); style.textContent = ` .cursor-blink { animation: cursorBlink 1s step-end infinite; color: var(--accent-primary); font-weight: 300; } @keyframes cursorBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } `; document.head.appendChild(style); // ===== JOBS PANEL ===== // Custom dropdown helpers window.toggleJobsDropdown = function (id) { const el = document.getElementById(id); if (!el) return; const isOpen = el.classList.contains('open'); // Close all first document.querySelectorAll('.jobs-custom-select.open').forEach(d => d.classList.remove('open')); if (!isOpen) el.classList.add('open'); }; window.selectJobsOption = function (dropdownId, selectId, value, label) { // Update hidden select value const sel = document.getElementById(selectId); if (sel) sel.value = value; // Update visible label const labelEl = document.getElementById(dropdownId + 'Label'); if (labelEl) labelEl.textContent = label; // Mark active option const menu = document.getElementById(dropdownId + 'Menu'); if (menu) { menu.querySelectorAll('.jobs-select-option').forEach(o => o.classList.remove('active')); event?.target?.classList.add('active'); } // Close document.getElementById(dropdownId)?.classList.remove('open'); }; // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.jobs-custom-select')) { document.querySelectorAll('.jobs-custom-select.open').forEach(d => d.classList.remove('open')); } }); window.openJobsPanel = function () { const panel = document.getElementById('jobsPanel'); const overlay = document.getElementById('jobsPanelOverlay'); if (!panel) return; panel.style.display = 'flex'; overlay.style.display = 'block'; // Slide in animation panel.style.transform = 'translateX(100%)'; panel.style.transition = 'transform 0.3s cubic-bezier(0.4,0,0.2,1)'; requestAnimationFrame(() => { panel.style.transform = 'translateX(0)'; }); // Bind Enter on search input const inp = document.getElementById('jobsSearchInput'); if (inp && !inp._jobsBound) { inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') loadJobs(); }); inp._jobsBound = true; } }; window.closeJobsPanel = function () { const panel = document.getElementById('jobsPanel'); const overlay = document.getElementById('jobsPanelOverlay'); if (!panel) return; panel.style.transform = 'translateX(100%)'; setTimeout(() => { panel.style.display = 'none'; overlay.style.display = 'none'; }, 300); }; window.autoFillJobSearch = async function () { // Try to pull CV text from the RAG document list const docs = state.documents; if (!docs || docs.length === 0) { showToast('⚠️ Primero sube tu CV en el panel de Documentos', 'warning'); return; } // Use the first loaded document name as a query hint // Then ask the AI to extract job title keywords showToast('🤖 Extrayendo perfil del CV...', 'info'); try { const res = await apiPost('/api/chat', { query: 'Basándote en mi CV, responde SOLO con el título de puesto más específico y relevante para buscar empleo, en máximo 4 palabras. Por ejemplo: "Desarrollador Full Stack" o "Diseñador UX Senior". Sin explicaciones, sin puntos, sin listas. Solo el título.', chat_history: [], mode: 'general' }); const keywords = res.response?.trim().replace(/\n/g, ' ').replace(/["'*]/g, '').slice(0, 60) || ''; if (keywords) { document.getElementById('jobsSearchInput').value = keywords; showToast('✅ Puesto detectado: ' + keywords); loadJobs(); } } catch (e) { showToast('❌ No se pudo extraer el perfil: ' + e.message); } }; window.loadJobs = async function () { const query = document.getElementById('jobsSearchInput').value.trim(); if (!query) { showToast('⚠️ Escribe qué empleo quieres buscar', 'warning'); return; } const country = document.getElementById('jobsCountry').value; const datePosted = document.getElementById('jobsDatePosted').value; const remoteOnly = document.getElementById('jobsRemoteOnly').checked; const btn = document.getElementById('jobsSearchBtn'); const resultsEl = document.getElementById('jobsResults'); const footerEl = document.getElementById('jobsFooter'); // Show skeletons btn.innerHTML = ''; btn.style.pointerEvents = 'none'; resultsEl.innerHTML = Array(5).fill(`
    `).join(''); footerEl.style.display = 'none'; try { let url = `/api/jobs?query=${encodeURIComponent(query)}&date_posted=${datePosted}&num_pages=1`; if (country) url += `&country=${country}`; if (remoteOnly) url += `&remote_only=true`; const data = await apiGet(url); const jobs = data.jobs || []; if (jobs.length === 0) { resultsEl.innerHTML = `
    😔

    Sin resultados

    Prueba con otros términos o cambia los filtros.

    `; } else { resultsEl.innerHTML = jobs.map(j => renderJobCard(j)).join(''); footerEl.style.display = 'block'; footerEl.textContent = `Mostrando ${jobs.length} ofertas · LinkedIn · Indeed · Glassdoor · más`; } } catch (err) { resultsEl.innerHTML = `
    ❌ Error: ${err.message}
    `; } finally { btn.innerHTML = 'Buscar'; btn.style.pointerEvents = 'auto'; } }; function renderJobCard(j) { const remoteTag = j.is_remote ? `🏠 Remoto` : ''; const typeTag = j.employment_type ? `${j.employment_type}` : ''; const salaryTag = j.salary ? `
    💰 ${j.salary}
    ` : ''; const posted = j.posted_at ? new Date(j.posted_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }) : ''; const logo = j.company_logo ? `` : `
    🏢
    `; return `
    ${logo}
    ${j.title}
    ${j.company} · ${j.location || 'Sin ubicación'}
    ${remoteTag}${typeTag} ${posted ? `${posted}` : ''}
    ${j.description_snippet ? `

    ${j.description_snippet}

    ` : ''} ${salaryTag}
    Aplicar
    `; }