Files
AutoBooking/Customer-dashboard-original.html
T

5 lines
57 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Sistema de Reserva de Viajes</title> </head> <body> <!-- ORIGIN (auto-GPS + manual override) --> <div id="origin-block" class="forminator-ui" style="display:grid; gap:10px;"> <label for="origin-input" class="forminator-label" style="color:#FF6F00 !important; font-weight:600;"> <strong>Origen</strong> </label> <div style="display:flex; gap:8px; align-items:center;"> <input id="origin-input" type="text" class="forminator-input" placeholder="Usar GPS o escribir dirección" style="flex:1; background:#fff; color:#000; border:none; box-shadow:2px 2px 8px rgba(0,0,0,0.2); border-radius:6px; height:55px;padding:5px 5px;"> <button id="btn-gps" type="button" class="forminator-button" aria-label="Usar mi ubicación" title="Usar mi ubicación" style="background-color:#FF6F00; width:55px; height:55px; display:inline-flex; align-items:center; justify-content:center; padding:0;"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" aria-hidden="true"> <path d="M12 8a4 4 0 1 0 0 8a4 4 0 0 0 0-8zm9 3h-2.07a7.002 7.002 0 0 0-5.93-5.93V3h-2v2.07A7.002 7.002 0 0 0 5.07 11H3v2h2.07a7.002 7.002 0 0 0 5.93 5.93V21h2v-2.07A7.002 7.002 0 0 0 18.93 13H21v-2zM12 17a5 5 0 1 1 0-10a5 5 0 0 1 0 10z"/> </svg> </button> </div> <small id="origin-status" style="color:#ffffff;opacity:.8;min-height:18px;display:block;"></small> </div> <!-- DESTINATION --> <div id="dest-block" style="display:grid; gap:8px; margin-top:16px;"> <label for="dest-input" class="forminator-label" style="color:#FF6F00 !important; font-weight:600;"> <strong>Destino</strong> </label> <input id="dest-input" type="text" class="forminator-input" placeholder="Escribir dirección" autocomplete="off" autocorrect="off" spellcheck="false" style="width:93%; background:#fff; color:#000; border:none; box-shadow:2px 2px 8px rgba(0,0,0,0.2); border-radius:6px; height:55px;padding:5px 5px;"> <small id="dest-status" style="color:#ffffff;opacity:.8;min-height:18px;display:block;"></small> </div> <!-- STOPS (waypoints) --> <div id="stops-section" class="forminator-ui" style="display:grid; gap:8px; margin-top:16px;"> <label class="forminator-label" style="color:#FF6F00 !important; font-weight:600;"><strong>Paradas (opcional)</strong></label> <div id="stops-wrap" style="display:grid; gap:8px;"></div> <div> <button id="btn-add-stop" type="button" class="forminator-button" style="background:#FF6F00; border:none; border-radius:8px; padding:10px 14px;"> Agregar parada </button> </div> <small style="color:#ffffff;opacity:.8;">Agregar paradas intermedias antes del destino. La ruta y estimación se actualizarán automáticamente.</small> </div> <!-- DRIVER CARD --> <div id="driver-card" class="forminator-ui" style="display:none; margin:12px 0; padding:12px; border-radius:10px; background:#3E2723;"> <div style="display:flex; gap:12px; align-items:center;"> <img id="drv-photo" src="" alt="" style="width:56px;height:56px;border-radius:10px;object-fit:cover;background:#00000033;"> <div style="display:grid; gap:2px; min-width:0;"> <div id="drv-name" style="color:#fff; font-weight:700; line-height:1.1;">Conductor</div> <div id="drv-rating" style="color:#fff; opacity:.9; font-size:14px;">★ —</div> <div id="drv-vehicle" style="color:#fff; opacity:.85; font-size:14px;"></div> <div id="drv-plate" style="color:#fff; opacity:.85; font-size:14px;"></div> </div> </div> <div id="drv-extra" style="display:none; margin-top:10px; padding-top:10px; border-top:1px solid rgba(255,255,255,.2); color:#fff;"> <div id="drv-eta" style="font-weight:600;"></div> <div id="drv-busy" style="opacity:.9; font-size:13px;"></div> </div> <div id="ride-chat" style="display:none; margin-top:10px; padding-top:10px; border-top:1px solid rgba(255,255,255,.2);"> <div style="display:flex; justify-content:space-between; align-items:center; color:#fff;"> <strong>Mensajes</strong> <a id="drv-call" href="#" style="color:#FF6F00; text-decoration:underline;">Llamar conductor</a> </div> <div id="chat-log" style="margin-top:8px; background:#fff; color:#000; height:120px; overflow:auto; border-radius:6px; padding:8px; font-size:14px;"></div> <div style="display:flex; gap:8px; margin-top:8px;"> <input id="chat-input" type="text" placeholder="Escribir mensaje..." style="flex:1; height:36px; border-radius:6px; border:none; padding:0 10px;"> <button id="chat-send" class="forminator-button" style="background:#FF6F00; border:none; border-radius:6px; padding:0 14px; height:36px;">Enviar</button> </div> <small style="opacity:.85; color:#fff;">Los mensajes se actualizan cada 5 segundos.</small> </div> </div> <!-- ESTIMATE --> <div id="estimate-card" class="forminator-ui" style="margin-top:16px; padding:16px;"> <label class="forminator-label" style="color:#FF6F00 !important; font-weight:600;"> <strong>Estimación</strong> </label> <div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:8px;"> <div style="min-width:160px;"> <div style="color:#ffffff;opacity:.9;">Distancia</div> <div id="est-distance" style="font-size:20px; font-weight:600; color:#fff;"></div> </div> <div style="min-width:160px;"> <div style="color:#ffffff;opacity:.9;">Tiempo (tráfico)</div> <div id="est-duration" style="font-size:20px; font-weight:600; color:#fff;"></div> </div> <div style="min-width:200px;"> <div style="color:#ffffff;opacity:.9;">Costo estimado</div> <div id="est-cost" style="font-size:20px; font-weight:700; color:#fff;"></div> </div> </div> <small id="est-note" style="color:#ffffff;opacity:.7;display:block;margin-top:6px;"> Unidades basadas en tu ubicación. Los costos son estimados; <em>en caso de trancón se hará un reajuste</em>. </small> </div> <!-- ACTION: Confirm & Pay --> <div style="margin-top:16px;"> <button id="btn-confirm-pay" class="forminator-button" style="background:#FF6F00; border:none; border-radius:8px; padding:12px 16px; font-weight:700;"> Confirmar y pagar </button> <small id="pay-status" style="display:block;color:#fff;opacity:.8;margin-top:6px;"></small> </div> <!-- Configuración de tarifas (fallback) --> <script id="tarifas-config" type="application/json"> { "default": { "currency":"USD", "symbol":"$", "base": 2.50, "per_km": 1.20, "per_minute": 0.25, "min_fare": 5.00 }, "US": { "currency":"USD", "symbol":"$", "base": 2.75, "per_mile": 1.80, "per_minute": 0.35, "min_fare": 6.50 }, "CO": { "currency":"COP", "symbol":"$", "base": 4000, "per_km": 1800, "per_minute": 200, "min_fare": 7000 } } </script> <script> // =================== SISTEMA DE RESERVA DE VIAJES OPTIMIZADO =================== (function() { 'use strict'; // ================= CONFIGURACIÓN Y CONSTANTES ================= const CONFIG = { GMAPS_KEY: "AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q", UPDATE_INTERVALS: { RIDE_INFO: 5000, CHAT: 5000, PROXIMITY_CHECK: 3000 }, PROXIMITY_THRESHOLD: 50, // metros DEBOUNCE_DELAY: 800, GEOCODE_TIMEOUT: 8000 }; // ================= UTILIDADES GLOBALES ================= const Utils = { // Cache para geocodificación geocodeCache: new Map(), // Función debounce mejorada debounce(fn, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), delay); }; }, // Parsing de coordenadas mejorado parseCoords(text) { const match = text && text.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/); return match ? { lat: parseFloat(match[1]), lng: parseFloat(match[2]) } : null; }, // Formateo de dinero robusto formatMoney(amount, currency, countryCode) { const localeMap = { 'US': 'en-US', 'CO': 'es-CO' }; const locale = localeMap[countryCode] || 'es'; try { return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount); } catch (e) { return `${amount.toFixed(2)} ${currency}`; } }, // Cálculo de distancia haversine haversineDistance(pos1, pos2) { const R = 6371000; // metros const toRad = deg => deg * Math.PI / 180; const dLat = toRad(pos2.lat - pos1.lat); const dLng = toRad(pos2.lng - pos1.lng); const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(toRad(pos1.lat)) * Math.cos(toRad(pos2.lat)) * Math.sin(dLng/2) * Math.sin(dLng/2); return 2 * R * Math.asin(Math.sqrt(a)); }, // Conversiones de unidades kmFromMeters: m => m / 1000, milesFromMeters: m => m / 1609.344, minutesFromSeconds: s => s / 60, // Escape HTML escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, // Selector de elementos robusto $(id) { return document.getElementById(id); } }; // ================= GESTOR DE GOOGLE MAPS ================= const GoogleMapsManager = { isLoaded: false, loadPromise: null, async ensureLoaded() { if (this.isLoaded) return; if (this.loadPromise) return this.loadPromise; this.loadPromise = new Promise((resolve) => { if (window.google && window.google.maps && window.google.maps.places) { this.isLoaded = true; resolve(); return; } if (!document.getElementById('gmaps-js-places')) { const script = document.createElement('script'); script.id = 'gmaps-js-places'; script.src = `https://maps.googleapis.com/maps/api/js?key=${CONFIG.GMAPS_KEY}&libraries=places`; script.async = true; document.head.appendChild(script); } const checkInterval = setInterval(() => { if (window.google && window.google.maps && window.google.maps.places && window.google.maps.Geocoder && window.google.maps.DistanceMatrixService) { clearInterval(checkInterval); this.isLoaded = true; resolve(); } }, 100); }); return this.loadPromise; }, getMapIframe() { return Utils.$('customer-map') || document.querySelector('#customer-map-wrap iframe') || document.querySelector('iframe[src*="google.com/maps"]'); }, updateMapPlace(lat, lng, zoom = 15) { const iframe = this.getMapIframe(); if (!iframe) return; iframe.src = `https://www.google.com/maps/embed/v1/place?key=${CONFIG.GMAPS_KEY}&q=${encodeURIComponent(lat + "," + lng)}&zoom=${zoom}`; }, updateMapDirections(originLL, destLL, waypoints = []) { const iframe = this.getMapIframe(); if (!iframe) return; let url = `https://www.google.com/maps/embed/v1/directions?key=${CONFIG.GMAPS_KEY}` + `&origin=${encodeURIComponent(originLL.lat + "," + originLL.lng)}` + `&destination=${encodeURIComponent(destLL.lat + "," + destLL.lng)}&mode=driving`; if (waypoints.length > 0) { const waypointStr = waypoints.slice(0, 8).map(wp => `${wp.lat},${wp.lng}`).join('|'); url += `&waypoints=${encodeURIComponent(waypointStr)}`; } iframe.src = url; } }; // ================= GESTOR DE GEOCODIFICACIÓN ================= const GeocodingManager = { async geocodeAddress(address) { // Verificar cache if (Utils.geocodeCache.has(address)) { return Utils.geocodeCache.get(address); } await GoogleMapsManager.ensureLoaded(); return new Promise((resolve, reject) => { const geocoder = new google.maps.Geocoder(); geocoder.geocode({ address }, (results, status) => { if (status === "OK" && results[0]?.geometry?.location) { const location = results[0].geometry.location; const result = { lat: location.lat(), lng: location.lng(), formatted: results[0].formatted_address }; // Guardar en cache Utils.geocodeCache.set(address, result); resolve(result); } else { reject(new Error(`Geocoding failed: ${status}`)); } }); }); }, async reverseGeocode(lat, lng) { const cacheKey = `${lat},${lng}`; if (Utils.geocodeCache.has(cacheKey)) { return Utils.geocodeCache.get(cacheKey); } await GoogleMapsManager.ensureLoaded(); return new Promise((resolve, reject) => { const geocoder = new google.maps.Geocoder(); geocoder.geocode({ location: { lat, lng } }, (results, status) => { if (status === "OK" && results[0]?.formatted_address) { const address = results[0].formatted_address; Utils.geocodeCache.set(cacheKey, address); resolve(address); } else { reject(new Error(`Reverse geocoding failed: ${status}`)); } }); }); }, async getCountryFromCoords(lat, lng) { await GoogleMapsManager.ensureLoaded(); return new Promise((resolve) => { const geocoder = new google.maps.Geocoder(); geocoder.geocode({ location: { lat, lng } }, (results, status) => { if (status !== 'OK' || !results?.length) { resolve(null); return; } const countryComponent = (results[0].address_components || []) .find(comp => (comp.types || []).includes('country')); resolve(countryComponent ? countryComponent.short_name : null); }); }); } }; // ================= GESTOR DE COORDENADAS ================= const CoordinateManager = { saveCoordinates(elementId, lat, lng) { const element = Utils.$(elementId); if (element) { element.dataset.lat = String(lat); element.dataset.lng = String(lng); } }, getCoordinates(elementId) { const element = Utils.$(elementId); if (element && element.dataset.lat && element.dataset.lng) { return { lat: parseFloat(element.dataset.lat), lng: parseFloat(element.dataset.lng) }; } return null; }, async getCoordinatesFromInput(elementId) { const coords = this.getCoordinates(elementId); if (coords) return coords; const element = Utils.$(elementId); if (!element) return null; const text = (element.value || '').trim(); if (!text) return null; // Intentar parsing directo de coordenadas const parsedCoords = Utils.parseCoords(text); if (parsedCoords) return parsedCoords; // Geocodificar la dirección try { const result = await GeocodingManager.geocodeAddress(text); return { lat: result.lat, lng: result.lng }; } catch (e) { return null; } } }; // ================= GESTOR DE TARIFAS ================= const TariffManager = { cachedTariffs: null, async getTariffs() { if (this.cachedTariffs) return this.cachedTariffs; // Intentar obtener desde la API if (window.AB_TARIFFS) { this.cachedTariffs = window.AB_TARIFFS; return this.cachedTariffs; } // Fallback a configuración local try { const configElement = Utils.$('tarifas-config'); this.cachedTariffs = JSON.parse(configElement.textContent || configElement.innerText || '{}'); } catch (e) { this.cachedTariffs = {}; } return this.cachedTariffs; }, async calculateCost(distanceMeters, durationSeconds, countryCode) { const tariffs = await this.getTariffs(); const countryTariffs = (countryCode && tariffs[countryCode]) ? tariffs[countryCode] : (tariffs.default || {}); const currency = countryTariffs.currency || 'USD'; const base = Number(countryTariffs.base || 0); const perKm = Number(countryTariffs.per_km || 0); const perMile = Number(countryTariffs.per_mile || 0); const perMinute = Number(countryTariffs.per_minute || 0); const minFare = Number(countryTariffs.min_fare || 0); const unitPref = (countryCode === 'US') ? 'IMPERIAL' : 'METRIC'; const distanceNum = (unitPref === 'IMPERIAL') ? Utils.milesFromMeters(distanceMeters) : Utils.kmFromMeters(distanceMeters); const minutes = Utils.minutesFromSeconds(durationSeconds); const variableDist = (unitPref === 'IMPERIAL' && perMile > 0) ? perMile * distanceNum : (perKm > 0 ? perKm * distanceNum : 0); const variableTime = perMinute > 0 ? perMinute * minutes : 0; let estimate = base + variableDist + variableTime; if (minFare > 0) estimate = Math.max(estimate, minFare); return { amount: estimate, currency, unitPref, distanceNum, minutes }; } }; // ================= GESTOR DE ORIGEN ================= const OriginManager = { init() { const input = Utils.$('origin-input'); const statusElement = Utils.$('origin-status'); const gpsButton = Utils.$('btn-gps'); if (!input || !gpsButton) return; // Inicializar con GPS automáticamente this.detectLocation(statusElement); // Configurar botón GPS gpsButton.addEventListener('click', () => { this.detectLocation(statusElement); }); // Configurar autocompletado y eventos this.setupAutocomplete(input, statusElement); this.setupEvents(input, statusElement); }, detectLocation(statusElement) { if (statusElement) statusElement.textContent = 'Detectando ubicación...'; if (!navigator.geolocation) { if (statusElement) statusElement.textContent = 'Geolocalización no soportada.'; return; } navigator.geolocation.getCurrentPosition( (position) => { this.setFromCoords( position.coords.latitude, position.coords.longitude, statusElement ); }, () => { if (statusElement) statusElement.textContent = 'Permiso denegado o tiempo agotado.'; }, { enableHighAccuracy: true, timeout: CONFIG.GEOCODE_TIMEOUT } ); }, async setFromCoords(lat, lng, statusElement) { const input = Utils.$('origin-input'); if (!input) return; input.value = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; GoogleMapsManager.updateMapPlace(lat, lng, 15); CoordinateManager.saveCoordinates('origin-input', lat, lng); try { const address = await GeocodingManager.reverseGeocode(lat, lng); input.value = address; if (statusElement) statusElement.textContent = 'Origen establecido.'; } catch (e) { if (statusElement) statusElement.textContent = 'Origen establecido (coordenadas).'; } // Recalcular estimación EstimateManager.updateEstimate(); }, async setupAutocomplete(input, statusElement) { await GoogleMapsManager.ensureLoaded(); const autocomplete = new google.maps.places.Autocomplete(input, { types: ['geocode'], fields: ['geometry', 'formatted_address', 'name'] }); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); if (place && place.geometry && place.geometry.location) { const lat = place.geometry.location.lat(); const lng = place.geometry.location.lng(); GoogleMapsManager.updateMapPlace(lat, lng, 15); input.value = place.formatted_address || place.name || input.value; CoordinateManager.saveCoordinates('origin-input', lat, lng); if (statusElement) statusElement.textContent = 'Origen actualizado.'; EstimateManager.updateEstimate(); } }); }, setupEvents(input, statusElement) { const geocodeTyped = Utils.debounce(async () => { const text = (input.value || '').trim(); if (!text) return; // Evitar geocodificar coordenadas if (Utils.parseCoords(text)) return; if (statusElement) statusElement.textContent = 'Geocodificando dirección...'; try { const result = await GeocodingManager.geocodeAddress(text); input.value = result.formatted; GoogleMapsManager.updateMapPlace(result.lat, result.lng, 15); CoordinateManager.saveCoordinates('origin-input', result.lat, result.lng); if (statusElement) statusElement.textContent = 'Origen actualizado.'; EstimateManager.updateEstimate(); } catch (e) { if (statusElement) statusElement.textContent = 'Dirección no encontrada. Intenta con una más específica.'; } }, CONFIG.DEBOUNCE_DELAY); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); geocodeTyped(); } }); input.addEventListener('blur', geocodeTyped); } }; // ================= GESTOR DE DESTINO ================= const DestinationManager = { init() { const input = Utils.$('dest-input'); const statusElement = Utils.$('dest-status'); if (!input) return; this.setupAutocomplete(input, statusElement); this.setupEvents(input, statusElement); }, async setupAutocomplete(input, statusElement) { await GoogleMapsManager.ensureLoaded(); const autocomplete = new google.maps.places.Autocomplete(input, { types: ['geocode'], fields: ['geometry', 'formatted_address', 'name'] }); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); if (place && place.geometry && place.geometry.location) { const lat = place.geometry.location.lat(); const lng = place.geometry.location.lng(); input.value = place.formatted_address || place.name || input.value; CoordinateManager.saveCoordinates('dest-input', lat, lng); if (statusElement) statusElement.textContent = 'Destino establecido.'; this.updateRouteAndEstimate(); } }); }, setupEvents(input, statusElement) { const geocodeTyped = Utils.debounce(async () => { const text = (input.value || '').trim(); if (!text) { if (statusElement) statusElement.textContent = 'Escriba una dirección.'; return; } const coords = Utils.parseCoords(text); if (coords) { CoordinateManager.saveCoordinates('dest-input', coords.lat, coords.lng); if (statusElement) statusElement.textContent = 'Destino establecido (coordenadas).'; this.updateRouteAndEstimate(); return; } if (statusElement) statusElement.textContent = 'Geocodificando dirección...'; try { const result = await GeocodingManager.geocodeAddress(text); input.value = result.formatted; CoordinateManager.saveCoordinates('dest-input', result.lat, result.lng); if (statusElement) statusElement.textContent = 'Destino establecido.'; this.updateRouteAndEstimate(); } catch (e) { if (statusElement) statusElement.textContent = 'Dirección no encontrada. Intenta con una más específica.'; } }, CONFIG.DEBOUNCE_DELAY); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); geocodeTyped(); } }); input.addEventListener('blur', geocodeTyped); }, updateRouteAndEstimate() { const originCoords = CoordinateManager.getCoordinates('origin-input'); const destCoords = CoordinateManager.getCoordinates('dest-input'); const stops = StopsManager.getStopsCoordinates(); if (destCoords) { if (originCoords) { GoogleMapsManager.updateMapDirections(originCoords, destCoords, stops); } else { GoogleMapsManager.updateMapPlace(destCoords.lat, destCoords.lng); } EstimateManager.updateEstimate(); } } }; // ================= GESTOR DE PARADAS ================= const StopsManager = { init() { const addButton = Utils.$('btn-add-stop'); if (!addButton) return; addButton.addEventListener('click', () => { this.addStopRow(); }); }, addStopRow() { const container = Utils.$('stops-wrap'); if (!container) return; const row = document.createElement('div'); row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center'; row.innerHTML = ` <input type="text" placeholder="Dirección de parada" class="forminator-input stop-input" style="flex:1; background:#fff; color:#000; border:none; box-shadow:2px 2px 8px rgba(0,0,0,0.2); border-radius:6px; height:55px; padding:5px 5px;"> <button type="button" class="forminator-button" style="background:#FF6F00; border:none; border-radius:8px; padding:10px 12px;">Quitar</button> `; const input = row.querySelector('.stop-input'); const removeButton = row.querySelector('button'); removeButton.addEventListener('click', () => { row.remove(); this.updateRouteWithStops(); }); container.appendChild(row); this.setupStopAutocomplete(input); input.focus(); }, async setupStopAutocomplete(input) { await GoogleMapsManager.ensureLoaded(); const autocomplete = new google.maps.places.Autocomplete(input, { types: ['geocode'], fields: ['geometry', 'formatted_address', 'name'] }); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); if (place && place.geometry && place.geometry.location) { const lat = place.geometry.location.lat(); const lng = place.geometry.location.lng(); input.value = place.formatted_address || place.name || input.value; input.dataset.lat = String(lat); input.dataset.lng = String(lng); this.updateRouteWithStops(); } }); const geocodeStop = Utils.debounce(async () => { const text = (input.value || '').trim(); if (!text) return; try { const result = await GeocodingManager.geocodeAddress(text); input.value = result.formatted; input.dataset.lat = String(result.lat); input.dataset.lng = String(result.lng); this.updateRouteWithStops(); } catch (e) { // Silencio en caso de error } }, CONFIG.DEBOUNCE_DELAY); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); geocodeStop(); } }); input.addEventListener('blur', geocodeStop); }, getStopsCoordinates() { const stopInputs = document.querySelectorAll('.stop-input'); return Array.from(stopInputs).map(input => { if (input.dataset.lat && input.dataset.lng) { return { lat: parseFloat(input.dataset.lat), lng: parseFloat(input.dataset.lng) }; } return null; }).filter(Boolean); }, updateRouteWithStops() { const originCoords = CoordinateManager.getCoordinates('origin-input'); const destCoords = CoordinateManager.getCoordinates('dest-input'); const stops = this.getStopsCoordinates(); if (originCoords && destCoords) { GoogleMapsManager.updateMapDirections(originCoords, destCoords, stops); EstimateManager.updateEstimateWithStops(); } } }; // ================= GESTOR DE ESTIMACIONES ================= const EstimateManager = { isCalculating: false, async updateEstimate() { if (this.isCalculating) return; this.isCalculating = true; try { const originCoords = await CoordinateManager.getCoordinatesFromInput('origin-input'); const destCoords = await CoordinateManager.getCoordinatesFromInput('dest-input'); if (!originCoords || !destCoords) { this.clearEstimate(); return; } await this.calculateRoute(originCoords, destCoords); } catch (e) { this.clearEstimate(); } finally { this.isCalculating = false; } }, async updateEstimateWithStops() { if (this.isCalculating) return; this.isCalculating = true; try { const originCoords = CoordinateManager.getCoordinates('origin-input'); const destCoords = CoordinateManager.getCoordinates('dest-input'); const stops = StopsManager.getStopsCoordinates(); if (!originCoords || !destCoords) { this.clearEstimate(); return; } await this.calculateRouteWithStops(originCoords, destCoords, stops); } catch (e) { this.clearEstimate(); } finally { this.isCalculating = false; } }, async calculateRoute(origin, destination) { await GoogleMapsManager.ensureLoaded(); const service = new google.maps.DistanceMatrixService(); return new Promise((resolve) => { service.getDistanceMatrix({ origins: [origin], destinations: [destination], travelMode: google.maps.TravelMode.DRIVING, drivingOptions: { departureTime: new Date(), trafficModel: google.maps.TrafficModel.BEST_GUESS } }, async (response, status) => { if (status === 'OK' && response?.rows?.[0]?.elements?.[0]?.status === 'OK') { const element = response.rows[0].elements[0]; const meters = element.distance?.value || 0; const seconds = element.duration_in_traffic?.value || element.duration?.value || 0; await this.displayEstimate(meters, seconds, origin); } else { this.clearEstimate(); } resolve(); }); }); }, async calculateRouteWithStops(origin, destination, stops) { if (stops.length === 0) { return this.calculateRoute(origin, destination); } await GoogleMapsManager.ensureLoaded(); const service = new google.maps.DistanceMatrixService(); const waypoints = [origin].concat(stops).concat([destination]); let totalMeters = 0; let totalSeconds = 0; // Calcular cada tramo por separado for (let i = 0; i < waypoints.length - 1; i++) { await new Promise((resolve) => { service.getDistanceMatrix({ origins: [waypoints[i]], destinations: [waypoints[i + 1]], travelMode: google.maps.TravelMode.DRIVING, drivingOptions: { departureTime: new Date(), trafficModel: google.maps.TrafficModel.BEST_GUESS } }, (response, status) => { if (status === 'OK' && response?.rows?.[0]?.elements?.[0]?.status === 'OK') { const element = response.rows[0].elements[0]; totalMeters += element.distance?.value || 0; totalSeconds += element.duration_in_traffic?.value || element.duration?.value || 0; } resolve(); }); }); } await this.displayEstimate(totalMeters, totalSeconds, origin); }, async displayEstimate(meters, seconds, originCoords) { const distanceElement = Utils.$('est-distance'); const durationElement = Utils.$('est-duration'); const costElement = Utils.$('est-cost'); if (!distanceElement || !durationElement || !costElement) return; const countryCode = await GeocodingManager.getCountryFromCoords(originCoords.lat, originCoords.lng); const costData = await TariffManager.calculateCost(meters, seconds, countryCode); // Mostrar distancia const distanceText = costData.unitPref === 'IMPERIAL' ? `${costData.distanceNum.toFixed(2)} mi` : `${costData.distanceNum.toFixed(2)} km`; distanceElement.textContent = distanceText; // Mostrar duración durationElement.textContent = `${Math.round(costData.minutes)} min`; // Mostrar costo costElement.textContent = Utils.formatMoney(costData.amount, costData.currency, countryCode || 'US'); }, clearEstimate() { const elements = ['est-distance', 'est-duration', 'est-cost']; elements.forEach(id => { const element = Utils.$(id); if (element) element.textContent = '—'; }); } }; // ================= GESTOR DE INFORMACIÓN DEL CONDUCTOR ================= const DriverManager = { updateInterval: null, rideId: null, init() { const urlParams = new URLSearchParams(window.location.search); this.rideId = urlParams.get('ride_id'); if (this.rideId) { this.startUpdates(); this.setupChat(); } }, startUpdates() { this.fetchRideInfo(); this.updateInterval = setInterval(() => { this.fetchRideInfo(); }, CONFIG.UPDATE_INTERVALS.RIDE_INFO); }, async fetchRideInfo() { if (!this.rideId) return; try { const response = await fetch(`/wp-json/autobooking/v1/ride_info?ride_id=${encodeURIComponent(this.rideId)}`, { credentials: 'same-origin' }); const data = await response.json(); if (!response.ok || !data?.ok) return; if (data.driver) { await this.displayDriverInfo(data.driver); } else { this.hideDriverCard(); } this.updateChat(); } catch (e) { // Error silencioso } }, async displayDriverInfo(driver) { const card = Utils.$('driver-card'); const extraInfo = Utils.$('drv-extra'); if (!card) return; // Información básica del conductor this.updateElement('drv-name', driver.name || 'Conductor'); this.updateElement('drv-rating', this.formatRating(driver.rating)); this.updateElement('drv-vehicle', driver.vehicle || ''); this.updateElement('drv-plate', driver.plate ? `Placa: ${driver.plate}` : ''); // Foto del conductor const photo = Utils.$('drv-photo'); if (photo && driver.photo) { photo.src = driver.photo; } // Teléfono del conductor const callButton = Utils.$('drv-call'); if (callButton && driver.phone) { callButton.href = `tel:${driver.phone}`; } // ETA y estado await this.updateDriverStatus(driver); card.style.display = 'block'; if (extraInfo) extraInfo.style.display = 'block'; }, async updateDriverStatus(driver) { const etaElement = Utils.$('drv-eta'); const busyElement = Utils.$('drv-busy'); if (!etaElement || !busyElement) return; // Calcular ETA si hay coordenadas del conductor if (driver.lat && driver.lng) { const originCoords = CoordinateManager.getCoordinates('origin-input'); if (originCoords) { const eta = await this.calculateETA( { lat: parseFloat(driver.lat), lng: parseFloat(driver.lng) }, originCoords ); etaElement.textContent = eta ? `ETA al origen: ${eta}` : 'ETA al origen: —'; } } else { etaElement.textContent = 'ETA al origen: —'; } // Estado de ocupado if (driver.on_ride) { const finishTime = typeof driver.finish_eta_min === 'number' ? Math.max(0, Math.round(driver.finish_eta_min)) : null; busyElement.textContent = finishTime !== null ? `El conductor está terminando un viaje · aprox. ${finishTime} min para terminar.` : 'El conductor está terminando un viaje.'; } else { busyElement.textContent = ''; } }, async calculateETA(driverCoords, originCoords) { await GoogleMapsManager.ensureLoaded(); return new Promise((resolve) => { const service = new google.maps.DistanceMatrixService(); service.getDistanceMatrix({ origins: [driverCoords], destinations: [originCoords], travelMode: google.maps.TravelMode.DRIVING, drivingOptions: { departureTime: new Date(), trafficModel: google.maps.TrafficModel.BEST_GUESS } }, (response, status) => { if (status === 'OK' && response?.rows?.[0]?.elements?.[0]?.status === 'OK') { const element = response.rows[0].elements[0]; resolve(element.duration_in_traffic?.text || element.duration?.text || null); } else { resolve(null); } }); }); }, formatRating(rating) { if (!rating || !rating.avg) return '★ —'; const avg = rating.avg; const count = rating.count || 0; const stars = Math.max(0, Math.min(5, Math.round(avg * 2) / 2)); const fullStars = Math.floor(stars); const halfStar = (stars - fullStars) >= 0.5 ? 1 : 0; const emptyStars = 5 - fullStars - halfStar; const starText = '★'.repeat(fullStars) + (halfStar ? '☆' : '') + '☆'.repeat(emptyStars); return `${starText} (${avg.toFixed(1)})${count ? ` · ${count}` : ''}`; }, hideDriverCard() { const card = Utils.$('driver-card'); if (card) card.style.display = 'none'; }, updateElement(id, text) { const element = Utils.$(id); if (element) element.textContent = text; }, // Gestión del chat setupChat() { const sendButton = Utils.$('chat-send'); const inputField = Utils.$('chat-input'); if (sendButton && inputField) { sendButton.addEventListener('click', () => { this.sendMessage(); }); inputField.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.sendMessage(); } }); } }, async sendMessage() { const inputField = Utils.$('chat-input'); const sendButton = Utils.$('chat-send'); if (!inputField || !sendButton) return; const text = (inputField.value || '').trim(); if (!text) return; sendButton.disabled = true; try { const response = await fetch('/wp-json/autobooking/v1/chat/send', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ride_id: this.rideId, text: text }) }); inputField.value = ''; if (response.ok) { this.updateChat(); } } catch (e) { // Error silencioso } finally { sendButton.disabled = false; } }, async updateChat() { if (!this.rideId) return; try { const response = await fetch(`/wp-json/autobooking/v1/chat?ride_id=${encodeURIComponent(this.rideId)}`, { credentials: 'same-origin' }); if (response.ok) { const data = await response.json(); if (data?.ok && Array.isArray(data.messages)) { this.renderChat(data.messages); const chatWrap = Utils.$('ride-chat'); if (chatWrap) chatWrap.style.display = 'block'; } else { this.hideChatWrap(); } } else { this.hideChatWrap(); } } catch (e) { this.hideChatWrap(); } }, renderChat(messages) { const chatLog = Utils.$('chat-log'); if (!chatLog) return; chatLog.innerHTML = messages.map(message => { const sender = message.who === 'driver' ? 'Conductor' : 'Tú'; return `<div><strong>${sender}:</strong> ${Utils.escapeHtml(message.text || '')}</div>`; }).join(''); chatLog.scrollTop = chatLog.scrollHeight; }, hideChatWrap() { const chatWrap = Utils.$('ride-chat'); if (chatWrap) chatWrap.style.display = 'none'; }, destroy() { if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } } }; // ================= GESTOR DE PAGO ================= const PaymentManager = { init() { const confirmButton = Utils.$('btn-confirm-pay'); if (confirmButton) { confirmButton.addEventListener('click', () => { this.startCheckout(); }); } this.checkOrderReceived(); }, async startCheckout() { const confirmButton = Utils.$('btn-confirm-pay'); const statusElement = Utils.$('pay-status'); if (!confirmButton) return; const originCoords = CoordinateManager.getCoordinates('origin-input'); const destCoords = CoordinateManager.getCoordinates('dest-input'); if (!originCoords || !destCoords) { if (statusElement) statusElement.textContent = 'Establece origen y destino primero.'; return; } const estimate = this.getEstimateAmount(); if (!estimate || estimate <= 0) { if (statusElement) statusElement.textContent = 'Estimación inválida.'; return; } confirmButton.disabled = true; confirmButton.textContent = 'Redirigiendo...'; if (statusElement) statusElement.textContent = ''; try { const currency = await this.getCurrencyFromLocation(originCoords); const stops = this.getStopsData(); const response = await fetch('/wp-json/autobooking/v1/wc/checkout', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currency, estimate, origin_text: Utils.$('origin-input').value, origin_lat: originCoords.lat, origin_lng: originCoords.lng, dest_text: Utils.$('dest-input').value, dest_lat: destCoords.lat, dest_lng: destCoords.lng, stops }) }); const data = await response.json(); if (!response.ok || !data?.ok || !data?.pay_url) { throw new Error(data?.message || 'Error en checkout'); } window.location.href = data.pay_url; } catch (e) { confirmButton.disabled = false; confirmButton.textContent = 'Confirmar y pagar'; if (statusElement) { statusElement.textContent = e.message || 'Error iniciando checkout.'; } } }, getEstimateAmount() { const costElement = Utils.$('est-cost'); if (!costElement) return 0; const text = costElement.textContent.replace(/[^\d.,]/g, '').replace('.', '').replace(',', '.'); const amount = parseFloat(text); return isNaN(amount) ? 0 : amount; }, async getCurrencyFromLocation(coords) { const countryCode = await GeocodingManager.getCountryFromCoords(coords.lat, coords.lng); const tariffs = await TariffManager.getTariffs(); if (countryCode && tariffs[countryCode]) { return tariffs[countryCode].currency || 'USD'; } return tariffs.default?.currency || 'USD'; }, getStopsData() { const stopInputs = document.querySelectorAll('.stop-input'); return Array.from(stopInputs).map(input => ({ text: input.value || '', lat: input.dataset.lat ? parseFloat(input.dataset.lat) : null, lng: input.dataset.lng ? parseFloat(input.dataset.lng) : null })).filter(stop => stop.text); }, checkOrderReceived() { const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('order-received')) { const statusElement = Utils.$('pay-status'); if (statusElement) { statusElement.textContent = '✔ Pago autorizado. ¡Gracias!'; } } } }; // ================= GESTOR DE PROXIMIDAD ================= const ProximityManager = { watchId: null, rideId: null, destination: null, redirected: false, init() { const urlParams = new URLSearchParams(window.location.search); this.rideId = urlParams.get('ride_id'); if (this.rideId) { this.startProximityCheck(); } }, startProximityCheck() { this.checkRideStatus(); setInterval(() => { if (!this.redirected) { this.checkRideStatus(); } }, CONFIG.UPDATE_INTERVALS.PROXIMITY_CHECK); }, async checkRideStatus() { try { const response = await fetch(`/wp-json/autobooking/v1/ride_info?ride_id=${encodeURIComponent(this.rideId)}`, { credentials: 'same-origin' }); const data = await response.json(); if (!response.ok || !data?.ok) return; if (data.status === 'finished') { this.redirectToReview(); return; } if (data.status === 'in_progress' && data.dest?.lat && data.dest?.lng) { this.destination = data.dest; this.startLocationWatch(); } } catch (e) { // Error silencioso } }, startLocationWatch() { if (!this.destination || !navigator.geolocation || this.redirected || this.watchId) { return; } this.watchId = navigator.geolocation.watchPosition( (position) => { const currentPosition = { lat: position.coords.latitude, lng: position.coords.longitude }; const distance = Utils.haversineDistance(currentPosition, this.destination); if (distance <= CONFIG.PROXIMITY_THRESHOLD) { this.redirectToReview(); } }, () => { // Error silencioso en geolocalización }, { enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 } ); }, redirectToReview() { if (this.redirected) return; this.redirected = true; if (this.watchId) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } window.location.href = `/review/?ride_id=${encodeURIComponent(this.rideId)}`; }, destroy() { if (this.watchId) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } } }; // ================= GESTOR DE INTERVALOS ================= const IntervalManager = { intervals: new Set(), add(intervalId) { this.intervals.add(intervalId); }, clear() { this.intervals.forEach(id => clearInterval(id)); this.intervals.clear(); } }; // ================= INICIALIZACIÓN PRINCIPAL ================= const RideBookingApp = { async init() { try { // Cargar tarifas desde API this.loadTariffs(); // Inicializar gestores en orden await GoogleMapsManager.ensureLoaded(); OriginManager.init(); DestinationManager.init(); StopsManager.init(); DriverManager.init(); PaymentManager.init(); ProximityManager.init(); // Configurar observadores para cambios en coordenadas this.setupCoordinateObservers(); console.log('Sistema de reserva de viajes inicializado correctamente'); } catch (e) { console.error('Error inicializando sistema de reservas:', e); } }, loadTariffs() { if (window.AB_TARIFFS) return; const apiUrl = `${window.location.origin}/wp-json/autobooking/v1/tariffs`; fetch(apiUrl, { cache: 'no-store', credentials: 'same-origin' }) .then(response => response.json()) .then(data => { window.AB_TARIFFS = data; }) .catch(() => { // Usar fallback de configuración embebida }); }, setupCoordinateObservers() { const observeElement = (elementId) => { const element = Utils.$(elementId); if (!element) return; new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'attributes' && (mutation.attributeName === 'data-lat' || mutation.attributeName === 'data-lng')) { EstimateManager.updateEstimate(); break; } } }).observe(element, { attributes: true, attributeFilter: ['data-lat', 'data-lng'] }); }; observeElement('origin-input'); observeElement('dest-input'); }, destroy() { IntervalManager.clear(); DriverManager.destroy(); ProximityManager.destroy(); } }; // ================= INICIALIZACIÓN AUTOMÁTICA ================= if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { RideBookingApp.init(); }); } else { RideBookingApp.init(); } // Cleanup al salir de la página window.addEventListener('beforeunload', () => { RideBookingApp.destroy(); }); // Exponer utilidades globalmente para debugging window.RideBookingDebug = { Utils, GoogleMapsManager, GeocodingManager, CoordinateManager, TariffManager, EstimateManager, DriverManager, PaymentManager }; })(); </script> </body> </html>