almeira-web/transporte-cali-buenaventura/backend/public/js/app.js

1043 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pacifictrans Go - Interactive Web Platform Logic
let currentTariffs = {
tarifaPasajeroFija: 35000,
precioBasePaquete: 25000,
precioKmPaquete: 1200,
factorPaquetes: { sobre: 1.0, mediano: 1.6, grande: 2.2 },
seguroBasePaquete: 5000,
porcentajeComisionAdmin: 15,
costoPenalidadCancelacion: 15000,
radioMaxPoolingMetros: 4000,
numeroAtencionWhatsApp: '573167455877',
nombreSoporte: 'Atención Pacifictrans Go'
};
let currentUser = null;
let leafletMap = null;
let deferredPrompt = null;
// Drivers Flota con coordenadas reales en el corredor Cali - Buenaventura
const activeDrivers = [
{ id: '1', nombre: 'Mario Fernando G.', vehiculo: 'Renault Duster (WLZ-890)', barrio: 'Granada (Comuna 2)', distCali: '1.2 km', lat: 3.4552, lng: -76.5350, estado: 'DISPONIBLE' },
{ id: '2', nombre: 'Hernán Darío P.', vehiculo: 'Toyota Fortuner (VBF-312)', barrio: 'San Antonio (Comuna 3)', distCali: '2.4 km', lat: 3.4485, lng: -76.5415, estado: 'ASIGNADO' },
{ id: '3', nombre: 'Mauricio V.', vehiculo: 'Chevrolet Tracker (HJK-456)', barrio: 'El Ingenio (Comuna 17)', distCali: '6.8 km', lat: 3.3850, lng: -76.5290, estado: 'DISPONIBLE' },
{ id: '4', nombre: 'Javier Restrepo', vehiculo: 'Nissan Kicks (RTY-789)', barrio: 'Limonar (Comuna 19)', distCali: '5.1 km', lat: 3.6580, lng: -76.6900, estado: 'EN_RUTA' }
];
document.addEventListener('DOMContentLoaded', () => {
initPWA();
initTabs();
initAuthSystem();
initPassengerCalculator();
initFreightCalculator();
initLeafletRadarMap();
initReviewsAndStats();
initTariffsManager();
fetchTariffs();
fetchStatsAndReviews();
});
/* ==========================================================================
1. PWA & IPHONE INSTALLATION SYSTEM
========================================================================== */
function initPWA() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('✅ [PWA] Service Worker registrado exitosamente:', reg.scope))
.catch(err => console.warn('⚠️ [PWA Service Worker Warning]:', err));
});
}
// Guardar evento de instalación de Android
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
});
const btnInstall = document.getElementById('btn-install-pwa');
const modalInstall = document.getElementById('modal-pwa-install');
const btnClose = document.getElementById('btn-close-pwa-modal');
const btnTriggerAndroid = document.getElementById('btn-trigger-android-pwa');
if (btnInstall && modalInstall) {
btnInstall.addEventListener('click', () => {
modalInstall.classList.add('active');
});
}
if (btnClose && modalInstall) {
btnClose.addEventListener('click', () => {
modalInstall.classList.remove('active');
});
modalInstall.addEventListener('click', (e) => {
if (e.target === modalInstall) modalInstall.classList.remove('active');
});
}
if (btnTriggerAndroid) {
btnTriggerAndroid.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('PWA Outcome:', outcome);
deferredPrompt = null;
if (modalInstall) modalInstall.classList.remove('active');
} else {
alert('Para instalar en iPhone, sigue los 4 pasos mostrados arriba en Safari. En Android Chrome, toca los 3 puntos (⋮) y selecciona "Instalar aplicación".');
}
});
}
}
/* ==========================================================================
2. TABS & ADMIN SUBTABS NAVIGATION
========================================================================== */
function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
window.activateTab = function(targetId) {
tabBtns.forEach(b => {
if (b.getAttribute('data-tab') === targetId) {
b.classList.add('active');
} else {
b.classList.remove('active');
}
});
tabContents.forEach(c => {
if (c.id === targetId) {
c.classList.add('active');
} else {
c.classList.remove('active');
}
});
// Si se activa el radar, refrescar el tamaño del mapa Leaflet
if (targetId === 'tab-admin' && leafletMap) {
setTimeout(() => {
leafletMap.invalidateSize();
}, 250);
}
};
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.getAttribute('data-tab');
window.activateTab(targetId);
});
});
// Enlaces de la barra de navegación
const navBtnAdmin = document.getElementById('nav-btn-admin');
if (navBtnAdmin) {
navBtnAdmin.addEventListener('click', (e) => {
e.preventDefault();
window.activateTab('tab-admin');
document.getElementById('admin')?.scrollIntoView({ behavior: 'smooth' });
});
}
const navBtnStats = document.getElementById('nav-btn-stats');
if (navBtnStats) {
navBtnStats.addEventListener('click', (e) => {
e.preventDefault();
window.activateTab('tab-estadisticas');
document.getElementById('tab-estadisticas')?.scrollIntoView({ behavior: 'smooth' });
});
}
// Sub-tabs de la sección administrativa
const subRadarBtn = document.getElementById('btn-admin-subtab-radar');
const subTarifasBtn = document.getElementById('btn-admin-subtab-tarifas');
const viewRadar = document.getElementById('admin-view-radar');
const viewTarifas = document.getElementById('admin-view-tarifas');
if (subRadarBtn && subTarifasBtn && viewRadar && viewTarifas) {
subRadarBtn.addEventListener('click', () => {
subRadarBtn.classList.add('active');
subTarifasBtn.classList.remove('active');
viewRadar.style.display = 'block';
viewTarifas.style.display = 'none';
if (leafletMap) {
setTimeout(() => leafletMap.invalidateSize(), 200);
}
});
subTarifasBtn.addEventListener('click', () => {
subTarifasBtn.classList.add('active');
subRadarBtn.classList.remove('active');
viewRadar.style.display = 'none';
viewTarifas.style.display = 'block';
});
}
}
/* ==========================================================================
3. AUTHENTICATION (DRIPZONE STYLE CON OTP WHATSAPP + EMAIL)
========================================================================== */
function initAuthSystem() {
const modal = document.getElementById('modal-auth-dripzone');
const btnOpen = document.getElementById('btn-open-auth-modal');
const btnClose = document.getElementById('btn-close-auth-modal');
const btnLogout = document.getElementById('btn-logout');
const modeLoginBtn = document.getElementById('btn-auth-mode-login');
const modeRegisterBtn = document.getElementById('btn-auth-mode-register');
const groupName = document.getElementById('group-auth-name');
const authModalTitle = document.getElementById('auth-modal-title');
const step1 = document.getElementById('auth-step-1');
const step2 = document.getElementById('auth-step-2');
const formStep1 = document.getElementById('form-auth-request-otp');
const btnVerifyOtp = document.getElementById('btn-submit-verify-otp');
const btnResend = document.getElementById('btn-auth-resend-otp');
const reqError = document.getElementById('auth-request-error');
const verError = document.getElementById('auth-verify-error');
const targetLabel = document.getElementById('auth-sent-target');
let authMode = 'login'; // 'login' | 'register'
let currentIdentifier = '';
let currentEmail = '';
// Abrir y cerrar modal
if (btnOpen && modal) {
btnOpen.addEventListener('click', () => {
reqError.style.display = 'none';
verError.style.display = 'none';
step1.style.display = 'block';
step2.style.display = 'none';
modal.classList.add('active');
});
}
if (btnClose && modal) {
btnClose.addEventListener('click', () => modal.classList.remove('active'));
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('active');
});
}
// Alternar entre Login y Registro
if (modeLoginBtn && modeRegisterBtn) {
modeLoginBtn.addEventListener('click', () => {
authMode = 'login';
modeLoginBtn.classList.add('active');
modeRegisterBtn.classList.remove('active');
if (groupName) groupName.style.display = 'none';
if (authModalTitle) authModalTitle.textContent = 'Acceso Seguro con Verificación OTP';
});
modeRegisterBtn.addEventListener('click', () => {
authMode = 'register';
modeRegisterBtn.classList.add('active');
modeLoginBtn.classList.remove('active');
if (groupName) groupName.style.display = 'block';
if (authModalTitle) authModalTitle.textContent = 'Registro de Usuario & Validación 2FA';
});
}
// Paso 1: Enviar OTP por WhatsApp
if (formStep1) {
formStep1.addEventListener('submit', async (e) => {
e.preventDefault();
const phoneInput = document.getElementById('auth-input-phone');
const emailInput = document.getElementById('auth-input-email');
const nameInput = document.getElementById('auth-input-name');
const btnSubmit = document.getElementById('btn-submit-send-otp');
const phone = phoneInput?.value.trim() || '';
const email = emailInput?.value.trim() || '';
const name = nameInput?.value.trim() || '';
if (!phone) {
showError(reqError, 'Ingresa tu número de WhatsApp.');
return;
}
currentIdentifier = phone;
currentEmail = email;
btnSubmit.disabled = true;
btnSubmit.innerHTML = '⏳ Enviando código de seguridad...';
reqError.style.display = 'none';
try {
const res = await fetch('/api/auth/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: phone, email })
});
const data = await res.json();
if (res.ok && data.success) {
step1.style.display = 'none';
step2.style.display = 'block';
if (targetLabel) targetLabel.textContent = data.maskedPhone || `+${phone}`;
// Si el servidor devolvió preview (entorno local sin SIM de Evolution), mostrar aviso
if (data.codePreview) {
console.log(`[Demo Code OTP]: ${data.codePreview}`);
verError.style.display = 'block';
verError.style.color = '#38bdf8';
verError.innerHTML = `ℹ️ Código de prueba generado: <strong>${data.codePreview}</strong>`;
}
focusFirstOtp();
} else {
showError(reqError, data.error || 'No se pudo enviar el código OTP.');
}
} catch (err) {
showError(reqError, 'Error de conexión. Inténtalo de nuevo.');
} finally {
btnSubmit.disabled = false;
btnSubmit.innerHTML = '📲 Enviar Código de Verificación OTP';
}
});
}
// Cajas OTP con avance automático y soporte de pegado
const otpInputs = [
document.getElementById('otp-1'),
document.getElementById('otp-2'),
document.getElementById('otp-3'),
document.getElementById('otp-4'),
document.getElementById('otp-5'),
document.getElementById('otp-6')
];
otpInputs.forEach((input, index) => {
if (!input) return;
input.addEventListener('input', (e) => {
const val = e.target.value;
if (val.length === 1 && index < 5) {
otpInputs[index + 1].focus();
}
checkAutoSubmitOtp();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && index > 0) {
otpInputs[index - 1].focus();
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasteData = e.clipboardData.getData('text').trim();
if (/^\d{6}$/.test(pasteData)) {
pasteData.split('').forEach((digit, i) => {
if (otpInputs[i]) otpInputs[i].value = digit;
});
checkAutoSubmitOtp();
}
});
});
function focusFirstOtp() {
otpInputs.forEach(i => { if (i) i.value = ''; });
if (otpInputs[0]) otpInputs[0].focus();
}
function getEnteredOtp() {
return otpInputs.map(i => i?.value || '').join('');
}
function checkAutoSubmitOtp() {
const code = getEnteredOtp();
if (code.length === 6) {
submitVerifyOtp(code);
}
}
// Paso 2: Verificar OTP
if (btnVerifyOtp) {
btnVerifyOtp.addEventListener('click', () => {
const code = getEnteredOtp();
if (code.length < 6) {
showError(verError, 'Ingresa los 6 dígitos del código.');
return;
}
submitVerifyOtp(code);
});
}
async function submitVerifyOtp(code) {
if (!btnVerifyOtp) return;
btnVerifyOtp.disabled = true;
btnVerifyOtp.innerHTML = '⏳ Verificando código...';
verError.style.display = 'none';
const nameInput = document.getElementById('auth-input-name');
const name = nameInput?.value.trim() || '';
try {
const res = await fetch('/api/auth/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: currentIdentifier,
code,
nombre: name,
email: currentEmail
})
});
const data = await res.json();
if (res.ok && data.success) {
localStorage.setItem('pt_token', data.token);
currentUser = data.user;
updateUserUI(currentUser);
modal.classList.remove('active');
alert(`¡Bienvenido a Pacifictrans Go, ${currentUser.nombre}! Tu identidad ha sido verificada con éxito.`);
} else {
showError(verError, data.error || 'Código incorrecto. Verifica los 6 dígitos.');
}
} catch (err) {
showError(verError, 'Error al validar el código OTP.');
} finally {
btnVerifyOtp.disabled = false;
btnVerifyOtp.innerHTML = '✅ Validar y Acceder';
}
}
// Reenviar OTP
if (btnResend) {
btnResend.addEventListener('click', () => {
formStep1.dispatchEvent(new Event('submit'));
});
}
// Cerrar sesión
if (btnLogout) {
btnLogout.addEventListener('click', () => {
localStorage.removeItem('pt_token');
currentUser = null;
updateUserUI(null);
});
}
// Validar sesión persistida
checkPersistedSession();
}
async function checkPersistedSession() {
const token = localStorage.getItem('pt_token');
if (!token) return;
try {
const res = await fetch(`/api/auth/me?token=${token}`);
if (res.ok) {
const data = await res.json();
if (data.user) {
currentUser = data.user;
updateUserUI(currentUser);
}
} else {
localStorage.removeItem('pt_token');
}
} catch (err) {
console.warn('⚠️ [Session Verify Error]:', err);
}
}
function updateUserUI(user) {
const btnAuthOpen = document.getElementById('btn-open-auth-modal');
const userBadge = document.getElementById('user-profile-badge');
const userNameEl = document.getElementById('user-display-name');
const inputUserName = document.getElementById('user-name');
const inputUserPhone = document.getElementById('user-phone');
const inputSenderPhone = document.getElementById('sender-phone');
if (user) {
if (btnAuthOpen) btnAuthOpen.style.display = 'none';
if (userBadge) userBadge.style.display = 'inline-flex';
if (userNameEl) userNameEl.textContent = user.nombre.split(' ')[0];
// Autofill en formularios de la plataforma
if (inputUserName && !inputUserName.value) inputUserName.value = user.nombre;
if (inputUserPhone && !inputUserPhone.value) inputUserPhone.value = user.telefono;
if (inputSenderPhone && !inputSenderPhone.value) inputSenderPhone.value = user.telefono;
} else {
if (btnAuthOpen) btnAuthOpen.style.display = 'inline-flex';
if (userBadge) userBadge.style.display = 'none';
}
}
function showError(el, msg) {
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
}
/* ==========================================================================
4. LEAFLET RADAR FLEET MAP (MAPA ESTÁNDAR CON TOOLTIPS)
========================================================================== */
function initLeafletRadarMap() {
const mapContainer = document.getElementById('fleet-live-map');
if (!mapContainer || typeof L === 'undefined') return;
// Centro entre Cali (3.45) y Buenaventura (3.88)
const corridorCenter = [3.65, -76.78];
leafletMap = L.map('fleet-live-map', {
center: corridorCenter,
zoom: 10,
zoomControl: true,
scrollWheelZoom: false
});
// Capa de Mapa Estándar OpenStreetMap
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© OpenStreetMap contributors • Pacifictrans Go GPS'
}).addTo(leafletMap);
// Línea poligonal oficial de la Carretera Vía al Mar (Ruta 40)
const corridorWaypoints = [
[3.4516, -76.5320], // Cali
[3.4750, -76.5650], // Portada al Mar
[3.5800, -76.6300], // Km 18 / Saladito
[3.6580, -76.6900], // Dagua
[3.7650, -76.7350], // Loboguerrero
[3.7920, -76.8150], // Cisneros
[3.8450, -76.9200], // Córdoba
[3.8814, -77.0315] // Buenaventura
];
L.polyline(corridorWaypoints, {
color: '#06b6d4',
weight: 5,
opacity: 0.85,
dashArray: '8, 6'
}).addTo(leafletMap);
// Marcadores de Hitos Urbanos
addCityMarker(leafletMap, [3.4516, -76.5320], '🟢 Cali (Km 0)');
addCityMarker(leafletMap, [3.6580, -76.6900], '🔵 Dagua (Km 45)');
addCityMarker(leafletMap, [3.7650, -76.7350], '🔵 Loboguerrero (Km 60)');
addCityMarker(leafletMap, [3.8814, -77.0315], '🟡 Buenaventura (Km 115)');
// Vehículos de la Flota en Tiempo Real
activeDrivers.forEach((driver, index) => {
const customIcon = L.divIcon({
className: 'vehicle-marker-icon',
html: `
<div class="vehicle-pin" title="${driver.nombre}">
🚗
</div>
`,
iconSize: [44, 44],
iconAnchor: [22, 22]
});
const marker = L.marker([driver.lat, driver.lng], { icon: customIcon }).addTo(leafletMap);
// Tooltip enriquecido al pasar el cursor o pulsar
const tooltipContent = `
<div class="vehicle-tooltip-card">
<div class="vehicle-tooltip-header">
<span class="vehicle-tooltip-name">#${index + 1} ${driver.nombre}</span>
<span class="vehicle-tooltip-status status-badge-${driver.estado.toLowerCase()}">● ${driver.estado}</span>
</div>
<div class="vehicle-tooltip-car">${driver.vehiculo}</div>
<div class="vehicle-tooltip-residence">🏠 Domicilio: <strong>${driver.barrio}</strong></div>
<div style="font-size: 0.75rem; color: #38bdf8;">📍 Cercanía a 1er Pasajero: ${driver.distCali}</div>
</div>
`;
marker.bindTooltip(tooltipContent, {
direction: 'top',
className: 'custom-vehicle-tooltip',
offset: [0, -20]
});
marker.on('click', () => {
window.asignarConductorDemo(driver.nombre, driver.barrio);
});
});
renderSuggestedDriversList();
}
function addCityMarker(map, coords, label) {
const icon = L.divIcon({
className: 'city-marker-icon',
html: `<div class="city-marker-pin">${label}</div>`,
iconSize: [110, 26],
iconAnchor: [55, 13]
});
L.marker(coords, { icon }).addTo(map);
}
function renderSuggestedDriversList() {
const driverListEl = document.getElementById('suggested-drivers-list');
if (!driverListEl) return;
driverListEl.innerHTML = activeDrivers.map((d, index) => `
<div class="driver-card">
<div>
<strong style="color: #fff; font-size: 1.05rem;">#${index + 1} ${d.nombre}</strong>
<div style="font-size: 0.85rem; color: #94a3b8;">${d.vehiculo}</div>
<div style="font-size: 0.82rem; color: #06b6d4; margin-top: 3px;">
🏠 Residencia en Cali: <strong>${d.barrio}</strong>
</div>
<span class="driver-tag">Distancia al 1er Pasajero: ${d.distCali}</span>
</div>
<div style="text-align: right;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 6px; font-size: 0.75rem; font-weight: 700; background: ${d.estado === 'DISPONIBLE' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(245, 158, 11, 0.2)'}; color: ${d.estado === 'DISPONIBLE' ? '#10b981' : '#f59e0b'};">
${d.estado}
</span>
<button onclick="asignarConductorDemo('${d.nombre}', '${d.barrio}')" style="margin-top: 8px; display: block; background: #0f172a; border: 1px solid rgba(6, 182, 212, 0.4); color: #06b6d4; font-size: 0.8rem; padding: 5px 12px; border-radius: 6px; cursor: pointer;">
Asignar y Programar
</button>
</div>
</div>
`).join('');
}
window.asignarConductorDemo = function(conductor, barrio) {
const resultDiv = document.getElementById('schedule-dispatch-result');
if (resultDiv) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = `
<div style="background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.4); border-radius: 12px; padding: 1.25rem; color: #10b981;">
<h4 style="margin-bottom: 0.5rem; font-size: 1.1rem; color: #fff;">✅ Conductor Asignado por Residencia: <strong>${conductor}</strong> (${barrio})</h4>
<p style="color: #cbd5e1; font-size: 0.92rem; margin-bottom: 0.75rem;">
🚀 <strong>Disparo Inmediato de Notificación WhatsApp Ejecutado:</strong>
Los 4 pasajeros han recibido su confirmación oficial con el conductor asignado, vehículo, hora estimada escalonada de recogida y enlace de Waze en vivo.
</p>
<div style="background: rgba(0,0,0,0.4); padding: 0.75rem; border-radius: 8px; font-family: monospace; font-size: 0.85rem; color: #6ee7b7;">
📲 [Evolution API WhatsApp Status: 201 Created] Enviado a los 4 Pasajeros + Conductor.
</div>
</div>
`;
resultDiv.scrollIntoView({ behavior: 'smooth' });
}
};
/* ==========================================================================
5. TRIP STATISTICS & COMMUNITY REVIEWS
========================================================================== */
function initReviewsAndStats() {
const btnOpenModal = document.getElementById('btn-open-review-modal');
const modalReview = document.getElementById('modal-add-review');
const btnCloseModal = document.getElementById('btn-close-review-modal');
const formReview = document.getElementById('form-submit-review');
const starPicker = document.getElementById('star-picker');
const hiddenScore = document.getElementById('review-input-score');
if (btnOpenModal && modalReview) {
btnOpenModal.addEventListener('click', () => {
if (currentUser && document.getElementById('review-input-name')) {
document.getElementById('review-input-name').value = currentUser.nombre;
}
modalReview.classList.add('active');
});
}
if (btnCloseModal && modalReview) {
btnCloseModal.addEventListener('click', () => modalReview.classList.remove('active'));
modalReview.addEventListener('click', (e) => {
if (e.target === modalReview) modalReview.classList.remove('active');
});
}
// Selector interactivo de estrellas (1-5)
if (starPicker) {
const stars = starPicker.querySelectorAll('span');
stars.forEach(star => {
star.addEventListener('click', () => {
const val = parseInt(star.getAttribute('data-star') || '5', 10);
if (hiddenScore) hiddenScore.value = val;
stars.forEach((s, idx) => {
if (idx < val) s.classList.add('selected');
else s.classList.remove('selected');
});
});
});
}
// Envío de reseña
if (formReview) {
formReview.addEventListener('submit', async (e) => {
e.preventDefault();
const btnSubmit = document.getElementById('btn-submit-review');
const score = parseFloat(hiddenScore?.value || '5');
const name = document.getElementById('review-input-name')?.value.trim();
const role = document.getElementById('review-input-role')?.value;
const target = document.getElementById('review-input-target')?.value.trim();
const comment = document.getElementById('review-input-comment')?.value.trim();
if (!name || !comment) return;
btnSubmit.disabled = true;
btnSubmit.innerHTML = '⏳ Publicando reseña...';
try {
const res = await fetch('/api/stats-reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autorId: currentUser?.id || `anon-${Date.now()}`,
autorNombre: name,
autorRol: role,
destinatarioNombre: target,
puntuacion: score,
comentario: comment,
etiquetas: ['Puntualidad ⏱️', 'Servicio Seguro 🛡️', 'Verificado ✅']
})
});
if (res.ok) {
formReview.reset();
if (modalReview) modalReview.classList.remove('active');
fetchStatsAndReviews();
alert('¡Gracias por tu calificación! Ha sido agregada al muro de la comunidad.');
}
} catch (err) {
console.warn('Error al enviar reseña:', err);
} finally {
btnSubmit.disabled = false;
btnSubmit.innerHTML = '⭐ Publicar Mi Calificación';
}
});
}
}
async function fetchStatsAndReviews() {
try {
const res = await fetch('/api/stats-reviews');
if (res.ok) {
const data = await res.json();
if (data.stats?.global) {
const g = data.stats.global;
const totalViajesEl = document.getElementById('stat-total-viajes');
const ratingAvgEl = document.getElementById('stat-rating-avg');
const puntualidadEl = document.getElementById('stat-puntualidad');
const conductoresActivosEl = document.getElementById('stat-conductores-activos');
if (totalViajesEl) totalViajesEl.textContent = `${g.totalViajes}+`;
if (ratingAvgEl) ratingAvgEl.textContent = `${g.calificacionPromedio} / 5.0`;
if (puntualidadEl) puntualidadEl.textContent = g.indicePuntualidad;
if (conductoresActivosEl) conductoresActivosEl.textContent = g.conductoresCertificados;
}
if (data.recentReviews && Array.isArray(data.recentReviews)) {
renderReviewsGrid(data.recentReviews);
}
}
} catch (err) {
console.warn('⚠️ [Stats/Reviews Error]:', err);
}
}
function renderReviewsGrid(reviews) {
const container = document.getElementById('reviews-list-grid');
if (!container) return;
container.innerHTML = reviews.map(r => {
const starsCount = Math.round(r.puntuacion || 5);
const starsString = '★'.repeat(starsCount) + '☆'.repeat(5 - starsCount);
const tagsHtml = (r.etiquetas || []).map(t => `<span class="review-tag-pill">${t}</span>`).join('');
const isPasajero = r.autorRol === 'PASAJERO';
return `
<div class="review-card">
<div>
<div class="review-card-header">
<div>
<div class="review-author">${r.autorNombre}</div>
<span class="review-role-badge ${isPasajero ? 'review-role-pasajero' : 'review-role-conductor'}">
${isPasajero ? '👤 Pasajero Verificado' : '🚗 Conductor Certificado'}
</span>
</div>
<div class="review-stars-row" title="${r.puntuacion} de 5 estrellas">${starsString}</div>
</div>
<div style="font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.5rem;">
Ruta: <strong>${r.ruta || 'Cali ➔ Buenaventura'}</strong> • Calificó a: <strong style="color: #cbd5e1;">${r.destinatarioNombre}</strong>
</div>
<div class="review-text">"${r.comentario}"</div>
</div>
<div class="review-tags-list">
${tagsHtml}
</div>
</div>
`;
}).join('');
}
/* ==========================================================================
6. CONFIGURACIÓN DE TARIFAS Y CANAL DE ATENCIÓN WHATSAPP
========================================================================== */
async function fetchTariffs() {
try {
const res = await fetch('/api/tariffs');
if (res.ok) {
const data = await res.json();
if (data.config) {
currentTariffs = { ...currentTariffs, ...data.config };
syncTariffsToUI();
}
}
} catch (err) {
console.warn('⚠️ [Tarifas API]: Modo local/offline activo, utilizando tarifas por defecto.', err);
syncTariffsToUI();
}
}
function syncTariffsToUI() {
// 1. Formulario administrativo de tarifas y soporte
const elPasajero = document.getElementById('cfg-tarifa-pasajero');
const elBasePaquete = document.getElementById('cfg-base-paquete');
const elKmPaquete = document.getElementById('cfg-km-paquete');
const elSobre = document.getElementById('cfg-factor-sobre');
const elMediano = document.getElementById('cfg-factor-mediano');
const elGrande = document.getElementById('cfg-factor-grande');
const elSeguro = document.getElementById('cfg-seguro-base');
const elComision = document.getElementById('cfg-comision');
const elPenalidad = document.getElementById('cfg-penalidad');
const elRadio = document.getElementById('cfg-radio-pooling');
const elPhone = document.getElementById('cfg-admin-phone');
const elName = document.getElementById('cfg-admin-name');
if (elPasajero) elPasajero.value = currentTariffs.tarifaPasajeroFija;
if (elBasePaquete) elBasePaquete.value = currentTariffs.precioBasePaquete;
if (elKmPaquete) elKmPaquete.value = currentTariffs.precioKmPaquete;
if (elSobre && currentTariffs.factorPaquetes) elSobre.value = currentTariffs.factorPaquetes.sobre || 1.0;
if (elMediano && currentTariffs.factorPaquetes) elMediano.value = currentTariffs.factorPaquetes.mediano || 1.6;
if (elGrande && currentTariffs.factorPaquetes) elGrande.value = currentTariffs.factorPaquetes.grande || 2.2;
if (elSeguro) elSeguro.value = currentTariffs.seguroBasePaquete;
if (elComision) elComision.value = currentTariffs.porcentajeComisionAdmin;
if (elPenalidad) elPenalidad.value = currentTariffs.costoPenalidadCancelacion;
if (elRadio) elRadio.value = currentTariffs.radioMaxPoolingMetros;
if (elPhone && currentTariffs.numeroAtencionWhatsApp) elPhone.value = currentTariffs.numeroAtencionWhatsApp;
if (elName && currentTariffs.nombreSoporte) elName.value = currentTariffs.nombreSoporte;
// 2. Sincronización del enlace dinámico de atención WhatsApp en el footer y navbar
const phoneFormatted = formatPhoneNumber(currentTariffs.numeroAtencionWhatsApp || '573167455877');
const cleanPhone = (currentTariffs.numeroAtencionWhatsApp || '573167455877').replace(/\D/g, '');
const footerPhoneEl = document.getElementById('footer-support-phone');
const footerLinkEl = document.getElementById('footer-support-link');
if (footerPhoneEl) footerPhoneEl.textContent = phoneFormatted;
if (footerLinkEl) footerLinkEl.href = `https://wa.me/${cleanPhone}?text=Hola%20Pacifictrans%20Go,%20deseo%20informaci%C3%B3n%20sobre%20un%20servicio`;
// 3. Precios en módulo de reservas de pasajeros
const formattedPasajero = `$${currentTariffs.tarifaPasajeroFija.toLocaleString('es-CO')} COP`;
const totalPriceEl = document.querySelector('.total-price');
if (totalPriceEl) totalPriceEl.textContent = formattedPasajero;
const btnBook = document.getElementById('btn-book-pool');
if (btnBook && !btnBook.disabled) {
btnBook.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
Confirmar y Pagar Cupo (${formattedPasajero})
`;
}
// 4. Recalcular módulo de mensajería y fletes
if (window.recalculateFreight) {
window.recalculateFreight();
}
}
function formatPhoneNumber(num) {
const clean = num.replace(/\D/g, '');
if (clean.startsWith('57') && clean.length === 12) {
return `+57 ${clean.slice(2, 5)} ${clean.slice(5, 8)} ${clean.slice(8)}`;
}
return `+${clean}`;
}
function initTariffsManager() {
const form = document.getElementById('form-tariffs-config');
const statusEl = document.getElementById('tariffs-save-status');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btn-save-tariffs');
if (btn) {
btn.disabled = true;
btn.innerHTML = '⏳ Guardando en MongoDB...';
}
const payload = {
tarifaPasajeroFija: parseFloat(document.getElementById('cfg-tarifa-pasajero')?.value || '35000'),
precioBasePaquete: parseFloat(document.getElementById('cfg-base-paquete')?.value || '25000'),
precioKmPaquete: parseFloat(document.getElementById('cfg-km-paquete')?.value || '1200'),
factorPaquetes: {
sobre: parseFloat(document.getElementById('cfg-factor-sobre')?.value || '1.0'),
mediano: parseFloat(document.getElementById('cfg-factor-mediano')?.value || '1.6'),
grande: parseFloat(document.getElementById('cfg-factor-grande')?.value || '2.2')
},
seguroBasePaquete: parseFloat(document.getElementById('cfg-seguro-base')?.value || '5000'),
porcentajeComisionAdmin: parseFloat(document.getElementById('cfg-comision')?.value || '15'),
costoPenalidadCancelacion: parseFloat(document.getElementById('cfg-penalidad')?.value || '15000'),
radioMaxPoolingMetros: parseFloat(document.getElementById('cfg-radio-pooling')?.value || '4000'),
numeroAtencionWhatsApp: document.getElementById('cfg-admin-phone')?.value.trim() || '573167455877',
nombreSoporte: document.getElementById('cfg-admin-name')?.value.trim() || 'Atención Pacifictrans Go'
};
try {
const res = await fetch('/api/tariffs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
currentTariffs = { ...currentTariffs, ...data.config };
syncTariffsToUI();
if (statusEl) {
statusEl.innerHTML = `✅ <strong>¡Configuración guardada y sincronizada!</strong> Pasajero: $${payload.tarifaPasajeroFija.toLocaleString('es-CO')} COP | WhatsApp Atención: +${payload.numeroAtencionWhatsApp}`;
statusEl.style.color = '#10b981';
}
} else {
throw new Error('Error al guardar en el servidor');
}
} catch (err) {
currentTariffs = { ...currentTariffs, ...payload };
syncTariffsToUI();
if (statusEl) {
statusEl.innerHTML = `✅ <strong>Configuración actualizada localmente.</strong>`;
statusEl.style.color = '#10b981';
}
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '💾 Guardar y Aplicar Configuración';
}
}
});
}
/* ==========================================================================
7. RESERVA DE PASAJEROS & POOLING
========================================================================== */
function initPassengerCalculator() {
const originSelect = document.getElementById('pool-origin');
const addressGroup = document.getElementById('cali-address-group');
const buenaStopGroup = document.getElementById('buena-stop-group');
const bookBtn = document.getElementById('btn-book-pool');
const poolDots = document.querySelectorAll('.seat-dot');
let currentSeatsFilled = 3;
function updateOriginDestination() {
if (!originSelect) return;
const isCali = originSelect.value === 'CALI';
if (addressGroup) addressGroup.style.display = isCali ? 'block' : 'none';
if (buenaStopGroup) buenaStopGroup.style.display = isCali ? 'block' : 'none';
}
if (originSelect) {
originSelect.addEventListener('change', updateOriginDestination);
updateOriginDestination();
}
if (bookBtn) {
bookBtn.addEventListener('click', async (e) => {
e.preventDefault();
// Verificación de seguridad: si no ha validado identidad con OTP, pedir autenticación
if (!currentUser) {
alert('🛡️ Por seguridad de los conductores y de los demás pasajeros, debes verificar tu identidad con OTP antes de confirmar tu reserva.');
document.getElementById('modal-auth-dripzone')?.classList.add('active');
return;
}
bookBtn.disabled = true;
bookBtn.innerHTML = `
<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor"></path>
</svg> Procesando Reserva...
`;
setTimeout(() => {
currentSeatsFilled = 4;
updateSeatVisuals(currentSeatsFilled);
const alertBox = document.getElementById('pool-dispatch-alert');
if (alertBox) {
alertBox.style.display = 'block';
alertBox.scrollIntoView({ behavior: 'smooth' });
}
bookBtn.disabled = false;
const formatted = `$${currentTariffs.tarifaPasajeroFija.toLocaleString('es-CO')} COP`;
bookBtn.innerHTML = `✅ Cupo Reservado Exitosamente (${formatted})`;
}, 1200);
});
}
function updateSeatVisuals(count) {
poolDots.forEach((dot, index) => {
dot.classList.remove('active', 'pending');
if (index < count) {
dot.classList.add('active');
} else if (index === count) {
dot.classList.add('pending');
}
});
const seatCounter = document.getElementById('seat-counter-text');
if (seatCounter) seatCounter.textContent = `${count}/4 Pasajeros`;
}
updateSeatVisuals(currentSeatsFilled);
}
/* ==========================================================================
8. CALCULADORA DE MENSAJERÍA & FLETES
========================================================================== */
function initFreightCalculator() {
const packageTypeSelect = document.getElementById('package-type');
const declaredValueInput = document.getElementById('declared-value');
const totalPriceEl = document.getElementById('freight-total-price');
const baseCostEl = document.getElementById('freight-base-cost');
const distCostEl = document.getElementById('freight-dist-cost');
const insuranceCostEl = document.getElementById('freight-insurance-cost');
const KM_DIST = 115; // Corredor Cali - Buenaventura
window.recalculateFreight = function() {
const rawFactor = parseFloat(packageTypeSelect?.value || '1.0');
let factor = rawFactor;
if (rawFactor === 1.0 && currentTariffs.factorPaquetes?.sobre) {
factor = currentTariffs.factorPaquetes.sobre;
} else if (rawFactor === 1.6 && currentTariffs.factorPaquetes?.mediano) {
factor = currentTariffs.factorPaquetes.mediano;
} else if (rawFactor === 2.2 && currentTariffs.factorPaquetes?.grande) {
factor = currentTariffs.factorPaquetes.grande;
}
const declaredValue = parseFloat(declaredValueInput?.value || '0');
const basePrice = currentTariffs.precioBasePaquete || 25000;
const costPerKm = currentTariffs.precioKmPaquete || 1200;
const distancePart = KM_DIST * costPerKm;
const subtotal = (basePrice + distancePart) * factor;
const seguro = Math.round(declaredValue * 0.01) + (currentTariffs.seguroBasePaquete || 0);
const total = Math.round(subtotal + seguro);
if (baseCostEl) baseCostEl.textContent = `$${basePrice.toLocaleString('es-CO')} COP`;
if (distCostEl) distCostEl.textContent = `$${Math.round(distancePart * factor).toLocaleString('es-CO')} COP`;
if (insuranceCostEl) insuranceCostEl.textContent = `$${seguro.toLocaleString('es-CO')} COP`;
if (totalPriceEl) totalPriceEl.textContent = `$${total.toLocaleString('es-CO')} COP`;
};
if (packageTypeSelect) packageTypeSelect.addEventListener('change', window.recalculateFreight);
if (declaredValueInput) declaredValueInput.addEventListener('input', window.recalculateFreight);
window.recalculateFreight();
}