Completeyourprofile111/voi-multi-ride-manager
0
1// Voi Multi-Account Manager2class VoiManager {3 constructor() {4 this.accounts = this.loadAccounts();5 this.currentAccount = null;6 this.currentTab = 'dashboard';7 this.apiBaseUrl = window.location.origin + '/api/voi'; // Backend proxy8 this.init();9 }10 11 init() {12 this.renderAccountSelector();13 if (this.accounts.length > 0) {14 this.selectAccount(this.accounts[0].id);15 }16 this.switchTab('dashboard');17 }18 19 // Account Management20 loadAccounts() {21 const stored = localStorage.getItem('voi_accounts');22 return stored ? JSON.parse(stored) : [];23 }24 25 saveAccounts() {26 localStorage.setItem('voi_accounts', JSON.stringify(this.accounts));27 }28 29 async selectAccount(accountId) {30 this.currentAccount = this.accounts.find(a => a.id === accountId);31 if (this.currentAccount) {32 await this.authenticateAccount();33 this.renderAccountSelector();34 this.loadCurrentTabContent();35 }36 }37 async authenticateAccount() {38 try {39 const response = await fetch(`${this.apiBaseUrl}/v1/auth/session`, {40 method: 'POST',41 headers: { 'Content-Type': 'application/json' },42 body: JSON.stringify({ 43 authenticationToken: this.currentAccount.authToken 44 })45 });46 47 if (response.ok) {48 const data = await response.json();49 this.currentAccount.accessToken = data.accessToken;50 if (data.authenticationToken) {51 this.currentAccount.authToken = data.authenticationToken;52 this.saveAccounts();53 }54 } else {55 throw new Error(`Authentication failed: ${response.status}`);56 }57 } catch (error) {58 console.error('Authentication failed:', error);59 this.showNotification('Authentication failed. Please check your token.', 'error');60 throw error;61 }62 }63addAccount(name, authToken) {64 const account = {65 id: 'acc_' + Date.now(),66 name: name,67 authToken: authToken,68 accessToken: null,69 addedAt: new Date().toISOString()70 };71 72 this.accounts.push(account);73 this.saveAccounts();74 this.selectAccount(account.id);75 this.showNotification('Account added successfully', 'success');76 }77 78 removeAccount(accountId) {79 this.accounts = this.accounts.filter(a => a.id !== accountId);80 this.saveAccounts();81 82 if (this.currentAccount?.id === accountId) {83 this.currentAccount = this.accounts.length > 0 ? this.accounts[0] : null;84 if (this.currentAccount) {85 this.selectAccount(this.currentAccount.id);86 } else {87 this.renderAccountSelector();88 this.loadCurrentTabContent();89 }90 }91 92 this.showNotification('Account removed', 'info');93 }94 95 // UI Rendering96 renderAccountSelector() {97 const selector = document.getElementById('accountSelector');98 99 if (this.accounts.length === 0) {100 selector.innerHTML = `101 <div class="text-gray-500 py-8 text-center w-full">102 <i data-feather="users" class="w-12 h-12 mx-auto mb-2"></i>103 <p>No accounts added yet</p>104 </div>105 `;106 feather.replace();107 return;108 }109 110 selector.innerHTML = this.accounts.map(account => `111 <div class="account-chip ${this.currentAccount?.id === account.id ? 'active' : 'bg-gray-100'} 112 px-4 py-2 rounded-full flex items-center gap-2"113 onclick="voiManager.selectAccount('${account.id}')">114 <div class="w-2 h-2 rounded-full ${this.currentAccount?.id === account.id ? 'bg-white' : 'bg-green-500'}"></div>115 <span class="font-medium">${account.name}</span>116 <button onclick="event.stopPropagation(); voiManager.removeAccount('${account.id}')" 117 class="ml-2 hover:opacity-70">118 <i data-feather="x" class="w-4 h-4"></i>119 </button>120 </div>121 `).join('');122 123 feather.replace();124 }125 126 // Tab Management127 switchTab(tabName) {128 this.currentTab = tabName;129 130 // Update tab buttons131 document.querySelectorAll('.tab-btn').forEach(btn => {132 btn.classList.toggle('active', btn.dataset.tab === tabName);133 });134 135 // Disable tabs if no accounts136 if (this.accounts.length === 0) {137 document.getElementById('tabContent').innerHTML = `138 <div class="text-center py-12 text-gray-500">139 <i data-feather="user-plus" class="w-16 h-16 mx-auto mb-4"></i>140 <p class="text-xl mb-4">Please add an account to continue</p>141 <button type="button" onclick="openAddAccountModal()" class="bg-purple-600 text-white px-6 py-2 rounded-lg hover:bg-purple-700 transition">142 Add Your First Account143 </button>144 </div>145 `;146 feather.replace();147 return;148 }149 150 this.loadCurrentTabContent();151 }152async loadCurrentTabContent() {153 const content = document.getElementById('tabContent');154 155 if (!this.currentAccount) {156 content.innerHTML = `157 <div class="text-center py-12 text-gray-500">158 <i data-feather="user-plus" class="w-16 h-16 mx-auto mb-4"></i>159 <p class="text-xl">Please add an account to continue</p>160 </div>161 `;162 feather.replace();163 return;164 }165 166 // Show loading skeleton167 content.innerHTML = this.getLoadingSkeleton();168 169 try {170 switch (this.currentTab) {171 case 'dashboard':172 await this.loadDashboard();173 break;174 case 'vehicles':175 await this.loadVehicles();176 break;177 case 'rides':178 await this.loadRides();179 break;180 case 'wallet':181 await this.loadWallet();182 break;183 }184 185 // Update quick stats186 await this.updateQuickStats();187 } catch (error) {188 console.error('Failed to load tab content:', error);189 content.innerHTML = `190 <div class="text-center py-12 text-red-500">191 <i data-feather="alert-circle" class="w-16 h-16 mx-auto mb-4"></i>192 <p class="text-xl">Failed to load content</p>193 <button onclick="voiManager.loadCurrentTabContent()" class="mt-4 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">194 Retry195 </button>196 </div>197 `;198 } catch (error) {199 console.error('Failed to load dashboard:', error);200 this.showNotification('Failed to load dashboard', 'error');201 }202 }203feather.replace();204 }205 206 // Content Loaders207 async loadDashboard() {208 try {209 const [userInfo, rideState] = await Promise.all([210 this.makeApiCall('/v1/users/me'),211 this.makeApiCall('/v1/rides/state')212 ]);213 214 const content = document.getElementById('tabContent');215 content.innerHTML = `216 <div class="space-y-6">217 <div class="bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl p-6 text-white">218 <h3 class="text-2xl font-bold mb-2">Welcome back, ${userInfo?.user?.name || 'Rider'}!</h3>219 <p class="opacity-90">${rideState?.data?.activeRide ? 'You have an active ride' : 'Ready for your next ride'}</p>220 </div>221 222 <div class="grid grid-cols-1 md:grid-cols-2 gap-6">223 <div class="border border-gray-200 rounded-lg p-4">224 <h4 class="font-semibold mb-2 flex items-center gap-2">225 <i data-feather="phone" class="w-5 h-5"></i>226 Contact227 </h4>228 <p class="text-gray-600">${userInfo?.user?.email || 'Not set'}</p>229 <p class="text-gray-600">${userInfo?.user?.phone || 'Not set'}</p>230 </div>231 232 <div class="border border-gray-200 rounded-lg p-4">233 <h4 class="font-semibold mb-2 flex items-center gap-2">234 <i data-feather="map-pin" class="w-5 h-5"></i>235 Location236 </h4>237 <p class="text-gray-600">${userInfo?.user?.country || 'Unknown'}</p>238 <p class="text-gray-600">Member since ${new Date(userInfo?.user?.createdAt || userInfo?.user?.addedAt || Date.now()).toLocaleDateString()}</p>239 </div>240 </div>241 242 ${rideState?.data?.activeRide ? this.getActiveRideCard(rideState.data.activeRide) : ''}243 </div>244 `;245 feather.replace();246 } catch (error) {247 console.error('Failed to load dashboard:', error);248 this.showNotification('Failed to load dashboard', 'error');249 }250 }251 async loadVehicles() {252 try {253 const zones = await this.makeApiCall('/v1/zones');254 const selectedZone = localStorage.getItem('selectedZone') || 'de_ber_1';255 256 // Show loading state257 const content = document.getElementById('tabContent');258 content.innerHTML = this.getLoadingSkeleton();259 260 const vehicles = await this.makeApiCall(`/v2/rides/vehicles?zone_id=${selectedZone}&include_suggestion=true`);261 262 content.innerHTML = `263 <div class="space-y-6">264 <div class="flex items-center justify-between">265 <h3 class="text-xl font-bold">Available Vehicles</h3>266 <select id="zoneSelect" onchange="voiManager.loadVehiclesForZone(this.value)" 267 class="px-4 py-2 border border-gray-300 rounded-lg">268 ${zones?.zones?.map(zone => 269 `<option value="${zone.zone_id}" ${zone.zone_id === selectedZone ? 'selected' : ''}>${zone.name}, ${zone.city}</option>`270 ).join('') || '<option>No zones available</option>'}271 </select>272 </div>273 274 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">275 ${vehicles?.data?.vehicle_groups?.[0]?.vehicles?.map(vehicle => 276 this.getVehicleCard(vehicle)277 ).join('') || '<p class="text-gray-500 col-span-full text-center py-8">No vehicles available in this zone</p>'}278 </div>279 </div>280 `;281 feather.replace();282 } catch (error) {283 console.error('Failed to load vehicles:', error);284 this.showNotification('Failed to load vehicles', 'error');285 }286 }287async loadVehiclesForZone(zoneId) {288 localStorage.setItem('selectedZone', zoneId);289 await this.loadVehicles();290 }291 async loadRides() {292 try {293 // Show loading state294 const content = document.getElementById('tabContent');295 content.innerHTML = this.getLoadingSkeleton();296 297 const history = await this.makeApiCall('/v2/rides/history?limit=10');298 299 content.innerHTML = `300 <div class="space-y-6">301 <div class="flex items-center justify-between">302 <h3 class="text-xl font-bold">Ride History</h3>303 <button type="button" onclick="voiManager.loadCurrentTabContent()" class="text-purple-600 hover:text-purple-700 text-sm flex items-center gap-1">304 <i data-feather="refresh-cw" class="w-4 h-4"></i>305 Refresh306 </button>307 </div>308 309 ${history?.data?.[0]?.history?.length ? `310 <div class="space-y-4">311 ${history.data[0].history.map(ride => this.getRideCard(ride)).join('')}312 </div>313 ` : '<p class="text-gray-500 text-center py-8">No rides found</p>'}314 </div>315 `;316 feather.replace();317 } catch (error) {318 console.error('Failed to load rides:', error);319 this.showNotification('Failed to load ride history', 'error');320 }321 }322async loadWallet() {323 try {324 // Show loading state325 const content = document.getElementById('tabContent');326 content.innerHTML = this.getLoadingSkeleton();327 328 const [wallet, payments] = await Promise.all([329 this.makeApiCall('/v1/user/wallet'),330 this.makeApiCall('/v2/payments/profiles?all=true')331 ]);332 333 content.innerHTML = `334 <div class="space-y-6">335 <div class="bg-gradient-to-r from-green-500 to-teal-500 rounded-xl p-6 text-white">336 <h3 class="text-2xl font-bold mb-2">Voi Credits</h3>337 <p class="text-4xl font-bold">€${(wallet?.data?.voiCreditsBalance || 0).toFixed(2)}</p>338 </div>339 340 <div class="border border-gray-200 rounded-lg p-4">341 <h4 class="font-semibold mb-4 flex items-center gap-2">342 <i data-feather="credit-card" class="w-5 h-5"></i>343 Payment Methods344 </h4>345 ${payments?.data?.length ? payments.data.map(profile => 346 profile.payment_methods?.map(method => `347 <div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">348 <div class="flex items-center gap-3">349 <div class="w-10 h-6 bg-gradient-to-r from-blue-600 to-blue-400 rounded flex items-center justify-center">350 <span class="text-white text-xs font-bold">${method.brand?.toUpperCase() || 'CARD'}</span>351 </div>352 <span>•••• ${method.hint || method.last4 || '****'}</span>353 ${method.default ? '<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">Default</span>' : ''}354 </div>355 </div>356 `).join('') || '<p class="text-gray-500">No payment methods in profile</p>'357 ) : '<p class="text-gray-500">No payment methods added</p>'}358 </div>359 </div>360 `;361 feather.replace();362 } catch (error) {363 console.error('Failed to load wallet:', error);364 this.showNotification('Failed to load wallet', 'error');365 }366 }367 async updateQuickStats() {368 const stats = document.getElementById('quickStats');369 370 try {371 const [wallet, rideState, history] = await Promise.all([372 this.makeApiCall('/v1/user/wallet'),373 this.makeApiCall('/v1/rides/state'),374 this.makeApiCall('/v2/rides/history?limit=1')375 ]);376 377 stats.innerHTML = `378 <div class="bg-white rounded-lg p-4 border border-gray-200 hover-card">379 <div class="flex items-center justify-between">380 <div>381 <p class="text-gray-500 text-sm">Balance</p>382 <p class="text-2xl font-bold text-gray-900">€${(wallet?.data?.voiCreditsBalance || 0).toFixed(2)}</p>383 </div>384 <i data-feather="wallet" class="w-8 h-8 text-purple-500"></i>385 </div>386 </div>387 388 <div class="bg-white rounded-lg p-4 border border-gray-200 hover-card">389 <div class="flex items-center justify-between">390 <div>391 <p class="text-gray-500 text-sm">Status</p>392 <p class="text-2xl font-bold text-gray-900">393 ${rideState?.data?.activeRide ? 'Active' : 'Idle'}394 </p>395 </div>396 <i data-feather="activity" class="w-8 h-8 ${rideState?.data?.activeRide ? 'text-green-500' : 'text-gray-400'}"></i>397 </div>398 </div>399 400 <div class="bg-white rounded-lg p-4 border border-gray-200 hover-card">401 <div class="flex items-center justify-between">402 <div>403 <p class="text-gray-500 text-sm">Total Rides</p>404 <p class="text-2xl font-bold text-gray-900">${history?.data?.[0]?.history?.length || 0}</p>405 </div>406 <i data-feather="trending-up" class="w-8 h-8 text-blue-500"></i>407 </div>408 </div>409 410 <div class="bg-white rounded-lg p-4 border border-gray-200 hover-card">411 <div class="flex items-center justify-between">412 <div>413 <p class="text-gray-500 text-sm">Accounts</p>414 <p class="text-2xl font-bold text-gray-900">${this.accounts.length}</p>415 </div>416 <i data-feather="users" class="w-8 h-8 text-indigo-500"></i>417 </div>418 </div>419 `;420 } catch (error) {421 console.error('Failed to update stats:', error);422 }423 424 feather.replace();425 }426// API Helper427 async makeApiCall(endpoint, options = {}) {428 if (!this.currentAccount?.accessToken) {429 throw new Error('No active account');430 }431 432 const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {433 ...options,434 headers: {435 'x-access-token': this.currentAccount.accessToken,436 'x-app-name': 'Rider',437 'x-device-name': 'Web',438 'x-app-version': '3.267.0',439 'Content-Type': 'application/json',440 ...options.headers441 }442 });443 444 if (response.status === 401) {445 // Token expired, re-authenticate446 try {447 await this.authenticateAccount();448 // Retry the request with new token449 const retryResponse = await fetch(`${this.apiBaseUrl}${endpoint}`, {450 ...options,451 headers: {452 'x-access-token': this.currentAccount.accessToken,453 'x-app-name': 'Rider',454 'x-device-name': 'Web',455 'x-app-version': '3.267.0',456 'Content-Type': 'application/json',457 ...options.headers458 }459 });460 if (!retryResponse.ok) {461 throw new Error(`API Error: ${retryResponse.status}`);462 }463 return retryResponse.json();464 } catch (authError) {465 console.error('Re-authentication failed:', authError);466 throw new Error('Authentication failed. Please re-add your account.');467 }468 }469 470 if (!response.ok) {471 throw new Error(`API Error: ${response.status}`);472 }473 474 return response.json();475 }476// UI Components477 getLoadingSkeleton() {478 return `479 <div class="space-y-4">480 <div class="skeleton h-8 w-1/3 rounded"></div>481 <div class="skeleton h-32 w-full rounded"></div>482 <div class="skeleton h-32 w-full rounded"></div>483 </div>484 `;485 }486 487 getVehicleCard(vehicle) {488 return `489 <div class="border border-gray-200 rounded-lg p-4 hover-card">490 <div class="flex items-center justify-between mb-3">491 <span class="font-bold text-lg">${vehicle.short}</span>492 <span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">493 ${vehicle.battery}% battery494 </span>495 </div>496 <div class="text-gray-600 text-sm mb-3">497 <i data-feather="map-pin" class="w-4 h-4 inline mr-1"></i>498 ${vehicle.location.lat.toFixed(4)}, ${vehicle.location.lng.toFixed(4)}499 </div>500 <button onclick="voiManager.scanVehicle('${vehicle.short}')" 501 class="w-full bg-purple-600 text-white py-2 rounded-lg hover:bg-purple-700 transition">502 Scan QR503 </button>504 </div>505 `;506 }507 508 getRideCard(ride) {509 return `510 <div class="border border-gray-200 rounded-lg p-4 hover-card">511 <div class="flex items-center justify-between mb-2">512 <span class="font-semibold">Ride #${ride.id.slice(-6)}</span>513 <span class="text-lg font-bold text-purple-600">€${(ride.cost / 100).toFixed(2)}</span>514 </div>515 <div class="text-gray-600 text-sm">516 <div class="flex items-center gap-2 mb-1">517 <i data-feather="clock" class="w-4 h-4"></i>518 ${new Date(ride.startTime).toLocaleString()} - ${new Date(ride.end).toLocaleTimeString()}519 </div>520 <div class="flex items-center gap-2">521 <i data-feather="navigation" class="w-4 h-4"></i>522 Vehicle: ${ride.vehicleShort}523 </div>524 </div>525 </div>526 `;527 }528 529 getActiveRideCard(ride) {530 return `531 <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">532 <div class="flex items-center justify-between mb-3">533 <h4 class="font-semibold text-yellow-800">Active Ride</h4>534 <span class="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded animate-pulse">535 In Progress536 </span>537 </div>538 <div class="text-sm text-gray-600 mb-3">539 <p>Vehicle: ${ride.vehicleShort}</p>540 <p>Started: ${new Date(ride.startTimeInSec * 1000).toLocaleTimeString()}</p>541 </div>542 <button onclick="voiManager.endRide()" 543 class="w-full bg-red-500 text-white py-2 rounded-lg hover:bg-red-600 transition">544 End Ride545 </button>546 </div>547 `;548 }549 550 // Actions551 async scanVehicle(vehicleCode) {552 try {553 const preride = await this.makeApiCall(`/v2/gateway/preride?vehicle_qr_code=${vehicleCode}`);554 console.log('Vehicle scanned:', preride);555 this.showNotification(`Vehicle ${vehicleCode} scanned successfully`, 'success');556 } catch (error) {557 console.error('Scan failed:', error);558 this.showNotification('Failed to scan vehicle: ' + (error.message || 'Unknown error'), 'error');559 }560 }561 async endRide() {562 if (!confirm('Are you sure you want to end this ride?')) return;563 564 try {565 // Show loading state566 const endButton = event.target;567 const originalText = endButton.innerHTML;568 endButton.innerHTML = '<div class="loading"></div> Ending...';569 endButton.disabled = true;570 571 // Get current position or use default572 let location = {573 radialAccuracy: 8.0,574 timestamp: Date.now() / 1000,575 latitude: 52.5250,576 longitude: 13.4150577 };578 579 if (navigator.geolocation) {580 try {581 const position = await new Promise((resolve, reject) => {582 navigator.geolocation.getCurrentPosition(resolve, reject, {583 timeout: 10000,584 enableHighAccuracy: true585 });586 });587 location.latitude = position.coords.latitude;588 location.longitude = position.coords.longitude;589 location.radialAccuracy = position.coords.accuracy || 8.0;590 } catch (geoError) {591 console.log('Geolocation error, using default location', geoError);592 }593 }594 595 await this.makeApiCall('/v2/rides/end_session', {596 method: 'POST',597 headers: {598 'Content-Type': 'application/json'599 },600 body: JSON.stringify({601 liftParkingRestrictions: false,602 location: location,603 vpsStatus: "NOT_APPLICABLE"604 })605 });606 607 this.showNotification('Ride ended successfully', 'success');608 await this.loadCurrentTabContent();609 } catch (error) {610 console.error('Failed to end ride:', error);611 this.showNotification('Failed to end ride: ' + (error.message || 'Please try again.'), 'error');612 } finally {613 // Reset button state614 const endButton = document.querySelector('.bg-red-500');615 if (endButton) {616 endButton.innerHTML = 'End Ride';617 endButton.disabled = false;618 }619 }620 }621showNotification(message, type = 'info') {622 const colors = {623 success: 'bg-green-500',624 error: 'bg-red-500',625 info: 'bg-blue-500',626 warning: 'bg-yellow-500'627 };628 629 const icons = {630 success: 'check-circle',631 error: 'x-circle',632 info: 'info',633 warning: 'alert-triangle'634 };635 636 // Remove any existing notifications637 document.querySelectorAll('.notification-toast').forEach(n => n.remove());638 639 const notification = document.createElement('div');640 notification.className = `notification-toast fixed top-4 right-4 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg z-50 fade-in`;641 notification.innerHTML = `642 <div class="flex items-center gap-2">643 <i data-feather="${icons[type]}" class="w-5 h-5"></i>644 <span>${message}</span>645 </div>646 `;647 648 document.body.appendChild(notification);649 feather.replace();650 651 setTimeout(() => {652 notification.style.opacity = '0';653 notification.style.transition = 'opacity 0.3s ease-out';654 setTimeout(() => {655 if (notification.parentNode) {656 notification.remove();657 }658 }, 300);659 }, 4000);660 }661}662// Initialize global instance663let voiManager;664 665// Global error handler666window.addEventListener('error', (event) => {667 console.error('Global error:', event.error);668 if (voiManager) {669 voiManager.showNotification('An unexpected error occurred', 'error');670 }671});672 673// Unhandled promise rejection handler674window.addEventListener('unhandledrejection', (event) => {675 console.error('Unhandled promise rejection:', event.reason);676 if (voiManager) {677 voiManager.showNotification('A network error occurred', 'error');678 }679 event.preventDefault();680});681// UI Functions682function initializeApp() {683 voiManager = new VoiManager();684}685 686function switchTab(tabName) {687 if (voiManager) {688 voiManager.switchTab(tabName);689 }690}691 692function openAddAccountModal() {693 const modal = document.getElementById('addAccountModal');694 if (modal) {695 modal.classList.remove('hidden');696 }697}698 699function closeAddAccountModal() {700 const modal = document.getElementById('addAccountModal');701 if (modal) {702 modal.classList.add('hidden');703 const accountName = document.getElementById('accountName');704 const authToken = document.getElementById('authToken');705 if (accountName) accountName.value = '';706 if (authToken) authToken.value = '';707 }708}709 710function addAccount(event) {711 event.preventDefault();712 713 const accountName = document.getElementById('accountName');714 const authToken = document.getElementById('authToken');715 716 if (!accountName || !authToken) return;717 718 const name = accountName.value.trim();719 const token = authToken.value.trim();720 721 if (!name || !token) {722 if (voiManager) {723 voiManager.showNotification('Please fill in all fields', 'error');724 }725 return;726 }727 728 if (voiManager) {729 voiManager.addAccount(name, token);730 }731 closeAddAccountModal();732}733// Performance optimizations734if ('serviceWorker' in navigator) {735 window.addEventListener('load', () => {736 navigator.serviceWorker.register('/sw.js')737 .then(registration => console.log('SW registered'))738 .catch(err => console.log('SW registration failed'));739 });740}741 742// Debounce helper743function debounce(func, wait) {744 let timeout;745 return function executedFunction(...args) {746 const later = () => {747 clearTimeout(timeout);748 func(...args);749 };750 clearTimeout(timeout);751 timeout = setTimeout(later, wait);752 };753}