montana/Русский/Логистика/miniapp.html

1209 lines
52 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>SeaFare Montana</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: var(--tg-theme-bg-color, #0a0e17);
--bg2: var(--tg-theme-secondary-bg-color, #111827);
--text: var(--tg-theme-text-color, #e0e6ed);
--hint: var(--tg-theme-hint-color, #7b8a9e);
--link: var(--tg-theme-link-color, #5eaaef);
--btn: var(--tg-theme-button-color, #00bfff);
--btn-text: var(--tg-theme-button-text-color, #ffffff);
--accent: #00bfff;
--card: rgba(17, 24, 39, 0.95);
--border: rgba(255,255,255,0.08);
--safe-top: var(--tg-content-safe-area-inset-top, 0px);
--safe-bottom: var(--tg-safe-area-inset-bottom, 0px);
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text);
}
/* Tabs */
.app { display: flex; flex-direction: column; height: 100vh; }
.tab-content { flex: 1; position: relative; overflow: hidden; }
.tab-pane { display: none; position: absolute; inset: 0; }
.tab-pane.active { display: flex; flex-direction: column; }
.bottom-nav {
display: flex; background: var(--bg2); border-top: 1px solid var(--border);
padding-bottom: var(--safe-bottom); z-index: 1000;
}
.nav-item {
flex: 1; text-align: center; padding: 8px 0 6px; cursor: pointer;
color: var(--hint); font-size: 11px; transition: color 0.2s;
}
.nav-item.active { color: var(--accent); }
.nav-item svg { display: block; margin: 0 auto 2px; width: 24px; height: 24px; }
/* Map */
#map { flex: 1; z-index: 1; }
.map-search {
position: absolute; top: calc(8px + var(--safe-top)); left: 10px; right: 10px;
z-index: 1001;
}
.map-search input {
width: 100%; padding: 10px 14px; border-radius: 12px; border: none;
background: var(--card); color: var(--text); font-size: 14px;
backdrop-filter: blur(10px); outline: none;
}
.map-search input::placeholder { color: var(--hint); }
/* Vessel popup (compact Leaflet popup near marker) */
.leaflet-popup-content-wrapper { background: var(--card) !important; color: var(--text) !important; border-radius: 10px !important; box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important; padding: 0 !important; }
.leaflet-popup-content { margin: 0 !important; min-width: 210px; max-width: 260px; }
.leaflet-popup-tip { background: var(--card) !important; }
.leaflet-popup-close-button { color: var(--hint) !important; font-size: 18px !important; top: 4px !important; right: 6px !important; }
.vp { padding: 12px 14px; font-size: 14px; line-height: 1.5; }
.vp-name { font-size: 15px; font-weight: 700; color: var(--accent); margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
.vp-row { display: flex; justify-content: space-between; gap: 10px; padding: 2px 0; }
.vp-lbl { color: var(--hint); font-size: 13px; white-space: nowrap; }
.vp-val { color: #fff; font-size: 13px; font-weight: 600; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
.vp-btn { display: block; width: 100%; margin-top: 10px; padding: 8px; border: 1px solid var(--accent); border-radius: 8px; background: transparent; color: var(--accent); font-size: 13px; font-weight: 600; cursor: pointer; text-align: center; }
.vp-btn:active { background: var(--accent); color: var(--bg); }
/* Chat */
.chat-container { display: flex; flex-direction: column; height: 100%; padding-top: var(--safe-top); }
.chat-messages {
flex: 1; overflow-y: auto; padding: 12px; display: flex;
flex-direction: column; gap: 8px;
}
.msg { max-width: 85%; padding: 10px 14px; border-radius: 16px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
.msg-user { align-self: flex-end; background: var(--btn); color: var(--btn-text); border-bottom-right-radius: 4px; }
.msg-bot { align-self: flex-start; background: var(--bg2); color: var(--text); border-bottom-left-radius: 4px; }
.msg-bot pre { background: rgba(0,0,0,0.3); padding: 8px; border-radius: 8px; overflow-x: auto; font-size: 12px; margin: 4px 0; }
.msg-typing { align-self: flex-start; background: var(--bg2); padding: 12px 18px; border-radius: 16px; }
.msg-typing span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--hint); margin: 0 2px; animation: typing 1.2s infinite; }
.msg-typing span:nth-child(2) { animation-delay: 0.2s; }
.msg-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%,60%,100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-4px); } }
.chat-chips { display: flex; gap: 6px; padding: 8px 12px; overflow-x: auto; flex-shrink: 0; }
.chat-chip {
white-space: nowrap; padding: 6px 12px; border-radius: 16px; font-size: 12px;
background: var(--bg2); color: var(--accent); border: 1px solid var(--border);
cursor: pointer; flex-shrink: 0;
}
.chat-input-area {
display: flex; gap: 8px; padding: 8px 12px;
padding-bottom: calc(8px + var(--safe-bottom));
background: var(--bg); border-top: 1px solid var(--border);
}
.chat-input-area textarea {
flex: 1; resize: none; border: none; border-radius: 20px; padding: 10px 14px;
background: var(--bg2); color: var(--text); font-size: 14px;
font-family: inherit; outline: none; max-height: 100px;
}
.chat-input-area textarea::placeholder { color: var(--hint); }
.chat-send {
width: 40px; height: 40px; border-radius: 50%; border: none;
background: var(--btn); color: var(--btn-text); font-size: 18px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; align-self: flex-end;
}
/* Profile */
.profile-container {
height: 100%; overflow-y: auto; padding: 16px;
padding-top: calc(16px + var(--safe-top));
padding-bottom: calc(16px + var(--safe-bottom));
}
.profile-header {
text-align: center; padding: 20px 0;
}
.profile-avatar {
width: 64px; height: 64px; border-radius: 50%; background: var(--btn);
display: flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 700; color: var(--btn-text); margin: 0 auto 10px;
}
.profile-name { font-size: 18px; font-weight: 600; }
.profile-role { font-size: 13px; color: var(--hint); margin-top: 2px; text-transform: capitalize; }
.profile-card {
background: var(--bg2); border-radius: 12px; padding: 16px; margin-top: 16px;
}
.profile-card-title { font-size: 12px; color: var(--hint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.profile-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); }
.profile-row:last-child { border-bottom: none; }
.profile-row-label { color: var(--hint); font-size: 14px; }
.profile-row-val { color: var(--text); font-size: 14px; font-weight: 500; }
.profile-watches { margin-top: 8px; }
.watch-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.watch-item:last-child { border-bottom: none; }
.watch-name { font-size: 14px; font-weight: 500; }
.watch-dest { font-size: 12px; color: var(--hint); }
.watch-remove { background: none; border: none; color: #ef4444; font-size: 12px; cursor: pointer; }
.profile-btn {
display: block; width: 100%; padding: 12px; margin-top: 16px; border: none;
border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
text-align: center;
}
.profile-btn-primary { background: var(--btn); color: var(--btn-text); }
.profile-btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
/* Not linked screen */
.not-linked {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; padding: 40px 24px; text-align: center;
}
.not-linked-icon { font-size: 64px; margin-bottom: 20px; }
.not-linked h2 { font-size: 20px; margin-bottom: 10px; }
.not-linked p { color: var(--hint); font-size: 14px; line-height: 1.6; margin-bottom: 24px; }
.not-linked a {
display: inline-block; padding: 12px 32px; background: var(--btn); color: var(--btn-text);
border-radius: 10px; text-decoration: none; font-weight: 600; font-size: 15px;
}
/* Loading */
.loading-screen {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100vh; gap: 16px;
}
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Map filters */
.map-filters {
position: absolute; top: calc(48px + var(--safe-top)); left: 10px; right: 10px;
z-index: 1001; display: flex; gap: 6px; overflow-x: auto; padding: 4px 0;
-webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.map-filters::-webkit-scrollbar { display: none; }
.filter-chip {
white-space: nowrap; padding: 4px 8px; border-radius: 12px; font-size: 10px;
font-weight: 600; cursor: pointer; flex-shrink: 0; border: 1.5px solid;
transition: opacity 0.2s, transform 0.1s; user-select: none;
}
.filter-chip.active { opacity: 1; }
.filter-chip.inactive { opacity: 0.35; }
.filter-chip:active { transform: scale(0.95); }
.filter-count {
position: absolute; top: calc(74px + var(--safe-top)); left: 14px;
z-index: 1001; font-size: 10px; color: var(--hint);
background: rgba(10,14,23,0.7); padding: 2px 6px; border-radius: 6px;
}
/* Vessel arrow markers */
.vessel-marker { background: none !important; border: none !important; }
.cluster-marker {
display: flex; align-items: center; justify-content: center;
border-radius: 50%; color: #fff; font-weight: 700;
text-shadow: 0 1px 2px rgba(0,0,0,0.6); cursor: pointer;
border: 2px solid rgba(255,255,255,0.5);
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transition: transform 0.15s ease;
}
.cluster-marker:hover { transform: scale(1.15); border-color: #fff; }
/* Leaflet overrides */
.leaflet-popup-content-wrapper { background: var(--card) !important; color: var(--text) !important; border-radius: 12px !important; }
.leaflet-popup-tip { background: var(--card) !important; }
.leaflet-control-zoom a { background: var(--card) !important; color: var(--text) !important; border-color: var(--border) !important; }
.leaflet-control-attribution { display: none !important; }
</style>
</head>
<body>
<!-- Loading -->
<div id="loadingScreen" class="loading-screen">
<div class="spinner"></div>
<div style="color:var(--hint); font-size:14px">SeaFare Montana</div>
</div>
<!-- Not linked -->
<div id="notLinkedScreen" class="not-linked" style="display:none">
<div class="not-linked-icon">&#x2693;</div>
<h2 id="nlTitle">Register First</h2>
<p id="nlText">To use the SeaFare bot, please register on our website and link your Telegram account in the profile settings.</p>
<a id="nlLink" href="https://seafare.efir.org" target="_blank" id="nlBtn">Open Website</a>
</div>
<!-- Main App -->
<div id="mainApp" class="app" style="display:none">
<!-- Map Tab -->
<div class="tab-content">
<div id="tabMap" class="tab-pane active">
<div class="map-search">
<input type="text" id="mapSearch" placeholder="Search vessel or port...">
</div>
<div class="map-filters" id="mapFilters"></div>
<div class="filter-count" id="filterCount"></div>
<div id="map"></div>
</div>
<!-- Chat Tab -->
<div id="tabChat" class="tab-pane">
<div class="chat-container">
<div class="chat-messages" id="chatMessages">
<div class="msg msg-bot">Hello! I'm your maritime AI assistant. Ask me about vessels, routes, contacts, or anything shipping-related.</div>
</div>
<div class="chat-chips" id="chatChips"></div>
<div class="chat-input-area">
<textarea id="chatInput" rows="1" placeholder="Ask anything..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}"></textarea>
<button class="chat-send" onclick="sendChat()">&#x27A4;</button>
</div>
</div>
</div>
<!-- Profile Tab -->
<div id="tabProfile" class="tab-pane">
<div class="profile-container">
<div class="profile-header">
<div class="profile-avatar" id="profAvatar">?</div>
<div class="profile-name" id="profName"></div>
<div class="profile-role" id="profRole"></div>
</div>
<div class="profile-card">
<div class="profile-card-title">Wallet</div>
<div class="profile-row">
<span class="profile-row-label">Balance</span>
<span class="profile-row-val" id="profBalance">$0.00</span>
</div>
</div>
<div class="profile-card">
<div class="profile-card-title">Vessel Watches</div>
<div class="profile-watches" id="profWatches">
<div style="color:var(--hint); font-size:13px; padding:8px 0">No active watches</div>
</div>
</div>
<div class="profile-card">
<div class="profile-card-title">Trade Routes</div>
<div class="profile-row-val" id="profRoutes" style="font-size:13px; line-height:1.8"></div>
</div>
<div class="profile-card">
<div class="profile-card-title">Cargo Types</div>
<div class="profile-row-val" id="profCargo" style="font-size:13px; line-height:1.8"></div>
</div>
<button class="profile-btn profile-btn-outline" onclick="openFullSite()">Open Full Website</button>
</div>
</div>
</div>
<!-- Bottom Nav -->
<div class="bottom-nav">
<div class="nav-item active" onclick="switchTab('map')" id="navMap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z"/><path d="M8 2v16"/><path d="M16 6v16"/></svg>
Map
</div>
<div class="nav-item" onclick="switchTab('chat')" id="navChat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Chat
</div>
<div class="nav-item" onclick="switchTab('profile')" id="navProfile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</div>
</div>
</div>
<script>
window.onerror = function(msg, url, line, col, err) {
console.error("JS Error:", msg, "at", url, line);
var ls = document.getElementById("loadingScreen");
if (ls && ls.style.display !== "none") {
ls.innerHTML = "<div style=\"color:#ff6b6b;padding:20px;text-align:center;font-size:14px;\">Error: " + msg + "<br>Line: " + line + "</div>";
}
return false;
};
// =============================================================================
// Telegram WebApp Init
// =============================================================================
let tg;
try {
tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
if (tg.isVersionAtLeast && tg.isVersionAtLeast('8.0')) {
try { tg.requestFullscreen(); } catch(e) {}
}
if (tg.setHeaderColor) tg.setHeaderColor(tg.themeParams?.secondary_bg_color || '#111827');
if (tg.setBackgroundColor) tg.setBackgroundColor(tg.themeParams?.bg_color || '#0a0e17');
} catch(e) {
console.error('TG SDK init error:', e);
tg = { initData: '', initDataUnsafe: {}, themeParams: {}, ready: function(){}, expand: function(){}, close: function(){}, MainButton: { show:function(){}, hide:function(){}, onClick:function(){}, setText:function(){} }, BackButton: { show:function(){}, hide:function(){}, onClick:function(){} }, isVersionAtLeast: function(){return false;}, setHeaderColor:function(){}, setBackgroundColor:function(){} };
}
const API_BASE = '';
let authToken = null;
let currentUser = null;
let currentTab = 'map';
let leafletMap = null;
let vesselMarkers = {};
let selectedVessel = null;
let vesselPopup = null;
// Supercluster state
let _supercluster = null;
let _allVesselsGeoJSON = [];
let _clusterMarkers = [];
let _bulkLoaded = false;
let _bulkRefreshTimer = null;
const CLUSTER_ZOOM = 10;
const TYPE_CAT_NAMES = {0:'tanker',1:'bulk',2:'container',3:'cargo',4:'passenger',5:'roro',6:'fishing',7:'tug',8:'offshore',9:'other'};
let _mapLoadEpoch = 0;
let _clusterMarkersById = {};
let _lastClusterZoom = -1;
let chatHistory = [];
let mapRefreshTimer = null;
// =============================================================================
// i18n
// =============================================================================
const _i18n = {
en: {
tab_map: 'Map', tab_chat: 'Chat', tab_profile: 'Profile',
search_placeholder: 'Search vessel or port...',
chat_welcome: "Hello! I'm your maritime AI assistant. Ask me about vessels, routes, contacts, or anything shipping-related.",
chip1: 'Vessels near Istanbul', chip1_q: 'Vessels near Istanbul',
chip2: 'Freight rates', chip2_q: 'Freight rate Black Sea',
chip3: 'Track vessel', chip3_q: 'Track vessel',
chip4: 'Contacts', chip4_q: 'Find contacts for MAERSK',
ask_placeholder: 'Ask anything...',
wallet: 'Wallet', balance: 'Balance',
watches: 'Vessel Watches', no_watches: 'No active watches',
trade_routes: 'Trade Routes', cargo_types: 'Cargo Types',
open_website: 'Open Full Website',
ai_details: 'AI Details', track_btn: 'Track',
nl_title: 'Register First',
nl_text: 'To use the SeaFare bot, register on our website and link your Telegram in profile settings.',
nl_link: 'Open Website',
type: 'Type', flag: 'Flag', destination: 'Destination',
speed: 'Speed', course: 'Course',
tell_about: 'Tell me about vessel ',
track_vessel: 'Track vessel ',
track_notify: ' and notify me when it arrives',
search_vessel: 'Search vessel ',
error: 'Error: ', member: 'member',
},
ru: {
tab_map: 'Карта', tab_chat: 'Чат', tab_profile: 'Профиль',
search_placeholder: 'Поиск судна или порта...',
chat_welcome: 'Привет! Я морской AI-помощник. Спросите о судах, маршрутах, фрахтовых ставках или чём-либо в судоходстве.',
chip1: 'Суда у Стамбула', chip1_q: 'Суда рядом со Стамбулом',
chip2: 'Фрахтовые ставки', chip2_q: 'Фрахтовые ставки Чёрное море',
chip3: 'Отследить судно', chip3_q: 'Отследить судно',
chip4: 'Загруженность порта', chip4_q: 'Загруженность порта Новороссийск',
ask_placeholder: 'Спросите что угодно...',
wallet: 'Кошелёк', balance: 'Баланс',
watches: 'Отслеживания', no_watches: 'Нет активных отслеживаний',
trade_routes: 'Торговые маршруты', cargo_types: 'Типы грузов',
open_website: 'Открыть сайт',
ai_details: 'Подробнее', track_btn: 'Отследить',
nl_title: 'Сначала регистрация',
nl_text: 'Чтобы пользоваться ботом SeaFare, зарегистрируйтесь на сайте и привяжите Telegram в настройках профиля.',
nl_link: 'Открыть сайт',
type: 'Тип', flag: 'Флаг', destination: 'Назначение',
speed: 'Скорость', course: 'Курс',
tell_about: 'Расскажи о судне ',
track_vessel: 'Отследить судно ',
track_notify: ' и уведомить о прибытии',
search_vessel: 'Найти судно ',
error: 'Ошибка: ', member: 'участник',
},
es: {
tab_map: 'Mapa', tab_chat: 'Chat', tab_profile: 'Perfil',
search_placeholder: 'Buscar buque o puerto...',
chat_welcome: '¡Hola! Soy tu asistente marítimo de IA. Pregúntame sobre buques, rutas, fletes o cualquier tema naviero.',
chip1: 'Buques en Estambul', chip1_q: 'Buques cerca de Estambul',
chip2: 'Tarifas de flete', chip2_q: 'Tarifas de flete Mar Negro',
chip3: 'Rastrear buque', chip3_q: 'Rastrear buque',
chip4: 'Congestión portuaria', chip4_q: 'Congestión portuaria Novorossiysk',
ask_placeholder: 'Pregunta lo que sea...',
wallet: 'Billetera', balance: 'Saldo',
watches: 'Seguimientos', no_watches: 'Sin seguimientos activos',
trade_routes: 'Rutas comerciales', cargo_types: 'Tipos de carga',
open_website: 'Abrir sitio web',
ai_details: 'Detalles IA', track_btn: 'Rastrear',
nl_title: 'Regístrese primero',
nl_text: 'Para usar el bot SeaFare, regístrese en nuestro sitio web y vincule su Telegram en la configuración del perfil.',
nl_link: 'Abrir sitio web',
type: 'Tipo', flag: 'Bandera', destination: 'Destino',
speed: 'Velocidad', course: 'Rumbo',
tell_about: 'Cuéntame sobre el buque ',
track_vessel: 'Rastrear buque ',
track_notify: ' y notificarme cuando llegue',
search_vessel: 'Buscar buque ',
error: 'Error: ', member: 'miembro',
}
};
let _lang = 'en';
function t(k) { return (_i18n[_lang] && _i18n[_lang][k]) || _i18n.en[k] || k; }
function applyTranslations() {
// Bottom nav
document.getElementById('navMap').lastChild.textContent = t('tab_map');
document.getElementById('navChat').lastChild.textContent = t('tab_chat');
document.getElementById('navProfile').lastChild.textContent = t('tab_profile');
// Map
document.getElementById('mapSearch').placeholder = t('search_placeholder');
// Chat
const welcomeMsg = document.querySelector('#chatMessages .msg-bot');
if (welcomeMsg) welcomeMsg.textContent = t('chat_welcome');
document.getElementById('chatInput').placeholder = t('ask_placeholder');
// Chat chips (rebuild with translated text + queries)
const chipsEl = document.getElementById('chatChips');
const chipData = [
{ label: t('chip1'), query: t('chip1_q') },
{ label: t('chip2'), query: t('chip2_q') },
{ label: t('chip3'), query: t('chip3_q') },
{ label: t('chip4'), query: t('chip4_q') },
];
chipsEl.innerHTML = chipData.map(c =>
`<div class="chat-chip" onclick="quickChat('${c.query.replace(/'/g, "\\'")}')">${c.label}</div>`
).join('');
// Vessel card buttons
var _bd = document.querySelector('.btn-details'); if(_bd) _bd.textContent = t('ai_details');
var _bt = document.querySelector('.btn-track'); if(_bt) _bt.textContent = t('track_btn');
// Profile
document.querySelectorAll('.profile-card-title').forEach(el => {
const txt = el.textContent.trim();
if (txt === 'Wallet') el.textContent = t('wallet');
else if (txt === 'Vessel Watches') el.textContent = t('watches');
else if (txt === 'Trade Routes') el.textContent = t('trade_routes');
else if (txt === 'Cargo Types') el.textContent = t('cargo_types');
});
var _bl = document.querySelector('.profile-row-label'); if(_bl) _bl.textContent = t('balance');
const watchesEmpty = document.querySelector('#profWatches > div');
if (watchesEmpty) watchesEmpty.textContent = t('no_watches');
var _po = document.querySelector('.profile-btn-outline'); if(_po) _po.textContent = t('open_website');
// Not linked
document.getElementById('nlTitle').textContent = t('nl_title');
document.getElementById('nlText').textContent = t('nl_text');
document.getElementById('nlLink').textContent = t('nl_link');
}
// =============================================================================
// Auth
// =============================================================================
async function authenticate() {
// 1. Try Telegram WebApp auth (inside Telegram)
if (tg.initData) {
try {
const resp = await fetch(API_BASE + '/api/v1/auth/telegram-webapp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: tg.initData })
});
const data = await resp.json();
if (data.success) {
authToken = data.token;
currentUser = data.user;
return true;
}
window._authError = data.error || 'unknown';
return false;
} catch (e) {
window._authError = e.message;
return false;
}
}
// 2. Fallback: try web session token (opened in browser, not Telegram)
var webToken = localStorage.getItem('authToken');
if (webToken) {
try {
const resp = await fetch(API_BASE + '/api/v1/profile', {
headers: { 'Authorization': 'Bearer ' + webToken }
});
if (resp.ok) {
authToken = webToken;
const pd = await resp.json();
currentUser = pd.profile || {};
return true;
}
} catch (e) {}
}
window._authError = 'not_in_telegram';
return false;
}
async function init() {
// Detect language from Telegram
const tgLang = (tg.initDataUnsafe?.user?.language_code || 'en').substring(0, 2);
_lang = ['ru', 'es'].includes(tgLang) ? tgLang : 'en';
try { applyTranslations(); } catch(e) { console.warn('applyTranslations:', e.message); }
const ok = await authenticate();
document.getElementById('loadingScreen').style.display = 'none';
if (!ok) {
// If opened in browser (not Telegram) and no web session — redirect to main site
if (window._authError === 'not_in_telegram') {
window.location.href = '/';
return;
}
document.getElementById('notLinkedScreen').style.display = 'flex';
return;
}
document.getElementById('mainApp').style.display = 'flex';
await loadChatHistory();
initMap();
loadProfile();
// BackButton navigation
tg.BackButton.onClick(function() {
if (currentTab !== 'map') {
switchTab('map');
} else {
tg.close();
}
});
}
// =============================================================================
// Tab Navigation
// =============================================================================
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
document.getElementById('nav' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
if (tab === 'map') {
tg.BackButton.hide();
if (leafletMap) setTimeout(() => leafletMap.invalidateSize(), 100);
startMapRefresh();
} else {
tg.BackButton.show();
stopMapRefresh();
}
if (tab === 'profile') loadProfile();
if (tg.HapticFeedback) tg.HapticFeedback.selectionChanged();
}
// =============================================================================
// Map
// =============================================================================
function initMap() {
leafletMap = L.map('map', {
zoomControl: false,
attributionControl: false,
}).setView([37.5, 24.0], 6);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 18,
}).addTo(leafletMap);
L.control.zoom({ position: 'bottomright' }).addTo(leafletMap);
// Init Supercluster
if (typeof Supercluster !== 'undefined') {
_supercluster = new Supercluster({
radius: 60, maxZoom: CLUSTER_ZOOM - 1, minZoom: 2,
map: (props) => {
const counts = {};
for (let i = 0; i < 10; i++) counts['c' + i] = 0;
counts['c' + props.type_cat] = 1;
return counts;
},
reduce: (acc, props) => {
for (let i = 0; i < 10; i++) acc['c' + i] += props['c' + i];
}
});
}
let _mvTimeout = null;
leafletMap.on('moveend', () => {
clearTimeout(_mvTimeout);
_mvTimeout = setTimeout(() => {
if (!leafletMap) return;
const zoom = leafletMap.getZoom();
if (zoom >= CLUSTER_ZOOM) {
loadMapVessels().then(() => {
if (leafletMap && leafletMap.getZoom() >= CLUSTER_ZOOM) clearClusters();
});
} else if (_bulkLoaded) {
renderClusters();
clearIndividualMarkers();
}
}, 300);
});
initFilters();
// Handle clicks inside vessel popup (standard Leaflet pattern)
leafletMap.on('popupopen', function() {
var btn = document.querySelector('.vp-btn');
if (btn) {
btn.onclick = function() { askAboutVessel(); };
}
});
// Search
document.getElementById('mapSearch').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchOnMap(this.value.trim());
}
});
loadBulkVessels();
}
function clearClusters() {
_clusterMarkers.forEach(m => leafletMap.removeLayer(m));
_clusterMarkers = [];
_clusterMarkersById = {};
_lastClusterZoom = -1;
}
function clearIndividualMarkers() {
Object.values(vesselMarkers).forEach(m => { if (leafletMap.hasLayer(m)) leafletMap.removeLayer(m); });
vesselMarkers = {};
}
async function loadBulkVessels() {
if (!leafletMap) return;
try {
const resp = await fetch(API_BASE + '/api/v1/map/vessels/all', {
headers: authToken ? { 'Authorization': 'Bearer ' + authToken } : {}
});
if (!resp.ok) { loadMapVessels(); return; }
const data = await resp.json();
if (!data.success || !data.vessels) { loadMapVessels(); return; }
_allVesselsGeoJSON = [];
for (const v of data.vessels) {
if (v[1] == null || v[2] == null) continue;
_allVesselsGeoJSON.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [v[2], v[1]] },
properties: {
mmsi: v[0], type_cat: v[3] != null ? v[3] : 9,
heading: v[4], speed: v[5], course: v[6], name: v[7] || ''
}
});
}
if (_supercluster && _allVesselsGeoJSON.length > 0) {
_supercluster.load(_allVesselsGeoJSON);
_bulkLoaded = true;
if (leafletMap.getZoom() < CLUSTER_ZOOM) {
clearIndividualMarkers();
renderClusters();
}
}
if (_bulkRefreshTimer) clearInterval(_bulkRefreshTimer);
_bulkRefreshTimer = setInterval(loadBulkVessels, 120000);
if (leafletMap.getZoom() >= CLUSTER_ZOOM) loadMapVessels();
} catch (e) {
console.warn('Bulk load failed:', e);
loadMapVessels();
}
}
function renderClusters() {
if (!_supercluster || !_bulkLoaded || !leafletMap) return;
const b = leafletMap.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
const zoom = leafletMap.getZoom();
// Zoom changed → full rebuild
if (zoom !== _lastClusterZoom) {
clearClusters();
_lastClusterZoom = zoom;
}
const clusters = _supercluster.getClusters(bbox, zoom);
const desiredKeys = new Set();
let totalShown = 0;
clusters.forEach(c => {
const coords = c.geometry.coordinates;
const props = c.properties;
if (props.cluster) {
const key = 'c_' + props.cluster_id;
let filteredCount = 0;
for (let i = 0; i < 10; i++) {
const catName = TYPE_CAT_NAMES[i];
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
if (activeFilters.has(filterCat)) filteredCount += (props['c' + i] || 0);
}
if (filteredCount === 0) return;
desiredKeys.add(key);
totalShown += filteredCount;
if (_clusterMarkersById[key]) return; // diff: skip existing
let maxCat = 9, maxCount = 0;
for (let i = 0; i < 10; i++) {
const catName = TYPE_CAT_NAMES[i];
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
if (activeFilters.has(filterCat) && (props['c' + i] || 0) > maxCount) {
maxCount = props['c' + i]; maxCat = i;
}
}
const color = VESSEL_COLORS[TYPE_CAT_NAMES[maxCat]] || VESSEL_COLORS.other;
const size = Math.min(56, Math.max(22, 18 + Math.sqrt(filteredCount) * 3));
const label = filteredCount >= 1000 ? (filteredCount / 1000).toFixed(1) + 'K' : String(filteredCount);
const fontSize = filteredCount >= 1000 ? 9 : (filteredCount >= 100 ? 10 : 11);
const icon = L.divIcon({
html: `<div class="cluster-marker" style="width:${size}px;height:${size}px;background:radial-gradient(circle at 35% 35%,${color}ee,${color}99);font-size:${fontSize}px">${label}</div>`,
className: '', iconSize: [size, size], iconAnchor: [size/2, size/2]
});
const m = L.marker([coords[1], coords[0]], { icon: icon });
const clusterId = props.cluster_id;
m.on('click', () => {
const expZoom = _supercluster.getClusterExpansionZoom(clusterId);
leafletMap.setView([coords[1], coords[0]], Math.min(expZoom, CLUSTER_ZOOM));
});
m.addTo(leafletMap);
_clusterMarkers.push(m);
_clusterMarkersById[key] = m;
} else {
const key = 's_' + (props.mmsi || coords.join(','));
const catName = TYPE_CAT_NAMES[props.type_cat] || 'other';
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
if (!activeFilters.has(filterCat)) return;
desiredKeys.add(key);
totalShown++;
if (_clusterMarkersById[key]) return; // diff: skip existing
const v = { _cat: catName, heading: props.heading, course: props.course, name: props.name, mmsi: props.mmsi };
const m = L.marker([coords[1], coords[0]], { icon: createVesselIcon(v) });
m.on('click', () => { leafletMap.setView([coords[1], coords[0]], CLUSTER_ZOOM); });
m.addTo(leafletMap);
_clusterMarkers.push(m);
_clusterMarkersById[key] = m;
}
});
// Remove markers no longer in viewport
for (const key of Object.keys(_clusterMarkersById)) {
if (!desiredKeys.has(key)) {
leafletMap.removeLayer(_clusterMarkersById[key]);
const idx = _clusterMarkers.indexOf(_clusterMarkersById[key]);
if (idx !== -1) _clusterMarkers.splice(idx, 1);
delete _clusterMarkersById[key];
}
}
document.getElementById('filterCount').textContent = totalShown + ' / ' + _allVesselsGeoJSON.length;
}
function startMapRefresh() {
if (_bulkLoaded && !_bulkRefreshTimer) {
_bulkRefreshTimer = setInterval(loadBulkVessels, 120000);
}
}
function stopMapRefresh() {
if (_bulkRefreshTimer) { clearInterval(_bulkRefreshTimer); _bulkRefreshTimer = null; }
if (mapRefreshTimer) { clearInterval(mapRefreshTimer); mapRefreshTimer = null; }
}
async function loadMapVessels() {
if (!leafletMap) return;
const epoch = ++_mapLoadEpoch;
const b = leafletMap.getBounds();
try {
const resp = await fetch(API_BASE + '/api/v1/map/vessels?' +
`lat_min=${b.getSouth().toFixed(4)}&lat_max=${b.getNorth().toFixed(4)}` +
`&lon_min=${b.getWest().toFixed(4)}&lon_max=${b.getEast().toFixed(4)}&limit=500`, {
headers: authToken ? { 'Authorization': 'Bearer ' + authToken } : {}
});
if (!resp.ok) return;
if (epoch !== _mapLoadEpoch) return;
const data = await resp.json();
if (!data.success || epoch !== _mapLoadEpoch) return;
allVesselsCache = data.vessels || [];
updateMarkers(allVesselsCache);
} catch (e) { console.error('Map load error:', e); }
}
const VESSEL_COLORS = {
tanker:'#d32f2f', bulk:'#ef6c00', container:'#388e3c', cargo:'#f9a825',
general:'#f9a825', passenger:'#1565c0', roro:'#00838f', offshore:'#ad1457',
tug:'#00acc1', fishing:'#6a1b9a', highspeed:'#fdd835', pleasure:'#7b1fa2',
military:'#546e7a', sailing:'#ec407a', other:'#78909c'
};
const VESSEL_FILTER_LABELS = {
tanker: {en:'Tanker', ru:'Танкер', es:'Tanque'},
bulk: {en:'Bulk', ru:'Балкер', es:'Granelero'},
container: {en:'Container', ru:'Контейнер', es:'Contenedor'},
cargo: {en:'Cargo', ru:'Карго', es:'Carga'},
passenger: {en:'Passenger', ru:'Пассаж.', es:'Pasaje'},
roro: {en:'Ro-Ro', ru:'Ро-Ро', es:'Ro-Ro'},
fishing: {en:'Fishing', ru:'Рыба', es:'Pesca'},
tug: {en:'Tug', ru:'Буксир', es:'Remolc.'},
offshore: {en:'Offshore', ru:'Офшор', es:'Offshore'},
other: {en:'Other', ru:'Другие', es:'Otros'},
};
let activeFilters = new Set(Object.keys(VESSEL_FILTER_LABELS)); // all active by default
let allVesselsCache = []; // last loaded vessels for re-filtering
function initFilters() {
const bar = document.getElementById('mapFilters');
bar.innerHTML = '';
for (const [cat, labels] of Object.entries(VESSEL_FILTER_LABELS)) {
const chip = document.createElement('div');
chip.className = 'filter-chip active';
chip.dataset.cat = cat;
chip.textContent = labels[_lang] || labels.en;
const color = VESSEL_COLORS[cat] || VESSEL_COLORS.other;
chip.style.background = color + '22';
chip.style.borderColor = color;
chip.style.color = color;
chip.onclick = () => toggleFilter(cat, chip);
bar.appendChild(chip);
}
}
function toggleFilter(cat, chip) {
if (activeFilters.has(cat)) {
activeFilters.delete(cat);
chip.classList.remove('active');
chip.classList.add('inactive');
} else {
activeFilters.add(cat);
chip.classList.remove('inactive');
chip.classList.add('active');
}
applyFilters();
if (tg.HapticFeedback) tg.HapticFeedback.selectionChanged();
}
function applyFilters() {
if (leafletMap && leafletMap.getZoom() < CLUSTER_ZOOM && _bulkLoaded) {
renderClusters();
return;
}
updateMarkers(allVesselsCache);
}
function vesselTypeCategory(code, cat) {
// Prefer server-side type_category
if (cat && cat !== 'other' && VESSEL_COLORS[cat]) return cat;
code = parseInt(code) || 0;
if (code >= 70 && code < 80) return 'cargo';
if (code >= 80 && code < 90) return 'tanker';
if (code >= 60 && code < 70) return 'passenger';
if (code >= 30 && code < 40) return 'fishing';
if (code >= 50 && code < 60) return 'tug';
if (code >= 40 && code < 50) return 'highspeed';
return 'other';
}
function createVesselIcon(v) {
const cat = v._cat || 'other';
const color = VESSEL_COLORS[cat] || VESSEL_COLORS.other;
const h = (v.heading != null && v.heading !== 511) ? v.heading : null;
const rot = h || v.course || 0;
const svg = `<svg width="18" height="22" viewBox="0 0 20 24" xmlns="http://www.w3.org/2000/svg" style="transform:rotate(${rot}deg)"><path d="M10 0L18 20L10 16L2 20Z" fill="${color}" stroke="#000" stroke-width="0.8" opacity="0.9"/></svg>`;
return L.divIcon({ html: svg, className: 'vessel-marker', iconSize: [18,22], iconAnchor: [9,11] });
}
function updateMarkers(vessels) {
const seen = new Set();
let visCount = 0;
for (const v of vessels) {
const key = v.mmsi || v.name;
if (!key) continue;
const lat = parseFloat(v.lat || v.latitude);
const lon = parseFloat(v.lon || v.longitude);
if (isNaN(lat) || isNaN(lon)) continue;
const cat = vesselTypeCategory(v.type_code || v.ship_type, v.type_category);
v._cat = cat;
// Normalize category for filter matching
const filterCat = VESSEL_FILTER_LABELS[cat] ? cat : 'other';
const visible = activeFilters.has(filterCat);
seen.add(key);
if (vesselMarkers[key]) {
if (visible) {
vesselMarkers[key].setLatLng([lat, lon]);
vesselMarkers[key].setIcon(createVesselIcon(v));
if (!leafletMap.hasLayer(vesselMarkers[key])) vesselMarkers[key].addTo(leafletMap);
visCount++;
} else {
if (leafletMap.hasLayer(vesselMarkers[key])) leafletMap.removeLayer(vesselMarkers[key]);
}
} else if (visible) {
const m = L.marker([lat, lon], { icon: createVesselIcon(v) });
m.vesselData = v;
m.on('click', function() { showVesselCard(this.vesselData); });
m.addTo(leafletMap);
vesselMarkers[key] = m;
visCount++;
}
if (vesselMarkers[key]) vesselMarkers[key].vesselData = v;
}
// Remove stale markers
for (const key of Object.keys(vesselMarkers)) {
if (!seen.has(key)) {
leafletMap.removeLayer(vesselMarkers[key]);
delete vesselMarkers[key];
}
}
// Update count
document.getElementById('filterCount').textContent = visCount + ' / ' + vessels.length;
}
function showVesselCard(v) {
selectedVessel = v;
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
const name = v.name || v.mmsi || '\u2014';
const rows = [];
const typeName = v.type_name || v.ship_type_name || v.type_category || v._cat || '';
if (typeName) rows.push([t('type'), typeName]);
const flag = (v.flag || v.country || '').trim();
if (flag) rows.push([t('flag'), flag]);
const dwt = v.dwt || v.deadweight;
if (dwt) rows.push(['DWT', Number(dwt).toLocaleString()]);
const dest = (v.destination || '').trim();
if (dest) rows.push([t('destination'), dest]);
if (v.speed != null && v.speed !== '') rows.push([t('speed'), v.speed + ' kn']);
if (v.course != null && v.course !== '') rows.push([t('course'), v.course + '\u00b0']);
if (v.mmsi) rows.push(['MMSI', v.mmsi]);
if (v.imo) rows.push(['IMO', v.imo]);
const rowsHtml = rows.map(function(r) {
return '<div class="vp-row"><span class="vp-lbl">' + r[0] + '</span><span class="vp-val">' + r[1] + '</span></div>';
}).join('');
const html = '<div class="vp"><div class="vp-name">' + name + '</div>' + rowsHtml +
'<div class="vp-btn">AI Details</div></div>';
const lat = parseFloat(v.lat || v.latitude);
const lon = parseFloat(v.lon || v.longitude);
vesselPopup = L.popup({ closeButton: true, maxWidth: 260, minWidth: 210, offset: [0, -8] })
.setLatLng([lat, lon])
.setContent(html)
.openOn(leafletMap);
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
}
function closeVesselCard() {
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
selectedVessel = null;
}
function askAboutVessel() {
if (!selectedVessel) return;
var name = selectedVessel.name || selectedVessel.mmsi;
var imo = selectedVessel.imo;
var mmsi = selectedVessel.mmsi;
var query = 'Vessel details: ' + name;
if (imo) query += ' IMO ' + imo;
else if (mmsi) query += ' MMSI ' + mmsi;
// Close popup and clear selection
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
selectedVessel = null;
// Switch to chat and send
switchTab('chat');
sendChatMessage(query);
}
function trackVessel() {
if (!selectedVessel) return;
const name = selectedVessel.name || selectedVessel.mmsi;
closeVesselCard();
switchTab('chat');
sendChatMessage(t('track_vessel') + name + t('track_notify'));
}
async function searchOnMap(query) {
if (!query) return;
// Use chat API to search, then parse coordinates from response
switchTab('chat');
sendChatMessage(t('search_vessel') + query);
}
// =============================================================================
// Chat
// =============================================================================
async function loadChatHistory() {
try {
const resp = await fetch(API_BASE + '/api/v1/chat/history?limit=30', {
headers: { 'Authorization': 'Bearer ' + authToken }
});
if (!resp.ok) return;
const data = await resp.json();
if (!data.messages || !data.messages.length) return;
const messagesEl = document.getElementById('chatMessages');
messagesEl.innerHTML = '';
for (const msg of data.messages) {
const div = document.createElement('div');
div.className = msg.role === 'user' ? 'msg msg-user' : 'msg msg-bot';
if (msg.role === 'user') {
div.textContent = msg.message;
} else {
div.innerHTML = formatResponse(msg.message);
}
messagesEl.appendChild(div);
chatHistory.push({ role: msg.role, content: msg.message });
}
messagesEl.scrollTop = messagesEl.scrollHeight;
document.getElementById('chatChips').style.display = 'none';
} catch (e) { console.error('History load error:', e); }
}
let chatBusy = false;
function sendChat() {
const input = document.getElementById('chatInput');
const text = input.value.trim();
if (!text || chatBusy) return;
input.value = '';
input.style.height = 'auto';
sendChatMessage(text);
}
function quickChat(text) {
sendChatMessage(text);
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
}
async function sendChatMessage(text) {
if (chatBusy) return;
chatBusy = true;
document.getElementById('chatChips').style.display = 'none';
const messagesEl = document.getElementById('chatMessages');
// User message
const userDiv = document.createElement('div');
userDiv.className = 'msg msg-user';
userDiv.textContent = text;
messagesEl.appendChild(userDiv);
// Typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'msg-typing';
typingDiv.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(typingDiv);
messagesEl.scrollTop = messagesEl.scrollHeight;
chatHistory.push({ role: 'user', content: text });
try {
const lang = (tg.initDataUnsafe?.user?.language_code || 'en').substring(0, 2);
const resp = await fetch(API_BASE + '/api/v1/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken
},
body: JSON.stringify({ message: text, lang: lang })
});
if (!resp.ok) {
let errMsg = 'HTTP ' + resp.status;
try { const ej = await resp.json(); errMsg = ej.error || ej.response || errMsg; } catch(_) {}
throw new Error(errMsg);
}
const data = await resp.json();
typingDiv.remove();
const botDiv = document.createElement('div');
botDiv.className = 'msg msg-bot';
botDiv.innerHTML = formatResponse(data.response || 'No response');
messagesEl.appendChild(botDiv);
chatHistory.push({ role: 'assistant', content: data.response || '' });
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
} catch (e) {
typingDiv.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg msg-bot';
errDiv.textContent = t('error') + e.message;
messagesEl.appendChild(errDiv);
}
chatBusy = false;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function formatResponse(text) {
// Handle SHOWMAP markers → buttons
text = text.replace(/\{\{SHOWMAP~([^~]+)~([^~]+)~([^~]+)~([^}]+)\}\}/g,
(_, lat, lon, zoom, label) => {
return `<button onclick="flyToMap(${lat},${lon},${zoom})" style="display:inline-block;padding:6px 12px;margin:4px 0;background:var(--btn);color:var(--btn-text);border:none;border-radius:8px;font-size:12px;cursor:pointer">${label} &#x1F5FA;</button>`;
});
// Basic markdown
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
// Code blocks
text = text.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
text = text.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.3);padding:2px 4px;border-radius:4px">$1</code>');
return text;
}
function flyToMap(lat, lon, zoom) {
switchTab('map');
if (leafletMap) {
leafletMap.setView([lat, lon], zoom || 10);
setTimeout(loadMapVessels, 500);
}
}
// =============================================================================
// Profile
// =============================================================================
async function loadProfile() {
if (!authToken) return;
try {
const [profResp, walResp] = await Promise.all([
fetch(API_BASE + '/api/v1/profile', { headers: { 'Authorization': 'Bearer ' + authToken } }),
fetch(API_BASE + '/api/v1/wallet', { headers: { 'Authorization': 'Bearer ' + authToken } }).catch(() => null),
]);
const profData = await profResp.json();
if (profData.success && profData.profile) {
const p = profData.profile;
const name = currentUser?.name || p.company_name || 'User';
document.getElementById('profAvatar').textContent = name.charAt(0).toUpperCase();
document.getElementById('profName').textContent = name;
document.getElementById('profRole').textContent = p.role || t('member');
document.getElementById('profRoutes').textContent =
(p.trade_routes && p.trade_routes.length) ? p.trade_routes.join(', ') : '—';
document.getElementById('profCargo').textContent =
(p.cargo_types && p.cargo_types.length) ? p.cargo_types.map(c => c.replace(/_/g,' ')).join(', ') : '—';
}
if (walResp) {
const walData = await walResp.json();
if (walData.success) {
document.getElementById('profBalance').textContent = '$' + (parseFloat(walData.balance) || 0).toFixed(2);
}
}
// Load watches
loadWatches();
} catch (e) { console.error('Profile load error:', e); }
}
async function loadWatches() {
// Watches are Telegram-specific, loaded via chat command
// For now show static message
}
function openFullSite() {
tg.openLink('https://seafare.efir.org');
}
// =============================================================================
// Auto-resize textarea
// =============================================================================
document.addEventListener('DOMContentLoaded', function() {
const ta = document.getElementById('chatInput');
if (ta) {
ta.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
}
});
// =============================================================================
// Start
// =============================================================================
init().catch(function(e) {
console.error("Init error:", e);
var ls = document.getElementById("loadingScreen");
if (ls) ls.style.display = "none";
var nl = document.getElementById("notLinkedScreen");
if (nl) nl.style.display = "flex";
});
</script>
</body>
</html>