7800 lines
366 KiB
HTML
7800 lines
366 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="description" content="Монтана Логистик. Отслеживание судов, расчёт маршрутов, ставки фрахта, подбор грузов по 116,000+ портам мира.">
|
||
<title>SeaFare · Montana Protocol — Монтана Логистик</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="anonymous" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="anonymous"></script>
|
||
<script src="https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js"></script>
|
||
<script src="https://accounts.google.com/gsi/client?hl=en" async defer onload="window._gsiReady=true; if(typeof initGoogleButton==='function') initGoogleButton();"></script>
|
||
<style>/* v3.42.0-montana-1772900879 */
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
:root {
|
||
--bg-dark: #0a0a0a;
|
||
--bg-panel: #111111;
|
||
--bg-chat: #0d0d0d;
|
||
--bg-input: #1a1a1a;
|
||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||
--accent: #D4AF37;
|
||
--accent2: #9A7A2E;
|
||
--text: #e0e6ed;
|
||
--text-dim: #7b8da3;
|
||
--user-msg: #2a2208;
|
||
--bot-msg: rgba(26, 26, 26, 0.6);
|
||
--border: rgba(212, 175, 55, 0.12);
|
||
--border-strong: rgba(212, 175, 55, 0.22);
|
||
--success: #00c853;
|
||
--warn: #ffa726;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: var(--bg-dark);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
display: flex;
|
||
overflow: hidden;
|
||
-webkit-text-size-adjust: 100%;
|
||
text-size-adjust: 100%;
|
||
overscroll-behavior: none;
|
||
padding-top: env(safe-area-inset-top, 0px);
|
||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||
}
|
||
|
||
/* Auth mode: скрываем приложение пока не залогинен */
|
||
body.auth-mode .sidebar,
|
||
body.auth-mode .sidebar-backdrop,
|
||
body.auth-mode .main {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
width: 280px;
|
||
background: var(--bg-panel);
|
||
border-right: 1px solid var(--border-strong);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.logo-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
background: linear-gradient(135deg, #D4AF37, #F1D173);
|
||
border-radius: 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 19px;
|
||
}
|
||
|
||
.logo-text h1 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
.logo-text span {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 9px;
|
||
background: rgba(0, 200, 83, 0.1);
|
||
border: 1px solid rgba(0, 200, 83, 0.3);
|
||
border-radius: 20px;
|
||
font-size: 10px;
|
||
color: var(--success);
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--success);
|
||
border-radius: 50%;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
/* Services */
|
||
.services {
|
||
padding: 8px 12px;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.services > h3 {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--text-dim);
|
||
margin-bottom: 6px;
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.service-group {
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.service-group-title {
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.2px;
|
||
color: var(--accent);
|
||
padding: 4px 8px 2px;
|
||
margin-bottom: 1px;
|
||
opacity: 0.7;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.service-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 5px 8px;
|
||
border-radius: 7px;
|
||
margin-bottom: 1px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.service-item:hover {
|
||
background: rgba(0, 180, 216, 0.1);
|
||
}
|
||
|
||
.service-icon {
|
||
font-size: 14px;
|
||
width: 22px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.service-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.service-info .name {
|
||
font-size: 11.5px;
|
||
font-weight: 500;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.service-info .price {
|
||
font-size: 9.5px;
|
||
color: var(--text-dim);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.price-tag {
|
||
font-size: 9px;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
background: rgba(0, 180, 216, 0.15);
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.price-free {
|
||
background: rgba(0, 200, 83, 0.15);
|
||
color: var(--success);
|
||
}
|
||
|
||
.price-promo {
|
||
background: rgba(255, 183, 0, 0.15);
|
||
color: #e6a800;
|
||
font-size: 8px;
|
||
line-height: 1.3;
|
||
text-align: center;
|
||
padding: 2px 6px;
|
||
}
|
||
.price-promo .price-orig {
|
||
text-decoration: line-through;
|
||
opacity: 0.6;
|
||
display: block;
|
||
font-size: 7px;
|
||
}
|
||
.price-promo .price-now {
|
||
display: block;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.service-group-divider {
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin: 3px 8px;
|
||
}
|
||
|
||
/* Sidebar quick filters (logged-in users) */
|
||
.sidebar-filters {
|
||
padding: 8px 12px 10px;
|
||
display: none;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
.sidebar-filters-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
cursor: pointer; padding: 4px 0 6px; user-select: none;
|
||
}
|
||
.sidebar-filters-header h3 {
|
||
font-size: 11px; text-transform: uppercase; letter-spacing: 1.2px;
|
||
color: var(--accent); margin: 0; padding-left: 2px; font-weight: 700;
|
||
}
|
||
.sidebar-filters-header .sf-arrow {
|
||
font-size: 10px; color: var(--text-dim); transition: transform 0.2s;
|
||
}
|
||
.sidebar-filters.collapsed .sf-body { display: none; }
|
||
.sidebar-filters.collapsed .sf-arrow { transform: rotate(-90deg); }
|
||
.sf-body { padding-top: 4px; display: flex; flex-direction: column; gap: 6px; }
|
||
.sf-section {
|
||
background: rgba(255,255,255,0.025);
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
border-radius: 10px;
|
||
padding: 8px 10px 10px;
|
||
}
|
||
.sf-label {
|
||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.8px;
|
||
color: rgba(0,180,216,0.65); margin: 0 0 6px 1px; font-weight: 600;
|
||
}
|
||
.sf-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||
.sf-chip {
|
||
padding: 3px 9px; border-radius: 12px; font-size: 10px;
|
||
border: 1px solid rgba(255,255,255,0.10); color: var(--text-dim);
|
||
cursor: pointer; transition: all 0.15s ease; user-select: none;
|
||
background: rgba(255,255,255,0.03); white-space: nowrap;
|
||
}
|
||
.sf-chip:hover {
|
||
border-color: rgba(0,180,216,0.4); color: var(--text);
|
||
background: rgba(0,180,216,0.08);
|
||
}
|
||
.sf-chip.active {
|
||
background: rgba(0,180,216,0.18); border-color: rgba(0,180,216,0.5);
|
||
color: #00b4d8; font-weight: 600;
|
||
box-shadow: 0 0 6px rgba(0,180,216,0.12);
|
||
}
|
||
.sf-select {
|
||
width: 100%; padding: 5px 10px; font-size: 11px;
|
||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);
|
||
border-radius: 8px; color: var(--text);
|
||
appearance: none; -webkit-appearance: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat; background-position: right 8px center;
|
||
cursor: pointer;
|
||
}
|
||
.sf-select:focus { border-color: var(--accent); outline: none; }
|
||
.sf-select option { background: var(--bg-panel); color: var(--text); }
|
||
.sf-input {
|
||
width: 100%; padding: 5px 10px; font-size: 11px;
|
||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);
|
||
border-radius: 8px; color: var(--text); box-sizing: border-box;
|
||
}
|
||
.sf-input:focus { border-color: var(--accent); outline: none; }
|
||
.sf-input::placeholder { color: var(--text-dim); opacity: 0.5; }
|
||
.sf-row { display: flex; gap: 6px; align-items: center; margin-top: 6px; }
|
||
.sf-row .sf-input { flex: 1; }
|
||
.sf-row .sf-unit { font-size: 10px; color: var(--text-dim); white-space: nowrap; }
|
||
.sf-hint-btn {
|
||
font-size: 12px; color: var(--text-dim); cursor: pointer;
|
||
opacity: 0.5; transition: opacity 0.2s; margin-left: 6px;
|
||
user-select: none;
|
||
}
|
||
.sf-hint-btn:hover { opacity: 1; }
|
||
.sf-hint {
|
||
display: none; font-size: 11px; color: var(--text-dim);
|
||
line-height: 1.5; padding: 6px 8px; margin-bottom: 6px;
|
||
background: rgba(0,180,216,0.06); border-radius: 8px;
|
||
border: 1px solid rgba(0,180,216,0.12);
|
||
}
|
||
.sf-hint.visible { display: block; }
|
||
|
||
/* Subscription plan badge in sidebar */
|
||
.sidebar-plan {
|
||
padding: 8px 12px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.plan-badge {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 8px 10px;
|
||
background: linear-gradient(135deg, rgba(0,180,216,0.08), rgba(0,119,182,0.08));
|
||
border: 1px solid rgba(0,180,216,0.2); border-radius: 8px;
|
||
cursor: pointer; transition: all 0.2s;
|
||
}
|
||
.plan-badge:hover { border-color: rgba(0,180,216,0.4); }
|
||
.plan-badge-left { display: flex; align-items: center; gap: 6px; }
|
||
.plan-badge-icon { font-size: 14px; }
|
||
.plan-badge-info .plan-badge-name { font-size: 11px; font-weight: 600; color: var(--text); }
|
||
.plan-badge-info .plan-badge-desc { font-size: 9px; color: var(--text-dim); }
|
||
.plan-badge-arrow { font-size: 13px; color: var(--accent); }
|
||
.plan-badge.pro { background: linear-gradient(135deg, rgba(255,107,53,0.08), rgba(255,68,68,0.08)); border-color: rgba(255,107,53,0.25); }
|
||
.plan-badge.basic { background: linear-gradient(135deg, rgba(0,200,83,0.08), rgba(0,168,70,0.08)); border-color: rgba(0,200,83,0.25); }
|
||
|
||
/* Subscription Modal */
|
||
.sub-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.92); z-index: 9000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
||
.sub-overlay.hidden { display: none; }
|
||
.sub-modal { background: var(--bg-panel); border-radius: 16px; padding: 32px; width: 640px; max-width: 95vw; max-height: 90vh; max-height: 90dvh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.4); border: 1px solid var(--border); }
|
||
.sub-modal h2 { color: var(--text); font-size: 20px; margin-bottom: 6px; display: flex; align-items: center; gap: 10px; }
|
||
.sub-modal .sub-subtitle { color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }
|
||
.sub-plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||
.sub-plan-card { background: rgba(22,34,64,0.5); border-radius: 14px; padding: 20px 16px; text-align: center; border: 1px solid var(--border); transition: all 0.2s; position: relative; }
|
||
.sub-plan-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||
.sub-plan-card.current { border-color: var(--success); box-shadow: 0 0 0 1px var(--success); }
|
||
.sub-plan-card.recommended { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||
.sub-plan-card .plan-tag { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 10px; padding: 2px 10px; border-radius: 10px; font-weight: 600; white-space: nowrap; }
|
||
.sub-plan-card .plan-tag.rec { background: var(--accent); }
|
||
.sub-plan-card .plan-tag.cur { background: var(--success); }
|
||
.sub-plan-card .plan-name { font-size: 16px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
||
.sub-plan-card .plan-price { font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 2px; }
|
||
.sub-plan-card .plan-period { font-size: 11px; color: var(--text-dim); margin-bottom: 12px; }
|
||
.sub-plan-card .plan-features { text-align: left; font-size: 12px; color: var(--text-dim); line-height: 1.8; }
|
||
.sub-plan-card .plan-features .feat { display: flex; align-items: flex-start; gap: 6px; }
|
||
.sub-plan-card .plan-features .feat-icon { color: var(--success); flex-shrink: 0; }
|
||
.sub-plan-card .plan-upgrade-btn { margin-top: 14px; width: 100%; padding: 9px; border: 1px solid var(--accent); border-radius: 8px; background: transparent; color: var(--accent); font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||
.sub-plan-card .plan-upgrade-btn:hover { background: var(--accent); color: #fff; }
|
||
.sub-plan-card .plan-upgrade-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.sub-plan-card .plan-upgrade-btn.current-btn { border-color: var(--success); color: var(--success); }
|
||
.sub-close { display: block; text-align: center; margin-top: 16px; color: var(--text-dim); cursor: pointer; font-size: 13px; }
|
||
.sub-close:hover { color: var(--text); }
|
||
.sub-result { margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 13px; text-align: center; }
|
||
.sub-result.success { background: rgba(0,200,83,0.1); color: var(--success); border: 1px solid rgba(0,200,83,0.3); }
|
||
.sub-result.error { background: rgba(255,82,82,0.1); color: #ff5252; border: 1px solid rgba(255,82,82,0.3); }
|
||
@media (max-width: 768px) {
|
||
.sub-plans { grid-template-columns: 1fr; }
|
||
.sub-modal { padding: 24px 16px; }
|
||
}
|
||
|
||
/* Sidebar footer */
|
||
.sidebar-footer {
|
||
padding: 8px 12px;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.sidebar-footer a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
/* Main chat area */
|
||
.main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Chat header */
|
||
.chat-header {
|
||
padding: 12px 20px;
|
||
background: var(--bg-panel);
|
||
border-bottom: 1px solid var(--border-strong);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.chat-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chat-header-left h2 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.chat-header-left span {
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.header-btn {
|
||
padding: 5px 11px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.header-btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Language switcher */
|
||
.lang-switcher {
|
||
display: flex;
|
||
gap: 2px;
|
||
background: var(--bg-input);
|
||
border-radius: 6px;
|
||
padding: 2px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.lang-btn {
|
||
padding: 4px 10px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.lang-btn:hover {
|
||
color: var(--text);
|
||
}
|
||
|
||
.lang-btn.active {
|
||
background: var(--accent2);
|
||
color: white;
|
||
}
|
||
|
||
/* Messages */
|
||
.messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px 24px 100px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
background: var(--bg-chat);
|
||
scroll-behavior: smooth;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
gap: 12px;
|
||
max-width: 85%;
|
||
animation: messageIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||
}
|
||
|
||
@keyframes messageIn {
|
||
from { opacity: 0; transform: translateY(10px) scale(0.98); filter: blur(3px); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||
}
|
||
|
||
.message.user {
|
||
align-self: flex-end;
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.msg-avatar {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.message.bot .msg-avatar {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||
}
|
||
|
||
.message.user .msg-avatar {
|
||
background: var(--user-msg);
|
||
}
|
||
|
||
.msg-content {
|
||
padding: 16px 20px;
|
||
border-radius: 18px;
|
||
font-size: 15px;
|
||
line-height: 1.65;
|
||
letter-spacing: -0.01em;
|
||
overflow-x: hidden;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.message.bot .msg-content {
|
||
background: var(--bot-msg);
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.message.user .msg-content {
|
||
background: linear-gradient(135deg, var(--accent2), #005a99);
|
||
color: white;
|
||
border-radius: 20px;
|
||
padding: 12px 18px;
|
||
}
|
||
|
||
.msg-content pre {
|
||
background: rgba(0, 0, 0, 0.35);
|
||
padding: 14px 18px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
margin: 10px 0;
|
||
font-size: 13px;
|
||
overflow-x: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.msg-content code {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
padding: 2px 7px;
|
||
border-radius: 5px;
|
||
font-size: 13px;
|
||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||
}
|
||
|
||
.msg-content * {
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.msg-content strong {
|
||
word-break: break-word;
|
||
}
|
||
|
||
.msg-content .vessel-card {
|
||
background: rgba(0, 180, 216, 0.06);
|
||
border: 1px solid rgba(0, 180, 216, 0.15);
|
||
border-radius: 14px;
|
||
padding: 16px;
|
||
margin: 10px 0;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.vessel-card .vessel-name {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.vessel-card .vessel-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 2px;
|
||
padding: 4px 0;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||
}
|
||
|
||
.vessel-card .vessel-row:last-child { border-bottom: none; }
|
||
|
||
.vessel-card .vessel-label {
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.msg-time {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
margin-top: 6px;
|
||
padding: 0 4px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.message.user .msg-time {
|
||
text-align: right;
|
||
}
|
||
|
||
/* Typing indicator */
|
||
.typing {
|
||
display: flex;
|
||
gap: 5px;
|
||
padding: 8px 4px;
|
||
}
|
||
|
||
.typing span {
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
animation: typingPulse 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||
.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||
|
||
@keyframes typingPulse {
|
||
0%, 60%, 100% { transform: translateY(0) scale(1); opacity: 0.4; }
|
||
30% { transform: translateY(-5px) scale(1.2); opacity: 1; }
|
||
}
|
||
|
||
.typing-status {
|
||
font-size: 11px;
|
||
color: var(--accent);
|
||
opacity: 0.85;
|
||
margin-top: 2px;
|
||
min-height: 14px;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.typing-timer {
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
opacity: 0.9;
|
||
margin-top: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.5px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
@keyframes statusFade {
|
||
from { opacity: 0; transform: translateY(3px); }
|
||
to { opacity: 0.85; transform: translateY(0); }
|
||
}
|
||
|
||
/* Input area */
|
||
.input-area {
|
||
padding: 0 24px 20px;
|
||
background: linear-gradient(to top, var(--bg-panel) 60%, transparent);
|
||
position: relative;
|
||
margin-top: -80px;
|
||
pointer-events: none;
|
||
padding-top: 80px;
|
||
}
|
||
.input-area > * { pointer-events: auto; }
|
||
|
||
.input-glass {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: flex-end;
|
||
background: rgba(22, 34, 64, 0.65);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 24px;
|
||
padding: 6px 6px 6px 18px;
|
||
transition: border-color 0.25s, box-shadow 0.25s;
|
||
-webkit-transform: translateZ(0);
|
||
}
|
||
.input-glass:focus-within {
|
||
border-color: rgba(0, 180, 216, 0.4);
|
||
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.08), 0 4px 24px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.input-wrapper {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
.input-wrapper textarea {
|
||
width: 100%;
|
||
padding: 10px 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text);
|
||
font-size: 16px;
|
||
font-family: inherit;
|
||
resize: none;
|
||
outline: none;
|
||
min-height: 40px;
|
||
max-height: 120px;
|
||
line-height: 1.5;
|
||
-webkit-appearance: none;
|
||
-webkit-text-size-adjust: 100%;
|
||
caret-color: var(--accent);
|
||
}
|
||
|
||
.input-wrapper textarea::placeholder {
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
/* action button — mic/send swap */
|
||
.input-action-btn {
|
||
width: 42px;
|
||
height: 42px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: transform 0.2s, background 0.25s;
|
||
position: relative;
|
||
touch-action: manipulation;
|
||
-webkit-tap-highlight-color: transparent;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
.input-action-btn .ia-icon {
|
||
position: absolute;
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
}
|
||
.input-action-btn .ia-mic {
|
||
color: var(--text-dim);
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
display: flex;
|
||
}
|
||
.input-action-btn .ia-send {
|
||
color: white;
|
||
opacity: 0;
|
||
transform: scale(0.5) rotate(-90deg);
|
||
display: flex;
|
||
}
|
||
.input-action-btn svg {
|
||
width: 22px;
|
||
height: 22px;
|
||
}
|
||
/* Empty state — mic visible */
|
||
.input-action-btn {
|
||
background: transparent;
|
||
}
|
||
.input-action-btn:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
/* Has text — send visible */
|
||
.input-action-btn.has-text {
|
||
background: var(--accent);
|
||
}
|
||
.input-action-btn.has-text:hover {
|
||
transform: scale(1.08);
|
||
background: #0095cc;
|
||
}
|
||
.input-action-btn.has-text:active {
|
||
transform: scale(0.93);
|
||
}
|
||
.input-action-btn.has-text .ia-mic {
|
||
opacity: 0;
|
||
transform: scale(0.5);
|
||
}
|
||
.input-action-btn.has-text .ia-send {
|
||
opacity: 1;
|
||
transform: scale(1) rotate(0deg);
|
||
}
|
||
/* Recording state */
|
||
.input-action-btn.recording {
|
||
background: rgba(255, 59, 48, 0.15);
|
||
animation: pulse-rec 1.5s ease infinite;
|
||
}
|
||
.input-action-btn.recording .ia-mic {
|
||
color: #ff3b30;
|
||
}
|
||
@keyframes pulse-rec {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,59,48,0.4); }
|
||
50% { box-shadow: 0 0 0 8px rgba(255,59,48,0); }
|
||
}
|
||
/* Hide old buttons */
|
||
.send-btn { display: none !important; }
|
||
.voice-btn { display: none !important; }
|
||
|
||
.input-hint {
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
margin-top: 10px;
|
||
padding: 0 8px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* Quick actions */
|
||
.quick-actions {
|
||
display: none;
|
||
}
|
||
|
||
/* Examples panel */
|
||
.examples-toggle {
|
||
display: flex; align-items: center; gap: 6px;
|
||
margin: 0 auto 8px; padding: 6px 16px;
|
||
background: rgba(0,180,216,0.10);
|
||
border: 1px solid rgba(0,180,216,0.25);
|
||
border-radius: 20px; cursor: pointer;
|
||
color: rgba(0,180,216,0.9); font-size: 12px; font-weight: 600;
|
||
transition: all 0.2s; user-select: none;
|
||
width: fit-content;
|
||
animation: exPulse 3s ease-in-out infinite;
|
||
}
|
||
.examples-toggle:hover {
|
||
background: rgba(0,180,216,0.18);
|
||
border-color: rgba(0,180,216,0.4);
|
||
transform: translateY(-1px);
|
||
}
|
||
.examples-toggle.open {
|
||
background: rgba(0,180,216,0.15);
|
||
border-color: rgba(0,180,216,0.4);
|
||
animation: none;
|
||
}
|
||
.examples-toggle .ex-icon { font-size: 14px; }
|
||
.examples-toggle .ex-arrow {
|
||
font-size: 10px; transition: transform 0.2s;
|
||
opacity: 0.6;
|
||
}
|
||
.examples-toggle.open .ex-arrow { transform: rotate(180deg); }
|
||
|
||
@keyframes exPulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(0,180,216,0); }
|
||
50% { box-shadow: 0 0 12px 2px rgba(0,180,216,0.15); }
|
||
}
|
||
|
||
.examples-panel {
|
||
display: none; overflow: hidden;
|
||
opacity: 0; position: absolute;
|
||
bottom: 100%; left: 0; right: 0;
|
||
margin-bottom: 4px; z-index: 50;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.examples-panel.open {
|
||
display: block; opacity: 1;
|
||
}
|
||
.examples-inner {
|
||
background: rgba(15,23,48,0.85);
|
||
backdrop-filter: blur(16px);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 16px;
|
||
padding: 12px 14px;
|
||
display: flex; flex-direction: column; gap: 10px;
|
||
max-height: 45vh; overflow-y: auto;
|
||
}
|
||
.ex-cat { display: flex; flex-direction: column; gap: 4px; }
|
||
.ex-cat-title {
|
||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.8px;
|
||
color: rgba(0,180,216,0.55); font-weight: 600; padding-left: 2px;
|
||
}
|
||
.ex-items { display: flex; flex-direction: column; gap: 3px; }
|
||
.ex-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 7px 10px; border-radius: 10px;
|
||
background: rgba(255,255,255,0.03);
|
||
border: 1px solid rgba(255,255,255,0.05);
|
||
cursor: pointer; transition: all 0.15s;
|
||
color: var(--text-dim); font-size: 12px;
|
||
}
|
||
.ex-item:hover {
|
||
background: rgba(0,180,216,0.10);
|
||
border-color: rgba(0,180,216,0.25);
|
||
color: var(--text);
|
||
}
|
||
.ex-item .ex-q { flex: 1; }
|
||
.ex-item .ex-go {
|
||
font-size: 10px; color: rgba(0,180,216,0.4);
|
||
transition: color 0.15s;
|
||
}
|
||
.ex-item:hover .ex-go { color: rgba(0,180,216,0.8); }
|
||
|
||
.quick-btn {
|
||
padding: 6px 13px;
|
||
background: rgba(22, 34, 64, 0.5);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 18px;
|
||
color: var(--text-dim);
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.quick-btn:hover {
|
||
border-color: rgba(0, 180, 216, 0.3);
|
||
color: var(--accent);
|
||
background: rgba(0, 180, 216, 0.08);
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 4px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.16); }
|
||
.messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.08) transparent; }
|
||
|
||
/* Full-page login screen */
|
||
.auth-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: #000000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
overflow-y: auto;
|
||
padding: max(20px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||
}
|
||
.auth-overlay::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background:
|
||
radial-gradient(ellipse 50% 40% at 50% 30%, rgba(212, 175, 55, 0.07) 0%, transparent 60%),
|
||
radial-gradient(ellipse 80% 60% at 50% 100%, rgba(212, 175, 55, 0.03) 0%, transparent 50%);
|
||
pointer-events: none;
|
||
}
|
||
.auth-overlay.hidden { display: none; }
|
||
|
||
.auth-modal {
|
||
position: relative;
|
||
background: rgba(6, 4, 1, 0.98);
|
||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||
border-radius: 20px;
|
||
padding: 40px 44px;
|
||
width: 420px;
|
||
max-width: min(420px, calc(100vw - 32px));
|
||
box-shadow: 0 0 80px rgba(212, 175, 55, 0.06), 0 32px 80px rgba(0,0,0,0.85);
|
||
/* backdrop-filter: blur(20px); */
|
||
margin: auto;
|
||
}
|
||
|
||
.auth-brand {
|
||
text-align: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
.auth-brand-logo {
|
||
font-size: 56px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
margin-bottom: 14px;
|
||
color: #D4AF37;
|
||
letter-spacing: -2px;
|
||
display: block;
|
||
}
|
||
.auth-brand-name {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #D4AF37;
|
||
letter-spacing: 1px;
|
||
}
|
||
.auth-brand-sub {
|
||
display: none;
|
||
font-size: 12px;
|
||
color: rgba(255,255,255,0.35);
|
||
margin-top: 4px;
|
||
letter-spacing: 2px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* Legacy .auth-logo kept for compatibility */
|
||
.auth-logo {
|
||
display: none;
|
||
}
|
||
|
||
.auth-modal h3 {
|
||
text-align: center; font-size: 15px;
|
||
margin-bottom: 18px; color: var(--text-dim);
|
||
}
|
||
|
||
.auth-field { margin-bottom: 12px; }
|
||
.auth-field input {
|
||
width: 100%; padding: 11px 14px;
|
||
background: var(--bg-input); border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text);
|
||
font-size: 14px; font-family: inherit; outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.auth-field input:focus { border-color: var(--accent); }
|
||
|
||
.auth-submit {
|
||
width: 100%; padding: 11px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||
border: none; border-radius: 8px; color: white;
|
||
font-size: 16px; font-weight: 700; cursor: pointer;
|
||
transition: opacity 0.2s; margin-top: 8px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||
}
|
||
.auth-submit:hover { opacity: 0.9; }
|
||
.auth-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.auth-divider {
|
||
display: flex; align-items: center; gap: 12px;
|
||
margin: 16px 0; color: var(--text-dim); font-size: 12px;
|
||
}
|
||
.auth-divider::before, .auth-divider::after {
|
||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||
}
|
||
.google-btn-wrap {
|
||
display: flex; justify-content: center; margin-bottom: 4px;
|
||
}
|
||
|
||
.auth-switch { text-align: center; margin-top: 14px; }
|
||
.auth-switch a { color: var(--accent); text-decoration: none; font-size: 14px; font-weight: 500; }
|
||
|
||
/* Auth language selector */
|
||
.auth-lang-selector {
|
||
display: flex; justify-content: center; gap: 8px; margin-bottom: 16px;
|
||
}
|
||
.auth-lang-btn {
|
||
background: transparent; border: 1px solid var(--border);
|
||
border-radius: 20px; padding: 5px 14px; cursor: pointer;
|
||
font-size: 14px; color: var(--text-dim); transition: all 0.2s;
|
||
}
|
||
.auth-lang-btn.active {
|
||
border-color: var(--accent); color: var(--accent);
|
||
background: rgba(212,175,55,0.1);
|
||
}
|
||
.auth-lang-btn:hover { border-color: var(--accent); }
|
||
|
||
/* Registration steps */
|
||
.reg-steps {
|
||
display: flex; justify-content: center; gap: 6px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.reg-step-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--border); transition: all 0.3s;
|
||
}
|
||
.reg-step-dot.active { background: var(--accent); transform: scale(1.3); }
|
||
.reg-step-dot.done { background: var(--accent); opacity: 0.5; }
|
||
|
||
.auth-code-input {
|
||
display: flex; justify-content: center; gap: 8px; margin-bottom: 12px;
|
||
}
|
||
.auth-code-input input {
|
||
width: 42px; height: 48px; text-align: center;
|
||
font-size: 20px; font-weight: 700; font-family: monospace;
|
||
background: var(--bg-input); border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text); outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.auth-code-input input:focus { border-color: var(--accent); }
|
||
.auth-code-timer {
|
||
text-align: center; font-size: 12px; color: var(--text-dim);
|
||
margin-bottom: 12px;
|
||
}
|
||
.auth-resend {
|
||
text-align: center; margin-top: 8px;
|
||
}
|
||
.auth-resend a {
|
||
color: var(--accent); text-decoration: none; font-size: 13px;
|
||
}
|
||
.auth-resend a.disabled {
|
||
color: var(--text-dim); pointer-events: none; opacity: 0.5;
|
||
}
|
||
|
||
.auth-close { display: none !important; text-align: center; margin-top: 10px; }
|
||
.auth-close a { color: var(--text-dim); text-decoration: none; font-size: 12px; }
|
||
.auth-close a:hover { color: var(--text); }
|
||
|
||
.auth-error {
|
||
background: rgba(255, 82, 82, 0.1);
|
||
border: 1px solid rgba(255, 82, 82, 0.3);
|
||
color: #ff5252; padding: 9px 14px; border-radius: 8px;
|
||
font-size: 13px; margin-bottom: 12px; text-align: center;
|
||
}
|
||
|
||
/* User info in header */
|
||
.user-info {
|
||
font-size: 13px; color: var(--text-dim);
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.admin-badge {
|
||
padding: 2px 8px; border-radius: 10px;
|
||
background: rgba(255, 107, 53, 0.2);
|
||
color: #ff6b35; font-size: 10px; font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.admin-revenue-btn {
|
||
background: rgba(255, 107, 53, 0.15) !important;
|
||
color: #ff6b35 !important;
|
||
border: 1px solid rgba(255, 107, 53, 0.3) !important;
|
||
font-size: 12px !important;
|
||
padding: 3px 8px !important;
|
||
}
|
||
/* Revenue Modal */
|
||
.revenue-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.6); z-index: 1001;
|
||
display: flex; align-items: center; justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.revenue-overlay.hidden { display: none; }
|
||
.revenue-modal {
|
||
background: var(--bg-panel); border-radius: 16px;
|
||
padding: 32px; width: 520px; max-width: 95vw;
|
||
max-height: 90vh; max-height: 90dvh; overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.revenue-modal h2 {
|
||
color: var(--text); font-size: 20px; margin-bottom: 20px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.revenue-cards {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.revenue-cards.triple { grid-template-columns: 1fr 1fr 1fr; }
|
||
.revenue-card {
|
||
background: rgba(22, 34, 64, 0.5); border-radius: 12px;
|
||
padding: 16px; text-align: center;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.revenue-card.highlight {
|
||
background: rgba(0, 200, 120, 0.08);
|
||
border-color: rgba(0, 200, 120, 0.25);
|
||
}
|
||
.revenue-card .label {
|
||
font-size: 11px; color: var(--text-dim);
|
||
text-transform: uppercase; letter-spacing: 0.5px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.revenue-card .value {
|
||
font-size: 22px; font-weight: 700; color: var(--text);
|
||
}
|
||
.revenue-card .value.sm { font-size: 16px; }
|
||
.revenue-card .value.green { color: var(--success); }
|
||
.revenue-card .value.orange { color: #ff6b35; }
|
||
.revenue-card .value.blue { color: var(--accent); }
|
||
.revenue-section-title {
|
||
color: var(--text); font-size: 14px; font-weight: 600;
|
||
margin: 20px 0 10px; padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.revenue-bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin: 12px 0 4px; }
|
||
.revenue-bar {
|
||
flex: 1; background: var(--accent); border-radius: 3px 3px 0 0;
|
||
min-height: 2px; position: relative; transition: height 0.3s;
|
||
}
|
||
.revenue-bar:hover { opacity: 0.8; }
|
||
.revenue-bar-labels { display: flex; gap: 4px; font-size: 9px; color: var(--text-dim); }
|
||
.revenue-bar-labels span { flex: 1; text-align: center; }
|
||
.revenue-table {
|
||
width: 100%; border-collapse: collapse; margin-top: 16px;
|
||
}
|
||
.revenue-table th {
|
||
text-align: left; padding: 8px 10px; font-size: 11px;
|
||
color: var(--text-dim); text-transform: uppercase;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.revenue-table td {
|
||
padding: 8px 10px; font-size: 13px; color: var(--text);
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.revenue-close {
|
||
position: absolute; top: 12px; right: 16px;
|
||
background: none; border: none; color: var(--text-dim);
|
||
font-size: 24px; cursor: pointer;
|
||
}
|
||
.revenue-close:hover { color: var(--text); }
|
||
.balance-badge {
|
||
padding: 2px 8px; border-radius: 10px;
|
||
background: rgba(0, 200, 83, 0.15);
|
||
color: var(--success); font-size: 11px; font-weight: 600;
|
||
}
|
||
.topup-btn {
|
||
background: rgba(0, 200, 83, 0.15) !important;
|
||
color: var(--success) !important;
|
||
border: 1px solid rgba(0, 200, 83, 0.3) !important;
|
||
font-size: 12px !important;
|
||
padding: 3px 8px !important;
|
||
}
|
||
.topup-btn:hover {
|
||
background: rgba(0, 200, 83, 0.25) !important;
|
||
}
|
||
|
||
/* Deposit Modal */
|
||
.deposit-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.85); z-index: 1001;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.deposit-overlay.hidden { display: none; }
|
||
.deposit-modal {
|
||
background: var(--bg-panel); border-radius: 16px;
|
||
padding: 32px; width: 440px; max-width: 95vw;
|
||
max-height: 90vh; max-height: 90dvh; overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
border: 1px solid var(--border);
|
||
text-align: center;
|
||
}
|
||
.deposit-modal h2 {
|
||
color: var(--text); font-size: 20px; margin-bottom: 16px;
|
||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||
}
|
||
.deposit-network {
|
||
display: inline-block;
|
||
padding: 4px 12px; border-radius: 8px;
|
||
background: rgba(0, 180, 216, 0.15); color: var(--accent);
|
||
font-size: 13px; font-weight: 600; margin-bottom: 16px;
|
||
}
|
||
.deposit-qr {
|
||
margin: 16px auto;
|
||
width: 200px; height: 200px;
|
||
background: #fff; border-radius: 12px;
|
||
padding: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.deposit-qr img { width: 100%; height: 100%; }
|
||
.deposit-address-wrap {
|
||
background: var(--bg-input); border: 1px solid var(--border);
|
||
border-radius: 10px; padding: 12px; margin: 12px 0;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.deposit-address {
|
||
flex: 1; font-family: monospace; font-size: 12px;
|
||
color: var(--text); word-break: break-all; text-align: left;
|
||
}
|
||
.deposit-copy-btn {
|
||
background: var(--accent); border: none; border-radius: 6px;
|
||
color: #fff; padding: 6px 12px; font-size: 12px;
|
||
cursor: pointer; flex-shrink: 0; transition: all 0.2s;
|
||
}
|
||
.deposit-copy-btn:hover { opacity: 0.85; }
|
||
.deposit-check-btn {
|
||
background: linear-gradient(135deg, var(--success), #00a844);
|
||
border: none; border-radius: 10px; color: #fff;
|
||
padding: 12px 24px; font-size: 14px; font-weight: 600;
|
||
cursor: pointer; width: 100%; margin-top: 16px;
|
||
transition: all 0.2s;
|
||
}
|
||
.deposit-check-btn:hover { opacity: 0.9; transform: scale(1.02); }
|
||
.deposit-check-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
.deposit-result {
|
||
margin-top: 12px; padding: 10px; border-radius: 8px;
|
||
font-size: 13px;
|
||
}
|
||
.deposit-result.success {
|
||
background: rgba(0, 200, 83, 0.1); color: var(--success);
|
||
border: 1px solid rgba(0, 200, 83, 0.3);
|
||
}
|
||
.deposit-result.empty {
|
||
background: rgba(255, 167, 38, 0.1); color: var(--warn);
|
||
border: 1px solid rgba(255, 167, 38, 0.3);
|
||
}
|
||
.deposit-result.error {
|
||
background: rgba(255, 82, 82, 0.1); color: #ff5252;
|
||
border: 1px solid rgba(255, 82, 82, 0.3);
|
||
}
|
||
.deposit-close {
|
||
display: inline-block; margin-top: 16px;
|
||
color: var(--text-dim); cursor: pointer; font-size: 13px;
|
||
}
|
||
.deposit-close:hover { color: var(--text); }
|
||
.deposit-history { margin-top: 16px; text-align: left; }
|
||
.deposit-history h4 { font-size: 13px; color: var(--text-dim); margin-bottom: 8px; }
|
||
.deposit-tx {
|
||
font-size: 11px; color: var(--text-dim); padding: 4px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; justify-content: space-between;
|
||
}
|
||
.deposit-tx .amount { color: var(--success); font-weight: 600; }
|
||
.deposit-tx .amount.negative { color: #ff5252; }
|
||
|
||
/* Deposit/Withdraw Tabs */
|
||
.wallet-tabs {
|
||
display: flex; gap: 0; margin-bottom: 20px;
|
||
border-radius: 10px; overflow: hidden;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.wallet-tab {
|
||
flex: 1; padding: 10px 16px; font-size: 13px; font-weight: 600;
|
||
cursor: pointer; border: none; transition: all 0.2s;
|
||
background: var(--bg-input); color: var(--text-dim);
|
||
}
|
||
.wallet-tab.active {
|
||
background: var(--accent); color: #fff;
|
||
}
|
||
.wallet-tab:hover:not(.active) { background: var(--bg-hover); }
|
||
.wallet-tab-content { display: none; }
|
||
.wallet-tab-content.active { display: block; }
|
||
|
||
/* Withdraw form */
|
||
.withdraw-form { text-align: left; }
|
||
.withdraw-field { margin-bottom: 14px; }
|
||
.withdraw-field label {
|
||
display: block; font-size: 12px; color: var(--text-dim);
|
||
margin-bottom: 4px; font-weight: 600;
|
||
}
|
||
.withdraw-field input {
|
||
width: 100%; padding: 10px 12px; font-size: 14px;
|
||
background: var(--bg-input); border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text); outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
.withdraw-field input:focus { border-color: var(--accent); }
|
||
.withdraw-balance-info {
|
||
font-size: 12px; color: var(--text-dim); margin-bottom: 16px;
|
||
text-align: center;
|
||
}
|
||
.withdraw-balance-info strong { color: var(--success); }
|
||
.withdraw-submit-btn {
|
||
background: linear-gradient(135deg, #ff6b35, #ff4444);
|
||
border: none; border-radius: 10px; color: #fff;
|
||
padding: 12px 24px; font-size: 14px; font-weight: 600;
|
||
cursor: pointer; width: 100%; transition: all 0.2s;
|
||
}
|
||
.withdraw-submit-btn:hover { opacity: 0.9; transform: scale(1.02); }
|
||
.withdraw-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
.withdraw-result {
|
||
margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 13px;
|
||
}
|
||
.withdraw-result.success {
|
||
background: rgba(0, 200, 83, 0.1); color: var(--success);
|
||
border: 1px solid rgba(0, 200, 83, 0.3);
|
||
}
|
||
.withdraw-result.error {
|
||
background: rgba(255, 82, 82, 0.1); color: #ff5252;
|
||
border: 1px solid rgba(255, 82, 82, 0.3);
|
||
}
|
||
.withdraw-history { margin-top: 16px; text-align: left; }
|
||
.withdraw-history h4 { font-size: 13px; color: var(--text-dim); margin-bottom: 8px; }
|
||
.withdraw-tx {
|
||
font-size: 11px; color: var(--text-dim); padding: 4px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.withdraw-tx .amount { color: #ff5252; font-weight: 600; }
|
||
.withdraw-status {
|
||
font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||
}
|
||
.withdraw-status.pending { background: rgba(255,167,38,0.15); color: var(--warn); }
|
||
.withdraw-status.completed { background: rgba(0,200,83,0.15); color: var(--success); }
|
||
.withdraw-status.rejected { background: rgba(255,82,82,0.15); color: #ff5252; }
|
||
|
||
/* Profile Cabinet Modal */
|
||
.profile-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.92); z-index: 9000;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.profile-overlay.hidden { display: none; }
|
||
.profile-modal {
|
||
background: var(--bg-panel); border-radius: 16px;
|
||
padding: 32px; width: 560px; max-width: 95vw;
|
||
max-height: 90vh; max-height: 90dvh; overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.profile-modal h2 {
|
||
color: var(--text); font-size: 20px; margin-bottom: 8px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.profile-modal .profile-subtitle {
|
||
color: var(--text-dim); font-size: 13px; margin-bottom: 20px;
|
||
}
|
||
.profile-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
.profile-section h3 {
|
||
font-size: 14px; color: var(--accent); margin-bottom: 10px;
|
||
text-transform: uppercase; letter-spacing: 0.5px;
|
||
}
|
||
.profile-field {
|
||
margin-bottom: 12px;
|
||
}
|
||
.profile-field label {
|
||
display: block; font-size: 12px; color: var(--text-dim);
|
||
margin-bottom: 4px; font-weight: 600;
|
||
}
|
||
.profile-field input, .profile-field select, .profile-field textarea {
|
||
width: 100%; padding: 9px 12px;
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text); font-size: 14px;
|
||
font-family: inherit; box-sizing: border-box;
|
||
}
|
||
.profile-field input:focus, .profile-field select:focus, .profile-field textarea:focus {
|
||
border-color: var(--accent); outline: none;
|
||
}
|
||
.profile-field textarea { resize: vertical; min-height: 60px; }
|
||
.profile-field select { cursor: pointer; }
|
||
|
||
.chip-group {
|
||
display: flex; flex-wrap: wrap; gap: 6px;
|
||
}
|
||
.chip {
|
||
padding: 5px 12px; border-radius: 16px; font-size: 12px;
|
||
border: 1px solid var(--border); color: var(--text-dim);
|
||
cursor: pointer; transition: all 0.2s; user-select: none;
|
||
background: transparent;
|
||
}
|
||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||
.chip.active {
|
||
background: rgba(0,122,255,0.15); border-color: var(--accent);
|
||
color: var(--accent); font-weight: 600;
|
||
}
|
||
|
||
.port-suggestions {
|
||
position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||
background: var(--bg-panel); border: 1px solid var(--border-strong);
|
||
border-radius: 0 0 8px 8px; max-height: 200px; overflow-y: auto;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||
}
|
||
.port-suggestions.hidden { display: none; }
|
||
.port-suggestion {
|
||
padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border);
|
||
}
|
||
.port-suggestion:last-child { border-bottom: none; }
|
||
.port-suggestion:hover { background: var(--bg-input); }
|
||
.port-suggestion small { color: var(--text-dim); margin-left: 6px; }
|
||
|
||
.profile-vessels-list {
|
||
display: flex; flex-direction: column; gap: 6px; margin-top: 6px;
|
||
}
|
||
.profile-vessel-row {
|
||
display: flex; gap: 8px; align-items: center;
|
||
}
|
||
.profile-vessel-row input { flex: 1; }
|
||
.profile-vessel-row button {
|
||
background: transparent; border: 1px solid var(--border);
|
||
border-radius: 6px; color: var(--text-dim); cursor: pointer;
|
||
width: 30px; height: 30px; font-size: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.profile-vessel-row button:hover { border-color: #ff5252; color: #ff5252; }
|
||
.add-vessel-btn {
|
||
background: transparent; border: 1px dashed var(--border);
|
||
border-radius: 8px; color: var(--text-dim); cursor: pointer;
|
||
padding: 8px; font-size: 13px; width: 100%; margin-top: 6px;
|
||
}
|
||
.add-vessel-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
.profile-actions {
|
||
display: flex; gap: 12px; margin-top: 24px;
|
||
}
|
||
.profile-save-btn {
|
||
flex: 1; padding: 11px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||
border: none; border-radius: 8px; color: white;
|
||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||
}
|
||
.profile-save-btn:hover { opacity: 0.9; }
|
||
.tg-link-btn {
|
||
padding: 8px 16px; border: 1px solid #0088cc; border-radius: 8px;
|
||
background: transparent; color: #0088cc; cursor: pointer;
|
||
font-size: 13px; font-weight: 600;
|
||
}
|
||
.tg-link-btn:hover { background: #0088cc; color: white; }
|
||
.tg-deep-link {
|
||
display: inline-block; padding: 8px 16px; border-radius: 8px;
|
||
background: #0088cc; color: white !important; text-decoration: none;
|
||
font-size: 13px; font-weight: 600;
|
||
}
|
||
.tg-deep-link:hover { background: #006699; }
|
||
.tg-link-hint { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
||
.tg-linked-badge {
|
||
display: inline-block; padding: 6px 12px; border-radius: 8px;
|
||
background: rgba(0,200,80,0.15); color: #00c850; font-size: 13px; font-weight: 600;
|
||
}
|
||
.tg-unlink-btn {
|
||
margin-left: 8px; padding: 6px 12px; border: 1px solid var(--border);
|
||
border-radius: 6px; background: transparent; color: var(--text-secondary);
|
||
cursor: pointer; font-size: 12px;
|
||
}
|
||
.tg-unlink-btn:hover { border-color: #e74c3c; color: #e74c3c; }
|
||
.profile-cancel-btn {
|
||
flex: 1; padding: 11px;
|
||
background: transparent; border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text-dim);
|
||
font-size: 14px; cursor: pointer;
|
||
}
|
||
.profile-cancel-btn:hover { border-color: var(--text); color: var(--text); }
|
||
|
||
.profile-saved-msg {
|
||
color: var(--success); font-size: 13px; text-align: center;
|
||
margin-top: 12px; display: none;
|
||
}
|
||
|
||
.purchased-contact-card {
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 10px; padding: 12px 14px; margin-bottom: 8px;
|
||
}
|
||
.purchased-contact-card .pcc-company {
|
||
font-weight: 600; font-size: 14px; color: var(--text);
|
||
}
|
||
.purchased-contact-card .pcc-type {
|
||
display: inline-block; font-size: 11px; color: var(--accent);
|
||
background: rgba(0,122,255,0.1); border-radius: 10px;
|
||
padding: 2px 8px; margin-left: 6px; vertical-align: middle;
|
||
}
|
||
.purchased-contact-card .pcc-detail {
|
||
font-size: 12px; color: var(--text-dim); margin-top: 4px;
|
||
}
|
||
.purchased-contact-card .pcc-detail a {
|
||
color: var(--accent); text-decoration: none;
|
||
}
|
||
.purchased-contact-card .pcc-date {
|
||
font-size: 11px; color: var(--text-dim); opacity: 0.6; margin-top: 6px;
|
||
}
|
||
.purchased-empty {
|
||
color: var(--text-dim); font-size: 13px; text-align: center; padding: 16px 0;
|
||
}
|
||
|
||
/* ============ Map View ============ */
|
||
#mapContainer {
|
||
flex: 1; display: none; position: relative; background: var(--bg-chat);
|
||
}
|
||
#mapContainer.active { display: flex; }
|
||
#vesselMap { width: 100%; height: 100%; }
|
||
.main.map-mode .messages,
|
||
.main.map-mode .input-area { display: none; }
|
||
|
||
.map-toggle-btn {
|
||
padding: 5px 11px; border: 1px solid var(--accent); border-radius: 6px;
|
||
background: transparent; color: var(--accent); font-size: 11px;
|
||
cursor: pointer; transition: all 0.2s; font-weight: 500;
|
||
}
|
||
.map-toggle-btn:hover { background: rgba(0, 180, 216, 0.1); }
|
||
.map-toggle-btn.active { background: var(--accent); color: #fff; }
|
||
|
||
.map-stats {
|
||
position: absolute; bottom: 20px; left: 20px;
|
||
background: rgba(17, 29, 53, 0.9); border: 1px solid var(--border-strong);
|
||
border-radius: 8px; padding: 8px 14px; font-size: 12px;
|
||
color: var(--text-dim); z-index: 500; backdrop-filter: blur(8px);
|
||
}
|
||
.map-stats strong { color: var(--accent); }
|
||
|
||
/* Leaflet dark theme overrides */
|
||
.leaflet-popup-content-wrapper {
|
||
background: var(--bg-panel) !important; color: var(--text) !important;
|
||
border: 1px solid var(--border-strong) !important; border-radius: 10px !important;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important;
|
||
}
|
||
.leaflet-popup-tip { background: var(--bg-panel) !important; }
|
||
.leaflet-popup-content {
|
||
font-family: 'Inter', system-ui, sans-serif; font-size: 13px;
|
||
line-height: 1.5; margin: 10px 14px;
|
||
}
|
||
.leaflet-popup-content .vp-title {
|
||
font-weight: 600; font-size: 14px; color: var(--accent); margin-bottom: 6px;
|
||
}
|
||
.leaflet-popup-content .vp-row {
|
||
display: flex; justify-content: space-between; gap: 12px;
|
||
padding: 2px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.leaflet-popup-content .vp-row:last-child { border-bottom: none; }
|
||
.leaflet-popup-content .vp-lbl { color: var(--text-dim); font-size: 11px; }
|
||
.leaflet-popup-content .vp-val { font-weight: 500; }
|
||
.vp-btn {
|
||
padding: 4px 10px; border: 1px solid var(--border-strong); border-radius: 4px;
|
||
background: var(--bg-input); color: var(--text); cursor: pointer;
|
||
font-size: 11px; font-family: inherit; transition: background .15s;
|
||
}
|
||
.vp-btn:hover { background: var(--accent); color: #fff; }
|
||
/* Chat map buttons (rendered from {{SHOWMAP|...}}) */
|
||
.chat-map-btn {
|
||
display: inline-block;
|
||
padding: 5px 14px;
|
||
margin: 4px 2px;
|
||
border: 1px solid rgba(0,180,216,0.4);
|
||
border-radius: 8px;
|
||
background: rgba(0,180,216,0.1);
|
||
color: #00b4d8;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
transition: all .15s;
|
||
}
|
||
.chat-map-btn:hover { background: #00b4d8; color: #fff; }
|
||
@media (max-width: 768px) {
|
||
.chat-map-btn { padding: 4px 10px; font-size: 11px; margin: 3px 2px; }
|
||
.msg-content table { font-size: 11px; }
|
||
.msg-content table th, .msg-content table td { padding: 4px 6px; }
|
||
.revenue-modal { width: 95vw; padding: 16px; }
|
||
.revenue-cards, .revenue-cards.triple { grid-template-columns: 1fr; gap: 8px; }
|
||
.revenue-table { font-size: 11px; display: block; overflow-x: auto; }
|
||
.revenue-table th, .revenue-table td { padding: 6px 8px; white-space: nowrap; }
|
||
}
|
||
.vessel-highlight-tooltip { font-weight: bold; font-size: 13px; }
|
||
|
||
/* Onboarding Wizard */
|
||
.wizard-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(10, 22, 40, 0.95);
|
||
display: flex; align-items: center; justify-content: center;
|
||
z-index: 1100;
|
||
}
|
||
.wizard-overlay.hidden { display: none; }
|
||
.wizard-modal {
|
||
background: var(--bg-panel); border: 1px solid var(--border);
|
||
border-radius: 16px; padding: 32px; width: 440px; max-width: 92vw;
|
||
max-height: 85vh; max-height: 85dvh; overflow-y: auto;
|
||
}
|
||
.wizard-progress {
|
||
display: flex; justify-content: center; gap: 8px; margin-bottom: 24px;
|
||
}
|
||
.wizard-progress .wiz-dot {
|
||
width: 10px; height: 10px; border-radius: 50%;
|
||
background: var(--border); transition: background .2s;
|
||
}
|
||
.wizard-progress .wiz-dot.active { background: var(--accent); }
|
||
.wizard-progress .wiz-dot.done { background: #22c55e; }
|
||
.wiz-title {
|
||
text-align: center; font-size: 18px; font-weight: 600;
|
||
color: var(--accent); margin-bottom: 8px;
|
||
}
|
||
.wiz-subtitle {
|
||
text-align: center; font-size: 13px; color: var(--text-dim);
|
||
margin-bottom: 20px;
|
||
}
|
||
.wiz-role-cards {
|
||
display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px;
|
||
}
|
||
.wiz-role-card {
|
||
border: 2px solid var(--border); border-radius: 12px;
|
||
padding: 16px 18px; cursor: pointer; transition: all .2s;
|
||
display: flex; align-items: center; gap: 14px;
|
||
}
|
||
.wiz-role-card:hover { border-color: var(--accent); background: rgba(0,180,216,0.05); }
|
||
.wiz-role-card.selected { border-color: var(--accent); background: rgba(0,180,216,0.1); }
|
||
.wiz-role-icon { font-size: 28px; flex-shrink: 0; }
|
||
.wiz-role-info h4 { margin: 0 0 4px; font-size: 15px; color: var(--text); }
|
||
.wiz-role-info p { margin: 0; font-size: 12px; color: var(--text-dim); }
|
||
.wiz-chips {
|
||
display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px;
|
||
}
|
||
.wiz-chip {
|
||
padding: 8px 16px; border-radius: 20px; font-size: 13px;
|
||
border: 1px solid var(--border); color: var(--text-dim);
|
||
cursor: pointer; transition: all 0.2s; user-select: none;
|
||
text-transform: capitalize;
|
||
}
|
||
.wiz-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||
.wiz-chip.active {
|
||
background: rgba(0,180,216,0.15); border-color: var(--accent);
|
||
color: var(--accent); font-weight: 600;
|
||
}
|
||
.wiz-field { margin-bottom: 14px; }
|
||
.wiz-field label {
|
||
display: block; font-size: 12px; color: var(--text-dim);
|
||
margin-bottom: 4px;
|
||
}
|
||
.wiz-field input {
|
||
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
|
||
border-radius: 8px; background: var(--bg-input); color: var(--text);
|
||
font-size: 14px; font-family: inherit;
|
||
}
|
||
.wiz-nav {
|
||
display: flex; justify-content: space-between; margin-top: 20px; gap: 10px;
|
||
}
|
||
.wiz-btn {
|
||
padding: 10px 24px; border-radius: 8px; border: 1px solid var(--border);
|
||
background: transparent; color: var(--text-dim); cursor: pointer;
|
||
font-size: 14px; font-family: inherit; transition: all .2s;
|
||
}
|
||
.wiz-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.wiz-btn-primary {
|
||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||
}
|
||
.wiz-btn-primary:hover { background: #0096b7; }
|
||
.wiz-btn-skip {
|
||
background: transparent; border-color: transparent; color: var(--text-dim);
|
||
font-size: 13px;
|
||
}
|
||
.wiz-btn-skip:hover { color: var(--text); }
|
||
|
||
/* Changelog popup */
|
||
.changelog-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||
z-index: 9999; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.changelog-popup {
|
||
background: var(--bg-panel); border: 1px solid var(--border-strong);
|
||
border-radius: 12px; padding: 24px; max-width: 520px; width: 90%;
|
||
max-height: 80vh; max-height: 80dvh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||
position: relative;
|
||
}
|
||
.changelog-popup h2 { margin: 0 0 16px; font-size: 18px; color: #00b4d8; }
|
||
.changelog-popup .cl-ver {
|
||
margin-bottom: 14px; padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.changelog-popup .cl-ver:last-child { border-bottom: none; }
|
||
.changelog-popup .cl-ver h3 {
|
||
font-size: 14px; margin: 0 0 4px; color: #00b4d8;
|
||
}
|
||
.changelog-popup .cl-ver .cl-date {
|
||
font-size: 11px; color: var(--text-dim); margin-bottom: 6px;
|
||
}
|
||
.changelog-popup .cl-ver ul {
|
||
margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.6;
|
||
}
|
||
.changelog-popup .cl-close {
|
||
position: absolute; top: 12px; right: 16px; background: none;
|
||
border: none; color: var(--text-dim); font-size: 20px; cursor: pointer;
|
||
}
|
||
.changelog-popup .cl-close:hover { color: #ff6b6b; }
|
||
|
||
/* Cargo Search Modal */
|
||
.cargo-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.6); z-index: 1001;
|
||
display: flex; align-items: center; justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.cargo-overlay.hidden { display: none; }
|
||
.cargo-modal {
|
||
background: var(--bg-panel); border-radius: 16px;
|
||
padding: 32px; width: 480px; max-width: 95vw;
|
||
max-height: 90vh; max-height: 90dvh; overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.cargo-modal h2 {
|
||
color: var(--text); font-size: 20px; margin: 0 0 6px 0;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.cargo-modal .cargo-subtitle {
|
||
color: var(--text-dim); font-size: 13px; margin-bottom: 20px;
|
||
}
|
||
.cargo-modal .cargo-section h3 {
|
||
color: var(--text-dim); font-size: 11px; text-transform: uppercase;
|
||
letter-spacing: 1px; margin: 16px 0 8px 0;
|
||
}
|
||
.cargo-type-group {
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
}
|
||
.cargo-type-option {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 12px 16px;
|
||
background: rgba(22,34,64,0.5);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.cargo-type-option:hover { border-color: var(--accent); }
|
||
.cargo-type-option.selected {
|
||
background: rgba(0,180,216,0.1);
|
||
border-color: var(--accent);
|
||
}
|
||
.cargo-type-option input[type="radio"] { display: none; }
|
||
.cargo-type-icon { font-size: 24px; min-width: 32px; text-align: center; }
|
||
.cargo-type-label { font-weight: 500; color: var(--text); }
|
||
.cargo-type-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
||
.cargo-field { margin-bottom: 12px; }
|
||
.cargo-field label {
|
||
display: block; font-size: 12px; color: var(--text-dim);
|
||
margin-bottom: 4px;
|
||
}
|
||
.cargo-field input {
|
||
width: 100%; padding: 10px 12px;
|
||
background: rgba(22,34,64,0.5); border: 1px solid var(--border);
|
||
border-radius: 8px; color: var(--text); font-size: 14px;
|
||
outline: none; box-sizing: border-box;
|
||
}
|
||
.cargo-field input:focus { border-color: var(--accent); }
|
||
.cargo-actions {
|
||
display: flex; gap: 12px; margin-top: 24px;
|
||
}
|
||
.cargo-search-btn {
|
||
flex: 1; padding: 11px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||
color: #fff; border: none; border-radius: 10px;
|
||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.cargo-search-btn:hover { opacity: 0.9; }
|
||
.cargo-cancel-btn {
|
||
padding: 11px 20px;
|
||
background: transparent; border: 1px solid var(--border);
|
||
color: var(--text-dim); border-radius: 10px;
|
||
font-size: 14px; cursor: pointer;
|
||
}
|
||
.cargo-cancel-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
|
||
.leaflet-control-zoom {
|
||
margin-top: 36px !important;
|
||
}
|
||
.leaflet-control-zoom a {
|
||
background: var(--bg-panel) !important; color: var(--text) !important;
|
||
border-color: var(--border-strong) !important;
|
||
}
|
||
.leaflet-control-zoom a:hover { background: var(--bg-input) !important; }
|
||
.leaflet-control-attribution {
|
||
background: rgba(17, 29, 53, 0.7) !important;
|
||
color: var(--text-dim) !important; font-size: 10px !important;
|
||
}
|
||
.leaflet-control-attribution a { color: var(--accent) !important; }
|
||
|
||
.vessel-tooltip {
|
||
background: var(--bg-panel) !important; color: var(--text) !important;
|
||
border: 1px solid var(--border-strong) !important; border-radius: 4px !important;
|
||
font-family: 'Inter', system-ui, sans-serif !important;
|
||
font-size: 11px !important; padding: 3px 8px !important;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.4) !important;
|
||
}
|
||
.vessel-tooltip::before { border-top-color: var(--border-strong) !important; }
|
||
.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; font-family: 'Inter', sans-serif;
|
||
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; }
|
||
|
||
.map-filter-bar {
|
||
position: absolute; top: 8px; left: 42px; right: 8px; z-index: 1001;
|
||
display: flex; gap: 5px; overflow-x: auto; padding: 4px 0;
|
||
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||
}
|
||
.map-filter-bar::-webkit-scrollbar { display: none; }
|
||
.map-filter-chip {
|
||
white-space: nowrap; padding: 4px 10px; border-radius: 12px; font-size: 11px;
|
||
font-weight: 600; cursor: pointer; flex-shrink: 0; border: 1.5px solid;
|
||
transition: opacity 0.2s; user-select: none;
|
||
background-clip: padding-box;
|
||
}
|
||
.map-filter-chip.active { opacity: 1; }
|
||
.map-filter-chip.inactive { opacity: 0.3; }
|
||
.map-filter-chip:hover { opacity: 0.85; }
|
||
@media (max-width: 768px) {
|
||
.map-filter-bar { left: 8px; top: 6px; gap: 4px; }
|
||
.map-filter-chip { padding: 3px 8px; font-size: 10px; border-radius: 10px; }
|
||
}
|
||
|
||
/* ============ Responsive ============ */
|
||
|
||
/* Tablet (≤ 1024px) */
|
||
@media (max-width: 1024px) {
|
||
.sidebar { width: 230px; }
|
||
.chat-header { padding: 10px 14px; }
|
||
.chat-header-left h2 { font-size: 13px; }
|
||
.messages { padding: 16px 14px 90px; gap: 18px; }
|
||
.input-area { padding: 0 14px 14px; margin-top: -70px; padding-top: 70px; }
|
||
}
|
||
|
||
/* Menu toggle button (hidden on desktop) */
|
||
.menu-toggle {
|
||
display: none;
|
||
width: 40px; height: 40px;
|
||
background: var(--accent);
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
font-size: 22px;
|
||
cursor: pointer;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 2px 8px rgba(0,122,255,0.3);
|
||
}
|
||
.menu-toggle:hover { background: #0066d6; }
|
||
|
||
/* Mobile (≤ 768px) */
|
||
@media (max-width: 768px) {
|
||
body { flex-direction: column; }
|
||
.map-toggle-btn { padding: 4px 9px; font-size: 10px; }
|
||
.map-stats { bottom: 10px; left: 10px; font-size: 11px; padding: 6px 10px; }
|
||
|
||
/* Sidebar → slide-in drawer from left */
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0; left: 0; bottom: 0;
|
||
width: 300px;
|
||
max-width: 85vw;
|
||
z-index: 900;
|
||
overflow-y: auto;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.3s ease;
|
||
box-shadow: none;
|
||
will-change: transform;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.sidebar.open {
|
||
transform: translateX(0);
|
||
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.main { width: 100%; height: 100dvh; height: var(--app-height, 100vh); }
|
||
|
||
/* Header: stack actions below title */
|
||
.chat-header {
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
}
|
||
.chat-header-left h2 { font-size: 14px; }
|
||
.chat-header-left span { display: none; }
|
||
|
||
.header-actions {
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.header-btn { padding: 5px 10px; font-size: 11px; }
|
||
.lang-btn { padding: 3px 8px; font-size: 11px; }
|
||
|
||
/* User info compact */
|
||
.user-info { font-size: 11px; gap: 4px; flex-wrap: wrap; }
|
||
.balance-badge { font-size: 10px; padding: 4px 8px; min-height: 28px; }
|
||
.admin-badge { min-height: 28px; }
|
||
|
||
/* Messages */
|
||
.messages { padding: 16px 10px 80px; gap: 16px; overscroll-behavior-y: contain; }
|
||
.message { max-width: 92%; }
|
||
.msg-content { padding: 12px 14px; font-size: 14px; border-radius: 16px; }
|
||
.msg-avatar { width: 26px; height: 26px; font-size: 12px; }
|
||
|
||
/* Input area */
|
||
.input-area { padding: 0 10px 12px; margin-top: -60px; padding-top: 60px; }
|
||
.input-glass {
|
||
border-radius: 20px;
|
||
padding: 4px 4px 4px 14px;
|
||
}
|
||
.input-wrapper textarea {
|
||
font-size: 14px;
|
||
min-height: 38px;
|
||
}
|
||
.send-btn { width: 36px; height: 36px; font-size: 14px; border-radius: 10px; }
|
||
.voice-btn { width: 36px; height: 36px; font-size: 16px; border-radius: 10px; }
|
||
.input-hint { display: none; }
|
||
|
||
/* Quick actions scroll horizontally */
|
||
.quick-actions {
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
margin-bottom: 6px;
|
||
gap: 5px;
|
||
justify-content: flex-start;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.quick-actions::-webkit-scrollbar { display: none; }
|
||
.quick-btn { font-size: 10.5px; padding: 4px 10px; flex-shrink: 0; }
|
||
|
||
/* Prevent iOS auto-zoom on focus */
|
||
input, textarea, select { font-size: 16px !important; }
|
||
|
||
/* Auth login page — mobile */
|
||
.auth-modal { padding: 28px 20px; width: calc(100vw - 32px); max-width: 100%; }
|
||
.auth-brand-logo { font-size: 32px; }
|
||
.auth-brand-name { font-size: 18px; }
|
||
.auth-brand { margin-bottom: 24px; }
|
||
.wizard-modal { padding: 28px 16px; width: 95vw; }
|
||
.wizard-modal .wiz-role-cards { flex-direction: column; }
|
||
.wizard-modal .wiz-role-card { padding: 14px; }
|
||
.profile-modal { padding: 24px 16px; width: 95vw; }
|
||
.deposit-modal { padding: 24px 16px; width: 95vw; }
|
||
.cargo-modal { padding: 24px 16px; }
|
||
.chip { font-size: 11px; padding: 4px 10px; min-height: 30px; display: inline-flex; align-items: center; }
|
||
|
||
/* Sidebar filters — larger on mobile */
|
||
.sf-select { font-size: 14px !important; padding: 8px 10px; }
|
||
.sf-input { font-size: 14px !important; padding: 8px 10px; }
|
||
.sf-chip { padding: 6px 12px; font-size: 12px; min-height: 32px; }
|
||
.sf-label { font-size: 10px; }
|
||
.sf-body { gap: 8px; }
|
||
|
||
/* Menu toggle button visible */
|
||
.menu-toggle { display: flex; }
|
||
}
|
||
|
||
/* Small phones (≤ 480px) */
|
||
@media (max-width: 480px) {
|
||
.auth-modal { padding: 24px 18px; border-radius: 16px; }
|
||
.auth-brand-logo { font-size: 28px; }
|
||
.auth-brand-name { font-size: 16px; }
|
||
.auth-brand-sub { font-size: 10px; }
|
||
.auth-field input { font-size: 16px !important; padding: 12px 14px; }
|
||
.auth-submit { padding: 13px; font-size: 14px; }
|
||
.chat-header-left h2 { font-size: 13px; }
|
||
.header-actions { gap: 4px; }
|
||
.header-btn { padding: 6px 10px; font-size: 11px; min-height: 32px; }
|
||
.lang-btn { padding: 4px 8px; font-size: 11px; min-height: 32px; }
|
||
.message { max-width: 96%; }
|
||
.msg-content { font-size: 13px; padding: 10px 12px; }
|
||
.msg-avatar { width: 22px; height: 22px; font-size: 10px; }
|
||
}
|
||
|
||
/* Sidebar backdrop overlay */
|
||
.sidebar-backdrop {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 899;
|
||
}
|
||
.sidebar-backdrop.open { display: block; }
|
||
|
||
/* Sidebar close button (only on mobile) */
|
||
.sidebar-close {
|
||
display: none;
|
||
position: absolute;
|
||
top: 16px; right: 16px;
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text-dim);
|
||
font-size: 18px;
|
||
width: 32px; height: 32px;
|
||
cursor: pointer;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.sidebar-close:hover { color: var(--text); border-color: var(--accent); }
|
||
@media (max-width: 768px) {
|
||
.sidebar-close { display: flex; }
|
||
}
|
||
</style>
|
||
<script>
|
||
/* Fix 100vh for old mobile browsers that don't support dvh */
|
||
function setVH() {
|
||
var vh = window.innerHeight;
|
||
document.documentElement.style.setProperty('--app-height', vh + 'px');
|
||
}
|
||
setVH();
|
||
window.addEventListener('resize', setVH);
|
||
window.addEventListener('orientationchange', function() {
|
||
setTimeout(setVH, 200);
|
||
});
|
||
</script>
|
||
</head>
|
||
<body class="auth-mode">
|
||
|
||
<!-- Auth Modal -->
|
||
<div class="auth-overlay" id="authOverlay">
|
||
<div class="auth-modal" role="dialog" aria-modal="true" aria-label="Authentication">
|
||
<div class="auth-brand">
|
||
<div class="auth-brand-logo">Ɉ</div>
|
||
<div class="auth-brand-name">SeaFare Montana</div>
|
||
</div>
|
||
<div class="auth-logo" style="display:none">
|
||
<div class="logo-icon">⚓</div>
|
||
<h2>SeaFare Montana</h2>
|
||
</div>
|
||
<!-- Language selector -->
|
||
<div class="auth-lang-selector" id="authLangSelector">
|
||
<button class="auth-lang-btn active" data-lang="en" onclick="setAuthLang('en')">🇬🇧 EN</button>
|
||
<button class="auth-lang-btn" data-lang="zh" onclick="setAuthLang('zh')">🇨🇳 中文</button>
|
||
<button class="auth-lang-btn" data-lang="es" onclick="setAuthLang('es')">🇪🇸 ES</button>
|
||
<button class="auth-lang-btn" data-lang="ru" onclick="setAuthLang('ru')">🇷🇺 RU</button>
|
||
</div>
|
||
<h3 id="authTitle"></h3>
|
||
<!-- Registration step indicator (hidden for login) -->
|
||
<div class="reg-steps" id="regSteps" style="display:none;">
|
||
<div class="reg-step-dot" id="regDot1"></div>
|
||
<div class="reg-step-dot" id="regDot2"></div>
|
||
<div class="reg-step-dot" id="regDot3"></div>
|
||
</div>
|
||
<div id="authError" class="auth-error" style="display:none;"></div>
|
||
<!-- Login form -->
|
||
<form id="authFormLogin" onsubmit="handleLogin(event)">
|
||
<div class="auth-field">
|
||
<label for="authEmail" class="sr-only">Email</label>
|
||
<input type="email" id="authEmail" required autocomplete="email" placeholder="Email">
|
||
</div>
|
||
<div class="auth-field">
|
||
<label for="authPassword" class="sr-only">Password</label>
|
||
<input type="password" id="authPassword" required minlength="6" autocomplete="current-password" placeholder="Password">
|
||
</div>
|
||
<button type="submit" class="auth-submit" id="authLoginBtn">Log In</button>
|
||
</form>
|
||
<!-- Register Step 1: Email -->
|
||
<form id="regStep1" onsubmit="handleRegStep1(event)" style="display:none;">
|
||
<div class="auth-field">
|
||
<label for="regEmail" class="sr-only">Email</label>
|
||
<input type="email" id="regEmail" required autocomplete="email" placeholder="Email">
|
||
</div>
|
||
<button type="submit" class="auth-submit" id="regSendCodeBtn">Send Code</button>
|
||
</form>
|
||
<!-- Register Step 2: Verify code -->
|
||
<div id="regStep2" style="display:none;">
|
||
<div class="auth-code-input" id="codeInputWrap"></div>
|
||
<div class="auth-code-timer" id="codeTimer"></div>
|
||
<button class="auth-submit" id="regVerifyBtn" onclick="handleRegStep2()">Verify</button>
|
||
<div class="auth-resend">
|
||
<a href="#" id="resendLink" onclick="resendCode(event)"></a>
|
||
</div>
|
||
</div>
|
||
<!-- Register Step 3: Name + Password -->
|
||
<form id="regStep3" onsubmit="handleRegStep3(event)" style="display:none;">
|
||
<div class="auth-field">
|
||
<label for="regName" class="sr-only">Name</label>
|
||
<input type="text" id="regName" autocomplete="name" placeholder="Name">
|
||
</div>
|
||
<div class="auth-field">
|
||
<label for="regPassword" class="sr-only">Password</label>
|
||
<input type="password" id="regPassword" required minlength="6" autocomplete="new-password" placeholder="Password">
|
||
</div>
|
||
<button type="submit" class="auth-submit" id="regCreateBtn">Create Account</button>
|
||
</form>
|
||
<div class="auth-divider" id="authDivider"><span>or</span></div>
|
||
<div class="google-btn-wrap" id="googleBtnWrap">
|
||
<div id="googleSignInBtn"></div>
|
||
<button type="button" id="googleFallbackBtn" onclick="triggerGoogleFallback()" style="display:flex;align-items:center;justify-content:center;width:100%;max-width:300px;margin:0 auto;padding:10px 16px;border-radius:8px;background:#fff;color:#3c4043;font-size:14px;font-weight:500;font-family:Inter,sans-serif;border:1px solid #dadce0;cursor:pointer;">
|
||
<svg width="18" height="18" viewBox="0 0 48 48" style="margin-right:8px;"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
|
||
Sign in with Google
|
||
</button>
|
||
</div>
|
||
<div class="auth-switch">
|
||
<a href="#" id="authSwitchLink" onclick="toggleAuthMode(event)">Don't have an account? Register</a>
|
||
</div>
|
||
<!-- auth-close removed: mandatory login, no guest access -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Onboarding Wizard Modal -->
|
||
<div class="wizard-overlay hidden" id="wizardOverlay">
|
||
<div class="wizard-modal">
|
||
<div class="wizard-progress" id="wizardProgress"></div>
|
||
<div id="wizardContent"></div>
|
||
<div class="wiz-nav" id="wizardNav"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Profile Cabinet Modal -->
|
||
<!-- Deposit Modal -->
|
||
<div class="deposit-overlay hidden" id="depositOverlay">
|
||
<div class="deposit-modal" role="dialog" aria-modal="true" aria-label="Wallet">
|
||
<h2>💰 <span data-i18n="topup_title">Top Up Balance</span></h2>
|
||
|
||
<!-- Tabs -->
|
||
<div class="wallet-tabs">
|
||
<button class="wallet-tab active" onclick="switchWalletTab('deposit')" id="walletTabDeposit" data-i18n="wallet_tab_deposit">Deposit</button>
|
||
<button class="wallet-tab" onclick="switchWalletTab('withdraw')" id="walletTabWithdraw" data-i18n="wallet_tab_withdraw">Withdraw</button>
|
||
</div>
|
||
|
||
<!-- Deposit Tab -->
|
||
<div class="wallet-tab-content active" id="walletContentDeposit">
|
||
<div class="deposit-network">USDT — TRC20 (Tron)</div>
|
||
<p style="font-size:13px; color:var(--text-dim); margin-bottom:8px;" data-i18n="topup_instruction">
|
||
Send USDT (TRC20) to the address below. Your balance will be credited after confirmation.
|
||
</p>
|
||
<div class="fee-info" id="depositFeeInfo" style="font-size:12px; color:var(--accent); background:rgba(0,200,200,0.06); border-radius:10px; padding:8px 12px; margin-bottom:12px;">
|
||
<span data-i18n="deposit_fee_info">Service fee: 2%. Example: send $100 — receive $98.</span>
|
||
</div>
|
||
<div id="depositQR" class="deposit-qr" style="display:none;"></div>
|
||
<div id="depositAddressWrap" class="deposit-address-wrap" style="display:none;">
|
||
<span id="depositAddress" class="deposit-address"></span>
|
||
<button class="deposit-copy-btn" onclick="copyDepositAddress()" data-i18n="topup_copy">Copy</button>
|
||
</div>
|
||
<div id="depositLoading" style="padding:20px; color:var(--text-dim);">Loading...</div>
|
||
<button class="deposit-check-btn" id="depositCheckBtn" onclick="checkDeposits()" data-i18n="topup_check">
|
||
Check for deposits
|
||
</button>
|
||
<div id="depositResult" class="deposit-result" style="display:none;"></div>
|
||
<div id="depositHistory" class="deposit-history" style="display:none;"></div>
|
||
</div>
|
||
|
||
<!-- Withdraw Tab -->
|
||
<div class="wallet-tab-content" id="walletContentWithdraw">
|
||
<div class="deposit-network">USDT — TRC20 (Tron)</div>
|
||
<div class="withdraw-balance-info">
|
||
<span data-i18n="withdraw_your_balance">Your balance:</span>
|
||
<strong id="withdrawBalanceDisplay">$0.00</strong> USDT
|
||
</div>
|
||
<div class="withdraw-form">
|
||
<div class="withdraw-field">
|
||
<label data-i18n="withdraw_address_label">TRC20 Wallet Address</label>
|
||
<input type="text" id="withdrawAddress" placeholder="T..." maxlength="34">
|
||
</div>
|
||
<div class="withdraw-field">
|
||
<label data-i18n="withdraw_amount_label">Amount (USDT)</label>
|
||
<input type="number" id="withdrawAmount" placeholder="0.00" min="2" step="0.01" oninput="updateWithdrawTotal()">
|
||
</div>
|
||
<div class="withdraw-fee-breakdown" id="withdrawFeeBreakdown" style="font-size:12px; color:var(--text-dim); background:rgba(0,200,200,0.06); border-radius:10px; padding:8px 12px; margin-bottom:10px;">
|
||
<div><span data-i18n="withdraw_fee_label">Network fee:</span> <strong>1.00 USDT</strong></div>
|
||
<div id="withdrawTotalLine" style="display:none;"><span data-i18n="withdraw_total_label">Total deducted:</span> <strong id="withdrawTotalDisplay">—</strong></div>
|
||
</div>
|
||
<p style="font-size:11px; color:var(--text-dim); margin-bottom:14px;" data-i18n="withdraw_note">
|
||
Minimum withdrawal: $2 USDT. Withdrawals are processed manually within 24 hours.
|
||
</p>
|
||
<button class="withdraw-submit-btn" id="withdrawSubmitBtn" onclick="submitWithdrawal()" data-i18n="withdraw_submit">
|
||
Request Withdrawal
|
||
</button>
|
||
<div id="withdrawResult" class="withdraw-result" style="display:none;"></div>
|
||
</div>
|
||
<div id="withdrawHistory" class="withdraw-history" style="display:none;"></div>
|
||
</div>
|
||
|
||
<span class="deposit-close" onclick="hideDeposit()" data-i18n="topup_close">Close</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-overlay hidden" id="profileOverlay">
|
||
<div class="profile-modal" role="dialog" aria-modal="true" aria-label="Profile">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||
<h2 style="margin:0">💼 <span data-i18n="profile_title">My Cabinet</span></h2>
|
||
<button onclick="hideProfile()" style="background:none;border:none;color:var(--hint);font-size:24px;cursor:pointer;padding:4px 8px;line-height:1" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="profile-subtitle" data-i18n="profile_subtitle">Fill out your profile so SeaFare Montana can give you more accurate, personalized answers.</div>
|
||
|
||
<div class="profile-section" id="purchasedContactsSection" style="display:none">
|
||
<h3 data-i18n="purchased_contacts_title">Purchased Contacts</h3>
|
||
<div id="purchasedContactsList"></div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_company_section">Company</h3>
|
||
<div class="profile-field">
|
||
<label data-i18n="profile_company">Company Name</label>
|
||
<input type="text" id="profCompany" placeholder="e.g. Pacific Shipping Ltd." data-i18n-placeholder="profile_company_placeholder">
|
||
</div>
|
||
<div class="profile-field">
|
||
<label data-i18n="profile_role">Your Role</label>
|
||
<select id="profRole">
|
||
<option value="" data-i18n="profile_role_select">— Select —</option>
|
||
<option value="shipowner">Shipowner</option>
|
||
<option value="operator">Operator</option>
|
||
<option value="charterer">Charterer</option>
|
||
<option value="broker">Broker</option>
|
||
<option value="freight_forwarder">Freight Forwarder</option>
|
||
<option value="port_agent">Port Agent</option>
|
||
<option value="surveyor">Surveyor</option>
|
||
<option value="other">Other</option>
|
||
</select>
|
||
</div>
|
||
<div class="profile-field">
|
||
<label data-i18n="profile_experience">Experience (years)</label>
|
||
<input type="number" id="profExperience" min="0" max="60" placeholder="0">
|
||
</div>
|
||
<div class="profile-field">
|
||
<label data-i18n="profile_fleet_size">Fleet Size (vessels)</label>
|
||
<input type="number" id="profFleetSize" min="0" placeholder="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_home_port_title">Home Port</h3>
|
||
<div class="profile-field" style="position:relative">
|
||
<input type="text" id="profHomePort" placeholder="Start typing port name..." autocomplete="off" data-i18n-placeholder="profile_home_port_placeholder">
|
||
<div class="port-suggestions hidden" id="profHomePortSuggestions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_vessel_types_title">Vessel Types</h3>
|
||
<div class="chip-group" id="profVesselTypes">
|
||
<span class="chip" data-val="bulk_carrier">Bulk Carrier</span>
|
||
<span class="chip" data-val="tanker">Tanker</span>
|
||
<span class="chip" data-val="container">Container</span>
|
||
<span class="chip" data-val="general_cargo">General Cargo</span>
|
||
<span class="chip" data-val="ro_ro">Ro-Ro</span>
|
||
<span class="chip" data-val="lng_carrier">LNG Carrier</span>
|
||
<span class="chip" data-val="chemical_tanker">Chemical Tanker</span>
|
||
<span class="chip" data-val="offshore">Offshore</span>
|
||
<span class="chip" data-val="tug">Tug</span>
|
||
<span class="chip" data-val="barge">Barge</span>
|
||
<span class="chip" data-val="passenger">Passenger</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_trade_routes_title">Trade Routes</h3>
|
||
<div class="chip-group" id="profTradeRoutes">
|
||
<span class="chip" data-val="Mediterranean">Mediterranean</span>
|
||
<span class="chip" data-val="Baltic">Baltic</span>
|
||
<span class="chip" data-val="Caspian">Caspian</span>
|
||
<span class="chip" data-val="Black Sea">Black Sea</span>
|
||
<span class="chip" data-val="North Sea">North Sea</span>
|
||
<span class="chip" data-val="Atlantic">Atlantic</span>
|
||
<span class="chip" data-val="Pacific">Pacific</span>
|
||
<span class="chip" data-val="Indian Ocean">Indian Ocean</span>
|
||
<span class="chip" data-val="Southeast Asia">Southeast Asia</span>
|
||
<span class="chip" data-val="Middle East / Persian Gulf">Middle East / Gulf</span>
|
||
<span class="chip" data-val="West Africa">West Africa</span>
|
||
<span class="chip" data-val="East Africa">East Africa</span>
|
||
<span class="chip" data-val="South America">South America</span>
|
||
<span class="chip" data-val="Caribbean">Caribbean</span>
|
||
<span class="chip" data-val="North America East Coast">N. America East</span>
|
||
<span class="chip" data-val="North America West Coast">N. America West</span>
|
||
<span class="chip" data-val="Australia / Oceania">Australia / Oceania</span>
|
||
<span class="chip" data-val="Arctic">Arctic</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_cargo_types_title">Cargo Types</h3>
|
||
<div class="chip-group" id="profCargoTypes">
|
||
<span class="chip" data-val="dry_bulk">Dry Bulk</span>
|
||
<span class="chip" data-val="liquid_bulk">Liquid Bulk</span>
|
||
<span class="chip" data-val="containerized">Containerized</span>
|
||
<span class="chip" data-val="breakbulk">Breakbulk</span>
|
||
<span class="chip" data-val="project_cargo">Project Cargo</span>
|
||
<span class="chip" data-val="reefer">Reefer</span>
|
||
<span class="chip" data-val="lng">LNG</span>
|
||
<span class="chip" data-val="lpg">LPG</span>
|
||
<span class="chip" data-val="chemicals">Chemicals</span>
|
||
<span class="chip" data-val="crude_oil">Crude Oil</span>
|
||
<span class="chip" data-val="refined_products">Refined Products</span>
|
||
<span class="chip" data-val="grain">Grain</span>
|
||
<span class="chip" data-val="coal">Coal</span>
|
||
<span class="chip" data-val="iron_ore">Iron Ore</span>
|
||
<span class="chip" data-val="fertilizer">Fertilizer</span>
|
||
<span class="chip" data-val="timber">Timber</span>
|
||
<span class="chip" data-val="cement">Cement</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_vessels_title">Vessels of Interest</h3>
|
||
<div class="profile-subtitle" data-i18n="profile_vessels_hint">Add vessel names or IMO numbers you want to track</div>
|
||
<div class="profile-vessels-list" id="profVesselsList"></div>
|
||
<button class="add-vessel-btn" onclick="addVesselRow()">+ <span data-i18n="profile_add_vessel">Add vessel</span></button>
|
||
</div>
|
||
|
||
<div class="profile-section">
|
||
<h3 data-i18n="profile_contact_section">Contact</h3>
|
||
<div class="profile-field">
|
||
<label data-i18n="profile_phone">Phone</label>
|
||
<input type="tel" id="profPhone" placeholder="+1 555 123 4567" data-i18n-placeholder="profile_phone_placeholder">
|
||
</div>
|
||
</div>
|
||
<div id="tgLinked" style="display:none"></div>
|
||
</div>
|
||
<div style="margin-top:10px">
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-actions" style="display:none">
|
||
<button class="profile-cancel-btn" onclick="hideProfile()" data-i18n="profile_cancel">Cancel</button>
|
||
<button class="profile-save-btn" onclick="saveProfile()" data-i18n="profile_save">Save Profile</button>
|
||
</div>
|
||
<textarea id="profNotes" style="display:none"></textarea>
|
||
<div class="profile-saved-msg" id="profileSavedMsg" data-i18n="profile_saved">Profile saved successfully!</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subscription Modal -->
|
||
<div class="sub-overlay hidden" id="subOverlay">
|
||
<div class="sub-modal" role="dialog" aria-modal="true" aria-label="Subscription Plans">
|
||
<h2>⭐ <span data-i18n="sub_title">Subscription Plans</span></h2>
|
||
<div class="sub-subtitle" data-i18n="sub_subtitle">Choose the plan that fits your needs. Upgrade anytime from your balance.</div>
|
||
<div class="sub-plans" id="subPlans"></div>
|
||
<div id="subResult" class="sub-result" style="display:none;"></div>
|
||
<span class="sub-close" onclick="hideSub()" data-i18n="sub_close">Close</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cargo Search Modal -->
|
||
<div class="cargo-overlay hidden" id="cargoOverlay">
|
||
<div class="cargo-modal" role="dialog" aria-modal="true" aria-label="Cargo Search">
|
||
<h2>🚢 <span data-i18n="cargo_form_title">Find Vessels for Cargo</span></h2>
|
||
<div class="cargo-subtitle" data-i18n="cargo_form_subtitle">Select cargo type and loading port to find available vessels</div>
|
||
|
||
<div class="cargo-section">
|
||
<h3 data-i18n="cargo_form_type">Cargo Type</h3>
|
||
<div class="cargo-type-group" id="cargoTypeGroup">
|
||
<label class="cargo-type-option" data-cargo="dry">
|
||
<input type="radio" name="cargoType" value="dry">
|
||
<div class="cargo-type-icon">⚓</div>
|
||
<div>
|
||
<div class="cargo-type-label" data-i18n="cargo_type_dry">Dry Cargo</div>
|
||
<div class="cargo-type-desc" data-i18n="cargo_type_dry_desc">Grain, coal, ore, cement, sugar, fertilizer</div>
|
||
</div>
|
||
</label>
|
||
<label class="cargo-type-option" data-cargo="container">
|
||
<input type="radio" name="cargoType" value="container">
|
||
<div class="cargo-type-icon">📦</div>
|
||
<div>
|
||
<div class="cargo-type-label" data-i18n="cargo_type_container">Containers</div>
|
||
<div class="cargo-type-desc" data-i18n="cargo_type_container_desc">TEU, electronics, machinery, furniture</div>
|
||
</div>
|
||
</label>
|
||
<label class="cargo-type-option" data-cargo="liquid">
|
||
<input type="radio" name="cargoType" value="liquid">
|
||
<div class="cargo-type-icon">💧</div>
|
||
<div>
|
||
<div class="cargo-type-label" data-i18n="cargo_type_liquid">Liquid Cargo</div>
|
||
<div class="cargo-type-desc" data-i18n="cargo_type_liquid_desc">Crude oil, chemicals, LNG, palm oil</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cargo-section">
|
||
<h3 data-i18n="cargo_form_ports">Port Details</h3>
|
||
<div class="cargo-field">
|
||
<label data-i18n="cargo_form_from_port">Loading Port *</label>
|
||
<input type="text" id="cargoFromPort" data-i18n-placeholder="cargo_form_from_port_ph" placeholder="e.g. Santos, Rotterdam, Singapore">
|
||
</div>
|
||
<div class="cargo-field">
|
||
<label data-i18n="cargo_form_to_port">Destination Port (optional)</label>
|
||
<input type="text" id="cargoToPort" data-i18n-placeholder="cargo_form_to_port_ph" placeholder="e.g. Rotterdam, Qingdao, Houston">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cargo-section">
|
||
<h3 data-i18n="cargo_form_details">Details (optional)</h3>
|
||
<div class="cargo-field">
|
||
<label data-i18n="cargo_form_tonnage">Tonnage (MT) or TEU</label>
|
||
<input type="number" id="cargoTonnage" min="0" placeholder="e.g. 50000">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cargo-actions">
|
||
<button class="cargo-cancel-btn" onclick="hideCargoForm()" data-i18n="cargo_form_cancel">Cancel</button>
|
||
<button class="cargo-search-btn" id="cargoSearchBtn" onclick="submitCargoForm()" data-i18n="cargo_form_search">Find Vessels</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API Costs Modal (admin only) -->
|
||
<div class="revenue-overlay hidden" id="costsOverlay">
|
||
<div class="revenue-modal" role="dialog" aria-modal="true" aria-label="API Costs" style="position:relative;">
|
||
<button class="revenue-close" onclick="hideCosts()">×</button>
|
||
<h2>⚙ AI API Costs</h2>
|
||
<div id="costsContent">
|
||
<div style="text-align:center; padding:40px; color:var(--text-dim);">Loading...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Revenue Modal (admin only) -->
|
||
<div class="revenue-overlay hidden" id="revenueOverlay">
|
||
<div class="revenue-modal" role="dialog" aria-modal="true" aria-label="Revenue" style="position:relative;">
|
||
<button class="revenue-close" onclick="hideRevenue()">×</button>
|
||
<h2>📈 Platform Revenue</h2>
|
||
<div id="revenueContent">
|
||
<div style="text-align:center; padding:40px; color:var(--text-dim);">Loading...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="toggleSidebar()"></div>
|
||
<aside class="sidebar" id="sidebar">
|
||
<button class="sidebar-close" onclick="toggleSidebar()" aria-label="Close menu">×</button>
|
||
<div class="sidebar-header">
|
||
<div class="logo">
|
||
<div class="logo-icon">⚓</div>
|
||
<div class="logo-text">
|
||
<h1>SeaFare Montana</h1>
|
||
|
||
</div>
|
||
</div>
|
||
<div class="status-badge">
|
||
<span class="status-dot"></span>
|
||
<span data-i18n="status">Online — Ready</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="services">
|
||
<h3 data-i18n="services_title">Services</h3>
|
||
|
||
<!-- TRACKING -->
|
||
<div class="service-group">
|
||
<div class="service-group-title" data-i18n="svc_group_tracking">Tracking</div>
|
||
<div class="service-item" data-quick="search_vessel">
|
||
<div class="service-icon">🚢</div>
|
||
<div class="service-info">
|
||
<div class="name" data-i18n="svc_search">Vessel Search</div>
|
||
<div class="price" data-i18n="svc_search_desc">Name, IMO, MMSI lookup</div>
|
||
</div>
|
||
<span class="price-tag price-free" data-i18n="free">Free</span>
|
||
</div>
|
||
<div class="service-item" data-quick="vessel_position">
|
||
<div class="service-icon">📍</div>
|
||
<div class="service-info">
|
||
<div class="name" data-i18n="svc_position">Vessel Position</div>
|
||
<div class="price" data-i18n="svc_position_desc">AIS real-time tracking</div>
|
||
</div>
|
||
<span class="price-tag price-free" data-i18n="free">Free</span>
|
||
</div>
|
||
<div class="service-item" data-quick="vessels_port">
|
||
<div class="service-icon">⚓</div>
|
||
<div class="service-info">
|
||
<div class="name" data-i18n="svc_port_vessels">Vessels Near Port</div>
|
||
<div class="price" data-i18n="svc_port_vessels_desc">Live ships at 16,000+ ports</div>
|
||
</div>
|
||
<span class="price-tag price-free" data-i18n="free">Free</span>
|
||
</div>
|
||
<div class="service-item" data-quick="owner_info">
|
||
<div class="service-icon">🏢</div>
|
||
<div class="service-info">
|
||
<div class="name" data-i18n="svc_owner">Owner / Operator</div>
|
||
<div class="price" data-i18n="svc_owner_desc">Company information</div>
|
||
</div>
|
||
<span class="price-tag price-free" data-i18n="free">Free</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="service-group-divider"></div>
|
||
|
||
<!-- COMMERCIAL -->
|
||
<div class="service-group">
|
||
<div class="service-group-title" data-i18n="svc_group_commercial">Commercial</div>
|
||
<div class="service-item" data-quick="route_calc">
|
||
<div class="service-icon">🗺</div>
|
||
<div class="service-info">
|
||
<div class="name" data-i18n="svc_route">Route Calculator</div>
|
||
<div class="price" data-i18n="svc_route_desc">Distance, time, cost range</div>
|
||
</div>
|
||
<span class="price-tag price-free" data-i18n="free">Free</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sidebar Quick Filters (logged-in only) -->
|
||
<div class="sidebar-filters" id="sidebarFilters">
|
||
<div class="sidebar-filters-header">
|
||
<div style="display:flex;align-items:center">
|
||
<h3 data-i18n="sf_title" onclick="document.getElementById('sidebarFilters').classList.toggle('collapsed')" style="cursor:pointer">My Filters</h3>
|
||
<span class="sf-hint-btn" onclick="event.stopPropagation();document.getElementById('sfHint').classList.toggle('visible')" title="Info">ⓘ</span>
|
||
</div>
|
||
<span class="sf-arrow" onclick="document.getElementById('sidebarFilters').classList.toggle('collapsed')" style="cursor:pointer">▼</span>
|
||
</div>
|
||
<div class="sf-body" id="sfBody">
|
||
<div class="sf-hint" id="sfHint" data-i18n="sf_hint_text">Select your preferences — AI agent will use them automatically in responses. Changes save instantly.</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_role">Role</div>
|
||
<select class="sf-select" id="sfRole" onchange="sidebarFilterChanged()">
|
||
<option value="">—</option>
|
||
<option value="shipowner" data-i18n="role_shipowner">Shipowner</option>
|
||
<option value="operator" data-i18n="role_operator">Operator</option>
|
||
<option value="charterer" data-i18n="role_charterer">Charterer</option>
|
||
<option value="broker" data-i18n="role_broker">Broker</option>
|
||
<option value="freight_forwarder" data-i18n="role_freight_forwarder">Freight Forwarder</option>
|
||
<option value="port_agent" data-i18n="role_port_agent">Port Agent</option>
|
||
<option value="surveyor" data-i18n="role_surveyor">Surveyor</option>
|
||
<option value="other" data-i18n="role_other">Other</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_vessel_types">Vessel Types</div>
|
||
<div class="sf-chips" id="sfVesselTypes">
|
||
<span class="sf-chip" data-val="bulk_carrier">Bulk</span>
|
||
<span class="sf-chip" data-val="tanker">Tanker</span>
|
||
<span class="sf-chip" data-val="container">Container</span>
|
||
<span class="sf-chip" data-val="general_cargo">General</span>
|
||
<span class="sf-chip" data-val="ro_ro">Ro-Ro</span>
|
||
<span class="sf-chip" data-val="lng_carrier">LNG</span>
|
||
<span class="sf-chip" data-val="chemical_tanker">Chemical</span>
|
||
<span class="sf-chip" data-val="offshore">Offshore</span>
|
||
<span class="sf-chip" data-val="tug">Tug</span>
|
||
<span class="sf-chip" data-val="barge">Barge</span>
|
||
<span class="sf-chip" data-val="passenger">Passenger</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_trade_routes">Trade Routes</div>
|
||
<div class="sf-chips" id="sfTradeRoutes">
|
||
<span class="sf-chip" data-val="Caspian">Caspian</span>
|
||
<span class="sf-chip" data-val="Black Sea">Black Sea</span>
|
||
<span class="sf-chip" data-val="Mediterranean">Med</span>
|
||
<span class="sf-chip" data-val="Baltic">Baltic</span>
|
||
<span class="sf-chip" data-val="North Sea">North Sea</span>
|
||
<span class="sf-chip" data-val="Atlantic">Atlantic</span>
|
||
<span class="sf-chip" data-val="Pacific">Pacific</span>
|
||
<span class="sf-chip" data-val="Indian Ocean">Indian</span>
|
||
<span class="sf-chip" data-val="Southeast Asia">SE Asia</span>
|
||
<span class="sf-chip" data-val="Middle East / Persian Gulf">Gulf</span>
|
||
<span class="sf-chip" data-val="West Africa">W.Africa</span>
|
||
<span class="sf-chip" data-val="South America">S.America</span>
|
||
<span class="sf-chip" data-val="Arctic">Arctic</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_cargo_types">Cargo</div>
|
||
<div class="sf-chips" id="sfCargoTypes">
|
||
<span class="sf-chip" data-val="dry_bulk">Dry Bulk</span>
|
||
<span class="sf-chip" data-val="liquid_bulk">Liquid</span>
|
||
<span class="sf-chip" data-val="containerized">Container</span>
|
||
<span class="sf-chip" data-val="crude_oil">Crude</span>
|
||
<span class="sf-chip" data-val="grain">Grain</span>
|
||
<span class="sf-chip" data-val="coal">Coal</span>
|
||
<span class="sf-chip" data-val="iron_ore">Iron Ore</span>
|
||
<span class="sf-chip" data-val="chemicals">Chemicals</span>
|
||
<span class="sf-chip" data-val="fertilizer">Fertilizer</span>
|
||
<span class="sf-chip" data-val="timber">Timber</span>
|
||
<span class="sf-chip" data-val="cement">Cement</span>
|
||
</div>
|
||
<div class="sf-row">
|
||
<input type="number" class="sf-input" id="sfTonnage" placeholder="0"
|
||
min="0" max="500000" onchange="sidebarFilterChanged()">
|
||
<span class="sf-unit" id="sfTonnageLabel">DWT, t</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_home_port">Home Port</div>
|
||
<div style="position:relative">
|
||
<input type="text" class="sf-input" id="sfHomePort"
|
||
placeholder="Baku, Rotterdam..." autocomplete="off"
|
||
data-i18n-placeholder="sf_home_port_ph">
|
||
<div class="port-suggestions hidden" id="sfPortSuggestions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sf-section">
|
||
<div class="sf-label" data-i18n="sf_search_radius">Search Radius</div>
|
||
<div class="sf-row">
|
||
<input type="number" class="sf-input" id="sfSearchRadius" placeholder="50"
|
||
min="10" max="1000" step="10" onchange="sidebarFilterChanged()">
|
||
<span class="sf-unit">NM</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-plan" id="sidebarPlan" style="display:none;">
|
||
<div class="plan-badge" id="planBadge" onclick="showSub()">
|
||
<div class="plan-badge-left">
|
||
<span class="plan-badge-icon">⭐</span>
|
||
<div class="plan-badge-info">
|
||
<div class="plan-badge-name" id="planBadgeName">Free Plan</div>
|
||
<div class="plan-badge-desc" data-i18n="sub_upgrade_hint">Tap to upgrade</div>
|
||
</div>
|
||
</div>
|
||
<span class="plan-badge-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-footer">
|
||
<div style="display:flex;align-items:center;gap:8px;justify-content:center;margin-bottom:4px;">
|
||
<span style="font-size:16px;">⚓</span>
|
||
<span style="background:linear-gradient(135deg,#d4af37,#00d4ff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-weight:600;font-size:12px;letter-spacing:1px;">MONTANA PROTOCOL</span>
|
||
</div>
|
||
<span data-i18n="footer">Montana Protocol • 25 years expertise</span><br>
|
||
<span id="appVersion" style="font-size:10px;opacity:0.5;cursor:pointer;text-decoration:underline dotted;" onclick="showChangelog()" title="View changelog"></span>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Chat -->
|
||
<main class="main">
|
||
<div class="chat-header">
|
||
<div class="chat-header-left">
|
||
<button class="menu-toggle" onclick="toggleSidebar()" title="Menu" aria-label="Open menu">☰</button>
|
||
<h2>⚓ SeaFare Montana</h2>
|
||
|
||
</div>
|
||
<div class="header-actions">
|
||
<span id="userInfo" class="user-info" style="display:none;">
|
||
<span id="adminBadge" class="admin-badge" style="display:none;">ADMIN</span>
|
||
<button id="revenueBtn" class="header-btn admin-revenue-btn" onclick="showRevenue()" style="display:none;">📈</button>
|
||
<button id="costsBtn" class="header-btn admin-revenue-btn" onclick="showCosts()" style="display:none;" title="API Costs">⚙</button>
|
||
<span id="userName"></span>
|
||
<span id="userBalance" class="balance-badge" onclick="showDeposit()" style="cursor:pointer;" title="Top Up"></span>
|
||
<button class="header-btn topup-btn" onclick="showDeposit()" data-i18n="topup_btn">💰</button>
|
||
<button class="header-btn" onclick="showProfile()" data-i18n="profile_btn" title="My Cabinet">💼</button>
|
||
</span>
|
||
<button class="map-toggle-btn" id="mapToggleBtn" onclick="toggleMapView()" data-i18n="map_toggle_map">🌎 Map</button>
|
||
<div class="lang-switcher">
|
||
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">EN</button>
|
||
<button class="lang-btn" data-lang="zh" onclick="setLang('zh')">中文</button>
|
||
<button class="lang-btn" data-lang="es" onclick="setLang('es')">ES</button>
|
||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">RU</button>
|
||
</div>
|
||
<button class="header-btn" onclick="clearChat()" data-i18n="clear_chat">Clear Chat</button>
|
||
<button class="header-btn" id="loginBtn" onclick="showAuthModal()" data-i18n="auth_login">Login</button>
|
||
<button class="header-btn" id="logoutBtn" onclick="doLogout()" style="display:none;" data-i18n="auth_logout">Logout</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="messages" id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
|
||
|
||
<div id="mapContainer">
|
||
<div class="map-filter-bar" id="mapFilterBar"></div>
|
||
<div id="mapFilterHint" style="display:none;position:absolute;top:38px;left:12px;font-size:9px;color:var(--accent);opacity:0.7;z-index:1000;pointer-events:none"></div>
|
||
<div id="vesselMap"></div>
|
||
<div class="map-stats" id="mapStats" style="display:none;">
|
||
<span data-i18n="map_vessels_shown">Vessels shown</span>: <strong id="mapVesselCount">0</strong>
|
||
• <span id="mapLastUpdate"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-area">
|
||
<div class="quick-actions" id="quickActions"></div>
|
||
<div style="position:relative">
|
||
<div class="examples-panel" id="examplesPanel">
|
||
<div class="examples-inner" id="examplesInner"></div>
|
||
</div>
|
||
<div class="examples-toggle" id="examplesToggle" onclick="toggleExamples()">
|
||
<span class="ex-icon">⚡</span>
|
||
<span data-i18n="ex_btn">Popular queries</span>
|
||
<span class="ex-arrow">▲</span>
|
||
</div>
|
||
</div>
|
||
<div class="input-glass">
|
||
<div class="input-wrapper">
|
||
<textarea id="userInput" rows="1" onkeydown="handleKey(event)" aria-label="Type your message"></textarea>
|
||
</div>
|
||
<button class="voice-btn" id="voiceBtn" onclick="toggleVoice()" title="Voice input" aria-label="Voice input">🎤</button>
|
||
<button class="send-btn" id="sendBtn" onclick="sendMessage()" aria-label="Send message">➤</button>
|
||
<button class="input-action-btn" id="actionBtn" aria-label="Send or voice">
|
||
<span class="ia-icon ia-mic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="1" width="6" height="11" rx="3"></rect><path d="M5 10a7 7 0 0 0 14 0"></path><line x1="12" y1="17" x2="12" y2="21"></line><line x1="8" y1="21" x2="16" y2="21"></line></svg></span>
|
||
<span class="ia-icon ia-send"><svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg></span>
|
||
</button>
|
||
</div>
|
||
<div class="input-hint" data-i18n="input_hint">Press Enter to send • Shift+Enter for new line</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// =============================================================================
|
||
// i18n — translations
|
||
// =============================================================================
|
||
const i18n = {
|
||
en: {
|
||
subtitle: '',
|
||
status: 'Online — Ready',
|
||
services_title: 'Services',
|
||
header_sub: '',
|
||
map_toggle_map: '\ud83c\udf0e Map',
|
||
map_toggle_chat: '\ud83d\udcac Chat',
|
||
map_vessels_shown: 'Vessels shown',
|
||
map_last_update: 'Updated',
|
||
map_zoom_hint: 'Zoom in to see vessels',
|
||
map_knots: 'kn',
|
||
map_destination: 'Destination',
|
||
map_status: 'Status',
|
||
map_type: 'Type',
|
||
map_flag: 'Flag',
|
||
map_speed: 'Speed',
|
||
map_course: 'Course',
|
||
map_dwt: 'DWT',
|
||
map_imo: 'IMO',
|
||
map_mmsi: 'MMSI',
|
||
clear_chat: 'Clear Chat',
|
||
input_hint: 'Press Enter to send \u2022 Shift+Enter for new line',
|
||
placeholder: 'Message...',
|
||
free: 'Free',
|
||
temp_free: 'Free now',
|
||
svc_search: 'Vessel Search',
|
||
svc_search_desc: 'Name, IMO, MMSI lookup',
|
||
svc_position: 'Vessel Position',
|
||
svc_position_desc: 'AIS real-time tracking',
|
||
svc_owner: 'Owner / Operator',
|
||
svc_owner_desc: 'Company information',
|
||
svc_contacts: 'Contact Introduction',
|
||
svc_contacts_desc: 'Shipper \u2194 Operator',
|
||
svc_group_tracking: 'Tracking',
|
||
svc_group_commercial: 'Commercial',
|
||
svc_group_contacts: 'Contacts',
|
||
svc_port_vessels: 'Vessels Near Port',
|
||
svc_port_vessels_desc: 'Live ships at 16,000+ ports',
|
||
svc_route: 'Route Calculator',
|
||
svc_route_desc: 'Distance, time, cost range',
|
||
svc_cargo: 'Cargo Matching',
|
||
svc_cargo_desc: 'Find vessels for your cargo',
|
||
footer: 'Montana Protocol \u2022 25 years expertise',
|
||
welcome: '<strong>Welcome to SeaFare Montana!</strong><br><br>' +
|
||
"Your AI maritime intelligence platform. 24 tools, 16,000+ ports, one conversation:<br><br>" +
|
||
'<strong>TRACKING</strong><br>' +
|
||
'🚢 Vessel search • 📍 Live position • ⚓ Vessels near port • 🏢 Owner/operator<br><br>' +
|
||
'<strong>COMMERCIAL</strong><br>' +
|
||
'🗺 Route calculator • 📈 Freight rates • 🔃 Cargo matching • 💲 Demurrage<br><br>' +
|
||
'🙋 <strong>Contact introductions</strong> — connect with operators (free)<br><br>' +
|
||
'Try the quick actions below or just ask me anything!',
|
||
chat_cleared: '<strong>Chat cleared.</strong><br>How can I help you today?',
|
||
confirm_clear: 'Are you sure you want to clear the chat history?',
|
||
error_generic: 'Sorry, something went wrong. Please try again.',
|
||
error_connection: 'Connection error. Make sure the server is running on port 5050.',
|
||
loading: 'Loading...',
|
||
rate_limited: 'Too many requests. Please slow down.',
|
||
// Quick action buttons (labels)
|
||
ex_btn: 'Popular queries',
|
||
ex_cat_search: 'Find vessel',
|
||
ex_cat_port: 'Vessels near port',
|
||
ex_cat_route: 'Routes',
|
||
ex_cat_cargo: 'Cargo',
|
||
ex_cat_info: 'Vessel info',
|
||
ex_q_find_vessel: 'Find vessel EVER GIVEN',
|
||
ex_q_where_vessel: 'Where is tanker NASIMI?',
|
||
ex_q_vessels_baku: 'What ships are near Baku right now?',
|
||
ex_q_vessels_novo: 'Vessels near Novorossiysk',
|
||
ex_q_route: 'Route from Aktau to Istanbul',
|
||
ex_q_distance: 'Distance Baku — Batumi',
|
||
ex_q_cargo_vessel: 'Ship for 5000t grain from Aktau',
|
||
ex_q_cargo_send: 'What cargo can I send from Baku?',
|
||
ex_q_owner: 'Who owns vessel LACHIN?',
|
||
ex_q_vessel_data: 'Vessel data IMO 8848745',
|
||
qb_search: 'Search vessel',
|
||
qb_vessels_port: 'Vessels near port',
|
||
qb_route: 'Route A\u2192B',
|
||
qb_cargo: 'Ship my cargo',
|
||
qb_contacts: 'Find contacts',
|
||
// Quick action messages sent to bot
|
||
qm_btn_search: 'Search vessel EVER GIVEN',
|
||
qm_btn_vessels_port: 'What vessels are near Rotterdam right now?',
|
||
qm_btn_route: 'Calculate route from Shanghai to Rotterdam for a Panamax bulk carrier, 75000 DWT',
|
||
qm_btn_cargo: 'I need to ship 50000 tons of grain from Santos to Rotterdam',
|
||
qm_btn_contacts: 'Find contacts for bulk carrier operators',
|
||
// Sidebar quick action messages
|
||
qm_search_vessel: 'Search vessel EVER GIVEN',
|
||
qm_vessel_position: 'Where is EVER GIVEN now?',
|
||
qm_vessels_port: 'What vessels are near Rotterdam right now?',
|
||
qm_owner_info: 'Who owns and operates EVER GIVEN?',
|
||
qm_route_calc: 'Calculate route from Shanghai to Rotterdam for a Panamax bulk carrier',
|
||
qm_cargo_match: 'I need to ship 50000 tons of grain from Santos to Rotterdam',
|
||
qm_port_congestion: 'What is the current congestion at Singapore port?',
|
||
qm_contacts: 'Find contacts for bulk carrier operators',
|
||
// Auth
|
||
auth_login: 'Login',
|
||
auth_logout: 'Logout',
|
||
auth_title_login: 'Log In',
|
||
auth_title_register: 'Create Account',
|
||
auth_email: 'Email',
|
||
auth_password: 'Password',
|
||
auth_name: 'Name (optional)',
|
||
auth_login_btn: 'Log In',
|
||
auth_google_btn: 'Sign in with Google',
|
||
auth_register_btn: 'Register',
|
||
auth_switch_to_register: "Don't have an account? Register",
|
||
auth_switch_to_login: 'Already have an account? Log In',
|
||
auth_email_exists_login: 'This email is already registered. Please log in.',
|
||
auth_send_code: 'Send Code',
|
||
auth_verify_code: 'Verify',
|
||
auth_create_account: 'Create Account',
|
||
auth_resend_code: 'Resend code',
|
||
auth_code_sent_to: 'Code sent to',
|
||
auth_step1_title: 'Enter your email',
|
||
auth_step2_title: 'Enter verification code',
|
||
auth_step3_title: 'Create your account',
|
||
auth_code_expires: 'Code expires in',
|
||
auth_or: 'or',
|
||
auth_error_generic: 'Something went wrong. Try again.',
|
||
user_balance: 'Balance',
|
||
profile_btn: 'Cabinet',
|
||
profile_title: 'My Cabinet',
|
||
profile_subtitle: 'Fill out your profile so SeaFare Montana can give you more accurate, personalized answers.',
|
||
purchased_contacts_title: 'Purchased Contacts',
|
||
purchased_contacts_empty: 'No purchased contacts yet. Unlock contacts through the chat.',
|
||
profile_company_section: 'Company',
|
||
profile_company: 'Company Name',
|
||
profile_role: 'Your Role',
|
||
profile_experience: 'Experience (years)',
|
||
profile_fleet_size: 'Fleet Size (vessels)',
|
||
profile_home_port_title: 'Home Port',
|
||
profile_home_port_placeholder: 'Start typing port name...',
|
||
wiz_home_port_label: 'Your home port (optional)',
|
||
profile_vessel_types_title: 'Vessel Types',
|
||
profile_trade_routes_title: 'Trade Routes',
|
||
profile_cargo_types_title: 'Cargo Types',
|
||
profile_vessels_title: 'Vessels of Interest',
|
||
profile_vessels_hint: 'Add vessel names or IMO numbers you want to track',
|
||
profile_add_vessel: 'Add vessel',
|
||
profile_contact_section: 'Contact',
|
||
profile_phone: 'Phone',
|
||
profile_notes_title: 'Notes',
|
||
profile_notes_placeholder: 'Any additional info for the AI agent...',
|
||
profile_company_placeholder: 'e.g. Pacific Shipping Ltd.',
|
||
profile_phone_placeholder: '+1 555 123 4567',
|
||
profile_role_select: '— Select —',
|
||
profile_cancel: 'Cancel',
|
||
profile_save: 'Save Profile',
|
||
profile_saved: 'Profile saved successfully!',
|
||
voice_permission: 'Please allow microphone access to use voice input.',
|
||
voice_not_supported: 'Voice input is not supported in this browser. Try Chrome or Yandex Browser.',
|
||
voice_listening: 'Listening...',
|
||
voice_tooltip: 'Voice input',
|
||
topup_btn: 'Top Up',
|
||
topup_title: 'Top Up Balance',
|
||
topup_instruction: 'Send USDT (TRC20) to the address below. Your balance will be credited after confirmation.',
|
||
topup_copy: 'Copy',
|
||
topup_copied: 'Copied!',
|
||
topup_check: 'Check for deposits',
|
||
topup_checking: 'Checking...',
|
||
topup_found: 'Deposited: ${amount} USDT (fee 2%). Credited: ${credited} USDT. Balance: ${balance} USDT',
|
||
topup_none: 'No new deposits found. If you already sent USDT, wait a few minutes and try again.',
|
||
topup_error: 'Error checking deposits. Try again later.',
|
||
topup_close: 'Close',
|
||
topup_history: 'Deposit History',
|
||
deposit_fee_info: 'Service fee: 2%. Example: send $100 — receive $98.',
|
||
wallet_tab_deposit: 'Deposit',
|
||
wallet_tab_withdraw: 'Withdraw',
|
||
withdraw_your_balance: 'Your balance:',
|
||
withdraw_address_label: 'TRC20 Wallet Address',
|
||
withdraw_amount_label: 'Amount (USDT)',
|
||
withdraw_fee_label: 'Network fee:',
|
||
withdraw_total_label: 'Total deducted:',
|
||
withdraw_note: 'Minimum withdrawal: $2 USDT. Withdrawals are processed manually within 24 hours.',
|
||
withdraw_submit: 'Request Withdrawal',
|
||
withdraw_success: 'Withdrawal submitted! Amount: ${amount} USDT (fee: $1). New balance: ${balance} USDT',
|
||
withdraw_history: 'Withdrawal History',
|
||
withdraw_status_pending: 'Pending',
|
||
withdraw_status_completed: 'Completed',
|
||
withdraw_status_rejected: 'Rejected',
|
||
// Subscription
|
||
sub_title: 'Subscription Plans',
|
||
sub_subtitle: 'Choose the plan that fits your needs. Upgrade anytime from your balance.',
|
||
sub_close: 'Close',
|
||
sub_current: 'Current',
|
||
sub_recommended: 'Best value',
|
||
sub_current_plan: 'Current plan',
|
||
sub_upgrade_to: 'Upgrade to',
|
||
sub_downgrade: 'Downgrade',
|
||
sub_per_month: 'per month',
|
||
sub_upgraded: 'Plan upgraded successfully!',
|
||
sub_error: 'Upgrade failed. Check your balance.',
|
||
sub_upgrade_hint: 'Tap to upgrade',
|
||
changelog_title: 'Changelog',
|
||
wiz_step1_title: 'What is your role?',
|
||
wiz_step1_sub: 'This helps us personalize your experience',
|
||
wiz_role_shipowner: 'Shipowner / Operator',
|
||
wiz_role_shipowner_desc: 'I own or operate vessels and look for cargo',
|
||
wiz_role_charterer: 'Cargo Owner',
|
||
wiz_role_charterer_desc: 'I have cargo and need to find vessels',
|
||
wiz_role_broker: 'Broker / Agent',
|
||
wiz_role_broker_desc: 'I connect shipowners with cargo owners',
|
||
wiz_step2_vtype: 'What type of vessels do you operate?',
|
||
wiz_step2_ctype: 'What type of cargo do you ship?',
|
||
wiz_step2_btype: 'What cargo types do your clients ship?',
|
||
wiz_step2_dwt: 'Average DWT (deadweight tonnage)',
|
||
wiz_step2_tonnage: 'Typical cargo tonnage',
|
||
wiz_step3_title: 'Your operating region',
|
||
wiz_step3_sub: 'Select your main waters (multiple allowed)',
|
||
wiz_step4_title: 'Contact details',
|
||
wiz_step4_sub: 'Optional — you can add later in your cabinet',
|
||
wiz_company: 'Company name',
|
||
wiz_phone: 'Phone',
|
||
wiz_back: 'Back',
|
||
wiz_next: 'Next',
|
||
wiz_skip: 'Skip',
|
||
wiz_finish: 'Start working',
|
||
cargo_form_title: 'Find Vessels for Cargo',
|
||
cargo_form_subtitle: 'Select cargo type and loading port to find available vessels',
|
||
cargo_form_type: 'Cargo Type',
|
||
cargo_type_dry: 'Dry Cargo',
|
||
cargo_type_dry_desc: 'Grain, coal, ore, cement, sugar, fertilizer',
|
||
cargo_type_container: 'Containers',
|
||
cargo_type_container_desc: 'TEU, electronics, machinery, furniture',
|
||
cargo_type_liquid: 'Liquid Cargo',
|
||
cargo_type_liquid_desc: 'Crude oil, chemicals, LNG, palm oil',
|
||
cargo_form_ports: 'Port Details',
|
||
cargo_form_from_port: 'Loading Port *',
|
||
cargo_form_from_port_ph: 'e.g. Santos, Rotterdam, Singapore',
|
||
cargo_form_to_port: 'Destination Port (optional)',
|
||
cargo_form_to_port_ph: 'e.g. Rotterdam, Qingdao, Houston',
|
||
cargo_form_details: 'Details (optional)',
|
||
cargo_form_tonnage: 'Tonnage (MT) or TEU',
|
||
cargo_form_cancel: 'Cancel',
|
||
cargo_form_search: 'Find Vessels',
|
||
cargo_form_error_type: 'Please select a cargo type',
|
||
cargo_form_error_port: 'Please enter a loading port',
|
||
sf_title: 'My Filters',
|
||
sf_role: 'Role',
|
||
sf_vessel_types: 'Vessel Types',
|
||
sf_trade_routes: 'Trade Routes',
|
||
sf_cargo_types: 'Cargo',
|
||
sf_home_port: 'Home Port',
|
||
sf_home_port_ph: 'Baku, Rotterdam...',
|
||
sf_search_radius: 'Search Radius',
|
||
sf_tonnage_dwt: 'DWT, t',
|
||
sf_tonnage_cargo: 'Cargo, t',
|
||
sf_tonnage_min: 'Min DWT',
|
||
sf_hint_text: 'Select your preferences \u2014 AI agent will use them automatically in responses. Changes save instantly.',
|
||
},
|
||
ru: {
|
||
subtitle: '',
|
||
status: 'Онлайн — Готов',
|
||
services_title: 'Услуги',
|
||
header_sub: '',
|
||
map_toggle_map: '\ud83c\udf0e Карта',
|
||
map_toggle_chat: '\ud83d\udcac Чат',
|
||
map_vessels_shown: 'Судов на карте',
|
||
map_last_update: 'Обновлено',
|
||
map_zoom_hint: 'Приблизьте для отображения судов',
|
||
map_knots: 'уз',
|
||
map_destination: 'Назначение',
|
||
map_status: 'Статус',
|
||
map_type: 'Тип',
|
||
map_flag: 'Флаг',
|
||
map_speed: 'Скорость',
|
||
map_course: 'Курс',
|
||
map_dwt: 'Дедвейт',
|
||
map_imo: 'IMO',
|
||
map_mmsi: 'MMSI',
|
||
clear_chat: 'Очистить чат',
|
||
input_hint: 'Enter — отправить \u2022 Shift+Enter — новая строка',
|
||
placeholder: 'Сообщение...',
|
||
free: 'Бесплатно',
|
||
temp_free: 'Пока бесплатно',
|
||
svc_search: 'Поиск судна',
|
||
svc_search_desc: 'По названию, IMO, MMSI',
|
||
svc_position: 'Позиция судна',
|
||
svc_position_desc: 'AIS отслеживание',
|
||
svc_owner: 'Владелец / Оператор',
|
||
svc_owner_desc: 'Информация о компании',
|
||
svc_contacts: 'Предоставление контактов',
|
||
svc_contacts_desc: 'Грузоотправитель \u2194 Оператор',
|
||
svc_group_tracking: 'Отслеживание',
|
||
svc_group_commercial: 'Коммерция',
|
||
svc_group_contacts: 'Контакты',
|
||
svc_port_vessels: 'Суда у порта',
|
||
svc_port_vessels_desc: 'Суда в 16 000+ портах',
|
||
svc_route: 'Маршрут',
|
||
svc_route_desc: 'Расстояние, время, стоимость',
|
||
svc_cargo: 'Подбор судна',
|
||
svc_cargo_desc: 'Найти судно под груз',
|
||
footer: 'Montana Protocol \u2022 25 лет опыта',
|
||
welcome: '<strong>Добро пожаловать в SeaFare Montana!</strong><br><br>' +
|
||
'Ваша AI-платформа морской разведки. 24 инструмента, 16 000+ портов, один чат:<br><br>' +
|
||
'<strong>ОТСЛЕЖИВАНИЕ</strong><br>' +
|
||
'🚢 Поиск судна • 📍 Позиция • ⚓ Суда у порта • 🏢 Владелец<br><br>' +
|
||
'<strong>КОММЕРЦИЯ</strong><br>' +
|
||
'🗺 Маршрут • 🔃 Подбор судна<br><br>' +
|
||
'🙋 <strong>Контакты</strong> — связь с операторами (бесплатно)<br><br>' +
|
||
'Попробуйте быстрые кнопки ниже или задайте вопрос!',
|
||
chat_cleared: '<strong>Чат очищен.</strong><br>Чем могу помочь?',
|
||
confirm_clear: 'Вы уверены, что хотите очистить историю чата?',
|
||
error_generic: 'Произошла ошибка. Попробуйте ещё раз.',
|
||
error_connection: 'Ошибка соединения. Убедитесь, что сервер запущен на порту 5050.',
|
||
loading: 'Загрузка...',
|
||
rate_limited: 'Слишком много запросов. Подождите немного.',
|
||
ex_btn: 'Популярные запросы',
|
||
ex_cat_search: 'Найти судно',
|
||
ex_cat_port: 'Суда у порта',
|
||
ex_cat_route: 'Маршруты',
|
||
ex_cat_cargo: 'Груз',
|
||
ex_cat_info: 'Данные судна',
|
||
ex_q_find_vessel: 'Найди судно EVER GIVEN',
|
||
ex_q_where_vessel: 'Где танкер NASIMI?',
|
||
ex_q_vessels_baku: 'Какие суда сейчас возле Баку?',
|
||
ex_q_vessels_novo: 'Суда рядом с Новороссийском',
|
||
ex_q_route: 'Маршрут из Актау в Стамбул',
|
||
ex_q_distance: 'Расстояние Баку — Батуми',
|
||
ex_q_cargo_vessel: 'Судно под 5000т зерна из Актау',
|
||
ex_q_cargo_send: 'Какой груз могу отправить из Баку?',
|
||
ex_q_owner: 'Кто владелец судна LACHIN?',
|
||
ex_q_vessel_data: 'Данные судна IMO 8848745',
|
||
qb_search: 'Найти судно',
|
||
qb_vessels_port: 'Суда у порта',
|
||
qb_route: 'Маршрут A\u2192B',
|
||
qb_cargo: 'Отправить груз',
|
||
qb_contacts: 'Контакты',
|
||
qm_btn_search: 'Найти судно EVER GIVEN',
|
||
qm_btn_vessels_port: 'Какие суда сейчас рядом с Роттердамом?',
|
||
qm_btn_route: 'Рассчитай маршрут из Шанхая в Роттердам для балкера Panamax, 75000 DWT',
|
||
qm_btn_cargo: 'Мне нужно отправить 50000 тонн зерна из Сантоса в Роттердам',
|
||
qm_btn_contacts: 'Найти контакты операторов балкеров',
|
||
qm_search_vessel: 'Найти судно EVER GIVEN',
|
||
qm_vessel_position: 'Где сейчас EVER GIVEN?',
|
||
qm_vessels_port: 'Какие суда сейчас рядом с Роттердамом?',
|
||
qm_owner_info: 'Кто владелец и оператор EVER GIVEN?',
|
||
qm_route_calc: 'Рассчитай маршрут из Шанхая в Роттердам для балкера Panamax',
|
||
qm_cargo_match: 'Мне нужно отправить 50000 тонн зерна из Сантоса в Роттердам',
|
||
qm_port_congestion: 'Какая загруженность порта Сингапур?',
|
||
qm_contacts: 'Найти контакты операторов балкеров',
|
||
// Auth
|
||
auth_login: 'Войти',
|
||
auth_logout: 'Выход',
|
||
auth_title_login: 'Вход',
|
||
auth_title_register: 'Регистрация',
|
||
auth_email: 'Email',
|
||
auth_password: 'Пароль',
|
||
auth_name: 'Имя (необязательно)',
|
||
auth_login_btn: 'Войти',
|
||
auth_google_btn: 'Войти через Google',
|
||
auth_register_btn: 'Зарегистрироваться',
|
||
auth_switch_to_register: 'Нет аккаунта? Зарегистрироваться',
|
||
auth_switch_to_login: 'Уже есть аккаунт? Войти',
|
||
auth_email_exists_login: 'Этот email уже зарегистрирован. Введите пароль.',
|
||
auth_send_code: 'Отправить код',
|
||
auth_verify_code: 'Подтвердить',
|
||
auth_create_account: 'Создать аккаунт',
|
||
auth_resend_code: 'Отправить код повторно',
|
||
auth_code_sent_to: 'Код отправлен на',
|
||
auth_step1_title: 'Введите ваш email',
|
||
auth_step2_title: 'Введите код подтверждения',
|
||
auth_step3_title: 'Создайте аккаунт',
|
||
auth_code_expires: 'Код истекает через',
|
||
auth_or: 'или',
|
||
auth_error_generic: 'Произошла ошибка. Попробуйте ещё раз.',
|
||
user_balance: 'Баланс',
|
||
profile_btn: 'Кабинет',
|
||
profile_title: 'Мой кабинет',
|
||
profile_subtitle: 'Заполните профиль, чтобы SeaFare Montana давал более точные и персонализированные ответы.',
|
||
purchased_contacts_title: 'Купленные контакты',
|
||
purchased_contacts_empty: 'Пока нет купленных контактов. Разблокируйте контакты через чат.',
|
||
profile_company_section: 'Компания',
|
||
profile_company: 'Название компании',
|
||
profile_role: 'Ваша роль',
|
||
profile_experience: 'Опыт (лет)',
|
||
profile_fleet_size: 'Размер флота (судов)',
|
||
profile_home_port_title: 'Домашний порт',
|
||
profile_home_port_placeholder: 'Начните вводить порт...',
|
||
wiz_home_port_label: 'Ваш домашний порт (опционально)',
|
||
profile_vessel_types_title: 'Типы судов',
|
||
profile_trade_routes_title: 'Торговые маршруты',
|
||
profile_cargo_types_title: 'Типы грузов',
|
||
profile_vessels_title: 'Интересующие суда',
|
||
profile_vessels_hint: 'Добавьте названия судов или номера IMO для отслеживания',
|
||
profile_add_vessel: 'Добавить судно',
|
||
profile_contact_section: 'Контакт',
|
||
profile_phone: 'Телефон',
|
||
profile_notes_title: 'Заметки',
|
||
profile_notes_placeholder: 'Дополнительная информация для AI-агента...',
|
||
profile_company_placeholder: 'напр. Волга Шиппинг',
|
||
profile_phone_placeholder: '+7 999 123 4567',
|
||
profile_role_select: '— Выберите —',
|
||
profile_cancel: 'Отмена',
|
||
profile_save: 'Сохранить профиль',
|
||
profile_saved: 'Профиль успешно сохранён!',
|
||
voice_permission: 'Разрешите доступ к микрофону для голосового ввода.',
|
||
voice_not_supported: 'Голосовой ввод не поддерживается в этом браузере. Попробуйте Chrome или Яндекс Браузер.',
|
||
voice_listening: 'Слушаю...',
|
||
voice_tooltip: 'Голосовой ввод',
|
||
topup_btn: 'Пополнить',
|
||
topup_title: 'Пополнение баланса',
|
||
topup_instruction: 'Отправьте USDT (TRC20) на адрес ниже. Баланс будет зачислен после подтверждения.',
|
||
topup_copy: 'Копировать',
|
||
topup_copied: 'Скопировано!',
|
||
topup_check: 'Проверить поступления',
|
||
topup_checking: 'Проверяю...',
|
||
topup_found: 'Получено: ${amount} USDT (комиссия 2%). Зачислено: ${credited} USDT. Баланс: ${balance} USDT',
|
||
topup_none: 'Новых поступлений нет. Если вы уже отправили USDT, подождите пару минут и попробуйте снова.',
|
||
topup_error: 'Ошибка проверки. Попробуйте позже.',
|
||
topup_close: 'Закрыть',
|
||
topup_history: 'История пополнений',
|
||
deposit_fee_info: 'Комиссия сервиса: 2%. Пример: отправляете $100 — получаете $98.',
|
||
wallet_tab_deposit: 'Пополнение',
|
||
wallet_tab_withdraw: 'Вывод',
|
||
withdraw_your_balance: 'Ваш баланс:',
|
||
withdraw_address_label: 'Адрес TRC20 кошелька',
|
||
withdraw_amount_label: 'Сумма (USDT)',
|
||
withdraw_fee_label: 'Комиссия сети:',
|
||
withdraw_total_label: 'Итого к списанию:',
|
||
withdraw_note: 'Минимальный вывод: $2 USDT. Выводы обрабатываются вручную в течение 24 часов.',
|
||
withdraw_submit: 'Запросить вывод',
|
||
withdraw_success: 'Заявка на вывод создана! Сумма: ${amount} USDT (комиссия: $1). Новый баланс: ${balance} USDT',
|
||
withdraw_history: 'История выводов',
|
||
withdraw_status_pending: 'В обработке',
|
||
withdraw_status_completed: 'Выполнен',
|
||
withdraw_status_rejected: 'Отклонён',
|
||
// Subscription
|
||
sub_title: 'Планы подписки',
|
||
sub_subtitle: 'Выберите подходящий план. Апгрейд в любой момент с баланса.',
|
||
sub_close: 'Закрыть',
|
||
sub_current: 'Текущий',
|
||
sub_recommended: 'Лучший выбор',
|
||
sub_current_plan: 'Текущий план',
|
||
sub_upgrade_to: 'Перейти на',
|
||
sub_downgrade: 'Повысить',
|
||
sub_per_month: 'в месяц',
|
||
sub_upgraded: 'План успешно обновлён!',
|
||
sub_error: 'Ошибка обновления. Проверьте баланс.',
|
||
sub_upgrade_hint: 'Нажмите для апгрейда',
|
||
changelog_title: 'История обновлений',
|
||
wiz_step1_title: 'Кто вы?',
|
||
wiz_step1_sub: 'Это поможет нам персонализировать работу',
|
||
wiz_role_shipowner: 'Судовладелец / Оператор',
|
||
wiz_role_shipowner_desc: 'У меня есть суда, ищу грузы',
|
||
wiz_role_charterer: 'Грузовладелец',
|
||
wiz_role_charterer_desc: 'У меня есть груз, нужны суда',
|
||
wiz_role_broker: 'Брокер / Агент',
|
||
wiz_role_broker_desc: 'Свожу судовладельцев с грузовладельцами',
|
||
wiz_step2_vtype: 'Какой тип судов вы оперируете?',
|
||
wiz_step2_ctype: 'Какой тип груза вы перевозите?',
|
||
wiz_step2_btype: 'Какие грузы перевозят ваши клиенты?',
|
||
wiz_step2_dwt: 'Средний DWT (дедвейт)',
|
||
wiz_step2_tonnage: 'Типичный тоннаж груза',
|
||
wiz_step3_title: 'Регион работы',
|
||
wiz_step3_sub: 'Выберите основные воды (можно несколько)',
|
||
wiz_step4_title: 'Контактные данные',
|
||
wiz_step4_sub: 'Необязательно — можно добавить позже в кабинете',
|
||
wiz_company: 'Название компании',
|
||
wiz_phone: 'Телефон',
|
||
wiz_back: 'Назад',
|
||
wiz_next: 'Далее',
|
||
wiz_skip: 'Пропустить',
|
||
wiz_finish: 'Начать работу',
|
||
cargo_form_title: 'Найти суда для груза',
|
||
cargo_form_subtitle: 'Выберите тип груза и порт погрузки для поиска доступных судов',
|
||
cargo_form_type: 'Тип груза',
|
||
cargo_type_dry: 'Сухой груз',
|
||
cargo_type_dry_desc: 'Зерно, уголь, руда, цемент, сахар, удобрения',
|
||
cargo_type_container: 'Контейнеры',
|
||
cargo_type_container_desc: 'TEU, электроника, техника, мебель',
|
||
cargo_type_liquid: 'Наливной груз',
|
||
cargo_type_liquid_desc: 'Нефть, химикаты, СПГ, пальмовое масло',
|
||
cargo_form_ports: 'Порт',
|
||
cargo_form_from_port: 'Порт погрузки *',
|
||
cargo_form_from_port_ph: 'напр. Сантос, Роттердам, Сингапур',
|
||
cargo_form_to_port: 'Порт назначения (необязательно)',
|
||
cargo_form_to_port_ph: 'напр. Роттердам, Циндао, Хьюстон',
|
||
cargo_form_details: 'Детали (необязательно)',
|
||
cargo_form_tonnage: 'Тоннаж (МТ) или TEU',
|
||
cargo_form_cancel: 'Отмена',
|
||
cargo_form_search: 'Найти суда',
|
||
cargo_form_error_type: 'Выберите тип груза',
|
||
cargo_form_error_port: 'Введите порт погрузки',
|
||
sf_title: 'Мои фильтры',
|
||
sf_role: 'Роль',
|
||
sf_vessel_types: 'Типы судов',
|
||
sf_trade_routes: 'Маршруты',
|
||
sf_cargo_types: 'Грузы',
|
||
sf_home_port: 'Домашний порт',
|
||
sf_home_port_ph: 'Баку, Роттердам...',
|
||
sf_search_radius: 'Радиус поиска',
|
||
sf_tonnage_dwt: 'DWT, т',
|
||
sf_tonnage_cargo: 'Груз, т',
|
||
sf_tonnage_min: 'Min DWT',
|
||
sf_hint_text: 'Выберите параметры \u2014 AI-агент будет автоматически учитывать их в ответах. Изменения сохраняются мгновенно.',
|
||
},
|
||
es: {
|
||
subtitle: '',
|
||
status: 'En línea — Listo',
|
||
services_title: 'Servicios',
|
||
header_sub: '',
|
||
map_toggle_map: '\ud83c\udf0e Mapa',
|
||
map_toggle_chat: '\ud83d\udcac Chat',
|
||
map_vessels_shown: 'Buques mostrados',
|
||
map_last_update: 'Actualizado',
|
||
map_zoom_hint: 'Ampliar para ver buques',
|
||
map_knots: 'kn',
|
||
map_destination: 'Destino',
|
||
map_status: 'Estado',
|
||
map_type: 'Tipo',
|
||
map_flag: 'Bandera',
|
||
map_speed: 'Velocidad',
|
||
map_course: 'Rumbo',
|
||
map_dwt: 'TPM',
|
||
map_imo: 'IMO',
|
||
map_mmsi: 'MMSI',
|
||
clear_chat: 'Limpiar chat',
|
||
input_hint: 'Enter para enviar \u2022 Shift+Enter nueva línea',
|
||
placeholder: 'Mensaje...',
|
||
free: 'Gratis',
|
||
temp_free: 'Gratis ahora',
|
||
svc_search: 'Búsqueda de buques',
|
||
svc_search_desc: 'Por nombre, IMO, MMSI',
|
||
svc_position: 'Posición del buque',
|
||
svc_position_desc: 'Seguimiento AIS en tiempo real',
|
||
svc_owner: 'Propietario / Operador',
|
||
svc_owner_desc: 'Información de la empresa',
|
||
svc_contacts: 'Contactos',
|
||
svc_contacts_desc: 'Cargador \u2194 Operador',
|
||
svc_group_tracking: 'Seguimiento',
|
||
svc_group_commercial: 'Comercial',
|
||
svc_group_contacts: 'Contactos',
|
||
svc_port_vessels: 'Buques en puerto',
|
||
svc_port_vessels_desc: 'Buques en 16.000+ puertos',
|
||
svc_route: 'Calculadora de rutas',
|
||
svc_route_desc: 'Distancia, tiempo, costo',
|
||
svc_cargo: 'Match de carga',
|
||
svc_cargo_desc: 'Buques para su carga',
|
||
footer: 'Montana Protocol \u2022 25 a\u00f1os de experiencia',
|
||
welcome: '<strong>\u00a1Bienvenido a SeaFare Montana!</strong><br><br>' +
|
||
'Su plataforma AI de inteligencia mar\u00edtima. 24 herramientas, 16.000+ puertos, una conversaci\u00f3n:<br><br>' +
|
||
'<strong>SEGUIMIENTO</strong><br>' +
|
||
'🚢 B\u00fasqueda • 📍 Posici\u00f3n • ⚓ Buques en puerto • 🏢 Propietario<br><br>' +
|
||
'<strong>COMERCIAL</strong><br>' +
|
||
'🗺 Rutas • 📈 Tarifas de flete • 🔃 Match de carga • 💲 Demoras<br><br>' +
|
||
'<strong>AN\u00c1LISIS</strong><br>' +
|
||
'📊 Rendimiento • 🌪 Ruta meteorol\u00f3gica • 🏘 Costos portuarios • 🛡 Seguro • 👨‍✈️ Tripulaci\u00f3n<br><br>' +
|
||
'🙋 <strong>Contactos</strong> \u2014 conexi\u00f3n con operadores (gratis)<br><br>' +
|
||
'\u00a1Pruebe las acciones r\u00e1pidas o haga cualquier pregunta!',
|
||
chat_cleared: '<strong>Chat limpiado.</strong><br>¿En qué puedo ayudarle hoy?',
|
||
confirm_clear: '¿Está seguro de que desea borrar el historial del chat?',
|
||
error_generic: 'Lo siento, algo salió mal. Inténtelo de nuevo.',
|
||
error_connection: 'Error de conexión. Asegúrese de que el servidor esté en el puerto 5050.',
|
||
loading: 'Cargando...',
|
||
rate_limited: 'Demasiadas solicitudes. Espere un momento.',
|
||
ex_btn: 'Consultas populares',
|
||
ex_cat_port: 'Buques en puerto',
|
||
ex_cat_search: 'Buscar buque',
|
||
ex_cat_route: 'Rutas',
|
||
ex_cat_cargo: 'Carga',
|
||
ex_cat_info: 'Datos del buque',
|
||
ex_q_vessels_baku: '¿Qué buques hay cerca de Bakú?',
|
||
ex_q_vessels_novo: 'Buques cerca de Novorossiysk',
|
||
ex_q_find_vessel: 'Buscar buque EVER GIVEN',
|
||
ex_q_where_vessel: '¿Dónde está el tanque NASIMI?',
|
||
ex_q_route: 'Ruta de Aktau a Estambul',
|
||
ex_q_distance: 'Distancia Bakú — Batumi',
|
||
ex_q_cargo_vessel: 'Buque para 5000t grano desde Aktau',
|
||
ex_q_cargo_send: '¿Qué carga puedo enviar desde Bakú?',
|
||
ex_q_owner: '¿Quién es dueño del buque LACHIN?',
|
||
ex_q_vessel_data: 'Datos del buque IMO 8848745',
|
||
qb_search: 'Buscar buque',
|
||
qb_vessels_port: 'Buques en puerto',
|
||
qb_route: 'Ruta A\u2192B',
|
||
qb_cargo: 'Enviar carga',
|
||
qb_contacts: 'Contactos',
|
||
qm_btn_search: 'Buscar buque EVER GIVEN',
|
||
qm_btn_vessels_port: '\u00bfQu\u00e9 buques hay cerca de R\u00f3terdam ahora?',
|
||
qm_btn_route: 'Calcula la ruta de Shangh\u00e1i a R\u00f3terdam para un granelero Panamax, 75000 DWT',
|
||
qm_btn_cargo: 'Necesito enviar 50000 toneladas de grano de Santos a R\u00f3terdam',
|
||
qm_btn_contacts: 'Buscar contactos de operadores de graneleros',
|
||
qm_search_vessel: 'Buscar buque EVER GIVEN',
|
||
qm_vessel_position: '\u00bfD\u00f3nde est\u00e1 EVER GIVEN ahora?',
|
||
qm_vessels_port: '\u00bfQu\u00e9 buques hay cerca de R\u00f3terdam?',
|
||
qm_owner_info: '\u00bfQui\u00e9n es el propietario de EVER GIVEN?',
|
||
qm_route_calc: 'Calcula la ruta de Shangh\u00e1i a R\u00f3terdam para un granelero Panamax',
|
||
qm_cargo_match: 'Necesito enviar 50000 toneladas de grano de Santos a R\u00f3terdam',
|
||
qm_port_congestion: '\u00bfCu\u00e1l es la congesti\u00f3n en el puerto de Singapur?',
|
||
qm_contacts: 'Buscar contactos de operadores de graneleros',
|
||
// Auth
|
||
auth_login: 'Entrar',
|
||
auth_logout: 'Salir',
|
||
auth_title_login: 'Iniciar sesión',
|
||
auth_title_register: 'Crear cuenta',
|
||
auth_email: 'Correo electrónico',
|
||
auth_password: 'Contraseña',
|
||
auth_name: 'Nombre (opcional)',
|
||
auth_login_btn: 'Iniciar sesión',
|
||
auth_google_btn: 'Iniciar sesión con Google',
|
||
auth_register_btn: 'Registrarse',
|
||
auth_switch_to_register: '¿No tiene cuenta? Regístrese',
|
||
auth_switch_to_login: '¿Ya tiene cuenta? Inicie sesión',
|
||
auth_email_exists_login: 'Este email ya está registrado. Inicie sesión.',
|
||
auth_send_code: 'Enviar código',
|
||
auth_verify_code: 'Verificar',
|
||
auth_create_account: 'Crear cuenta',
|
||
auth_resend_code: 'Reenviar código',
|
||
auth_code_sent_to: 'Código enviado a',
|
||
auth_step1_title: 'Ingrese su correo',
|
||
auth_step2_title: 'Ingrese el código de verificación',
|
||
auth_step3_title: 'Cree su cuenta',
|
||
auth_code_expires: 'El código expira en',
|
||
auth_or: 'o',
|
||
auth_error_generic: 'Algo salió mal. Inténtelo de nuevo.',
|
||
user_balance: 'Saldo',
|
||
profile_btn: 'Gabinete',
|
||
profile_title: 'Mi Gabinete',
|
||
profile_subtitle: 'Complete su perfil para que SeaFare Montana le dé respuestas más precisas y personalizadas.',
|
||
purchased_contacts_title: 'Contactos comprados',
|
||
purchased_contacts_empty: 'Aún no tiene contactos comprados. Desbloquee contactos a través del chat.',
|
||
profile_company_section: 'Empresa',
|
||
profile_company: 'Nombre de la empresa',
|
||
profile_role: 'Su rol',
|
||
profile_experience: 'Experiencia (años)',
|
||
profile_fleet_size: 'Tamaño de la flota (buques)',
|
||
profile_home_port_title: 'Puerto base',
|
||
profile_home_port_placeholder: 'Escriba el nombre del puerto...',
|
||
wiz_home_port_label: 'Su puerto base (opcional)',
|
||
profile_vessel_types_title: 'Tipos de buques',
|
||
profile_trade_routes_title: 'Rutas comerciales',
|
||
profile_cargo_types_title: 'Tipos de carga',
|
||
profile_vessels_title: 'Buques de interés',
|
||
profile_vessels_hint: 'Agregue nombres de buques o números IMO para seguimiento',
|
||
profile_add_vessel: 'Agregar buque',
|
||
profile_contact_section: 'Contacto',
|
||
profile_phone: 'Teléfono',
|
||
profile_notes_title: 'Notas',
|
||
profile_notes_placeholder: 'Información adicional para el agente AI...',
|
||
profile_company_placeholder: 'ej. Pacific Shipping Ltd.',
|
||
profile_phone_placeholder: '+34 600 123 456',
|
||
profile_role_select: '— Seleccionar —',
|
||
profile_cancel: 'Cancelar',
|
||
profile_save: 'Guardar perfil',
|
||
profile_saved: '¡Perfil guardado exitosamente!',
|
||
voice_permission: 'Permita el acceso al micrófono para la entrada de voz.',
|
||
voice_not_supported: 'La entrada de voz no es compatible con este navegador. Pruebe Chrome o Yandex Browser.',
|
||
voice_listening: 'Escuchando...',
|
||
voice_tooltip: 'Entrada de voz',
|
||
topup_btn: 'Recargar',
|
||
topup_title: 'Recargar saldo',
|
||
topup_instruction: 'Envíe USDT (TRC20) a la dirección de abajo. Su saldo se acreditará después de la confirmación.',
|
||
topup_copy: 'Copiar',
|
||
topup_copied: '¡Copiado!',
|
||
topup_check: 'Verificar depósitos',
|
||
topup_checking: 'Verificando...',
|
||
topup_found: 'Depositado: ${amount} USDT (comisión 2%). Acreditado: ${credited} USDT. Saldo: ${balance} USDT',
|
||
topup_none: 'No se encontraron nuevos depósitos. Si ya envió USDT, espere unos minutos e intente de nuevo.',
|
||
topup_error: 'Error al verificar. Intente más tarde.',
|
||
topup_close: 'Cerrar',
|
||
topup_history: 'Historial de depósitos',
|
||
deposit_fee_info: 'Comisión del servicio: 2%. Ejemplo: envía $100 — recibe $98.',
|
||
wallet_tab_deposit: 'Depósito',
|
||
wallet_tab_withdraw: 'Retiro',
|
||
withdraw_your_balance: 'Su saldo:',
|
||
withdraw_address_label: 'Dirección de billetera TRC20',
|
||
withdraw_amount_label: 'Monto (USDT)',
|
||
withdraw_fee_label: 'Comisión de red:',
|
||
withdraw_total_label: 'Total a deducir:',
|
||
withdraw_note: 'Retiro mínimo: $2 USDT. Los retiros se procesan manualmente en 24 horas.',
|
||
withdraw_submit: 'Solicitar retiro',
|
||
withdraw_success: '¡Retiro solicitado! Monto: ${amount} USDT (comisión: $1). Nuevo saldo: ${balance} USDT',
|
||
withdraw_history: 'Historial de retiros',
|
||
withdraw_status_pending: 'Pendiente',
|
||
withdraw_status_completed: 'Completado',
|
||
withdraw_status_rejected: 'Rechazado',
|
||
// Subscription
|
||
sub_title: 'Planes de suscripci\u00f3n',
|
||
sub_subtitle: 'Elija el plan que se adapte a sus necesidades. Mejore en cualquier momento desde su saldo.',
|
||
sub_close: 'Cerrar',
|
||
sub_current: 'Actual',
|
||
sub_recommended: 'Mejor valor',
|
||
sub_current_plan: 'Plan actual',
|
||
sub_upgrade_to: 'Cambiar a',
|
||
sub_downgrade: 'Bajar',
|
||
sub_per_month: 'por mes',
|
||
sub_upgraded: '\u00a1Plan actualizado exitosamente!',
|
||
sub_error: 'Error al actualizar. Verifique su saldo.',
|
||
sub_upgrade_hint: 'Toque para mejorar',
|
||
changelog_title: 'Historial de cambios',
|
||
wiz_step1_title: '¿Cuál es tu rol?',
|
||
wiz_step1_sub: 'Esto nos ayuda a personalizar tu experiencia',
|
||
wiz_role_shipowner: 'Armador / Operador',
|
||
wiz_role_shipowner_desc: 'Tengo buques y busco carga',
|
||
wiz_role_charterer: 'Cargador',
|
||
wiz_role_charterer_desc: 'Tengo carga y necesito buques',
|
||
wiz_role_broker: 'Bróker / Agente',
|
||
wiz_role_broker_desc: 'Conecto armadores con cargadores',
|
||
wiz_step2_vtype: '¿Qué tipo de buques opera?',
|
||
wiz_step2_ctype: '¿Qué tipo de carga transporta?',
|
||
wiz_step2_btype: '¿Qué cargas transportan sus clientes?',
|
||
wiz_step2_dwt: 'DWT promedio (peso muerto)',
|
||
wiz_step2_tonnage: 'Tonelaje típico de carga',
|
||
wiz_step3_title: 'Región de operación',
|
||
wiz_step3_sub: 'Seleccione sus aguas principales (múltiple permitido)',
|
||
wiz_step4_title: 'Datos de contacto',
|
||
wiz_step4_sub: 'Opcional — puede agregar después en su gabinete',
|
||
wiz_company: 'Nombre de empresa',
|
||
wiz_phone: 'Teléfono',
|
||
wiz_back: 'Atrás',
|
||
wiz_next: 'Siguiente',
|
||
wiz_skip: 'Omitir',
|
||
wiz_finish: 'Comenzar',
|
||
cargo_form_title: 'Buscar buques para carga',
|
||
cargo_form_subtitle: 'Seleccione tipo de carga y puerto de carga para encontrar buques disponibles',
|
||
cargo_form_type: 'Tipo de carga',
|
||
cargo_type_dry: 'Carga seca',
|
||
cargo_type_dry_desc: 'Grano, carbón, mineral, cemento, azúcar, fertilizante',
|
||
cargo_type_container: 'Contenedores',
|
||
cargo_type_container_desc: 'TEU, electrónica, maquinaria, muebles',
|
||
cargo_type_liquid: 'Carga líquida',
|
||
cargo_type_liquid_desc: 'Petróleo crudo, químicos, GNL, aceite de palma',
|
||
cargo_form_ports: 'Puerto',
|
||
cargo_form_from_port: 'Puerto de carga *',
|
||
cargo_form_from_port_ph: 'ej. Santos, Róterdam, Singapur',
|
||
cargo_form_to_port: 'Puerto de destino (opcional)',
|
||
cargo_form_to_port_ph: 'ej. Róterdam, Qingdao, Houston',
|
||
cargo_form_details: 'Detalles (opcional)',
|
||
cargo_form_tonnage: 'Tonelaje (MT) o TEU',
|
||
cargo_form_cancel: 'Cancelar',
|
||
cargo_form_search: 'Buscar buques',
|
||
cargo_form_error_type: 'Seleccione un tipo de carga',
|
||
cargo_form_error_port: 'Ingrese un puerto de carga',
|
||
sf_title: 'Mis filtros',
|
||
sf_role: 'Rol',
|
||
sf_vessel_types: 'Tipos de buques',
|
||
sf_trade_routes: 'Rutas',
|
||
sf_cargo_types: 'Carga',
|
||
sf_home_port: 'Puerto base',
|
||
sf_home_port_ph: 'Bakú, Rotterdam...',
|
||
sf_search_radius: 'Radio de búsqueda',
|
||
sf_tonnage_dwt: 'DWT, t',
|
||
sf_tonnage_cargo: 'Carga, t',
|
||
sf_tonnage_min: 'Min DWT',
|
||
sf_hint_text: 'Seleccione sus preferencias \u2014 el agente AI las usar\u00e1 autom\u00e1ticamente en las respuestas. Los cambios se guardan al instante.',
|
||
},
|
||
zh: {
|
||
subtitle: '',
|
||
status: '在线 — 就绪',
|
||
services_title: '服务',
|
||
header_sub: '',
|
||
map_toggle_map: '\ud83c\udf0e 地图',
|
||
map_toggle_chat: '\ud83d\udcac 聊天',
|
||
map_vessels_shown: '显示船舶',
|
||
map_last_update: '更新时间',
|
||
map_zoom_hint: '放大查看船舶',
|
||
map_knots: '节',
|
||
map_destination: '目的地',
|
||
map_status: '状态',
|
||
map_type: '类型',
|
||
map_flag: '旗帜',
|
||
map_speed: '速度',
|
||
map_course: '航向',
|
||
map_dwt: '载重吨',
|
||
map_imo: 'IMO',
|
||
map_mmsi: 'MMSI',
|
||
clear_chat: '清除聊天',
|
||
input_hint: 'Enter 发送 \u2022 Shift+Enter 换行',
|
||
placeholder: '发送消息...',
|
||
free: '免费',
|
||
temp_free: '现在免费',
|
||
svc_search: '船舶搜索',
|
||
svc_search_desc: '名称、IMO、MMSI 查询',
|
||
svc_position: '船舶位置',
|
||
svc_position_desc: 'AIS 实时追踪',
|
||
svc_owner: '船主 / 运营商',
|
||
svc_owner_desc: '公司信息',
|
||
svc_contacts: '联系人介绍',
|
||
svc_contacts_desc: '货主 \u2194 运营商',
|
||
svc_group_tracking: '追踪',
|
||
svc_group_commercial: '商业',
|
||
svc_group_contacts: '联系人',
|
||
svc_port_vessels: '港口附近船舶',
|
||
svc_port_vessels_desc: '16,000+ 港口实时船舶',
|
||
svc_route: '航线计算器',
|
||
svc_route_desc: '距离、时间、费用',
|
||
svc_cargo: '货物匹配',
|
||
svc_cargo_desc: '为货物寻找船舶',
|
||
footer: 'Montana Protocol \u2022 25年专业经验',
|
||
welcome: '<strong>欢迎来到 SeaFare Montana!</strong><br><br>' +
|
||
'AI 海运智能平台。24个工具,16,000+港口,一个对话:<br><br>' +
|
||
'<strong>追踪</strong><br>' +
|
||
'🚢 搜索船舶 • 📍 实时位置 • ⚓ 港口附近船舶 • 🏢 船主信息<br><br>' +
|
||
'<strong>商业</strong><br>' +
|
||
'🗺 航线计算 • 📈 运费 • 🔃 货物匹配 • 💲 滞期费<br><br>' +
|
||
'🙋 <strong>联系人介绍</strong> — 与运营商联系(免费)<br><br>' +
|
||
'试试下方快捷操作或直接提问!',
|
||
chat_cleared: '<strong>聊天已清除。</strong><br>今天有什么可以帮您?',
|
||
confirm_clear: '确定要清除聊天记录吗?',
|
||
error_generic: '抱歉,出现了问题。请重试。',
|
||
error_connection: '连接错误。请确保服务器在端口 5050 上运行。',
|
||
loading: '加载中...',
|
||
rate_limited: '请求过多,请稍候。',
|
||
ex_btn: '热门查询',
|
||
ex_cat_search: '搜索船舶',
|
||
ex_cat_port: '港口附近船舶',
|
||
ex_cat_route: '航线',
|
||
ex_cat_cargo: '货物',
|
||
ex_cat_info: '船舶信息',
|
||
ex_q_find_vessel: '搜索船舶 EVER GIVEN',
|
||
ex_q_where_vessel: '油轮 NASIMI 在哪里?',
|
||
ex_q_vessels_baku: '现在巴库附近有哪些船?',
|
||
ex_q_vessels_novo: '新罗西斯克附近的船舶',
|
||
ex_q_route: '从阿克套到伊斯坦布尔的航线',
|
||
ex_q_distance: '巴库到巴统的距离',
|
||
ex_q_cargo_vessel: '从阿克套运5000吨谷物的船舶',
|
||
ex_q_cargo_send: '我能从巴库发送什么货物?',
|
||
ex_q_owner: 'LACHIN号船的船主是谁?',
|
||
ex_q_vessel_data: '船舶数据 IMO 8848745',
|
||
qb_search: '搜索船舶',
|
||
qb_vessels_port: '港口附近船舶',
|
||
qb_route: 'A\u2192B 航线',
|
||
qb_cargo: '货物运输',
|
||
qb_contacts: '找联系人',
|
||
qm_btn_search: '搜索船舶 EVER GIVEN',
|
||
qm_btn_vessels_port: '鹿特丹附近现在有哪些船?',
|
||
qm_btn_route: '计算从上海到鹿特丹巴拿马型散货船75000 DWT的航线',
|
||
qm_btn_cargo: '我需要从桑托斯运50000吨谷物到鹿特丹',
|
||
qm_btn_contacts: '寻找散货船运营商联系人',
|
||
qm_search_vessel: '搜索船舶 EVER GIVEN',
|
||
qm_vessel_position: 'EVER GIVEN 现在在哪里?',
|
||
qm_vessels_port: '鹿特丹附近现在有哪些船?',
|
||
qm_owner_info: 'EVER GIVEN 的船主和运营商是谁?',
|
||
qm_route_calc: '计算从上海到鹿特丹巴拿马型散货船的航线',
|
||
qm_cargo_match: '我需要从桑托斯运50000吨谷物到鹿特丹',
|
||
qm_port_congestion: '新加坡港口目前的拥堵情况如何?',
|
||
qm_contacts: '寻找散货船运营商联系人',
|
||
auth_login: '登录',
|
||
auth_logout: '退出',
|
||
auth_title_login: '登录',
|
||
auth_title_register: '创建账户',
|
||
auth_email: '电子邮件',
|
||
auth_password: '密码',
|
||
auth_name: '姓名(可选)',
|
||
auth_login_btn: '登录',
|
||
auth_google_btn: '使用 Google 登录',
|
||
auth_register_btn: '注册',
|
||
auth_switch_to_register: '没有账户?注册',
|
||
auth_switch_to_login: '已有账户?登录',
|
||
auth_email_exists_login: '此邮箱已注册,请登录。',
|
||
auth_send_code: '发送验证码',
|
||
auth_verify_code: '验证',
|
||
auth_create_account: '创建账户',
|
||
auth_resend_code: '重新发送验证码',
|
||
auth_code_sent_to: '验证码已发送至',
|
||
auth_step1_title: '输入您的邮箱',
|
||
auth_step2_title: '输入验证码',
|
||
auth_step3_title: '创建您的账户',
|
||
auth_code_expires: '验证码有效期',
|
||
auth_or: '或',
|
||
auth_error_generic: '出现错误,请重试。',
|
||
user_balance: '余额',
|
||
profile_btn: '控制台',
|
||
profile_title: '我的控制台',
|
||
profile_subtitle: '填写您的资料,让 SeaFare Montana 提供更精确的个性化答案。',
|
||
purchased_contacts_title: '已购联系人',
|
||
purchased_contacts_empty: '还没有已购联系人。通过聊天解锁联系人。',
|
||
profile_company_section: '公司',
|
||
profile_company: '公司名称',
|
||
profile_role: '您的职位',
|
||
profile_experience: '经验(年)',
|
||
profile_fleet_size: '船队规模(艘)',
|
||
profile_home_port_title: '主基地港口',
|
||
profile_home_port_placeholder: '开始输入港口名称...',
|
||
wiz_home_port_label: '您的主基地港口(可选)',
|
||
profile_vessel_types_title: '船舶类型',
|
||
profile_trade_routes_title: '贸易航线',
|
||
profile_cargo_types_title: '货物类型',
|
||
profile_vessels_title: '关注的船舶',
|
||
profile_vessels_hint: '添加要追踪的船舶名称或 IMO 号码',
|
||
profile_add_vessel: '添加船舶',
|
||
profile_contact_section: '联系方式',
|
||
profile_phone: '电话',
|
||
profile_notes_title: '备注',
|
||
profile_notes_placeholder: 'AI 代理的其他信息...',
|
||
profile_company_placeholder: '例如 Pacific Shipping Ltd.',
|
||
profile_phone_placeholder: '+86 138 0000 0000',
|
||
profile_role_select: '— 请选择 —',
|
||
profile_cancel: '取消',
|
||
profile_save: '保存资料',
|
||
profile_saved: '资料保存成功!',
|
||
voice_permission: '请允许访问麦克风以使用语音输入。',
|
||
voice_not_supported: '此浏览器不支持语音输入。请使用 Chrome 浏览器。',
|
||
voice_listening: '正在收听...',
|
||
voice_tooltip: '语音输入',
|
||
topup_btn: '充值',
|
||
topup_title: '余额充值',
|
||
topup_instruction: '将 USDT (TRC20) 发送到以下地址。确认后余额将到账。',
|
||
topup_copy: '复制',
|
||
topup_copied: '已复制!',
|
||
topup_check: '检查存款',
|
||
topup_checking: '检查中...',
|
||
topup_found: '已存入:${amount} USDT(手续费 2%)。到账:${credited} USDT。余额:${balance} USDT',
|
||
topup_none: '未找到新存款。如果您已发送 USDT,请等几分钟后重试。',
|
||
topup_error: '检查存款出错,请稍后重试。',
|
||
topup_close: '关闭',
|
||
topup_history: '存款历史',
|
||
deposit_fee_info: '服务手续费:2%。例:发送 $100 — 到账 $98。',
|
||
wallet_tab_deposit: '存款',
|
||
wallet_tab_withdraw: '提款',
|
||
withdraw_your_balance: '您的余额:',
|
||
withdraw_address_label: 'TRC20 钱包地址',
|
||
withdraw_amount_label: '金额 (USDT)',
|
||
withdraw_fee_label: '网络手续费:',
|
||
withdraw_total_label: '总计扣除:',
|
||
withdraw_note: '最低提款:$2 USDT。提款在24小时内手动处理。',
|
||
withdraw_submit: '申请提款',
|
||
withdraw_success: '提款已提交!金额:${amount} USDT(手续费:$1)。新余额:${balance} USDT',
|
||
withdraw_history: '提款历史',
|
||
withdraw_status_pending: '待处理',
|
||
withdraw_status_completed: '已完成',
|
||
withdraw_status_rejected: '已拒绝',
|
||
sub_title: '订阅计划',
|
||
sub_subtitle: '选择适合您的计划。随时从余额升级。',
|
||
sub_close: '关闭',
|
||
sub_current: '当前',
|
||
sub_recommended: '最佳价值',
|
||
sub_current_plan: '当前计划',
|
||
sub_upgrade_to: '升级至',
|
||
sub_downgrade: '降级',
|
||
sub_per_month: '每月',
|
||
sub_upgraded: '计划升级成功!',
|
||
sub_error: '升级失败,请检查余额。',
|
||
sub_upgrade_hint: '点击升级',
|
||
changelog_title: '更新日志',
|
||
wiz_step1_title: '您的角色是什么?',
|
||
wiz_step1_sub: '这有助于我们个性化您的体验',
|
||
wiz_role_shipowner: '船主 / 运营商',
|
||
wiz_role_shipowner_desc: '我拥有船舶并寻找货物',
|
||
wiz_role_charterer: '货主',
|
||
wiz_role_charterer_desc: '我有货物需要找船',
|
||
wiz_role_broker: '经纪人 / 代理',
|
||
wiz_role_broker_desc: '我连接船主和货主',
|
||
wiz_step2_vtype: '您运营什么类型的船舶?',
|
||
wiz_step2_ctype: '您运输什么类型的货物?',
|
||
wiz_step2_btype: '您的客户运输什么类型的货物?',
|
||
wiz_step2_dwt: '平均载重吨(DWT)',
|
||
wiz_step2_tonnage: '典型货物吨位',
|
||
wiz_step3_title: '运营区域',
|
||
wiz_step3_sub: '选择您的主要水域(可多选)',
|
||
wiz_step4_title: '联系方式',
|
||
wiz_step4_sub: '可选 — 可稍后在控制台添加',
|
||
wiz_company: '公司名称',
|
||
wiz_phone: '电话',
|
||
wiz_back: '返回',
|
||
wiz_next: '下一步',
|
||
wiz_skip: '跳过',
|
||
wiz_finish: '开始使用',
|
||
cargo_form_title: '为货物寻找船舶',
|
||
cargo_form_subtitle: '选择货物类型和装货港以寻找可用船舶',
|
||
cargo_form_type: '货物类型',
|
||
cargo_type_dry: '干散货',
|
||
cargo_type_dry_desc: '粮食、煤炭、矿石、水泥、糖、化肥',
|
||
cargo_type_container: '集装箱',
|
||
cargo_type_container_desc: 'TEU、电子产品、机械、家具',
|
||
cargo_type_liquid: '液体货物',
|
||
cargo_type_liquid_desc: '原油、化学品、液化天然气、棕榈油',
|
||
cargo_form_ports: '港口详情',
|
||
cargo_form_from_port: '装货港 *',
|
||
cargo_form_from_port_ph: '例如 桑托斯、鹿特丹、新加坡',
|
||
cargo_form_to_port: '目的港(可选)',
|
||
cargo_form_to_port_ph: '例如 鹿特丹、青岛、休斯顿',
|
||
cargo_form_details: '详情(可选)',
|
||
cargo_form_tonnage: '吨位(MT)或 TEU',
|
||
cargo_form_cancel: '取消',
|
||
cargo_form_search: '搜索船舶',
|
||
cargo_form_error_type: '请选择货物类型',
|
||
cargo_form_error_port: '请输入装货港',
|
||
sf_title: '我的筛选条件',
|
||
sf_role: '角色',
|
||
sf_vessel_types: '船舶类型',
|
||
sf_trade_routes: '贸易航线',
|
||
sf_cargo_types: '货物',
|
||
sf_home_port: '主基地港口',
|
||
sf_home_port_ph: '巴库、鹿特丹...',
|
||
sf_search_radius: '搜索半径',
|
||
sf_tonnage_dwt: 'DWT(吨)',
|
||
sf_tonnage_cargo: '货物(吨)',
|
||
sf_tonnage_min: '最小 DWT',
|
||
sf_hint_text: '选择您的偏好 \u2014 AI 代理将自动在回复中使用它们。更改即时保存。',
|
||
}
|
||
};
|
||
|
||
// =============================================================================
|
||
// State
|
||
// =============================================================================
|
||
let currentLang = localStorage.getItem('seafare_lang') || 'en';
|
||
let userHasSentMessage = false;
|
||
let _chatHistory = []; // In-memory conversation history for context
|
||
let sessionChecked = false;
|
||
let authToken = localStorage.getItem('seafare_token') || null;
|
||
let currentUser = null;
|
||
let authMode = 'login';
|
||
let googleClientId = null;
|
||
|
||
const API_BASE = '';
|
||
let mapInstance = null;
|
||
let mapMarkers = [];
|
||
let _mapMarkersByMmsi = {}; // mmsi → {marker, data, lastSeen}
|
||
let mapRefreshTimer = null;
|
||
let mapMode = false;
|
||
|
||
// Supercluster state
|
||
let _supercluster = null;
|
||
let _allVesselsGeoJSON = [];
|
||
let _clusterMarkers = []; // L.marker refs for current clusters
|
||
let _bulkLoaded = false;
|
||
let _bulkRefreshTimer = null;
|
||
const CLUSTER_ZOOM = 10; // zoom < 10 = clusters, >= 10 = individual arrows
|
||
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; // monotonic counter for stale request detection
|
||
let _clusterMarkersById = {}; // key → L.marker (for diff rendering)
|
||
let _lastClusterZoom = -1; // last zoom clusters were rendered at
|
||
const messagesDiv = document.getElementById('messages');
|
||
const input = document.getElementById('userInput');
|
||
const sendBtn = document.getElementById('sendBtn');
|
||
|
||
function t(key) {
|
||
return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Auth
|
||
// =============================================================================
|
||
// =============================================================================
|
||
// Auth — Language selector + 3-step registration
|
||
// =============================================================================
|
||
let regStep = 1;
|
||
let regVerifiedToken = null;
|
||
let regEmail = '';
|
||
let _codeCountdown = null;
|
||
|
||
function setAuthLang(lang) {
|
||
googleBtnRendered = false;
|
||
currentLang = lang;
|
||
localStorage.setItem('seafare_lang', lang);
|
||
// Update auth modal buttons
|
||
document.querySelectorAll('.auth-lang-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.lang === lang);
|
||
});
|
||
// Sync main nav lang buttons
|
||
document.querySelectorAll('.lang-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.lang === lang);
|
||
});
|
||
updateAuthForm();
|
||
// Update all data-i18n elements on page
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.dataset.lang || el.dataset.i18n || el.getAttribute('data-i18n'));
|
||
});
|
||
// Translate profile chips
|
||
if (typeof translateProfileChips === 'function') translateProfileChips();
|
||
}
|
||
|
||
function showAuthModal() {
|
||
document.getElementById('authOverlay').classList.remove('hidden');
|
||
document.body.classList.add('auth-mode');
|
||
regStep = 1;
|
||
regVerifiedToken = null;
|
||
regEmail = '';
|
||
updateAuthForm();
|
||
// Highlight active lang button
|
||
document.querySelectorAll('.auth-lang-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.lang === currentLang);
|
||
});
|
||
// Re-init Google button
|
||
if (googleClientId) {
|
||
setTimeout(() => initGoogleButton(), 100);
|
||
if (!window.google) {
|
||
let _gsiRetry = setInterval(() => {
|
||
if (window.google) {
|
||
clearInterval(_gsiRetry);
|
||
updateAuthForm();
|
||
initGoogleButton();
|
||
}
|
||
}, 300);
|
||
setTimeout(() => clearInterval(_gsiRetry), 10000);
|
||
}
|
||
}
|
||
}
|
||
|
||
function hideAuthModal(e) {
|
||
// Mandatory login — only close if authenticated
|
||
if (!authToken) return;
|
||
if (e) e.preventDefault();
|
||
if (!authToken) return;
|
||
document.getElementById('authOverlay').classList.add('hidden');
|
||
document.body.classList.remove('auth-mode');
|
||
}
|
||
|
||
function toggleAuthMode(e) {
|
||
e.preventDefault();
|
||
authMode = authMode === 'login' ? 'register' : 'login';
|
||
regStep = 1;
|
||
regVerifiedToken = null;
|
||
document.getElementById('authError').style.display = 'none';
|
||
updateAuthForm();
|
||
}
|
||
|
||
function updateAuthForm() {
|
||
const isLogin = authMode === 'login';
|
||
const err = document.getElementById('authError');
|
||
err.style.display = 'none';
|
||
err.style.background = ''; err.style.borderColor = ''; err.style.color = '';
|
||
|
||
// Show/hide login form vs register steps
|
||
document.getElementById('authFormLogin').style.display = isLogin ? 'block' : 'none';
|
||
document.getElementById('regStep1').style.display = (!isLogin && regStep === 1) ? 'block' : 'none';
|
||
document.getElementById('regStep2').style.display = (!isLogin && regStep === 2) ? 'block' : 'none';
|
||
document.getElementById('regStep3').style.display = (!isLogin && regStep === 3) ? 'block' : 'none';
|
||
document.getElementById('regSteps').style.display = isLogin ? 'none' : 'flex';
|
||
|
||
// Step dots
|
||
for (let i = 1; i <= 3; i++) {
|
||
const dot = document.getElementById('regDot' + i);
|
||
dot.className = 'reg-step-dot';
|
||
if (i < regStep) dot.classList.add('done');
|
||
if (i === regStep) dot.classList.add('active');
|
||
}
|
||
|
||
// Title
|
||
if (isLogin) {
|
||
document.getElementById('authTitle').textContent = t('auth_title_login');
|
||
} else {
|
||
document.getElementById('authTitle').textContent = t('auth_step' + regStep + '_title');
|
||
}
|
||
|
||
// Buttons
|
||
if (isLogin) {
|
||
document.getElementById('authLoginBtn').textContent = t('auth_login_btn');
|
||
document.getElementById('authEmail').placeholder = t('auth_email');
|
||
document.getElementById('authPassword').placeholder = t('auth_password');
|
||
}
|
||
if (!isLogin && regStep === 1) {
|
||
document.getElementById('regSendCodeBtn').textContent = t('auth_send_code');
|
||
document.getElementById('regEmail').placeholder = t('auth_email');
|
||
}
|
||
if (!isLogin && regStep === 2) {
|
||
document.getElementById('regVerifyBtn').textContent = t('auth_verify_code');
|
||
document.getElementById('resendLink').textContent = t('auth_resend_code');
|
||
// Build 6-digit code inputs
|
||
const wrap = document.getElementById('codeInputWrap');
|
||
if (!wrap.querySelector('input')) {
|
||
wrap.innerHTML = '';
|
||
for (let i = 0; i < 6; i++) {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'text';
|
||
inp.maxLength = 1;
|
||
inp.inputMode = 'numeric';
|
||
inp.pattern = '[0-9]';
|
||
inp.id = 'code' + i;
|
||
inp.addEventListener('input', function() {
|
||
this.value = this.value.replace(/[^0-9]/g, '');
|
||
if (this.value && i < 5) document.getElementById('code' + (i+1)).focus();
|
||
});
|
||
inp.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Backspace' && !this.value && i > 0) {
|
||
document.getElementById('code' + (i-1)).focus();
|
||
}
|
||
});
|
||
inp.addEventListener('paste', function(e) {
|
||
e.preventDefault();
|
||
const paste = (e.clipboardData || window.clipboardData).getData('text').replace(/[^0-9]/g, '');
|
||
for (let j = 0; j < 6 && j < paste.length; j++) {
|
||
document.getElementById('code' + j).value = paste[j];
|
||
}
|
||
if (paste.length >= 6) document.getElementById('code5').focus();
|
||
});
|
||
wrap.appendChild(inp);
|
||
}
|
||
document.getElementById('code0').focus();
|
||
}
|
||
}
|
||
if (!isLogin && regStep === 3) {
|
||
document.getElementById('regCreateBtn').textContent = t('auth_create_account');
|
||
document.getElementById('regName').placeholder = t('auth_name');
|
||
document.getElementById('regPassword').placeholder = t('auth_password');
|
||
}
|
||
|
||
// Switch link
|
||
document.getElementById('authSwitchLink').textContent = t(isLogin ? 'auth_switch_to_register' : 'auth_switch_to_login');
|
||
|
||
// Google (only on login) — always show divider+button on login, hide on register
|
||
document.getElementById('authDivider').style.display = isLogin ? 'flex' : 'none';
|
||
document.getElementById('googleBtnWrap').style.display = isLogin ? 'flex' : 'none';
|
||
if (isLogin) {
|
||
document.querySelector('#authDivider span').textContent = t('auth_or');
|
||
var gfb = document.getElementById('googleFallbackBtn');
|
||
if (gfb) gfb.lastChild.textContent = ' ' + t('auth_google_btn');
|
||
if (googleClientId && window.google) initGoogleButton();
|
||
}
|
||
}
|
||
|
||
// --- Login ---
|
||
async function handleLogin(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('authLoginBtn');
|
||
btn.disabled = true;
|
||
document.getElementById('authError').style.display = 'none';
|
||
const email = document.getElementById('authEmail').value.trim();
|
||
const password = document.getElementById('authPassword').value;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/login', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({email, password, lang: currentLang})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
authToken = data.token;
|
||
currentUser = data.user;
|
||
localStorage.setItem('seafare_token', authToken);
|
||
hideAuthModal();
|
||
onLoginSuccess();
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// --- Register Step 1: send code ---
|
||
async function handleRegStep1(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('regSendCodeBtn');
|
||
btn.disabled = true;
|
||
document.getElementById('authError').style.display = 'none';
|
||
regEmail = document.getElementById('regEmail').value.trim().toLowerCase();
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/send-code', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({email: regEmail, lang: currentLang})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
regStep = 2;
|
||
updateAuthForm();
|
||
startCodeCountdown();
|
||
} else if (resp.status === 409) {
|
||
// Email already exists — switch to login, prefill email, show message
|
||
authMode = 'login';
|
||
regStep = 1;
|
||
updateAuthForm();
|
||
document.getElementById('authEmail').value = regEmail;
|
||
document.getElementById('authPassword').focus();
|
||
showAuthInfo(data.error || t('auth_email_exists_login'));
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function startCodeCountdown() {
|
||
let seconds = 600; // 10 min
|
||
const timerEl = document.getElementById('codeTimer');
|
||
const resendEl = document.getElementById('resendLink');
|
||
let resendCooldown = 60;
|
||
resendEl.classList.add('disabled');
|
||
if (_codeCountdown) clearInterval(_codeCountdown);
|
||
_codeCountdown = setInterval(() => {
|
||
seconds--;
|
||
resendCooldown--;
|
||
const m = Math.floor(seconds / 60);
|
||
const s = seconds % 60;
|
||
timerEl.textContent = t('auth_code_expires') + ' ' + m + ':' + String(s).padStart(2, '0');
|
||
if (resendCooldown <= 0) resendEl.classList.remove('disabled');
|
||
if (seconds <= 0) {
|
||
clearInterval(_codeCountdown);
|
||
timerEl.textContent = '';
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
async function resendCode(e) {
|
||
e.preventDefault();
|
||
if (document.getElementById('resendLink').classList.contains('disabled')) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/send-code', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({email: regEmail, lang: currentLang})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
startCodeCountdown();
|
||
// Clear code inputs
|
||
for (let i = 0; i < 6; i++) {
|
||
const inp = document.getElementById('code' + i);
|
||
if (inp) inp.value = '';
|
||
}
|
||
document.getElementById('code0').focus();
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
}
|
||
|
||
// --- Register Step 2: verify code ---
|
||
async function handleRegStep2() {
|
||
const btn = document.getElementById('regVerifyBtn');
|
||
btn.disabled = true;
|
||
document.getElementById('authError').style.display = 'none';
|
||
let code = '';
|
||
for (let i = 0; i < 6; i++) {
|
||
const v = (document.getElementById('code' + i) || {}).value || '';
|
||
code += v;
|
||
}
|
||
if (code.length !== 6) {
|
||
showAuthError(t('auth_error_generic'));
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/verify-code', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({email: regEmail, code: code, lang: currentLang})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success && data.verified_token) {
|
||
regVerifiedToken = data.verified_token;
|
||
if (_codeCountdown) clearInterval(_codeCountdown);
|
||
regStep = 3;
|
||
updateAuthForm();
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// --- Register Step 3: create account ---
|
||
async function handleRegStep3(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('regCreateBtn');
|
||
btn.disabled = true;
|
||
document.getElementById('authError').style.display = 'none';
|
||
const name = document.getElementById('regName').value.trim();
|
||
const password = document.getElementById('regPassword').value;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/register', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
verified_token: regVerifiedToken,
|
||
password: password,
|
||
name: name,
|
||
lang: currentLang
|
||
})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
authToken = data.token;
|
||
currentUser = data.user;
|
||
localStorage.setItem('seafare_token', authToken);
|
||
hideAuthModal();
|
||
onLoginSuccess();
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
let googleBtnRendered = false;
|
||
function triggerGoogleFallback() {
|
||
// Если GSI уже загружен — используем его
|
||
if (window.google && googleClientId) {
|
||
google.accounts.id.prompt();
|
||
return;
|
||
}
|
||
// Пробуем загрузить GSI
|
||
var s = document.createElement('script');
|
||
s.src = 'https://accounts.google.com/gsi/client';
|
||
s.onload = function() {
|
||
if (googleClientId) initGoogleButton();
|
||
if (window.google) google.accounts.id.prompt();
|
||
};
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function initGoogleButton() {
|
||
// Скрываем fallback когда GSI рендерит свою кнопку
|
||
var fb = document.getElementById('googleFallbackBtn');
|
||
if (!googleClientId || !window.google) return;
|
||
if (fb) fb.style.display = 'none';
|
||
try {
|
||
if (!googleBtnRendered) {
|
||
google.accounts.id.initialize({
|
||
client_id: googleClientId,
|
||
callback: handleGoogleResponse,
|
||
ux_mode: 'popup',
|
||
|
||
});
|
||
}
|
||
const container = document.getElementById('googleSignInBtn');
|
||
if (container) {
|
||
container.innerHTML = '';
|
||
const btnWidth = Math.min(300, window.innerWidth - 80);
|
||
google.accounts.id.renderButton(container, {
|
||
theme: 'outline', size: 'large',
|
||
width: btnWidth, text: 'signin_with',
|
||
|
||
});
|
||
}
|
||
googleBtnRendered = true;
|
||
} catch (e) {
|
||
console.error('GSI init error:', e);
|
||
}
|
||
}
|
||
|
||
async function handleGoogleResponse(response) {
|
||
document.getElementById('authError').style.display = 'none';
|
||
if (!response || !response.credential) {
|
||
showAuthError('Google sign-in failed. Please try again.');
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/google', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({credential: response.credential, lang: currentLang})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
authToken = data.token;
|
||
currentUser = data.user;
|
||
localStorage.setItem('seafare_token', authToken);
|
||
hideAuthModal();
|
||
onLoginSuccess();
|
||
} else {
|
||
showAuthError(data.error || t('auth_error_generic'));
|
||
}
|
||
} catch (err) {
|
||
console.error('Google auth error:', err);
|
||
showAuthError(t('auth_error_generic'));
|
||
}
|
||
}
|
||
|
||
function showAuthInfo(msg) {
|
||
const el = document.getElementById('authError');
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
el.style.background = 'rgba(212,175,55,0.1)';
|
||
el.style.borderColor = 'rgba(212,175,55,0.3)';
|
||
el.style.color = '#D4AF37';
|
||
}
|
||
|
||
function showAuthError(msg) {
|
||
const el = document.getElementById('authError');
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
async function checkSession() {
|
||
if (!authToken) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/me', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
currentUser = data.user;
|
||
onLoginSuccess();
|
||
} else {
|
||
localStorage.removeItem('seafare_token');
|
||
authToken = null;
|
||
}
|
||
} catch (e) {
|
||
// server unreachable — keep guest mode
|
||
}
|
||
}
|
||
|
||
async function onLoginSuccess() {
|
||
document.getElementById('userInfo').style.display = 'flex';
|
||
document.getElementById('userName').textContent = currentUser.name || currentUser.email;
|
||
document.getElementById('userBalance').textContent = '$' + (currentUser.balance || 0).toFixed(2);
|
||
document.getElementById('adminBadge').style.display = currentUser.is_admin ? '' : 'none';
|
||
document.getElementById('revenueBtn').style.display = currentUser.is_admin ? '' : 'none';
|
||
document.getElementById('costsBtn').style.display = currentUser.is_admin ? '' : 'none';
|
||
document.getElementById('logoutBtn').style.display = '';
|
||
document.getElementById('loginBtn').style.display = 'none';
|
||
updatePlanBadge();
|
||
|
||
// Load saved chat history
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/chat/history', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
if (data.messages && data.messages.length > 0) {
|
||
messagesDiv.innerHTML = '';
|
||
userHasSentMessage = true;
|
||
for (const msg of data.messages) {
|
||
addMessage(
|
||
msg.role === 'user' ? msg.message : formatResponse(msg.message),
|
||
msg.role === 'user'
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// Check if onboarding wizard is needed (no profile role set)
|
||
if (!localStorage.getItem('seafare_wizard_done')) {
|
||
try {
|
||
const profResp = await fetch(API_BASE + '/api/v1/profile', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (profResp.ok) {
|
||
const profData = await profResp.json();
|
||
if (profData.profile) {
|
||
profileData = profData.profile;
|
||
fillSidebarFilters(profData.profile);
|
||
}
|
||
if (!profData.profile || !profData.profile.role) {
|
||
fillSidebarFilters(profData.profile || {});
|
||
showOnboardingWizard();
|
||
return; // Don't show welcome yet — wizard will show it on finish
|
||
} else {
|
||
localStorage.setItem('seafare_wizard_done', '1');
|
||
}
|
||
}
|
||
} catch (e) { /* skip wizard on error */ }
|
||
} else {
|
||
// Wizard already done — still load profile for sidebar filters
|
||
try {
|
||
const profResp = await fetch(API_BASE + '/api/v1/profile', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (profResp.ok) {
|
||
const profData = await profResp.json();
|
||
if (profData.profile) {
|
||
profileData = profData.profile;
|
||
fillSidebarFilters(profData.profile);
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
async function doLogout() {
|
||
try {
|
||
await fetch(API_BASE + '/api/v1/auth/logout', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
|
||
authToken = null;
|
||
currentUser = null;
|
||
localStorage.removeItem('seafare_token');
|
||
document.getElementById('userInfo').style.display = 'none';
|
||
document.getElementById('adminBadge').style.display = 'none';
|
||
document.getElementById('revenueBtn').style.display = 'none';
|
||
document.getElementById('logoutBtn').style.display = 'none';
|
||
document.getElementById('loginBtn').style.display = '';
|
||
document.getElementById('sidebarPlan').style.display = 'none';
|
||
hideSidebarFilters();
|
||
messagesDiv.innerHTML = '';
|
||
userHasSentMessage = false;
|
||
showWelcome();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Revenue Dashboard (admin)
|
||
// =============================================================================
|
||
|
||
async function showRevenue() {
|
||
if (!authToken || !currentUser || !currentUser.is_admin) return;
|
||
document.getElementById('revenueOverlay').classList.remove('hidden');
|
||
document.getElementById('revenueContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:var(--text-dim);">Loading...</div>';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/admin/revenue', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
const data = await resp.json();
|
||
if (!data.success) {
|
||
document.getElementById('revenueContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:#ff6b6b;">Error: ' + escapeHtml(data.error || 'Unknown') + '</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// === PROFIT — main card ===
|
||
html += '<div class="revenue-cards">';
|
||
html += '<div class="revenue-card highlight"><div class="label">Platform Profit</div><div class="value green">$' + (data.platform_profit || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Service Revenue</div><div class="value green">$' + (data.total_revenue || 0).toFixed(2) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// === Revenue periods ===
|
||
html += '<div class="revenue-cards triple">';
|
||
html += '<div class="revenue-card"><div class="label">Today</div><div class="value sm green">$' + (data.today_revenue || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">7 Days</div><div class="value sm green">$' + (data.week_revenue || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">30 Days</div><div class="value sm green">$' + (data.month_revenue || 0).toFixed(2) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// === Daily revenue chart (last 14 days) ===
|
||
if (data.daily_revenue && data.daily_revenue.length > 0) {
|
||
html += '<div class="revenue-section-title">Daily Revenue (14 days)</div>';
|
||
const maxVal = Math.max(...data.daily_revenue.map(d => d.amount), 1);
|
||
html += '<div class="revenue-bar-chart">';
|
||
for (const d of data.daily_revenue) {
|
||
const pct = Math.max((d.amount / maxVal) * 100, 3);
|
||
html += '<div class="revenue-bar" style="height:' + pct + '%" title="' + escapeHtml(d.date) + ': $' + d.amount.toFixed(2) + '"></div>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="revenue-bar-labels">';
|
||
for (const d of data.daily_revenue) {
|
||
html += '<span>' + escapeHtml(d.date.slice(5)) + '</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
// === Financial flow ===
|
||
html += '<div class="revenue-section-title">Financial Flow</div>';
|
||
html += '<div class="revenue-cards">';
|
||
html += '<div class="revenue-card"><div class="label">Total Deposited</div><div class="value sm blue">$' + (data.total_deposited || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Deposit Fees (2%)</div><div class="value sm orange">$' + (data.deposit_fee_income || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">User Balances</div><div class="value sm">$' + (data.total_user_balances || 0).toFixed(2) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Withdrawn</div><div class="value sm">$' + (data.total_withdrawn || 0).toFixed(2) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// === User metrics ===
|
||
html += '<div class="revenue-section-title">Users</div>';
|
||
html += '<div class="revenue-cards">';
|
||
html += '<div class="revenue-card"><div class="label">Total Users</div><div class="value sm">' + (data.total_users || 0) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Paid Users</div><div class="value sm blue">' + (data.paying_users || 0) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Active Buyers</div><div class="value sm orange">' + (data.active_buyers || 0) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">New This Week</div><div class="value sm green">+' + (data.new_users_week || 0) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// === By service ===
|
||
if (data.by_service && data.by_service.length > 0) {
|
||
html += '<div class="revenue-section-title">By Service</div>';
|
||
html += '<table class="revenue-table"><tr><th>Service</th><th>Count</th><th>Total</th></tr>';
|
||
for (const s of data.by_service) {
|
||
html += '<tr><td>' + escapeHtml(s.service) + '</td><td>' + escapeHtml(String(s.count)) + '</td><td>$' + (s.total || 0).toFixed(2) + '</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
}
|
||
|
||
// === Recent deposits ===
|
||
if (data.recent_deposits && data.recent_deposits.length > 0) {
|
||
html += '<div class="revenue-section-title">Recent Deposits</div>';
|
||
html += '<table class="revenue-table"><tr><th>User</th><th>Amount</th><th>From</th><th>Date</th></tr>';
|
||
for (const d of data.recent_deposits) {
|
||
const date = d.date ? d.date.split(' ')[0] : '-';
|
||
html += '<tr><td>' + escapeHtml(d.user || '') + '</td><td>$' + (d.amount || 0).toFixed(2) + '</td><td style="font-size:11px">' + escapeHtml(d.from || '') + '</td><td>' + escapeHtml(date) + '</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
}
|
||
|
||
// === Recent charges ===
|
||
if (data.recent_charges && data.recent_charges.length > 0) {
|
||
html += '<div class="revenue-section-title">Recent Charges</div>';
|
||
html += '<table class="revenue-table"><tr><th>User</th><th>Service</th><th>Amount</th><th>Date</th></tr>';
|
||
for (const c of data.recent_charges) {
|
||
const date = c.date ? c.date.split(' ')[0] : '-';
|
||
html += '<tr><td>' + escapeHtml(c.user || '') + '</td><td>' + escapeHtml(c.service || '') + '</td><td>$' + (c.amount || 0).toFixed(2) + '</td><td>' + escapeHtml(date) + '</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
}
|
||
|
||
document.getElementById('revenueContent').innerHTML = html;
|
||
} catch (e) {
|
||
document.getElementById('revenueContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:#ff6b6b;">Connection error</div>';
|
||
}
|
||
}
|
||
|
||
function hideRevenue() {
|
||
document.getElementById('revenueOverlay').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('revenueOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) hideRevenue();
|
||
});
|
||
|
||
// =============================================================================
|
||
// API Costs Dashboard (admin)
|
||
// =============================================================================
|
||
|
||
async function showCosts() {
|
||
if (!authToken || !currentUser || !currentUser.is_admin) return;
|
||
document.getElementById('costsOverlay').classList.remove('hidden');
|
||
document.getElementById('costsContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:var(--text-dim);">Loading...</div>';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/admin/costs?days=30', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (!resp.ok) {
|
||
let errMsg = 'HTTP ' + resp.status;
|
||
try { const ej = await resp.json(); errMsg = ej.error || errMsg; } catch(_) {}
|
||
document.getElementById('costsContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:#ff6b6b;">Error: ' + escapeHtml(errMsg) + '</div>';
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
if (!data.success) {
|
||
document.getElementById('costsContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:#ff6b6b;">Error: ' + escapeHtml(data.error || 'Unknown') + '</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// Cost summary cards
|
||
html += '<div class="revenue-cards">';
|
||
html += '<div class="revenue-card highlight"><div class="label">Total (30d)</div><div class="value orange">$' + (data.total_cost_usd || 0).toFixed(4) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Avg/Query</div><div class="value sm">$' + (data.avg_cost_per_query || 0).toFixed(4) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// Period breakdown
|
||
html += '<div class="revenue-cards triple">';
|
||
html += '<div class="revenue-card"><div class="label">Today</div><div class="value sm orange">$' + (data.cost_today || 0).toFixed(4) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">7 Days</div><div class="value sm orange">$' + (data.cost_week || 0).toFixed(4) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">30 Days</div><div class="value sm orange">$' + (data.cost_month || 0).toFixed(4) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// Cache & efficiency
|
||
const cs = data.cache_stats || {};
|
||
const eff = data.efficiency || {};
|
||
html += '<div class="revenue-section-title">Cache & Efficiency</div>';
|
||
html += '<div class="revenue-cards">';
|
||
html += '<div class="revenue-card"><div class="label">Cache Hit Rate</div><div class="value sm green">' + (cs.cache_hit_rate_pct || 0).toFixed(1) + '%</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Queries</div><div class="value sm">' + (data.total_queries || 0) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Avg Iterations</div><div class="value sm">' + (eff.avg_iterations || 0) + '</div></div>';
|
||
html += '<div class="revenue-card"><div class="label">Avg In/Out</div><div class="value sm" style="font-size:13px">' + (eff.avg_input_per_query || 0) + '/' + (eff.avg_output_per_query || 0) + '</div></div>';
|
||
html += '</div>';
|
||
|
||
// Daily cost chart
|
||
if (data.daily_costs && data.daily_costs.length > 0) {
|
||
html += '<div class="revenue-section-title">Daily Cost (30 days)</div>';
|
||
const maxVal = Math.max(...data.daily_costs.map(d => d.cost), 0.001);
|
||
html += '<div class="revenue-bar-chart">';
|
||
for (const d of data.daily_costs) {
|
||
const pct = Math.max((d.cost / maxVal) * 100, 3);
|
||
html += '<div class="revenue-bar" style="height:' + pct + '%;background:rgba(255,107,53,0.6);" title="' + escapeHtml(d.date) + ': $' + d.cost.toFixed(4) + ' (' + d.queries + ' queries)"></div>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="revenue-bar-labels">';
|
||
for (const d of data.daily_costs) {
|
||
html += '<span>' + escapeHtml(d.date.slice(5)) + '</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
// Top users
|
||
if (data.top_users && data.top_users.length > 0) {
|
||
html += '<div class="revenue-section-title">Top Users by Cost</div>';
|
||
html += '<table class="revenue-table"><tr><th>User</th><th>Queries</th><th>Cost</th></tr>';
|
||
for (const u of data.top_users) {
|
||
html += '<tr><td>' + escapeHtml(u.email || 'anon') + '</td><td>' + u.queries + '</td><td>$' + (u.total_cost || 0).toFixed(4) + '</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
}
|
||
|
||
document.getElementById('costsContent').innerHTML = html;
|
||
} catch (e) {
|
||
console.error('showCosts error:', e);
|
||
document.getElementById('costsContent').innerHTML =
|
||
'<div style="text-align:center;padding:40px;color:#ff6b6b;">Error: ' + escapeHtml(String(e)) + '</div>';
|
||
}
|
||
}
|
||
|
||
function hideCosts() {
|
||
document.getElementById('costsOverlay').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('costsOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) hideCosts();
|
||
});
|
||
|
||
// =============================================================================
|
||
// Subscription Plans
|
||
// =============================================================================
|
||
let subPlans = null;
|
||
|
||
async function showSub() {
|
||
document.getElementById('subOverlay').classList.remove('hidden');
|
||
document.getElementById('subResult').style.display = 'none';
|
||
|
||
if (!subPlans) {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/subscription/plans');
|
||
const data = await resp.json();
|
||
if (data.success) subPlans = data.plans;
|
||
} catch (e) { /* offline */ }
|
||
}
|
||
|
||
if (!subPlans) {
|
||
// Fallback hardcoded plans
|
||
subPlans = {
|
||
free: { name: 'Free', price_usd: 0, lookups_per_day: 10, features: ['10 lookups/day', 'Vessel search & position', '16,000+ ports coverage', 'Route calculator', 'Demurrage calculator'] },
|
||
basic: { name: 'Basic', price_usd: 29, lookups_per_day: 100, features: ['100 lookups/day', 'All free features', 'Freight rates', 'Port congestion', 'Priority support'] },
|
||
pro: { name: 'Pro', price_usd: 99, lookups_per_day: -1, features: ['Unlimited lookups', 'All basic features', 'Bunker optimizer', 'Sanctions screening', 'API access'] }
|
||
};
|
||
}
|
||
|
||
renderSubPlans();
|
||
}
|
||
|
||
function hideSub() {
|
||
document.getElementById('subOverlay').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('subOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) hideSub();
|
||
});
|
||
|
||
function renderSubPlans() {
|
||
const container = document.getElementById('subPlans');
|
||
const userPlan = (currentUser && currentUser.plan) || 'free';
|
||
let html = '';
|
||
|
||
const planKeys = ['free', 'basic', 'pro'];
|
||
const planIcons = { free: '⚫', basic: '⭐', pro: '💎' };
|
||
|
||
planKeys.forEach(key => {
|
||
const plan = subPlans[key];
|
||
if (!plan) return;
|
||
const isCurrent = key === userPlan;
|
||
const isRec = key === 'basic' && userPlan === 'free';
|
||
const price = plan.price_usd || 0;
|
||
const features = plan.features || [];
|
||
|
||
let tagHtml = '';
|
||
if (isCurrent) tagHtml = `<span class="plan-tag cur">${t('sub_current')}</span>`;
|
||
else if (isRec) tagHtml = `<span class="plan-tag rec">${t('sub_recommended')}</span>`;
|
||
|
||
let btnHtml = '';
|
||
if (isCurrent) {
|
||
btnHtml = `<button class="plan-upgrade-btn current-btn" disabled>${t('sub_current_plan')}</button>`;
|
||
} else if (price === 0) {
|
||
btnHtml = `<button class="plan-upgrade-btn" onclick="upgradePlan('${key}')">${t('sub_downgrade')}</button>`;
|
||
} else {
|
||
btnHtml = `<button class="plan-upgrade-btn" onclick="upgradePlan('${key}')">${t('sub_upgrade_to')} ${escapeHtml(String(plan.name || key))} — $${parseFloat(price) || 0}</button>`;
|
||
}
|
||
|
||
const featHtml = features.map(f =>
|
||
`<div class="feat"><span class="feat-icon">✓</span> ${escapeHtml(String(f))}</div>`
|
||
).join('');
|
||
|
||
const cls = isCurrent ? 'current' : (isRec ? 'recommended' : '');
|
||
const safeName = escapeHtml(String(plan.name || key));
|
||
const safePrice = parseFloat(price) || 0;
|
||
|
||
html += `<div class="sub-plan-card ${cls}">
|
||
${tagHtml}
|
||
<div class="plan-name">${planIcons[key] || ''} ${safeName}</div>
|
||
<div class="plan-price">${safePrice === 0 ? t('free') : '$' + safePrice}</div>
|
||
<div class="plan-period">${safePrice === 0 ? '' : t('sub_per_month')}</div>
|
||
<div class="plan-features">${featHtml}</div>
|
||
${btnHtml}
|
||
</div>`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
async function upgradePlan(planKey) {
|
||
if (!authToken) { showAuthModal(); hideSub(); return; }
|
||
const resultEl = document.getElementById('subResult');
|
||
resultEl.style.display = 'none';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/subscription/upgrade', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
|
||
body: JSON.stringify({ plan: planKey, lang: currentLang })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultEl.className = 'sub-result success';
|
||
resultEl.textContent = data.message || t('sub_upgraded');
|
||
resultEl.style.display = 'block';
|
||
if (data.user) {
|
||
currentUser = data.user;
|
||
document.getElementById('userBalance').textContent = '$' + (currentUser.balance || 0).toFixed(2);
|
||
}
|
||
updatePlanBadge();
|
||
renderSubPlans();
|
||
} else {
|
||
resultEl.className = 'sub-result error';
|
||
resultEl.textContent = data.error || t('sub_error');
|
||
resultEl.style.display = 'block';
|
||
}
|
||
} catch (e) {
|
||
resultEl.className = 'sub-result error';
|
||
resultEl.textContent = 'Connection error';
|
||
resultEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function updatePlanBadge() {
|
||
const badge = document.getElementById('sidebarPlan');
|
||
const nameEl = document.getElementById('planBadgeName');
|
||
const badgeEl = document.getElementById('planBadge');
|
||
if (!currentUser) { badge.style.display = 'none'; return; }
|
||
|
||
badge.style.display = '';
|
||
const plan = (currentUser.plan || 'free').toLowerCase();
|
||
const names = { free: 'Free Plan', basic: 'Basic Plan', pro: 'Pro Plan' };
|
||
nameEl.textContent = names[plan] || 'Free Plan';
|
||
badgeEl.className = 'plan-badge ' + plan;
|
||
}
|
||
|
||
// =============================================================================
|
||
// =============================================================================
|
||
// Onboarding Wizard
|
||
// =============================================================================
|
||
let wizStep = 0;
|
||
let wizData = { role: '', vessel_types: [], cargo_types: [], trade_routes: [], fleet_size: 0, company_name: '', phone: '', home_port: '' };
|
||
|
||
const WIZ_VESSEL_TYPES = ['bulk_carrier', 'tanker', 'container', 'general_cargo', 'ro_ro', 'lng_carrier', 'chemical_tanker'];
|
||
const WIZ_CARGO_TYPES = ['grain', 'coal', 'iron_ore', 'crude_oil', 'refined_products', 'chemicals', 'containerized', 'fertilizer', 'timber', 'cement'];
|
||
|
||
const _chipLabels = {
|
||
en: {
|
||
bulk_carrier: 'Bulk Carrier', tanker: 'Tanker', container: 'Container',
|
||
general_cargo: 'General Cargo', ro_ro: 'Ro-Ro', lng_carrier: 'LNG Carrier',
|
||
chemical_tanker: 'Chemical Tanker',
|
||
grain: 'Grain', coal: 'Coal', iron_ore: 'Iron Ore', crude_oil: 'Crude Oil',
|
||
refined_products: 'Refined Products', chemicals: 'Chemicals',
|
||
containerized: 'Containerized', fertilizer: 'Fertilizer', timber: 'Timber', cement: 'Cement',
|
||
general_cargo: 'General Cargo', ro_ro: 'Ro-Ro', lng_carrier: 'LNG Carrier',
|
||
offshore: 'Offshore', tug: 'Tug', barge: 'Barge', passenger: 'Passenger',
|
||
dry_bulk: 'Dry Bulk', liquid_bulk: 'Liquid Bulk', breakbulk: 'Breakbulk',
|
||
project_cargo: 'Project Cargo', reefer: 'Reefer', lng: 'LNG', lpg: 'LPG'
|
||
},
|
||
ru: {
|
||
bulk_carrier: 'Балкер', tanker: 'Танкер', container: 'Контейнеровоз',
|
||
general_cargo: 'Генеральные грузы', ro_ro: 'Ро-Ро', lng_carrier: 'Газовоз СПГ',
|
||
chemical_tanker: 'Химовоз',
|
||
grain: 'Зерно', coal: 'Уголь', iron_ore: 'Железная руда', crude_oil: 'Сырая нефть',
|
||
refined_products: 'Нефтепродукты', chemicals: 'Химикаты',
|
||
containerized: 'Контейнеры', fertilizer: 'Удобрения', timber: 'Лес', cement: 'Цемент',
|
||
general_cargo: 'Генгрузы', ro_ro: 'Ро-Ро', lng_carrier: 'Газовоз СПГ',
|
||
offshore: 'Оффшор', tug: 'Буксир', barge: 'Баржа', passenger: 'Пассажирское',
|
||
dry_bulk: 'Сухой навал', liquid_bulk: 'Наливной', breakbulk: 'Генеральный',
|
||
project_cargo: 'Проектный груз', reefer: 'Рефрижератор', lng: 'СПГ', lpg: 'СУГ'
|
||
},
|
||
es: {
|
||
bulk_carrier: 'Granelero', tanker: 'Tanque', container: 'Portacontenedores',
|
||
general_cargo: 'Carga general', ro_ro: 'Ro-Ro', lng_carrier: 'GNL',
|
||
chemical_tanker: 'Quimiquero',
|
||
grain: 'Grano', coal: 'Carbón', iron_ore: 'Mineral de hierro', crude_oil: 'Petróleo crudo',
|
||
refined_products: 'Refinados', chemicals: 'Químicos',
|
||
containerized: 'Contenedores', fertilizer: 'Fertilizantes', timber: 'Madera', cement: 'Cemento',
|
||
general_cargo: 'Carga general', ro_ro: 'Ro-Ro', lng_carrier: 'GNL',
|
||
offshore: 'Offshore', tug: 'Remolcador', barge: 'Barcaza', passenger: 'Pasajeros',
|
||
dry_bulk: 'Granel seco', liquid_bulk: 'Granel líquido', breakbulk: 'Carga fraccionada',
|
||
project_cargo: 'Carga de proyecto', reefer: 'Refrigerado', lng: 'GNL', lpg: 'GLP'
|
||
},
|
||
zh: {
|
||
bulk_carrier: '散货船', tanker: '油轮', container: '集装箱船',
|
||
general_cargo: '普通货船', ro_ro: '滚装船', lng_carrier: '液化天然气船',
|
||
chemical_tanker: '化学品船',
|
||
grain: '谷物', coal: '煤炭', iron_ore: '铁矿石', crude_oil: '原油',
|
||
refined_products: '成品油', chemicals: '化学品',
|
||
containerized: '集装箱', fertilizer: '化肥', timber: '木材', cement: '水泥',
|
||
offshore: '近海船', tug: '拖船', barge: '驳船', passenger: '客船',
|
||
dry_bulk: '干散货', liquid_bulk: '液体散货', breakbulk: '杂货',
|
||
project_cargo: '项目货物', reefer: '冷藏船', lng: '液化天然气', lpg: '液化石油气'
|
||
}
|
||
};
|
||
|
||
const _regionLabels = {
|
||
en: {},
|
||
ru: {
|
||
'Mediterranean': 'Средиземное море', 'Baltic': 'Балтика', 'Caspian': 'Каспий',
|
||
'Black Sea': 'Чёрное море', 'Middle East / Persian Gulf': 'Ближний Восток / Персидский залив',
|
||
'North Sea': 'Северное море', 'Atlantic': 'Атлантика', 'Indian Ocean': 'Индийский океан',
|
||
'Southeast Asia': 'Юго-Восточная Азия',
|
||
'Pacific': 'Тихий океан', 'West Africa': 'Западная Африка',
|
||
'East Africa': 'Восточная Африка', 'South America': 'Южная Америка',
|
||
'Caribbean': 'Карибы', 'North America East Coast': 'Сев. Америка Восток',
|
||
'North America West Coast': 'Сев. Америка Запад',
|
||
'Australia / Oceania': 'Австралия / Океания', 'Arctic': 'Арктика'
|
||
},
|
||
es: {
|
||
'Mediterranean': 'Mediterráneo', 'Baltic': 'Báltico', 'Caspian': 'Caspio',
|
||
'Black Sea': 'Mar Negro', 'Middle East / Persian Gulf': 'Medio Oriente / Golfo Pérsico',
|
||
'North Sea': 'Mar del Norte', 'Atlantic': 'Atlántico', 'Indian Ocean': 'Océano Índico',
|
||
'Southeast Asia': 'Sudeste Asiático',
|
||
'Pacific': 'Pacífico', 'West Africa': 'África Occidental',
|
||
'East Africa': 'África Oriental', 'South America': 'Sudamérica',
|
||
'Caribbean': 'Caribe', 'North America East Coast': 'N. América Este',
|
||
'North America West Coast': 'N. América Oeste',
|
||
'Australia / Oceania': 'Australia / Oceanía', 'Arctic': 'Ártico'
|
||
},
|
||
zh: {
|
||
'Mediterranean': '地中海', 'Baltic': '波罗的海', 'Caspian': '里海',
|
||
'Black Sea': '黑海', 'Middle East / Persian Gulf': '中东 / 波斯湾',
|
||
'North Sea': '北海', 'Atlantic': '大西洋', 'Indian Ocean': '印度洋',
|
||
'Southeast Asia': '东南亚',
|
||
'Pacific': '太平洋', 'West Africa': '西非',
|
||
'East Africa': '东非', 'South America': '南美洲',
|
||
'Caribbean': '加勒比海', 'North America East Coast': '北美东海岸',
|
||
'North America West Coast': '北美西海岸',
|
||
'Australia / Oceania': '澳大利亚 / 大洋洲', 'Arctic': '北极'
|
||
}
|
||
};
|
||
function regionLabel(key) {
|
||
return (_regionLabels[currentLang] && _regionLabels[currentLang][key]) || key;
|
||
}
|
||
|
||
function translateProfileChips() {
|
||
document.querySelectorAll('#profVesselTypes .chip, #profCargoTypes .chip').forEach(el => {
|
||
const val = el.dataset.val;
|
||
if (val) el.textContent = chipLabel(val);
|
||
});
|
||
document.querySelectorAll('#profTradeRoutes .chip').forEach(el => {
|
||
const val = el.dataset.val;
|
||
if (val) el.textContent = regionLabel(val);
|
||
});
|
||
// Translate role dropdown options
|
||
var _roles = {en:{shipowner:'Shipowner',operator:'Operator',charterer:'Charterer',broker:'Broker',freight_forwarder:'Freight Forwarder',port_agent:'Port Agent',surveyor:'Surveyor',other:'Other'},zh:{shipowner:'船主',operator:'运营商',charterer:'租船人',broker:'经纪人',freight_forwarder:'货运代理',port_agent:'港口代理',surveyor:'检验员',other:'其他'},es:{shipowner:'Armador',operator:'Operador',charterer:'Fletador',broker:'Corredor',freight_forwarder:'Transitario',port_agent:'Agente portuario',surveyor:'Surveyor',other:'Otro'},ru:{shipowner:'Судовладелец',operator:'Оператор',charterer:'Фрахтователь',broker:'Брокер',freight_forwarder:'Экспедитор',port_agent:'Портовый агент',surveyor:'Сюрвейер',other:'Другое'}};
|
||
var sel = document.getElementById('profRole');
|
||
if (sel) { for (var i=0;i<sel.options.length;i++) { var k=sel.options[i].value; if(k) { sel.options[i].textContent=(_roles[currentLang]&&_roles[currentLang][k])||_roles.en[k]||k; } else { sel.options[i].textContent=t('profile_role_select'); } } }
|
||
// Sidebar role dropdown (same logic)
|
||
var sfSel = document.getElementById('sfRole');
|
||
if (sfSel) { for (var i=0;i<sfSel.options.length;i++) { var k=sfSel.options[i].value; if(k) { sfSel.options[i].textContent=(_roles[currentLang]&&_roles[currentLang][k])||_roles.en[k]||k; } else { sfSel.options[i].textContent='—'; } } }
|
||
// Sidebar vessel type + cargo chips
|
||
document.querySelectorAll('#sfVesselTypes .sf-chip, #sfCargoTypes .sf-chip').forEach(function(el) {
|
||
var v = el.dataset.val;
|
||
if (v) el.textContent = chipLabel(v);
|
||
});
|
||
// Sidebar trade route chips
|
||
document.querySelectorAll('#sfTradeRoutes .sf-chip').forEach(function(el) {
|
||
var v = el.dataset.val;
|
||
if (v) el.textContent = regionLabel(v);
|
||
});
|
||
// Translate all i18n placeholders
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function(el) { var k=el.getAttribute('data-i18n-placeholder'); el.placeholder=t(k); });
|
||
if (tgBtn) tgBtn.innerHTML = t('profile_tg_bot_btn');
|
||
// Update tonnage label
|
||
if (typeof updateTonnageLabel === 'function') updateTonnageLabel();
|
||
}
|
||
function chipLabel(key) {
|
||
return (_chipLabels[currentLang] && _chipLabels[currentLang][key]) || _chipLabels.en[key] || key.replace(/_/g, ' ');
|
||
}
|
||
const WIZ_REGIONS = ['Mediterranean', 'Baltic', 'Caspian', 'Black Sea', 'Middle East / Persian Gulf', 'North Sea', 'Atlantic', 'Indian Ocean', 'Southeast Asia'];
|
||
|
||
function showOnboardingWizard() {
|
||
wizStep = 1;
|
||
wizData = { role: '', vessel_types: [], cargo_types: [], trade_routes: [], fleet_size: 0, company_name: '', phone: '' };
|
||
document.getElementById('wizardOverlay').classList.remove('hidden');
|
||
renderWizStep();
|
||
}
|
||
|
||
function hideWizard() {
|
||
document.getElementById('wizardOverlay').classList.add('hidden');
|
||
}
|
||
|
||
function renderWizProgress() {
|
||
const box = document.getElementById('wizardProgress');
|
||
let html = '';
|
||
for (let i = 1; i <= 4; i++) {
|
||
const cls = i < wizStep ? 'wiz-dot done' : (i === wizStep ? 'wiz-dot active' : 'wiz-dot');
|
||
html += `<div class="${cls}"></div>`;
|
||
}
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function renderWizStep() {
|
||
renderWizProgress();
|
||
const content = document.getElementById('wizardContent');
|
||
const nav = document.getElementById('wizardNav');
|
||
|
||
if (wizStep === 1) renderWizStep1(content, nav);
|
||
else if (wizStep === 2) renderWizStep2(content, nav);
|
||
else if (wizStep === 3) renderWizStep3(content, nav);
|
||
else if (wizStep === 4) renderWizStep4(content, nav);
|
||
}
|
||
|
||
function renderWizStep1(box, nav) {
|
||
const roles = [
|
||
{ key: 'shipowner', icon: '\u2693', title: t('wiz_role_shipowner'), desc: t('wiz_role_shipowner_desc') },
|
||
{ key: 'charterer', icon: '\uD83D\uDCE6', title: t('wiz_role_charterer'), desc: t('wiz_role_charterer_desc') },
|
||
{ key: 'broker', icon: '\uD83E\uDD1D', title: t('wiz_role_broker'), desc: t('wiz_role_broker_desc') },
|
||
];
|
||
let html = `<div class="wiz-title">${t('wiz_step1_title')}</div>`;
|
||
html += `<div class="wiz-subtitle">${t('wiz_step1_sub')}</div>`;
|
||
html += '<div class="wiz-role-cards">';
|
||
for (const r of roles) {
|
||
const sel = wizData.role === r.key ? ' selected' : '';
|
||
html += `<div class="wiz-role-card${sel}" onclick="wizSelectRole('${r.key}')">`;
|
||
html += `<div class="wiz-role-icon">${r.icon}</div>`;
|
||
html += `<div class="wiz-role-info"><h4>${r.title}</h4><p>${r.desc}</p></div>`;
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
nav.innerHTML = ''; // No nav on step 1 — click card to advance
|
||
}
|
||
|
||
function wizSelectRole(role) {
|
||
wizData.role = role;
|
||
wizStep = 2;
|
||
renderWizStep();
|
||
}
|
||
|
||
function renderWizStep2(box, nav) {
|
||
let title, items, field, isMulti = true;
|
||
if (wizData.role === 'shipowner') {
|
||
title = t('wiz_step2_vtype');
|
||
items = WIZ_VESSEL_TYPES;
|
||
field = 'vessel_types';
|
||
} else {
|
||
title = wizData.role === 'charterer' ? t('wiz_step2_ctype') : t('wiz_step2_btype');
|
||
items = WIZ_CARGO_TYPES;
|
||
field = 'cargo_types';
|
||
}
|
||
|
||
let html = `<div class="wiz-title">${title}</div>`;
|
||
html += '<div class="wiz-chips" id="wizChips2">';
|
||
for (const item of items) {
|
||
const label = chipLabel(item);
|
||
const sel = (wizData[field] || []).includes(item) ? ' active' : '';
|
||
html += `<div class="wiz-chip${sel}" data-val="${item}" onclick="wizToggleChip(this,'${field}')">${label}</div>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
// DWT / tonnage input for shipowner/charterer
|
||
if (wizData.role === 'shipowner') {
|
||
html += `<div class="wiz-field"><label>${t('wiz_step2_dwt')}</label>`;
|
||
html += `<input type="number" id="wizDwt" min="0" max="500000" value="${wizData.fleet_size || ''}" placeholder="e.g. 50000"></div>`;
|
||
} else if (wizData.role === 'charterer') {
|
||
html += `<div class="wiz-field"><label>${t('wiz_step2_tonnage')}</label>`;
|
||
html += `<input type="number" id="wizTonnage" min="0" max="500000" value="${wizData.fleet_size || ''}" placeholder="e.g. 10000"></div>`;
|
||
}
|
||
|
||
box.innerHTML = html;
|
||
nav.innerHTML = `<button class="wiz-btn" onclick="wizPrev()">${t('wiz_back')}</button>` +
|
||
`<button class="wiz-btn wiz-btn-primary" onclick="wizNext2()">${t('wiz_next')}</button>`;
|
||
}
|
||
|
||
function wizToggleChip(el, field) {
|
||
const val = el.dataset.val;
|
||
el.classList.toggle('active');
|
||
if (!wizData[field]) wizData[field] = [];
|
||
const idx = wizData[field].indexOf(val);
|
||
if (idx >= 0) wizData[field].splice(idx, 1);
|
||
else wizData[field].push(val);
|
||
}
|
||
|
||
function wizNext2() {
|
||
// Save DWT/tonnage
|
||
const dwtEl = document.getElementById('wizDwt');
|
||
const tonEl = document.getElementById('wizTonnage');
|
||
if (dwtEl) wizData.fleet_size = parseInt(dwtEl.value) || 0;
|
||
if (tonEl) wizData.fleet_size = parseInt(tonEl.value) || 0;
|
||
|
||
// Validate: at least one chip selected
|
||
const field = wizData.role === 'shipowner' ? 'vessel_types' : 'cargo_types';
|
||
if (!wizData[field] || wizData[field].length === 0) return; // Don't advance
|
||
|
||
wizStep = 3;
|
||
renderWizStep();
|
||
}
|
||
|
||
function renderWizStep3(box, nav) {
|
||
let html = `<div class="wiz-title">${t('wiz_step3_title')}</div>`;
|
||
html += `<div class="wiz-subtitle">${t('wiz_step3_sub')}</div>`;
|
||
html += '<div class="wiz-chips" id="wizChips3">';
|
||
for (const region of WIZ_REGIONS) {
|
||
const sel = (wizData.trade_routes || []).includes(region) ? ' active' : '';
|
||
html += `<div class="wiz-chip${sel}" data-val="${region}" onclick="wizToggleChip(this,'trade_routes')">${regionLabel(region)}</div>`;
|
||
}
|
||
html += '</div>';
|
||
html += `<div class="wiz-field" style="margin-top:16px;position:relative">`;
|
||
html += `<label>${t('wiz_home_port_label')}</label>`;
|
||
html += `<input type="text" id="wizHomePort" value="${escapeHtml(wizData.home_port || '')}" placeholder="${t('profile_home_port_placeholder')}" autocomplete="off">`;
|
||
html += `<div class="port-suggestions hidden" id="wizHomePortSuggestions" style="background:var(--tg-theme-secondary-bg-color,#1e293b);border:1px solid rgba(255,255,255,0.1);border-radius:0 0 8px 8px;max-height:160px;overflow-y:auto;position:absolute;top:100%;left:0;right:0;z-index:10"></div>`;
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
_initWizPortAutocomplete();
|
||
nav.innerHTML = `<button class="wiz-btn" onclick="wizPrev()">${t('wiz_back')}</button>` +
|
||
`<button class="wiz-btn wiz-btn-primary" onclick="wizNext3()">${t('wiz_next')}</button>`;
|
||
}
|
||
|
||
function _initWizPortAutocomplete() {
|
||
const inp = document.getElementById('wizHomePort');
|
||
const box = document.getElementById('wizHomePortSuggestions');
|
||
if (!inp || !box) return;
|
||
let timer = null;
|
||
inp.addEventListener('input', function() {
|
||
clearTimeout(timer);
|
||
wizData.home_port = this.value;
|
||
const q = this.value.trim();
|
||
if (q.length < 2) { box.classList.add('hidden'); return; }
|
||
timer = setTimeout(async () => {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/ports/search?q=' + encodeURIComponent(q) + '&limit=6');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.ports || !data.ports.length) { box.classList.add('hidden'); return; }
|
||
box.innerHTML = data.ports.map(p =>
|
||
`<div style="padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid rgba(255,255,255,0.05)" data-name="${escapeHtml(p.name)}">${escapeHtml(p.name)} <small style="color:rgba(255,255,255,0.4)">${escapeHtml(p.country || '')}${p.unlocode ? ' · ' + p.unlocode : ''}</small></div>`
|
||
).join('');
|
||
box.classList.remove('hidden');
|
||
box.querySelectorAll('[data-name]').forEach(el => {
|
||
el.onclick = () => { inp.value = el.dataset.name; wizData.home_port = el.dataset.name; box.classList.add('hidden'); };
|
||
el.onmouseenter = () => { el.style.background = 'rgba(255,255,255,0.05)'; };
|
||
el.onmouseleave = () => { el.style.background = ''; };
|
||
});
|
||
} catch (e) { box.classList.add('hidden'); }
|
||
}, 300);
|
||
});
|
||
inp.addEventListener('blur', () => { setTimeout(() => box.classList.add('hidden'), 200); });
|
||
}
|
||
|
||
function wizNext3() {
|
||
if (!wizData.trade_routes || wizData.trade_routes.length === 0) return;
|
||
const hpEl = document.getElementById('wizHomePort');
|
||
if (hpEl) wizData.home_port = hpEl.value.trim();
|
||
wizStep = 4;
|
||
renderWizStep();
|
||
}
|
||
|
||
function renderWizStep4(box, nav) {
|
||
let html = `<div class="wiz-title">${t('wiz_step4_title')}</div>`;
|
||
html += `<div class="wiz-subtitle">${t('wiz_step4_sub')}</div>`;
|
||
html += `<div class="wiz-field"><label>${t('wiz_company')}</label>`;
|
||
html += `<input type="text" id="wizCompany" value="${wizData.company_name}" placeholder=""></div>`;
|
||
html += `<div class="wiz-field"><label>${t('wiz_phone')}</label>`;
|
||
html += `<input type="tel" id="wizPhone" value="${wizData.phone}" placeholder="+1 234 567 890"></div>`;
|
||
box.innerHTML = html;
|
||
nav.innerHTML = `<button class="wiz-btn" onclick="wizPrev()">${t('wiz_back')}</button>` +
|
||
`<button class="wiz-btn-skip" onclick="wizFinish()">${t('wiz_skip')}</button>` +
|
||
`<button class="wiz-btn wiz-btn-primary" onclick="wizFinish()">${t('wiz_finish')}</button>`;
|
||
}
|
||
|
||
function wizPrev() {
|
||
if (wizStep > 1) { wizStep--; renderWizStep(); }
|
||
}
|
||
|
||
async function wizFinish() {
|
||
// Collect step 4 data
|
||
const compEl = document.getElementById('wizCompany');
|
||
const phEl = document.getElementById('wizPhone');
|
||
if (compEl) wizData.company_name = compEl.value.trim();
|
||
if (phEl) wizData.phone = phEl.value.trim();
|
||
|
||
// Save to server
|
||
try {
|
||
const body = {
|
||
role: wizData.role,
|
||
vessel_types: wizData.vessel_types,
|
||
cargo_types: wizData.cargo_types,
|
||
trade_routes: wizData.trade_routes,
|
||
fleet_size: wizData.fleet_size,
|
||
company_name: wizData.company_name,
|
||
phone: wizData.phone,
|
||
home_port: wizData.home_port || '',
|
||
};
|
||
const resp = await fetch(API_BASE + '/api/v1/profile', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
if (data.success && data.profile && data.profile.role) {
|
||
localStorage.setItem('seafare_wizard_done', '1');
|
||
}
|
||
}
|
||
} catch (e) { console.error('Wizard save error:', e); }
|
||
|
||
hideWizard();
|
||
if (!userHasSentMessage) showWelcome();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Profile Cabinet
|
||
// =============================================================================
|
||
let profileData = null;
|
||
|
||
function showProfile() {
|
||
document.getElementById('profileOverlay').classList.remove('hidden');
|
||
loadProfile();
|
||
}
|
||
|
||
function hideProfile() {
|
||
document.getElementById('profileOverlay').classList.add('hidden');
|
||
document.getElementById('profileSavedMsg').style.display = 'none';
|
||
}
|
||
|
||
async function loadProfile() {
|
||
if (!authToken) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/profile', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success && data.profile) {
|
||
profileData = data.profile;
|
||
fillProfileForm(data.profile);
|
||
fillSidebarFilters(data.profile);
|
||
} else {
|
||
profileData = null;
|
||
clearProfileForm();
|
||
}
|
||
} catch (e) {
|
||
console.error('Load profile error:', e);
|
||
}
|
||
loadPurchasedContacts();
|
||
}
|
||
|
||
async function loadPurchasedContacts() {
|
||
const section = document.getElementById('purchasedContactsSection');
|
||
const list = document.getElementById('purchasedContactsList');
|
||
if (!authToken) { section.style.display = 'none'; return; }
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/purchased-contacts', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success && data.contacts && data.contacts.length > 0) {
|
||
section.style.display = '';
|
||
list.innerHTML = data.contacts.map(pc => {
|
||
const c = pc.contact || {};
|
||
const name = escapeHtml(c.company_name || c.contact_person || pc.query || '—');
|
||
const typ = escapeHtml(c.type || pc.contact_type || '');
|
||
const safeEmail = escapeHtml(c.email || '');
|
||
const email = c.email ? `<div class="pcc-detail">✉ <a href="mailto:${safeEmail}">${safeEmail}</a></div>` : '';
|
||
const safePhone = escapeHtml(c.phone || '');
|
||
const phone = c.phone ? `<div class="pcc-detail">📞 <a href="tel:${safePhone}">${safePhone}</a></div>` : '';
|
||
const addr = c.address ? `<div class="pcc-detail">📍 ${escapeHtml(c.address)}${c.country ? ', ' + escapeHtml(c.country) : ''}</div>` : '';
|
||
const safeUrl = (c.website && /^https?:\/\//i.test(c.website)) ? escapeHtml(c.website) : '';
|
||
const website = safeUrl ? `<div class="pcc-detail">🌐 <a href="${safeUrl}" target="_blank">${safeUrl}</a></div>` : '';
|
||
const person = c.contact_person && c.company_name ? `<div class="pcc-detail">👤 ${escapeHtml(c.contact_person)}</div>` : '';
|
||
const date = pc.purchased_at ? new Date(pc.purchased_at).toLocaleDateString() : '';
|
||
return `<div class="purchased-contact-card">
|
||
<span class="pcc-company">${name}</span>${typ ? `<span class="pcc-type">${typ}</span>` : ''}
|
||
${person}${email}${phone}${addr}${website}
|
||
${date ? `<div class="pcc-date">${escapeHtml(date)}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
section.style.display = '';
|
||
list.innerHTML = `<div class="purchased-empty">${t('purchased_contacts_empty')}</div>`;
|
||
}
|
||
} catch (e) {
|
||
section.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function fillProfileForm(p) {
|
||
document.getElementById('profCompany').value = p.company_name || '';
|
||
document.getElementById('profRole').value = p.role || '';
|
||
document.getElementById('profExperience').value = p.experience_years || '';
|
||
document.getElementById('profFleetSize').value = p.fleet_size || '';
|
||
document.getElementById('profPhone').value = p.phone || '';
|
||
document.getElementById('profHomePort').value = p.home_port || '';
|
||
document.getElementById('profNotes').value = p.notes || '';
|
||
|
||
// Chips
|
||
setChips('profVesselTypes', p.vessel_types || []);
|
||
setChips('profTradeRoutes', p.trade_routes || []);
|
||
setChips('profCargoTypes', p.cargo_types || []);
|
||
|
||
// Vessels of interest
|
||
const list = document.getElementById('profVesselsList');
|
||
list.innerHTML = '';
|
||
(p.vessels_of_interest || []).forEach(v => addVesselRow(v));
|
||
}
|
||
|
||
function clearProfileForm() {
|
||
document.getElementById('profCompany').value = '';
|
||
document.getElementById('profRole').value = '';
|
||
document.getElementById('profExperience').value = '';
|
||
document.getElementById('profFleetSize').value = '';
|
||
document.getElementById('profPhone').value = '';
|
||
document.getElementById('profNotes').value = '';
|
||
setChips('profVesselTypes', []);
|
||
setChips('profTradeRoutes', []);
|
||
setChips('profCargoTypes', []);
|
||
document.getElementById('profVesselsList').innerHTML = '';
|
||
}
|
||
|
||
function setChips(groupId, selected) {
|
||
document.querySelectorAll('#' + groupId + ' .chip').forEach(chip => {
|
||
chip.classList.toggle('active', selected.includes(chip.dataset.val));
|
||
});
|
||
}
|
||
|
||
function getChips(groupId) {
|
||
return Array.from(document.querySelectorAll('#' + groupId + ' .chip.active'))
|
||
.map(c => c.dataset.val);
|
||
}
|
||
|
||
function addVesselRow(value) {
|
||
const list = document.getElementById('profVesselsList');
|
||
const row = document.createElement('div');
|
||
row.className = 'profile-vessel-row';
|
||
row.innerHTML = '<input type="text" placeholder="e.g. EVER GIVEN or IMO 9811000" value="' + escapeHtml(value || '') + '">' +
|
||
'<button onclick="this.parentElement.remove()">×</button>';
|
||
list.appendChild(row);
|
||
}
|
||
|
||
function getVessels() {
|
||
return Array.from(document.querySelectorAll('#profVesselsList input'))
|
||
.map(i => i.value.trim())
|
||
.filter(v => v.length > 0);
|
||
}
|
||
|
||
async function saveProfile() {
|
||
if (!authToken) return;
|
||
const body = {
|
||
company_name: document.getElementById('profCompany').value.trim(),
|
||
role: document.getElementById('profRole').value,
|
||
experience_years: parseInt(document.getElementById('profExperience').value) || 0,
|
||
fleet_size: parseInt(document.getElementById('profFleetSize').value) || 0,
|
||
vessel_types: getChips('profVesselTypes'),
|
||
trade_routes: getChips('profTradeRoutes'),
|
||
cargo_types: getChips('profCargoTypes'),
|
||
vessels_of_interest: getVessels(),
|
||
phone: document.getElementById('profPhone').value.trim(),
|
||
home_port: document.getElementById('profHomePort').value.trim(),
|
||
notes: document.getElementById('profNotes').value.trim(),
|
||
};
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/profile', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + authToken
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
profileData = data.profile;
|
||
const msg = document.getElementById('profileSavedMsg');
|
||
msg.style.display = 'block';
|
||
setTimeout(() => { msg.style.display = 'none'; }, 3000);
|
||
}
|
||
} catch (e) {
|
||
console.error('Save profile error:', e);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Sidebar Quick Filters
|
||
// =============================================================================
|
||
let _sfSaveTimer = null;
|
||
|
||
function sidebarFilterChanged() {
|
||
clearTimeout(_sfSaveTimer);
|
||
_sfSaveTimer = setTimeout(saveSidebarFilters, 500);
|
||
// Update tonnage label based on role
|
||
updateTonnageLabel();
|
||
}
|
||
|
||
function updateTonnageLabel() {
|
||
const role = document.getElementById('sfRole').value;
|
||
const label = document.getElementById('sfTonnageLabel');
|
||
if (!label) return;
|
||
const fleet = ['shipowner', 'operator'];
|
||
const cargo = ['charterer', 'freight_forwarder'];
|
||
if (fleet.includes(role)) {
|
||
label.textContent = t('sf_tonnage_dwt') || 'DWT, t';
|
||
} else if (cargo.includes(role)) {
|
||
label.textContent = t('sf_tonnage_cargo') || 'Cargo, t';
|
||
} else {
|
||
label.textContent = t('sf_tonnage_min') || 'Min DWT';
|
||
}
|
||
}
|
||
|
||
async function saveSidebarFilters() {
|
||
if (!authToken) return;
|
||
const body = {
|
||
role: document.getElementById('sfRole').value,
|
||
vessel_types: getSfChips('sfVesselTypes'),
|
||
trade_routes: getSfChips('sfTradeRoutes'),
|
||
cargo_types: getSfChips('sfCargoTypes'),
|
||
home_port: document.getElementById('sfHomePort').value.trim(),
|
||
};
|
||
const tonnage = parseInt(document.getElementById('sfTonnage').value) || 0;
|
||
if (tonnage > 0) body.preferred_tonnage = tonnage;
|
||
const radius = parseInt(document.getElementById('sfSearchRadius').value) || 0;
|
||
if (radius >= 10) body.search_radius = radius;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/profile', {
|
||
method: 'PUT',
|
||
headers: {'Content-Type':'application/json','Authorization':'Bearer '+authToken},
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (resp.ok) {
|
||
if (typeof profileData === 'object' && profileData) {
|
||
Object.assign(profileData, body);
|
||
}
|
||
syncCabinetFromSidebar(body);
|
||
}
|
||
} catch(e) { console.warn('Filter save error:', e); }
|
||
}
|
||
|
||
function initSfChips() {
|
||
document.querySelectorAll('.sf-chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
chip.classList.toggle('active');
|
||
sidebarFilterChanged();
|
||
});
|
||
});
|
||
}
|
||
|
||
function getSfChips(groupId) {
|
||
return Array.from(document.querySelectorAll('#'+groupId+' .sf-chip.active'))
|
||
.map(c => c.dataset.val);
|
||
}
|
||
|
||
function setSfChips(groupId, selected) {
|
||
document.querySelectorAll('#'+groupId+' .sf-chip').forEach(chip => {
|
||
chip.classList.toggle('active', (selected||[]).includes(chip.dataset.val));
|
||
});
|
||
}
|
||
|
||
function fillSidebarFilters(profile) {
|
||
if (!profile) profile = {};
|
||
const el = document.getElementById('sidebarFilters');
|
||
if (!el) return;
|
||
document.getElementById('sfRole').value = profile.role || '';
|
||
setSfChips('sfVesselTypes', profile.vessel_types);
|
||
setSfChips('sfTradeRoutes', profile.trade_routes);
|
||
setSfChips('sfCargoTypes', profile.cargo_types);
|
||
document.getElementById('sfHomePort').value = profile.home_port || '';
|
||
document.getElementById('sfTonnage').value = profile.preferred_tonnage || '';
|
||
document.getElementById('sfSearchRadius').value = profile.search_radius || '';
|
||
updateTonnageLabel();
|
||
// Show filters, hide services
|
||
el.style.display = 'block';
|
||
var svc = document.querySelector('.services');
|
||
if (svc) svc.style.display = 'none';
|
||
}
|
||
|
||
function hideSidebarFilters() {
|
||
var el = document.getElementById('sidebarFilters');
|
||
if (el) el.style.display = 'none';
|
||
var svc = document.querySelector('.services');
|
||
if (svc) svc.style.display = '';
|
||
}
|
||
|
||
function syncCabinetFromSidebar(body) {
|
||
var el = document.getElementById('profRole');
|
||
if (el) el.value = body.role || '';
|
||
if (typeof setChips === 'function') {
|
||
setChips('profVesselTypes', body.vessel_types || []);
|
||
setChips('profTradeRoutes', body.trade_routes || []);
|
||
setChips('profCargoTypes', body.cargo_types || []);
|
||
}
|
||
el = document.getElementById('profHomePort');
|
||
if (el) el.value = body.home_port || '';
|
||
}
|
||
|
||
// Sidebar port autocomplete
|
||
(function() {
|
||
let _sfPortTimer = null;
|
||
const inp = document.getElementById('sfHomePort');
|
||
const box = document.getElementById('sfPortSuggestions');
|
||
if (!inp || !box) return;
|
||
inp.addEventListener('input', function() {
|
||
clearTimeout(_sfPortTimer);
|
||
const q = this.value.trim();
|
||
if (q.length < 2) { box.classList.add('hidden'); return; }
|
||
_sfPortTimer = setTimeout(async () => {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/ports/search?q=' + encodeURIComponent(q) + '&limit=8');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.ports || !data.ports.length) { box.classList.add('hidden'); return; }
|
||
box.innerHTML = data.ports.map(p =>
|
||
'<div class="port-suggestion" data-name="' + escapeHtml(p.name) + '">' + escapeHtml(p.name) + '<small>' + escapeHtml(p.country || '') + (p.unlocode ? ' \u00b7 ' + escapeHtml(p.unlocode) : '') + '</small></div>'
|
||
).join('');
|
||
box.classList.remove('hidden');
|
||
box.querySelectorAll('.port-suggestion').forEach(el => {
|
||
el.onclick = () => { inp.value = el.dataset.name; box.classList.add('hidden'); sidebarFilterChanged(); };
|
||
});
|
||
} catch (e) { box.classList.add('hidden'); }
|
||
}, 300);
|
||
});
|
||
inp.addEventListener('blur', () => { setTimeout(() => box.classList.add('hidden'), 200); });
|
||
inp.addEventListener('focus', function() { if (this.value.trim().length >= 2) this.dispatchEvent(new Event('input')); });
|
||
})();
|
||
|
||
// Init chips on load
|
||
initSfChips();
|
||
|
||
// ---------- Port autocomplete ----------
|
||
(function() {
|
||
let _portTimer = null;
|
||
const inp = document.getElementById('profHomePort');
|
||
const box = document.getElementById('profHomePortSuggestions');
|
||
if (!inp || !box) return;
|
||
inp.addEventListener('input', function() {
|
||
clearTimeout(_portTimer);
|
||
const q = this.value.trim();
|
||
if (q.length < 2) { box.classList.add('hidden'); return; }
|
||
_portTimer = setTimeout(async () => {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/ports/search?q=' + encodeURIComponent(q) + '&limit=8');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.ports || !data.ports.length) { box.classList.add('hidden'); return; }
|
||
box.innerHTML = data.ports.map(p =>
|
||
`<div class="port-suggestion" data-name="${escapeHtml(p.name)}">${escapeHtml(p.name)}<small>${escapeHtml(p.country || '')}${p.unlocode ? ' · ' + escapeHtml(p.unlocode) : ''}</small></div>`
|
||
).join('');
|
||
box.classList.remove('hidden');
|
||
box.querySelectorAll('.port-suggestion').forEach(el => {
|
||
el.onclick = () => { inp.value = el.dataset.name; box.classList.add('hidden'); };
|
||
});
|
||
} catch (e) { box.classList.add('hidden'); }
|
||
}, 300);
|
||
});
|
||
inp.addEventListener('blur', () => { setTimeout(() => box.classList.add('hidden'), 200); });
|
||
inp.addEventListener('focus', function() { if (this.value.trim().length >= 2) this.dispatchEvent(new Event('input')); });
|
||
})();
|
||
|
||
|
||
function showTgLinkError(msg) {
|
||
let errEl = document.getElementById('tgLinkError');
|
||
if (!errEl) {
|
||
errEl = document.createElement('div');
|
||
errEl.id = 'tgLinkError';
|
||
errEl.style.cssText = 'color:#ef4444;font-size:12px;margin-top:6px';
|
||
const parent = document.getElementById('tgNotLinked');
|
||
if (parent) parent.appendChild(errEl);
|
||
}
|
||
errEl.textContent = 'Error: ' + msg;
|
||
errEl.style.display = '';
|
||
}
|
||
|
||
|
||
async function showDeposit() {
|
||
if (!authToken) { showAuthModal(); return; }
|
||
document.getElementById('depositOverlay').classList.remove('hidden');
|
||
switchWalletTab('deposit');
|
||
document.getElementById('depositLoading').style.display = 'block';
|
||
document.getElementById('depositQR').style.display = 'none';
|
||
document.getElementById('depositAddressWrap').style.display = 'none';
|
||
document.getElementById('depositResult').style.display = 'none';
|
||
document.getElementById('depositHistory').style.display = 'none';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/wallet', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
depositAddress = data.address;
|
||
document.getElementById('depositLoading').style.display = 'none';
|
||
document.getElementById('depositAddress').textContent = data.address;
|
||
document.getElementById('depositAddressWrap').style.display = 'flex';
|
||
// QR code via free API
|
||
const qrEl = document.getElementById('depositQR');
|
||
qrEl.innerHTML = '<img src="https://api.qrserver.com/v1/create-qr-code/?size=184x184&data=' +
|
||
encodeURIComponent(data.address) + '" alt="QR">';
|
||
qrEl.style.display = 'flex';
|
||
// Show deposit history
|
||
if (data.deposits && data.deposits.length > 0) {
|
||
renderDepositHistory(data.deposits);
|
||
}
|
||
} else {
|
||
document.getElementById('depositLoading').textContent = data.error || 'Error';
|
||
}
|
||
} catch (e) {
|
||
document.getElementById('depositLoading').textContent = 'Connection error';
|
||
}
|
||
}
|
||
|
||
function hideDeposit() {
|
||
document.getElementById('depositOverlay').classList.add('hidden');
|
||
}
|
||
|
||
function copyDepositAddress() {
|
||
if (!depositAddress) return;
|
||
navigator.clipboard.writeText(depositAddress).then(() => {
|
||
const btn = document.querySelector('.deposit-copy-btn');
|
||
const orig = btn.textContent;
|
||
btn.textContent = t('topup_copied');
|
||
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||
}).catch(() => {
|
||
// Fallback for older browsers
|
||
const ta = document.createElement('textarea');
|
||
ta.value = depositAddress;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
});
|
||
}
|
||
|
||
async function checkDeposits() {
|
||
if (!authToken) return;
|
||
const btn = document.getElementById('depositCheckBtn');
|
||
const resultEl = document.getElementById('depositResult');
|
||
btn.disabled = true;
|
||
btn.textContent = t('topup_checking');
|
||
resultEl.style.display = 'none';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/wallet/check', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': 'Bearer ' + authToken,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
if (data.new_deposits > 0) {
|
||
resultEl.className = 'deposit-result success';
|
||
resultEl.textContent = t('topup_found')
|
||
.replace('${amount}', data.new_amount.toFixed(2))
|
||
.replace('${credited}', (data.credited_amount || data.new_amount).toFixed(2))
|
||
.replace('${balance}', data.balance.toFixed(2));
|
||
// Update header balance
|
||
if (currentUser) {
|
||
currentUser.balance = data.balance;
|
||
document.getElementById('userBalance').textContent = '$' + data.balance.toFixed(2);
|
||
}
|
||
} else {
|
||
resultEl.className = 'deposit-result empty';
|
||
resultEl.textContent = t('topup_none');
|
||
}
|
||
} else {
|
||
resultEl.className = 'deposit-result error';
|
||
resultEl.textContent = data.error || t('topup_error');
|
||
}
|
||
resultEl.style.display = 'block';
|
||
} catch (e) {
|
||
resultEl.className = 'deposit-result error';
|
||
resultEl.textContent = t('topup_error');
|
||
resultEl.style.display = 'block';
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = t('topup_check');
|
||
}
|
||
|
||
function renderDepositHistory(deposits) {
|
||
const container = document.getElementById('depositHistory');
|
||
let html = '<h4>' + t('topup_history') + '</h4>';
|
||
deposits.forEach(d => {
|
||
const date = new Date(d.confirmed_at).toLocaleDateString();
|
||
const txShort = d.tx_id ? d.tx_id.substring(0, 12) + '...' : '';
|
||
html += '<div class="deposit-tx"><span>' + escapeHtml(date) + ' ' + escapeHtml(txShort) + '</span><span class="amount">+' + d.amount.toFixed(2) + ' USDT</span></div>';
|
||
});
|
||
container.innerHTML = html;
|
||
container.style.display = 'block';
|
||
}
|
||
|
||
// =============================================================================
|
||
// Wallet Tabs
|
||
// =============================================================================
|
||
function switchWalletTab(tab) {
|
||
document.getElementById('walletTabDeposit').classList.toggle('active', tab === 'deposit');
|
||
document.getElementById('walletTabWithdraw').classList.toggle('active', tab === 'withdraw');
|
||
document.getElementById('walletContentDeposit').classList.toggle('active', tab === 'deposit');
|
||
document.getElementById('walletContentWithdraw').classList.toggle('active', tab === 'withdraw');
|
||
|
||
if (tab === 'withdraw') {
|
||
// Update balance display
|
||
const bal = currentUser ? currentUser.balance : 0;
|
||
document.getElementById('withdrawBalanceDisplay').textContent = '$' + bal.toFixed(2);
|
||
loadWithdrawals();
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Withdrawal
|
||
// =============================================================================
|
||
function updateWithdrawTotal() {
|
||
const amount = parseFloat(document.getElementById('withdrawAmount').value) || 0;
|
||
const totalLine = document.getElementById('withdrawTotalLine');
|
||
const totalDisplay = document.getElementById('withdrawTotalDisplay');
|
||
if (amount >= 2) {
|
||
const total = (amount + 1).toFixed(2);
|
||
totalDisplay.textContent = '$' + total + ' USDT';
|
||
totalLine.style.display = 'block';
|
||
} else {
|
||
totalLine.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function submitWithdrawal() {
|
||
if (!authToken) return;
|
||
const btn = document.getElementById('withdrawSubmitBtn');
|
||
const resultEl = document.getElementById('withdrawResult');
|
||
const address = document.getElementById('withdrawAddress').value.trim();
|
||
const amount = document.getElementById('withdrawAmount').value;
|
||
|
||
btn.disabled = true;
|
||
resultEl.style.display = 'none';
|
||
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/wallet/withdraw', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': 'Bearer ' + authToken,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ amount: parseFloat(amount), to_address: address, lang: currentLang })
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
resultEl.className = 'withdraw-result success';
|
||
resultEl.textContent = t('withdraw_success')
|
||
.replace('${amount}', data.withdrawal.amount.toFixed(2))
|
||
.replace('${balance}', data.balance.toFixed(2));
|
||
// Update user balance
|
||
if (currentUser) {
|
||
currentUser.balance = data.balance;
|
||
document.getElementById('userBalance').textContent = '$' + data.balance.toFixed(2);
|
||
document.getElementById('withdrawBalanceDisplay').textContent = '$' + data.balance.toFixed(2);
|
||
}
|
||
// Clear form
|
||
document.getElementById('withdrawAddress').value = '';
|
||
document.getElementById('withdrawAmount').value = '';
|
||
// Reload history
|
||
loadWithdrawals();
|
||
} else {
|
||
resultEl.className = 'withdraw-result error';
|
||
resultEl.textContent = data.error || 'Error';
|
||
}
|
||
resultEl.style.display = 'block';
|
||
} catch (e) {
|
||
resultEl.className = 'withdraw-result error';
|
||
resultEl.textContent = 'Connection error';
|
||
resultEl.style.display = 'block';
|
||
}
|
||
|
||
btn.disabled = false;
|
||
}
|
||
|
||
async function loadWithdrawals() {
|
||
if (!authToken) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/wallet/withdrawals', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success && data.withdrawals && data.withdrawals.length > 0) {
|
||
renderWithdrawHistory(data.withdrawals);
|
||
}
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
function renderWithdrawHistory(withdrawals) {
|
||
const container = document.getElementById('withdrawHistory');
|
||
let html = '<h4>' + t('withdraw_history') + '</h4>';
|
||
const validStatuses = ['pending', 'approved', 'rejected', 'completed'];
|
||
withdrawals.forEach(w => {
|
||
const date = escapeHtml(new Date(w.created_at).toLocaleDateString());
|
||
const addrShort = w.to_address ? escapeHtml(w.to_address.substring(0, 8) + '...' + w.to_address.substring(30)) : '';
|
||
const safeStatus = validStatuses.includes(w.status) ? w.status : 'pending';
|
||
const statusKey = 'withdraw_status_' + safeStatus;
|
||
const statusText = t(statusKey);
|
||
html += '<div class="withdraw-tx">' +
|
||
'<span>' + date + ' ' + addrShort + '</span>' +
|
||
'<span class="withdraw-status ' + safeStatus + '">' + escapeHtml(statusText) + '</span>' +
|
||
'<span class="amount">-' + parseFloat(w.amount || 0).toFixed(2) + ' USDT</span>' +
|
||
'</div>';
|
||
});
|
||
container.innerHTML = html;
|
||
container.style.display = 'block';
|
||
}
|
||
|
||
// Close deposit on overlay click
|
||
document.getElementById('depositOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) hideDeposit();
|
||
});
|
||
|
||
// =============================================================================
|
||
// Sidebar toggle (mobile)
|
||
// =============================================================================
|
||
function toggleSidebar() {
|
||
document.getElementById('sidebar').classList.toggle('open');
|
||
document.getElementById('sidebarBackdrop').classList.toggle('open');
|
||
}
|
||
|
||
// Close sidebar when clicking a service item (mobile)
|
||
document.querySelectorAll('.service-item[data-quick]').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
document.getElementById('sidebar').classList.remove('open');
|
||
document.getElementById('sidebarBackdrop').classList.remove('open');
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// Language switching
|
||
// =============================================================================
|
||
function setLang(lang) {
|
||
currentLang = lang;
|
||
localStorage.setItem('seafare_lang', lang);
|
||
|
||
// Update active button (main nav)
|
||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.lang === lang);
|
||
});
|
||
// Sync auth modal buttons
|
||
document.querySelectorAll('.auth-lang-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.lang === lang);
|
||
});
|
||
|
||
// Update all data-i18n elements
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.innerHTML = t(key);
|
||
});
|
||
|
||
// Update HTML lang attribute
|
||
document.documentElement.lang = lang;
|
||
|
||
// Update placeholder
|
||
input.placeholder = t('placeholder');
|
||
|
||
// Update quick action buttons
|
||
renderQuickActions();
|
||
|
||
// Update map toggle button text
|
||
const mapBtn = document.getElementById('mapToggleBtn');
|
||
if (mapBtn) mapBtn.innerHTML = mapMode ? t('map_toggle_chat') : t('map_toggle_map');
|
||
|
||
// Translate profile/wizard chips
|
||
if (typeof translateProfileChips === 'function') translateProfileChips();
|
||
|
||
// Re-render welcome if user hasn't sent any messages yet
|
||
// Skip on initial load — init() will show welcome after checkSession()
|
||
if (!userHasSentMessage && sessionChecked) {
|
||
messagesDiv.innerHTML = '';
|
||
showWelcome();
|
||
}
|
||
}
|
||
|
||
function toggleExamples() {
|
||
var panel = document.getElementById('examplesPanel');
|
||
var btn = document.getElementById('examplesToggle');
|
||
var isOpen = panel.classList.contains('open');
|
||
if (isOpen) {
|
||
panel.classList.remove('open');
|
||
btn.classList.remove('open');
|
||
} else {
|
||
renderExamples();
|
||
panel.classList.add('open');
|
||
btn.classList.add('open');
|
||
}
|
||
}
|
||
|
||
function renderExamples() {
|
||
var cats = [
|
||
{ key: 'ex_cat_search', items: ['ex_q_find_vessel', 'ex_q_where_vessel'] },
|
||
{ key: 'ex_cat_port', items: ['ex_q_vessels_baku', 'ex_q_vessels_novo'] },
|
||
{ key: 'ex_cat_route', items: ['ex_q_route', 'ex_q_distance'] },
|
||
{ key: 'ex_cat_cargo', items: ['ex_q_cargo_vessel', 'ex_q_cargo_send'] },
|
||
{ key: 'ex_cat_info', items: ['ex_q_owner', 'ex_q_vessel_data'] }
|
||
];
|
||
var html = '';
|
||
cats.forEach(function(cat) {
|
||
html += '<div class="ex-cat"><div class="ex-cat-title">' + t(cat.key) + '</div><div class="ex-items">';
|
||
cat.items.forEach(function(q) {
|
||
html += '<div class="ex-item" onclick="useExample(this)" data-key="' + q + '">' +
|
||
'<span class="ex-q">' + t(q) + '</span>' +
|
||
'<span class="ex-go">➤</span></div>';
|
||
});
|
||
html += '</div></div>';
|
||
});
|
||
document.getElementById('examplesInner').innerHTML = html;
|
||
}
|
||
|
||
// Close examples panel on click outside
|
||
document.addEventListener('click', function(e) {
|
||
var panel = document.getElementById('examplesPanel');
|
||
var btn = document.getElementById('examplesToggle');
|
||
if (!panel.classList.contains('open')) return;
|
||
if (panel.contains(e.target) || btn.contains(e.target)) return;
|
||
panel.classList.remove('open');
|
||
btn.classList.remove('open');
|
||
});
|
||
|
||
function useExample(el) {
|
||
var key = el.getAttribute('data-key');
|
||
var text = t(key);
|
||
document.getElementById('userInput').value = text;
|
||
document.getElementById('userInput').focus();
|
||
document.getElementById('examplesPanel').classList.remove('open');
|
||
document.getElementById('examplesToggle').classList.remove('open');
|
||
var ta = document.getElementById('userInput');
|
||
ta.style.height = 'auto';
|
||
ta.style.height = ta.scrollHeight + 'px';
|
||
}
|
||
|
||
function renderQuickActions() {
|
||
const qa = document.getElementById('quickActions');
|
||
const btns = [
|
||
['qm_btn_search', 'qb_search'],
|
||
['qm_btn_vessels_port', 'qb_vessels_port'],
|
||
['qm_btn_route', 'qb_route'],
|
||
['qm_btn_cargo', 'qb_cargo'],
|
||
|
||
['qm_btn_contacts', 'qb_contacts'],
|
||
];
|
||
qa.innerHTML = btns.map(([msg, lbl]) =>
|
||
`<button class="quick-btn" data-msg="${escapeHtml(t(msg))}" data-key="${msg}">${escapeHtml(t(lbl))}</button>`
|
||
).join('');
|
||
qa.querySelectorAll('.quick-btn[data-msg]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
if (btn.getAttribute('data-key') === 'qm_btn_cargo') {
|
||
showCargoForm();
|
||
return;
|
||
}
|
||
quickSend(btn.getAttribute('data-msg'));
|
||
});
|
||
});
|
||
}
|
||
|
||
// =============================================================================
|
||
// Cargo Search Form
|
||
// =============================================================================
|
||
function showCargoForm() {
|
||
document.getElementById('cargoOverlay').classList.remove('hidden');
|
||
document.querySelectorAll('.cargo-type-option').forEach(opt => opt.classList.remove('selected'));
|
||
document.querySelectorAll('input[name="cargoType"]').forEach(r => r.checked = false);
|
||
document.getElementById('cargoFromPort').value = '';
|
||
document.getElementById('cargoToPort').value = '';
|
||
document.getElementById('cargoTonnage').value = '';
|
||
setTimeout(() => document.getElementById('cargoFromPort').focus(), 100);
|
||
}
|
||
|
||
function hideCargoForm() {
|
||
document.getElementById('cargoOverlay').classList.add('hidden');
|
||
}
|
||
|
||
document.querySelectorAll('.cargo-type-option').forEach(option => {
|
||
option.addEventListener('click', () => {
|
||
document.querySelectorAll('.cargo-type-option').forEach(o => o.classList.remove('selected'));
|
||
option.classList.add('selected');
|
||
option.querySelector('input[type="radio"]').checked = true;
|
||
});
|
||
});
|
||
|
||
document.getElementById('cargoOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) hideCargoForm();
|
||
});
|
||
|
||
function submitCargoForm() {
|
||
const selectedRadio = document.querySelector('input[name="cargoType"]:checked');
|
||
if (!selectedRadio) {
|
||
alert(t('cargo_form_error_type'));
|
||
return;
|
||
}
|
||
const cargoValue = selectedRadio.value;
|
||
const cargoMap = {
|
||
en: { dry: 'dry bulk cargo', container: 'containers', liquid: 'crude oil' },
|
||
zh: { dry: '干散货', container: '集装箱', liquid: '液体货物' },
|
||
es: { dry: 'carga seca a granel', container: 'contenedores', liquid: 'carga líquida' },
|
||
ru: { dry: 'сухой груз навалом', container: 'контейнеры', liquid: 'наливной груз' }
|
||
};
|
||
const cargoText = (cargoMap[currentLang] || cargoMap.en)[cargoValue];
|
||
const fromPort = document.getElementById('cargoFromPort').value.trim();
|
||
if (!fromPort) {
|
||
alert(t('cargo_form_error_port'));
|
||
document.getElementById('cargoFromPort').focus();
|
||
return;
|
||
}
|
||
const toPort = document.getElementById('cargoToPort').value.trim();
|
||
const tonnage = document.getElementById('cargoTonnage').value.trim();
|
||
const msgTemplates = {
|
||
en: (cargo, from, to, tons) => {
|
||
let msg = `I need to ship ${cargo} from ${from}`;
|
||
if (to) msg += ` to ${to}`;
|
||
if (tons) msg += `, ${tons} tons`;
|
||
return msg;
|
||
},
|
||
ru: (cargo, from, to, tons) => {
|
||
let msg = `Мне нужно отправить ${cargo} из ${from}`;
|
||
if (to) msg += ` в ${to}`;
|
||
if (tons) msg += `, ${tons} тонн`;
|
||
return msg;
|
||
},
|
||
es: (cargo, from, to, tons) => {
|
||
let msg = `Necesito enviar ${cargo} desde ${from}`;
|
||
if (to) msg += ` a ${to}`;
|
||
if (tons) msg += `, ${tons} toneladas`;
|
||
return msg;
|
||
},
|
||
zh: (cargo, from, to, tons) => {
|
||
let msg = `我需要从${from}运输${cargo}`;
|
||
if (to) msg += `到${to}`;
|
||
if (tons) msg += `,${tons}吨`;
|
||
return msg;
|
||
}
|
||
};
|
||
const buildMsg = msgTemplates[currentLang] || msgTemplates.en;
|
||
const message = buildMsg(cargoText, fromPort, toPort, tonnage);
|
||
hideCargoForm();
|
||
quickSend(message);
|
||
}
|
||
|
||
// Sidebar service clicks
|
||
document.querySelectorAll('.service-item[data-quick]').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const quick = item.getAttribute('data-quick');
|
||
if (quick === 'cargo_match') {
|
||
showCargoForm();
|
||
return;
|
||
}
|
||
const key = 'qm_' + quick;
|
||
const msg = t(key);
|
||
if (msg && msg !== key) {
|
||
quickSend(msg);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// Chat functions
|
||
// =============================================================================
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function getTime(ts) {
|
||
const d = ts ? new Date(ts) : new Date();
|
||
if (isNaN(d.getTime())) return '';
|
||
return d.toLocaleTimeString(currentLang === 'ru' ? 'ru-RU' : currentLang === 'es' ? 'es-ES' : 'en-US',
|
||
{ hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function showWelcome() {
|
||
addMessage(t('welcome'), false);
|
||
}
|
||
|
||
function addMessage(text, isUser, timestamp) {
|
||
const div = document.createElement('div');
|
||
div.className = `message ${isUser ? 'user' : 'bot'}`;
|
||
const content = isUser ? escapeHtml(text) : text;
|
||
div.innerHTML = `
|
||
<div class="msg-avatar">${isUser ? '👤' : '⚓'}</div>
|
||
<div>
|
||
<div class="msg-content">${content}</div>
|
||
<div class="msg-time">${getTime(timestamp)}</div>
|
||
</div>
|
||
`;
|
||
messagesDiv.appendChild(div);
|
||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
let _thinkTimer = null;
|
||
let _thinkStart = 0;
|
||
|
||
function addTyping() {
|
||
const div = document.createElement('div');
|
||
div.className = 'message bot';
|
||
div.id = 'typing';
|
||
div.innerHTML = `
|
||
<div class="msg-avatar">⚓</div>
|
||
<div>
|
||
<div class="msg-content">
|
||
<div class="typing"><span></span><span></span><span></span></div>
|
||
<div class="typing-status" id="typingStatus"></div>
|
||
<div class="typing-timer" id="typingTimer"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
messagesDiv.appendChild(div);
|
||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||
_thinkStart = Date.now();
|
||
_thinkTimer = setInterval(() => {
|
||
const el = document.getElementById('typingTimer');
|
||
if (el) {
|
||
const sec = ((Date.now() - _thinkStart) / 1000).toFixed(1);
|
||
el.textContent = sec + 's';
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function updateTypingStatus(text) {
|
||
const el = document.getElementById('typingStatus');
|
||
if (el && text) {
|
||
el.textContent = text;
|
||
el.style.animation = 'none';
|
||
el.offsetHeight;
|
||
el.style.animation = 'statusFade 0.3s ease forwards';
|
||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function removeTyping() {
|
||
if (_thinkTimer) { clearInterval(_thinkTimer); _thinkTimer = null; }
|
||
const el = document.getElementById('typing');
|
||
if (el) el.remove();
|
||
}
|
||
|
||
function formatResponse(text) {
|
||
// 1. Extract code blocks FIRST (protect from escaping)
|
||
const codeBlocks = [];
|
||
text = text.replace(/```([\s\S]*?)```/g, (m, code) => {
|
||
codeBlocks.push('<pre>' + escapeHtml(code) + '</pre>');
|
||
return '\x00CB' + (codeBlocks.length - 1) + '\x00';
|
||
});
|
||
const inlineCodes = [];
|
||
text = text.replace(/`(.+?)`/g, (m, code) => {
|
||
inlineCodes.push('<code style="background:rgba(0,0,0,0.3);padding:2px 6px;border-radius:3px;font-size:12px;">' + escapeHtml(code) + '</code>');
|
||
return '\x00IC' + (inlineCodes.length - 1) + '\x00';
|
||
});
|
||
|
||
// 2. Escape ALL remaining text (prevents XSS via bold, italic, links, tables)
|
||
text = escapeHtml(text);
|
||
|
||
// 3. Markdown tables → HTML tables (on escaped text)
|
||
const lines = text.split('\n');
|
||
let result = [];
|
||
let inTable = false;
|
||
let tableRows = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
if (line.startsWith('|') && line.endsWith('|')) {
|
||
if (/^\|[\s\-:]+\|$/.test(line.replace(/\|/g, '|').replace(/[\s\-:]/g, ''))) {
|
||
continue;
|
||
}
|
||
if (!inTable) { inTable = true; tableRows = []; }
|
||
const cells = line.split('|').filter((c, idx, arr) => idx > 0 && idx < arr.length - 1).map(c => c.trim());
|
||
tableRows.push(cells);
|
||
} else {
|
||
if (inTable) {
|
||
result.push(buildTable(tableRows));
|
||
inTable = false;
|
||
tableRows = [];
|
||
}
|
||
result.push(line);
|
||
}
|
||
}
|
||
if (inTable) result.push(buildTable(tableRows));
|
||
text = result.join('\n');
|
||
|
||
// 4. Newlines
|
||
text = text.replace(/\n/g, '<br>');
|
||
|
||
// 5. Images, links, bold, italic (text is already escaped, so $1/$2 are safe)
|
||
text = text.replace(/!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:8px 0;display:block;" onerror="this.style.display=\'none\'">');
|
||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent);text-decoration:underline;">$1</a>');
|
||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
text = text.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>');
|
||
|
||
// 6. Restore code blocks
|
||
text = text.replace(/\x00CB(\d+)\x00/g, (m, i) => codeBlocks[parseInt(i)]);
|
||
text = text.replace(/\x00IC(\d+)\x00/g, (m, i) => inlineCodes[parseInt(i)]);
|
||
|
||
// 7. Map buttons: {{SHOWMAP~lat~lon~zoom~label}} → clickable button (~ avoids markdown table | conflict)
|
||
text = text.replace(/\{\{SHOWMAP~([^~]+)~([^~]+)~([^~]+)~([^}]+)\}\}/g,
|
||
(m, lat, lon, zoom, label) => {
|
||
const sLat = parseFloat(lat), sLon = parseFloat(lon), sZ = parseInt(zoom) || 12;
|
||
const sLabel = label.replace(/'/g, "\\'").replace(/"/g, '"');
|
||
return `<button class="chat-map-btn" onclick="showVesselOnMap(${sLat},${sLon},${sZ},'${sLabel}')"><span style="margin-right:4px">📍</span>${label}</button>`;
|
||
});
|
||
|
||
// 8. Collapse <br> between consecutive map buttons so they display inline
|
||
text = text.replace(/(<\/button>)\s*(<br>\s*)+(<button class="chat-map-btn")/g, '$1 $3');
|
||
|
||
return text;
|
||
}
|
||
|
||
function buildTable(rows) {
|
||
if (!rows.length) return '';
|
||
let html = '<div style="overflow-x:hidden;margin:8px 0"><table style="border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed;word-break:break-word;">';
|
||
rows.forEach((cells, idx) => {
|
||
const tag = idx === 0 ? 'th' : 'td';
|
||
const style = idx === 0
|
||
? 'padding:6px 10px;border-bottom:1px solid rgba(0,180,216,0.3);color:var(--accent);text-align:left;font-weight:600;overflow-wrap:break-word;word-break:break-word;'
|
||
: 'padding:5px 10px;border-bottom:1px solid rgba(255,255,255,0.05);overflow-wrap:break-word;word-break:break-word;';
|
||
html += '<tr>' + cells.map(c => `<${tag} style="${style}">${c}</${tag}>`).join('') + '</tr>';
|
||
});
|
||
html += '</table></div>';
|
||
return html;
|
||
}
|
||
|
||
function _detectMsgLang(text) {
|
||
if (/[а-яё]/i.test(text)) return 'ru';
|
||
if (/[\u4e00-\u9fff]/.test(text)) return 'zh';
|
||
if (/\b(buscar|busca|buques|cerca|ruta|puerto|barco)\b/i.test(text)) return 'es';
|
||
return null; // null = keep current lang
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
if (!authToken) { showAuthModal(); return; }
|
||
|
||
// Auto-detect language from message and switch UI
|
||
var detectedLang = _detectMsgLang(text);
|
||
if (detectedLang && detectedLang !== currentLang) {
|
||
setLang(detectedLang);
|
||
}
|
||
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
sendBtn.disabled = true;
|
||
var ab = document.getElementById('actionBtn');
|
||
if (ab) ab.classList.remove('has-text');
|
||
|
||
userHasSentMessage = true;
|
||
addMessage(text, true);
|
||
addTyping();
|
||
_chatHistory.push({ role: 'user', content: text });
|
||
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
|
||
const historyToSend = _chatHistory.slice(-10);
|
||
|
||
// Use SSE streaming endpoint for real-time status updates
|
||
const resp = await fetch(`${API_BASE}/api/v1/chat/stream`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({ message: text, lang: currentLang, history: historyToSend }),
|
||
signal: controller.signal
|
||
});
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!resp.ok) {
|
||
removeTyping();
|
||
if (resp.status === 401) { doLogout(); return; }
|
||
addMessage(t('error_generic'), false);
|
||
sendBtn.disabled = false;
|
||
input.focus();
|
||
return;
|
||
}
|
||
|
||
// Parse SSE stream
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let finalResponse = null;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
let eventType = null;
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.slice(7).trim();
|
||
} else if (line.startsWith('data: ') && eventType) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
if (eventType === 'status') {
|
||
updateTypingStatus(data.status);
|
||
} else if (eventType === 'done') {
|
||
finalResponse = data.response;
|
||
} else if (eventType === 'error') {
|
||
finalResponse = null;
|
||
}
|
||
} catch (parseErr) {}
|
||
eventType = null;
|
||
} else if (line === '') {
|
||
eventType = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
removeTyping();
|
||
if (finalResponse) {
|
||
addMessage(formatResponse(finalResponse), false);
|
||
_chatHistory.push({ role: 'assistant', content: finalResponse });
|
||
if (_chatHistory.length > 20) _chatHistory = _chatHistory.slice(-20);
|
||
} else {
|
||
addMessage(t('error_generic'), false);
|
||
}
|
||
} catch (e) {
|
||
removeTyping();
|
||
addMessage(t('error_connection'), false);
|
||
}
|
||
|
||
sendBtn.disabled = false;
|
||
input.focus();
|
||
}
|
||
|
||
function quickSend(text) {
|
||
input.value = text;
|
||
sendMessage();
|
||
}
|
||
|
||
function handleKey(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
}
|
||
|
||
// Close modals on Escape key
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
if (!document.getElementById('cargoOverlay').classList.contains('hidden')) hideCargoForm();
|
||
// ESC does not close mandatory login screen
|
||
else if (!document.getElementById('depositOverlay').classList.contains('hidden')) hideDeposit();
|
||
else if (!document.getElementById('profileOverlay').classList.contains('hidden')) hideProfile();
|
||
else if (!document.getElementById('subOverlay').classList.contains('hidden')) hideSub();
|
||
else if (!document.getElementById('costsOverlay').classList.contains('hidden')) hideCosts();
|
||
else if (!document.getElementById('revenueOverlay').classList.contains('hidden')) hideRevenue();
|
||
}
|
||
});
|
||
|
||
async function clearChat() {
|
||
if (!confirm(t('confirm_clear'))) return;
|
||
messagesDiv.innerHTML = '';
|
||
userHasSentMessage = false;
|
||
_chatHistory = [];
|
||
addMessage(t('chat_cleared'), false);
|
||
if (authToken) {
|
||
try {
|
||
await fetch(API_BASE + '/api/v1/chat/history', {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
// Auto-resize textarea + toggle action button
|
||
input.addEventListener('input', function() {
|
||
this.style.height = 'auto';
|
||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||
// Toggle mic/send icon
|
||
var ab = document.getElementById('actionBtn');
|
||
if (ab) {
|
||
if (this.value.trim()) {
|
||
ab.classList.add('has-text');
|
||
} else {
|
||
ab.classList.remove('has-text');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Action button: use touchend on iOS to avoid blur eating the first tap
|
||
(function() {
|
||
var ab = document.getElementById('actionBtn');
|
||
var handled = false;
|
||
|
||
function doAction(e) {
|
||
if (handled) { handled = false; return; }
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (ab.classList.contains('has-text')) {
|
||
sendMessage();
|
||
} else {
|
||
toggleVoice();
|
||
}
|
||
}
|
||
|
||
ab.addEventListener('touchend', function(e) {
|
||
handled = true;
|
||
doAction(e);
|
||
}, { passive: false });
|
||
|
||
ab.addEventListener('click', function(e) {
|
||
doAction(e);
|
||
});
|
||
})();
|
||
|
||
// Mobile keyboard: scroll into view on focus, reset on blur
|
||
input.addEventListener('focus', function() {
|
||
if (window.innerWidth > 768) return;
|
||
setTimeout(function() {
|
||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||
}, 300);
|
||
});
|
||
input.addEventListener('blur', function() {
|
||
if (window.innerWidth > 768) return;
|
||
// Force browser to recalculate viewport after keyboard closes
|
||
window.scrollTo(0, 0);
|
||
setTimeout(setVH, 100);
|
||
// Keep has-text class if there's text (prevent send button flicker on iOS)
|
||
var ab = document.getElementById('actionBtn');
|
||
if (ab && this.value.trim()) {
|
||
ab.classList.add('has-text');
|
||
}
|
||
});
|
||
|
||
// =============================================================================
|
||
// Voice Input (Web Speech API)
|
||
// =============================================================================
|
||
const voiceBtn = document.getElementById('voiceBtn');
|
||
let recognition = null;
|
||
let isRecording = false;
|
||
let voiceFinalTranscript = '';
|
||
let voiceStartText = '';
|
||
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (SpeechRecognition) {
|
||
voiceBtn.style.display = 'flex';
|
||
recognition = new SpeechRecognition();
|
||
recognition.continuous = false;
|
||
recognition.interimResults = true;
|
||
recognition.maxAlternatives = 1;
|
||
|
||
recognition.onstart = function() {
|
||
isRecording = true;
|
||
voiceBtn.classList.add('recording');
|
||
var ab = document.getElementById('actionBtn'); if (ab) ab.classList.add('recording');
|
||
voiceBtn.title = t('voice_listening');
|
||
voiceStartText = input.value;
|
||
voiceFinalTranscript = '';
|
||
};
|
||
|
||
recognition.onresult = function(event) {
|
||
let final_ = '';
|
||
let interim = '';
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const transcript = event.results[i][0].transcript;
|
||
if (event.results[i].isFinal) {
|
||
final_ = transcript;
|
||
} else {
|
||
interim = transcript;
|
||
}
|
||
}
|
||
if (final_) voiceFinalTranscript = final_;
|
||
const prefix = voiceStartText ? voiceStartText.trimEnd() + ' ' : '';
|
||
input.value = prefix + (voiceFinalTranscript || interim);
|
||
input.style.height = 'auto';
|
||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||
};
|
||
|
||
recognition.onend = function() {
|
||
if (isRecording) {
|
||
// User hasn't pressed stop — auto-restart for next phrase
|
||
try { recognition.start(); } catch(e) {}
|
||
return;
|
||
}
|
||
voiceBtn.classList.remove('recording');
|
||
var ab = document.getElementById('actionBtn'); if (ab) ab.classList.remove('recording');
|
||
voiceBtn.title = t('voice_tooltip');
|
||
if (voiceFinalTranscript) {
|
||
const prefix = voiceStartText ? voiceStartText.trimEnd() + ' ' : '';
|
||
input.value = fixPunctuation(prefix + voiceFinalTranscript.trim());
|
||
input.style.height = 'auto';
|
||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||
}
|
||
input.focus();
|
||
var ab2 = document.getElementById('actionBtn');
|
||
if (ab2 && input.value.trim()) ab2.classList.add('has-text');
|
||
};
|
||
|
||
recognition.onerror = function(event) {
|
||
isRecording = false;
|
||
voiceBtn.classList.remove('recording');
|
||
var ab = document.getElementById('actionBtn'); if (ab) ab.classList.remove('recording');
|
||
voiceBtn.title = t('voice_tooltip');
|
||
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||
alert(t('voice_permission'));
|
||
}
|
||
};
|
||
}
|
||
|
||
function toggleVoice() {
|
||
if (!recognition) return;
|
||
if (isRecording) {
|
||
isRecording = false;
|
||
recognition.stop();
|
||
var ab = document.getElementById('actionBtn'); if (ab) ab.classList.remove('recording');
|
||
document.getElementById('voiceBtn').classList.remove('recording');
|
||
} else {
|
||
const langMap = { en: 'en-US', zh: 'zh-CN', es: 'es-ES', ru: 'ru-RU' };
|
||
recognition.lang = langMap[currentLang] || 'en-US';
|
||
voiceFinalTranscript = '';
|
||
try { recognition.start(); } catch(e) { /* already started */ }
|
||
}
|
||
}
|
||
|
||
function fixPunctuation(text) {
|
||
if (!text) return text;
|
||
text = text.trim();
|
||
// Capitalize first letter
|
||
text = text.charAt(0).toUpperCase() + text.slice(1);
|
||
// Capitalize after sentence-ending punctuation
|
||
text = text.replace(/([.!?])\s+([a-zа-яёáéíóúñ])/g, function(m, p, c) {
|
||
return p + ' ' + c.toUpperCase();
|
||
});
|
||
// Add period at end if no punctuation
|
||
if (text.length > 2 && !/[.!?…,;:]$/.test(text)) {
|
||
text += '.';
|
||
}
|
||
// Clean up double spaces
|
||
text = text.replace(/\s{2,}/g, ' ');
|
||
return text;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Map View
|
||
// =============================================================================
|
||
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_TYPE_LABELS = {
|
||
tanker:'Tanker', bulk:'Bulk Carrier', container:'Container', cargo:'Cargo',
|
||
general:'General Cargo', passenger:'Passenger', roro:'Ro-Ro', offshore:'Offshore',
|
||
tug:'Tug/Pilot', fishing:'Fishing', highspeed:'High Speed', pleasure:'Pleasure Craft',
|
||
military:'Military', sailing:'Sailing', other:'Vessel'
|
||
};
|
||
|
||
// Map filter categories (subset for filter bar)
|
||
const MAP_FILTER_CATS = {
|
||
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 _mapActiveFilters = new Set(Object.keys(MAP_FILTER_CATS));
|
||
|
||
function syncMapFiltersWithProfile() {
|
||
// If logged in and profile has vessel_types → activate only those on map
|
||
if (!profileData || !profileData.vessel_types || !profileData.vessel_types.length) return;
|
||
const profileTypes = new Set();
|
||
// Map sidebar chip values to map filter categories
|
||
const typeToMapCat = {
|
||
'bulk_carrier': 'bulk', 'tanker': 'tanker', 'container': 'container',
|
||
'general_cargo': 'cargo', 'ro_ro': 'roro', 'lng_carrier': 'tanker',
|
||
'chemical_tanker': 'tanker', 'offshore': 'offshore', 'tug': 'tug',
|
||
'barge': 'cargo', 'passenger': 'passenger',
|
||
};
|
||
profileData.vessel_types.forEach(vt => {
|
||
const mapCat = typeToMapCat[vt] || vt;
|
||
profileTypes.add(mapCat);
|
||
});
|
||
// Deactivate map filter chips not in profile
|
||
_mapActiveFilters.clear();
|
||
profileTypes.forEach(cat => _mapActiveFilters.add(cat));
|
||
// Update chip visual state
|
||
document.querySelectorAll('.map-filter-chip').forEach(chip => {
|
||
const cat = chip.dataset.cat;
|
||
if (_mapActiveFilters.has(cat)) {
|
||
chip.classList.add('active');
|
||
chip.classList.remove('inactive');
|
||
} else {
|
||
chip.classList.remove('active');
|
||
chip.classList.add('inactive');
|
||
}
|
||
});
|
||
applyMapFilters();
|
||
// Show hint
|
||
const hint = document.getElementById('mapFilterHint');
|
||
if (hint) {
|
||
const names = Array.from(_mapActiveFilters).map(c => {
|
||
const labels = MAP_FILTER_CATS[c];
|
||
return labels ? (labels[currentLang] || labels.en) : c;
|
||
});
|
||
hint.textContent = '\u2139 ' + (currentLang === 'ru' ? 'Фильтр по профилю: ' : 'Profile filter: ') + names.join(', ');
|
||
hint.style.display = '';
|
||
}
|
||
}
|
||
|
||
function initMapFilters() {
|
||
const bar = document.getElementById('mapFilterBar');
|
||
if (!bar) return;
|
||
bar.innerHTML = '';
|
||
for (const [cat, labels] of Object.entries(MAP_FILTER_CATS)) {
|
||
const chip = document.createElement('div');
|
||
chip.className = 'map-filter-chip active';
|
||
chip.dataset.cat = cat;
|
||
chip.textContent = labels[currentLang] || 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 = () => {
|
||
if (_mapActiveFilters.has(cat)) {
|
||
_mapActiveFilters.delete(cat);
|
||
chip.classList.remove('active');
|
||
chip.classList.add('inactive');
|
||
} else {
|
||
_mapActiveFilters.add(cat);
|
||
chip.classList.remove('inactive');
|
||
chip.classList.add('active');
|
||
}
|
||
applyMapFilters();
|
||
};
|
||
bar.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
function getFilterCategory(typeCat) {
|
||
if (MAP_FILTER_CATS[typeCat]) return typeCat;
|
||
if (typeCat === 'general') return 'cargo';
|
||
if (typeCat === 'highspeed' || typeCat === 'pleasure' || typeCat === 'sailing' || typeCat === 'military') return 'other';
|
||
return 'other';
|
||
}
|
||
|
||
function applyMapFilters() {
|
||
if (mapInstance && mapInstance.getZoom() < CLUSTER_ZOOM && _bulkLoaded) {
|
||
// Cluster mode — re-render clusters with filter awareness
|
||
renderClusters();
|
||
return;
|
||
}
|
||
// Individual marker mode
|
||
let shown = 0;
|
||
for (const [mmsi, entry] of Object.entries(_mapMarkersByMmsi)) {
|
||
const cat = getFilterCategory(entry.data.type_category || 'other');
|
||
if (_mapActiveFilters.has(cat)) {
|
||
if (!mapInstance.hasLayer(entry.marker)) entry.marker.addTo(mapInstance);
|
||
shown++;
|
||
} else {
|
||
if (mapInstance.hasLayer(entry.marker)) mapInstance.removeLayer(entry.marker);
|
||
}
|
||
}
|
||
document.getElementById('mapVesselCount').textContent = shown;
|
||
}
|
||
|
||
function createVesselIcon(v) {
|
||
const color = VESSEL_COLORS[v.type_category] || VESSEL_COLORS.other;
|
||
const h = (v.heading != null && v.heading !== 511) ? v.heading : null;
|
||
const rot = h || v.course || 0;
|
||
const svg = `<svg width="20" height="24" 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="1" opacity="0.9"/></svg>`;
|
||
return L.divIcon({ html: svg, className: 'vessel-marker', iconSize: [20,24], iconAnchor: [10,12], popupAnchor: [0,-12] });
|
||
}
|
||
|
||
function buildPopup(v) {
|
||
const name = v.name || 'Unknown';
|
||
const mmsi = v.mmsi || '';
|
||
const typeLabel = VESSEL_TYPE_LABELS[v.type_category] || v.type || '';
|
||
const color = VESSEL_COLORS[v.type_category] || VESSEL_COLORS.other;
|
||
let html = `<div class="vp-title" style="border-left:3px solid ${color};padding-left:8px">${escapeHtml(name)}</div>`;
|
||
if (typeLabel) html += `<div class="vp-row"><span class="vp-lbl">${t('map_type')}</span><span class="vp-val">${escapeHtml(typeLabel)}</span></div>`;
|
||
if (v.flag) html += `<div class="vp-row"><span class="vp-lbl">${t('map_flag')}</span><span class="vp-val">${escapeHtml(v.flag)}</span></div>`;
|
||
if (v.imo) html += `<div class="vp-row"><span class="vp-lbl">${t('map_imo')}</span><span class="vp-val">${v.imo}</span></div>`;
|
||
if (mmsi) html += `<div class="vp-row"><span class="vp-lbl">${t('map_mmsi')}</span><span class="vp-val">${mmsi}</span></div>`;
|
||
if (v.speed != null) html += `<div class="vp-row"><span class="vp-lbl">${t('map_speed')}</span><span class="vp-val">${v.speed} ${t('map_knots')}</span></div>`;
|
||
if (v.course != null) html += `<div class="vp-row"><span class="vp-lbl">${t('map_course')}</span><span class="vp-val">${v.course}\u00b0</span></div>`;
|
||
if (v.status) html += `<div class="vp-row"><span class="vp-lbl">${t('map_status')}</span><span class="vp-val">${escapeHtml(v.status)}</span></div>`;
|
||
if (v.destination) html += `<div class="vp-row"><span class="vp-lbl">${t('map_destination')}</span><span class="vp-val">${escapeHtml(v.destination)}</span></div>`;
|
||
if (v.eta) html += `<div class="vp-row"><span class="vp-lbl">ETA</span><span class="vp-val">${escapeHtml(v.eta)}</span></div>`;
|
||
if (v.dwt) html += `<div class="vp-row"><span class="vp-lbl">${t('map_dwt')}</span><span class="vp-val">${Number(v.dwt).toLocaleString()}</span></div>`;
|
||
if (v.callsign) html += `<div class="vp-row"><span class="vp-lbl">Callsign</span><span class="vp-val">${escapeHtml(v.callsign)}</span></div>`;
|
||
if (v.length) html += `<div class="vp-row"><span class="vp-lbl">L\u00d7W</span><span class="vp-val">${v.length}m \u00d7 ${v.width || '?'}m</span></div>`;
|
||
// Button: details only
|
||
html += `<div style="margin-top:8px;display:flex;gap:6px">`;
|
||
const safeName = (v.name || '').replace(/'/g, "\\'");
|
||
const safeImo = v.imo || '';
|
||
html += `<button class="vp-btn" onclick="vesselDetailsToChat('${mmsi}','${safeName}','${safeImo}')">Details</button>`;
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
function vesselDetailsToChat(mmsi, name, imo) {
|
||
// Switch from map to chat
|
||
if (mapMode) toggleMapView();
|
||
// Build query for the AI agent
|
||
let query = 'Vessel details: ';
|
||
if (name && name !== 'Unknown') query += name;
|
||
if (imo) query += ` IMO ${imo}`;
|
||
else if (mmsi) query += ` MMSI ${mmsi}`;
|
||
// Send to chat
|
||
quickSend(query.trim());
|
||
}
|
||
|
||
function initMap() {
|
||
if (mapInstance) return;
|
||
mapInstance = L.map('vesselMap', { center:[37.5,24.0], zoom:6, minZoom:2, maxZoom:16 });
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||
attribution: '\u00a9 <a href="https://carto.com/">CARTO</a> \u00b7 <a href="https://osm.org/">OSM</a>',
|
||
subdomains: 'abcd', maxZoom: 19
|
||
}).addTo(mapInstance);
|
||
// 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];
|
||
}
|
||
});
|
||
}
|
||
mapInstance.on('moveend', debounceLoadVessels);
|
||
mapInstance.on('zoomend', debounceLoadVessels);
|
||
initMapFilters();
|
||
loadBulkVessels();
|
||
}
|
||
|
||
let _lvTimeout = null;
|
||
function debounceLoadVessels() {
|
||
if (_lvTimeout) clearTimeout(_lvTimeout);
|
||
_lvTimeout = setTimeout(() => {
|
||
if (!mapInstance || !mapMode) return;
|
||
const zoom = mapInstance.getZoom();
|
||
if (zoom >= CLUSTER_ZOOM) {
|
||
// High zoom: load arrows, THEN clear clusters (no flash)
|
||
loadMapVessels().then(() => {
|
||
if (mapInstance && mapInstance.getZoom() >= CLUSTER_ZOOM) clearClusters();
|
||
});
|
||
} else if (_bulkLoaded) {
|
||
// Low zoom: render clusters (sync <5ms), THEN clear arrows
|
||
renderClusters();
|
||
clearIndividualMarkers();
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function clearClusters() {
|
||
_clusterMarkers.forEach(m => mapInstance.removeLayer(m));
|
||
_clusterMarkers = [];
|
||
_clusterMarkersById = {};
|
||
_lastClusterZoom = -1;
|
||
}
|
||
|
||
function clearIndividualMarkers() {
|
||
Object.values(_mapMarkersByMmsi).forEach(e => mapInstance.removeLayer(e.marker));
|
||
_mapMarkersByMmsi = {};
|
||
}
|
||
|
||
async function loadBulkVessels() {
|
||
if (!mapInstance || !mapMode) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/map/vessels/all');
|
||
if (!resp.ok) { fallbackToArrows(); return; }
|
||
const data = await resp.json();
|
||
if (!data.success || !data.vessels) { fallbackToArrows(); return; }
|
||
// Convert compact array to GeoJSON
|
||
_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 we're at low zoom, render clusters now
|
||
if (mapInstance.getZoom() < CLUSTER_ZOOM) {
|
||
clearIndividualMarkers();
|
||
renderClusters();
|
||
}
|
||
}
|
||
// Schedule refresh every 2 minutes
|
||
if (_bulkRefreshTimer) clearInterval(_bulkRefreshTimer);
|
||
_bulkRefreshTimer = setInterval(() => {
|
||
if (mapMode) loadBulkVessels();
|
||
}, 120000);
|
||
// Also load arrows if at high zoom
|
||
if (mapInstance.getZoom() >= CLUSTER_ZOOM) loadMapVessels();
|
||
} catch (e) {
|
||
console.warn('Bulk load failed:', e);
|
||
fallbackToArrows();
|
||
}
|
||
}
|
||
|
||
function fallbackToArrows() {
|
||
// Supercluster unavailable — use existing per-viewport loading
|
||
_bulkLoaded = false;
|
||
loadMapVessels();
|
||
}
|
||
|
||
function renderClusters() {
|
||
if (!_supercluster || !_bulkLoaded || !mapInstance) return;
|
||
const b = mapInstance.getBounds();
|
||
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
|
||
const zoom = mapInstance.getZoom();
|
||
|
||
// Zoom changed → full rebuild (cluster_ids change between zoom levels)
|
||
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 = getFilterCategory(catName);
|
||
if (_mapActiveFilters.has(filterCat)) filteredCount += (props['c' + i] || 0);
|
||
}
|
||
if (filteredCount === 0) return;
|
||
desiredKeys.add(key);
|
||
totalShown += filteredCount;
|
||
|
||
// Existing marker — skip DOM work (diff optimization)
|
||
if (_clusterMarkersById[key]) return;
|
||
|
||
let maxCat = 9, maxCount = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const catName = TYPE_CAT_NAMES[i];
|
||
const filterCat = getFilterCategory(catName);
|
||
if (_mapActiveFilters.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(60, Math.max(24, 20 + Math.sqrt(filteredCount) * 3));
|
||
const label = filteredCount >= 1000 ? (filteredCount / 1000).toFixed(1) + 'K' : String(filteredCount);
|
||
const fontSize = filteredCount >= 1000 ? 10 : (filteredCount >= 100 ? 11 : 12);
|
||
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 });
|
||
let tip = '';
|
||
for (let i = 0; i < 10; i++) {
|
||
const cnt = props['c' + i] || 0;
|
||
if (cnt > 0) {
|
||
const catName = TYPE_CAT_NAMES[i];
|
||
const lbl = VESSEL_TYPE_LABELS[catName] || catName;
|
||
tip += (tip ? ' | ' : '') + lbl + ': ' + cnt;
|
||
}
|
||
}
|
||
m.bindTooltip(tip, { direction: 'top', offset: [0, -size/2], className: 'vessel-tooltip' });
|
||
const clusterId = props.cluster_id;
|
||
m.on('click', () => {
|
||
const expZoom = _supercluster.getClusterExpansionZoom(clusterId);
|
||
mapInstance.setView([coords[1], coords[0]], Math.min(expZoom, CLUSTER_ZOOM));
|
||
});
|
||
m.addTo(mapInstance);
|
||
_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 = getFilterCategory(catName);
|
||
if (!_mapActiveFilters.has(filterCat)) return;
|
||
desiredKeys.add(key);
|
||
totalShown++;
|
||
if (_clusterMarkersById[key]) return;
|
||
const v = {
|
||
type_category: 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.bindTooltip(props.name || props.mmsi || '?', {
|
||
direction: 'top', offset: [0, -14], className: 'vessel-tooltip'
|
||
});
|
||
m.on('click', () => { mapInstance.setView([coords[1], coords[0]], CLUSTER_ZOOM); });
|
||
m.addTo(mapInstance);
|
||
_clusterMarkers.push(m);
|
||
_clusterMarkersById[key] = m;
|
||
}
|
||
});
|
||
|
||
// Remove markers no longer in viewport (diff removal)
|
||
for (const key of Object.keys(_clusterMarkersById)) {
|
||
if (!desiredKeys.has(key)) {
|
||
mapInstance.removeLayer(_clusterMarkersById[key]);
|
||
const idx = _clusterMarkers.indexOf(_clusterMarkersById[key]);
|
||
if (idx !== -1) _clusterMarkers.splice(idx, 1);
|
||
delete _clusterMarkersById[key];
|
||
}
|
||
}
|
||
|
||
document.getElementById('mapVesselCount').textContent = totalShown;
|
||
document.getElementById('mapStats').style.display = 'block';
|
||
document.getElementById('mapLastUpdate').textContent =
|
||
t('map_last_update') + ' ' + new Date().toLocaleTimeString();
|
||
}
|
||
|
||
async function loadMapVessels() {
|
||
if (!mapInstance || !mapMode) return;
|
||
const epoch = ++_mapLoadEpoch;
|
||
const b = mapInstance.getBounds();
|
||
let latMin = b.getSouth(), latMax = b.getNorth();
|
||
let lonMin = b.getWest(), lonMax = b.getEast();
|
||
const center = mapInstance.getCenter();
|
||
if ((latMax - latMin) > 59) { latMin = center.lat - 29.5; latMax = center.lat + 29.5; }
|
||
if ((lonMax - lonMin) > 119) { lonMin = center.lng - 59.5; lonMax = center.lng + 59.5; }
|
||
const params = new URLSearchParams({
|
||
lat_min: latMin.toFixed(4), lat_max: latMax.toFixed(4),
|
||
lon_min: lonMin.toFixed(4), lon_max: lonMax.toFixed(4),
|
||
limit: 500
|
||
});
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/v1/map/vessels?${params}`);
|
||
if (!resp.ok) return;
|
||
if (epoch !== _mapLoadEpoch) return; // stale — user changed zoom
|
||
const data = await resp.json();
|
||
if (!data.success || epoch !== _mapLoadEpoch) return;
|
||
mergeMapMarkers(data.vessels);
|
||
document.getElementById('mapStats').style.display = 'block';
|
||
applyMapFilters();
|
||
syncMapFiltersWithProfile();
|
||
document.getElementById('mapLastUpdate').textContent =
|
||
t('map_last_update') + ' ' + new Date().toLocaleTimeString();
|
||
} catch (e) { console.warn('Map load failed:', e); }
|
||
}
|
||
|
||
function mergeMapMarkers(vessels) {
|
||
const now = Date.now();
|
||
const seen = new Set();
|
||
vessels.forEach(v => {
|
||
if (!v.latitude || !v.longitude) return;
|
||
const mmsi = v.mmsi || '';
|
||
if (!mmsi) return;
|
||
seen.add(mmsi);
|
||
const existing = _mapMarkersByMmsi[mmsi];
|
||
if (existing) {
|
||
// Update position and data
|
||
existing.marker.setLatLng([v.latitude, v.longitude]);
|
||
existing.marker.setIcon(createVesselIcon(v));
|
||
// Merge new data, keeping any enriched fields from details fetch
|
||
const old = existing.data;
|
||
Object.keys(v).forEach(k => { if (v[k] != null) old[k] = v[k]; });
|
||
existing.marker.setPopupContent(buildPopup(old));
|
||
existing.marker.setTooltipContent(old.name || mmsi || '?');
|
||
existing.lastSeen = now;
|
||
} else {
|
||
// New vessel — create marker
|
||
const m = L.marker([v.latitude, v.longitude], {
|
||
icon: createVesselIcon(v), title: v.name || mmsi || 'Unknown'
|
||
});
|
||
m.bindPopup(buildPopup(v), { maxWidth: 300 });
|
||
m.bindTooltip(v.name || mmsi || '?', {
|
||
direction:'top', offset:[0,-14], className:'vessel-tooltip'
|
||
});
|
||
m.addTo(mapInstance);
|
||
_mapMarkersByMmsi[mmsi] = { marker: m, data: {...v}, lastSeen: now };
|
||
}
|
||
});
|
||
// Remove vessels not seen for 5 minutes (but keep them visible until then)
|
||
const staleMs = 5 * 60 * 1000;
|
||
Object.keys(_mapMarkersByMmsi).forEach(mmsi => {
|
||
const entry = _mapMarkersByMmsi[mmsi];
|
||
if ((now - entry.lastSeen) > staleMs) {
|
||
mapInstance.removeLayer(entry.marker);
|
||
delete _mapMarkersByMmsi[mmsi];
|
||
}
|
||
});
|
||
}
|
||
|
||
function clearMapMarkers() {
|
||
Object.values(_mapMarkersByMmsi).forEach(e => mapInstance.removeLayer(e.marker));
|
||
_mapMarkersByMmsi = {};
|
||
}
|
||
|
||
function toggleMapView() {
|
||
mapMode = !mapMode;
|
||
const mainEl = document.querySelector('.main');
|
||
const mc = document.getElementById('mapContainer');
|
||
const btn = document.getElementById('mapToggleBtn');
|
||
if (mapMode) {
|
||
mainEl.classList.add('map-mode');
|
||
mc.classList.add('active');
|
||
btn.classList.add('active');
|
||
btn.innerHTML = t('map_toggle_chat');
|
||
initMap();
|
||
setTimeout(() => mapInstance.invalidateSize(), 100);
|
||
} else {
|
||
mainEl.classList.remove('map-mode');
|
||
mc.classList.remove('active');
|
||
btn.classList.remove('active');
|
||
btn.innerHTML = t('map_toggle_map');
|
||
if (mapRefreshTimer) { clearInterval(mapRefreshTimer); mapRefreshTimer = null; }
|
||
if (_bulkRefreshTimer) { clearInterval(_bulkRefreshTimer); _bulkRefreshTimer = null; }
|
||
}
|
||
}
|
||
|
||
function centerMapOn(lat, lon, zoom) {
|
||
if (!mapMode) toggleMapView();
|
||
initMap();
|
||
setTimeout(() => { mapInstance.invalidateSize(); mapInstance.setView([lat, lon], zoom || 10); }, 150);
|
||
}
|
||
|
||
function showVesselOnMap(lat, lon, zoom, label) {
|
||
centerMapOn(lat, lon, zoom || 12);
|
||
if (mapInstance) {
|
||
setTimeout(() => {
|
||
const marker = L.circleMarker([lat, lon], {
|
||
radius: 12, color: '#ff6b6b', fillColor: '#ff6b6b',
|
||
fillOpacity: 0.8, weight: 3
|
||
}).addTo(mapInstance);
|
||
if (label) marker.bindTooltip(label, {permanent: true, direction: 'top', className: 'vessel-highlight-tooltip'}).openTooltip();
|
||
setTimeout(() => { try { mapInstance.removeLayer(marker); } catch(e){} }, 30000);
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Changelog
|
||
// =============================================================================
|
||
const CHANGELOG = [
|
||
{
|
||
version: '3.39.2', date: '2026-03-03',
|
||
changes: {
|
||
en: ['Quick Filters in sidebar: role, vessel types, trade routes, cargo, tonnage, home port', 'Auto-save: every filter change saved instantly (500ms debounce)', 'Role-adaptive tonnage field: DWT for owners, cargo volume for charterers', 'Sidebar switches: Services for anonymous, My Filters for logged-in users', 'Port autocomplete in sidebar filters'],
|
||
ru: ['Быстрые фильтры в боковом меню: роль, типы судов, маршруты, грузы, тоннаж, порт', 'Авто-сохранение: каждое изменение фильтра сохраняется мгновенно', 'Тоннаж меняется по роли: DWT для судовладельцев, объём груза для фрахтователей', 'Боковое меню: Услуги для анонимных, Мои фильтры для авторизованных', 'Автодополнение портов в фильтрах'],
|
||
es: ['Filtros rápidos en menú lateral: rol, tipos de buques, rutas, carga, tonelaje, puerto base', 'Auto-guardado: cada cambio de filtro se guarda instantáneamente', 'Campo de tonelaje adaptativo: DWT para armadores, volumen de carga para fletadores', 'Menú lateral: Servicios para anónimos, Mis filtros para usuarios registrados', 'Autocompletado de puertos en filtros laterales'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.39.1', date: '2026-03-03',
|
||
changes: {
|
||
en: ['Active profile: all 9 profile fields now affect AI responses (vessel types, cargo, trade routes, experience, fleet size, home port)', '"Vessels nearby" command uses home port from profile automatically', 'Structured AI directives: experience-based communication style, fleet-aware responses', 'Profile-driven defaults for vessel search and cargo matching (soft defaults, explicit overrides)'],
|
||
ru: ['Активный профиль: все 9 полей профиля теперь влияют на ответы AI (типы судов, грузы, маршруты, опыт, флот, домашний порт)', 'Команда "суда рядом" автоматически использует домашний порт из профиля', 'Структурные AI-директивы: стиль общения по опыту, ответы с учётом флота', 'Профильные дефолты для поиска судов и подбора под груз (мягкие дефолты, явные запросы приоритетнее)'],
|
||
es: ['Perfil activo: los 9 campos del perfil ahora afectan las respuestas de IA (tipos de buques, carga, rutas, experiencia, flota, puerto base)', 'Comando "buques cerca" usa el puerto base del perfil automáticamente', 'Directivas de IA estructuradas: estilo de comunicación según experiencia, respuestas adaptadas a la flota', 'Valores predeterminados del perfil para búsqueda de buques y carga (valores suaves, solicitudes explícitas tienen prioridad)'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.36.3', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Fast path now works on ALL messages (was only first message — button clicks like Details took 60s+)', 'Vessel data: DB-only rule, no web search fallback for vessel info', 'Cleaned system prompt: removed stray text, shorter "not found" responses'],
|
||
ru: ['Fast path теперь работает на ВСЕХ сообщениях (раньше только на первом — кнопки Details/Position ждали 60с+)', 'Данные судов: только из БД, без web search для информации о судах', 'Очищен system prompt: убран мусор, короткие ответы "не найдено"'],
|
||
es: ['Fast path ahora funciona en TODOS los mensajes (antes solo el primero — botones Details/Position tardaban 60s+)', 'Datos de buques: solo desde BD, sin web search para info de buques', 'System prompt limpiado: texto basura eliminado, respuestas "no encontrado" más cortas'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.36.2', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Vessel details: instant DB-only lookup, removed slow photo loading', 'Removed external MarineTraffic scraping fallback — all data from our database'],
|
||
ru: ['Детали судна: мгновенный поиск только из БД, убрана загрузка фото', 'Убран fallback на внешний MarineTraffic scraping — все данные из нашей базы'],
|
||
es: ['Detalles de buque: búsqueda instantánea solo desde BD, eliminada carga de fotos', 'Eliminado fallback de scraping MarineTraffic externo — todos los datos desde nuestra base'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.36.1', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Cluster rendering: diff algorithm instead of full DOM rebuild on pan (80% fewer DOM ops)', 'Smooth zoom transitions: layer stacking eliminates blank flash between zoom levels', 'Memory leak fix: individual markers dict properly cleared', 'Race condition fix: epoch guard discards stale async responses', 'Timer leak fix: bulk refresh timer properly stopped/restarted', 'SQL optimization: DISTINCT ON replaces Python dedup for 60K+ rows', 'Type category normalization: case-insensitive matching'],
|
||
ru: ['Рендеринг кластеров: diff-алгоритм вместо полной перестройки DOM при сдвиге (на 80% меньше DOM-операций)', 'Плавные переходы зума: layer stacking убирает мерцание при смене уровней', 'Исправлена утечка памяти: словарь маркеров корректно очищается', 'Исправлена гонка запросов: epoch guard отбрасывает устаревшие ответы', 'Исправлена утечка таймеров: bulk refresh таймер корректно останавливается/перезапускается', 'SQL оптимизация: DISTINCT ON вместо Python-дедупликации для 60K+ строк', 'Нормализация type_category: регистронезависимое сопоставление'],
|
||
es: ['Renderizado de clusters: algoritmo diff en lugar de reconstrucción DOM completa al desplazar (80% menos ops DOM)', 'Transiciones de zoom suaves: layer stacking elimina el parpadeo entre niveles', 'Corrección de fuga de memoria: diccionario de marcadores se limpia correctamente', 'Corrección de condición de carrera: epoch guard descarta respuestas obsoletas', 'Corrección de fuga de timers: bulk refresh timer se detiene/reinicia correctamente', 'Optimización SQL: DISTINCT ON reemplaza dedup en Python para 60K+ filas', 'Normalización type_category: coincidencia insensible a mayúsculas'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.36.0', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Vessel map clustering: density circles at low zoom, individual arrows at high zoom (like MarineTraffic)', 'Supercluster spatial index for 30K+ vessels with <5ms rendering', 'Cluster colors by dominant vessel type, click to zoom in', 'Bulk vessel endpoint: all fleet positions in compact format (~300KB)', 'Filter chips work with clusters — counts update in real time'],
|
||
ru: ['Кластеризация судов на карте: кружки с количеством на низком зуме, стрелки на высоком (как MarineTraffic)', 'Supercluster пространственный индекс для 30К+ судов, рендер <5мс', 'Цвет кластеров по доминантному типу судна, клик для приближения', 'Bulk endpoint: все позиции флота в компактном формате (~300KB)', 'Фильтры работают с кластерами — счётчики обновляются в реальном времени'],
|
||
es: ['Agrupación de buques en mapa: círculos de densidad en zoom bajo, flechas individuales en zoom alto (como MarineTraffic)', 'Índice espacial Supercluster para 30K+ buques con renderizado <5ms', 'Colores de clusters por tipo dominante, clic para acercar', 'Endpoint bulk: todas las posiciones en formato compacto (~300KB)', 'Filtros funcionan con clusters — contadores se actualizan en tiempo real'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.35.0', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Profile cabinet: all labels, placeholders and examples translated to user language', 'Close (X) button in profile cabinet header', 'Role dropdown fully translated (Shipowner, Operator, Charterer, etc.)'],
|
||
ru: ['Личный кабинет: все надписи, подсказки и примеры переведены на язык пользователя', 'Кнопка закрытия (X) в заголовке кабинета', 'Выпадающий список ролей полностью переведён (Судовладелец, Оператор, Фрахтователь и др.)'],
|
||
es: ['Perfil: todas las etiquetas, placeholders y ejemplos traducidos al idioma del usuario', 'Botón de cierre (X) en el encabezado del perfil', 'Menú de roles completamente traducido (Armador, Operador, Fletador, etc.)'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.34.1', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Security: prompt injection protection (CRIT-001)', 'Security: stored XSS prevention with input sanitization (HIGH-001)', 'Security: Content-Security-Policy header added (LOW-001)', 'Security: brute force protection on login (MED-001)', 'Security: rate limiting on all endpoints (MED-002)', 'Contacts search now requires authentication (HIGH-002)', 'Subscription upgrade error handling fixed'],
|
||
ru: ['Безопасность: защита от prompt injection (CRIT-001)', 'Безопасность: защита от stored XSS, санитизация ввода (HIGH-001)', 'Безопасность: добавлен заголовок Content-Security-Policy (LOW-001)', 'Безопасность: защита от brute force на логине (MED-001)', 'Безопасность: rate limiting на все эндпоинты (MED-002)', 'Поиск контактов теперь требует авторизации (HIGH-002)', 'Исправлена ошибка обновления подписки'],
|
||
es: ['Seguridad: protección contra prompt injection (CRIT-001)', 'Seguridad: prevención de stored XSS, sanitización de entrada (HIGH-001)', 'Seguridad: encabezado Content-Security-Policy agregado (LOW-001)', 'Seguridad: protección contra fuerza bruta en login (MED-001)', 'Seguridad: rate limiting en todos los endpoints (MED-002)', 'Búsqueda de contactos ahora requiere autenticación (HIGH-002)', 'Error de actualización de suscripción corregido'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.34.0', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Smart Parse Layer 2: entity extraction + fuzzy vessel matching (0 AI tokens)', 'Lightweight AI Layer 3: intent extraction ~230 tokens vs ~3000 for full agent', '4-layer query processing: greetings → regex → smart parse → AI extract → full agent', 'Fuzzy vessel name search (trigram index, 41K+ vessels)', 'Multilingual entity extraction: vessel types, flags, cargo, ports (EN/RU/ES)'],
|
||
ru: ['Smart Parse (слой 2): извлечение сущностей + fuzzy поиск (0 токенов AI)', 'AI Extract (слой 3): лёгкий AI ~230 токенов вместо ~3000', '4 слоя обработки: приветствия → regex → smart parse → AI → полный агент', 'Fuzzy поиск судов (триграммы, 41K+ судов)', 'Многоязычное извлечение: типы судов, флаги, грузы, порты (EN/RU/ES)'],
|
||
es: ['Smart Parse (capa 2): extracción de entidades + búsqueda fuzzy (0 tokens AI)', 'AI Extract (capa 3): AI ligero ~230 tokens vs ~3000 agente completo', '4 capas de procesamiento: saludos → regex → smart parse → AI extract → agente', 'Búsqueda fuzzy de buques (índice trigrama, 41K+ buques)', 'Extracción multilingüe: tipos, banderas, cargas, puertos (EN/RU/ES)'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.33.2', date: '2026-03-01',
|
||
changes: {
|
||
en: ['Multi-provider AI chain: mistral \u2192 cerebras \u2192 groq \u2192 openrouter', 'Cerebras provider added (Qwen 3 235B, 1M tokens/day free)', 'Claude removed from auto chain \u2014 free providers only', '~18,000 free AI requests/day total capacity'],
|
||
ru: ['\u0426\u0435\u043f\u043e\u0447\u043a\u0430 AI: mistral \u2192 cerebras \u2192 groq \u2192 openrouter', '\u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d Cerebras (Qwen 3 235B, 1M tokens/\u0434\u0435\u043d\u044c \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e)', 'Claude \u0443\u0431\u0440\u0430\u043d \u0438\u0437 \u0430\u0432\u0442\u043e-\u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u2014 \u0442\u043e\u043b\u044c\u043a\u043e \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b', '~18 000 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 AI \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432/\u0434\u0435\u043d\u044c'],
|
||
es: ['Cadena AI: mistral \u2192 cerebras \u2192 groq \u2192 openrouter', 'Cerebras agregado (Qwen 3 235B, 1M tokens/d\u00eda gratis)', 'Claude eliminado de la cadena auto \u2014 solo proveedores gratuitos', '~18.000 solicitudes AI gratuitas/d\u00eda en total'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.30.1', date: '2026-02-28',
|
||
changes: {
|
||
en: ['Security audit fixes: /health info disclosure, CORS hardening, token payload (uid only)', 'Subscription error handling improved', 'nginx version header hidden'],
|
||
ru: ['\u0418\u0441\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0430\u0443\u0434\u0438\u0442\u0430 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438: /health, CORS, \u0442\u043e\u043a\u0435\u043d (\u0442\u043e\u043b\u044c\u043a\u043e uid)', '\u0423\u043b\u0443\u0447\u0448\u0435\u043d\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u043e\u0448\u0438\u0431\u043e\u043a \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438', '\u0421\u043a\u0440\u044b\u0442 \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a \u0432\u0435\u0440\u0441\u0438\u0438 nginx'],
|
||
es: ['Correcciones de auditor\u00eda: /health, CORS, token (solo uid)', 'Manejo de errores de suscripci\u00f3n mejorado', 'Encabezado de versi\u00f3n nginx oculto'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.29.0', date: '2026-02-28',
|
||
changes: {
|
||
en: ['Home Port selection in onboarding wizard and profile cabinet', 'Port autocomplete search (16,500+ ports)', 'AI agent aware of user home port for personalized responses'],
|
||
ru: ['Выбор домашнего порта в анкете и личном кабинете', 'Автодополнение портов (16 500+ портов)', 'AI-агент учитывает домашний порт для персонализации'],
|
||
es: ['Selección de puerto base en el asistente y perfil', 'Autocompletado de puertos (16.500+ puertos)', 'Agente AI consciente del puerto base del usuario'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.28.0', date: '2026-02-28',
|
||
changes: {
|
||
en: ['Vessel arrows on map with rotation by heading/course', 'Vessel type filter bar on map (Tanker, Bulk, Container, etc.)', 'Mini app: arrow icons, filters, error handling fix'],
|
||
ru: ['Стрелки судов на карте с вращением по курсу', 'Фильтр по типам судов на карте (Танкер, Балкер, Контейнер и др.)', 'Мини-приложение: стрелки, фильтры, исправление ошибок'],
|
||
es: ['Flechas de buques en mapa con rotación por rumbo', 'Filtro por tipo de buque en mapa (Tanque, Granelero, Contenedor, etc.)', 'Mini app: flechas, filtros, corrección de errores'],
|
||
}
|
||
},
|
||
{
|
||
version: '3.27.0', date: '2026-02-28',
|
||
changes: {
|
||
en: [
|
||
'Fast Path: simple queries (find vessel, position, vessels near port, route) handled without AI — 0 tokens, instant response',
|
||
'Supports 3 languages (EN/RU/ES) with auto-detection',
|
||
'Complex queries still routed to AI agent (Groq/Mistral/Claude)',
|
||
],
|
||
ru: [
|
||
'Fast Path: простые запросы (поиск судна, позиция, суда у порта, маршрут, санкции) обрабатываются без AI — 0 токенов, мгновенный ответ',
|
||
'Поддержка 3 языков (EN/RU/ES) с автоопределением',
|
||
'Сложные запросы по-прежнему идут через AI агент (Groq/Mistral/Claude)',
|
||
],
|
||
es: [
|
||
'Fast Path: consultas simples (buscar buque, posición, buques en puerto, ruta, sanciones) sin AI — 0 tokens, respuesta instantánea',
|
||
'Soporte de 3 idiomas (EN/RU/ES) con autodetección',
|
||
'Consultas complejas siguen siendo procesadas por el agente AI (Groq/Mistral/Claude)',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.26.0', date: '2026-02-28',
|
||
changes: {
|
||
en: [
|
||
'Multi-provider AI: Groq (free, fast) → Mistral (fallback) → Claude (premium) — automatic failover',
|
||
'Groq Llama 3.3 70B as primary AI with optimized tool calling (15 priority tools)',
|
||
'Token savings: free Groq handles most queries, Claude only when needed',
|
||
],
|
||
ru: [
|
||
'Мульти-провайдер AI: Groq (бесплатный, быстрый) → Mistral (резерв) → Claude (премиум) — автопереключение',
|
||
'Groq Llama 3.3 70B как основной AI с оптимизированным вызовом инструментов (15 приоритетных)',
|
||
'Экономия токенов: бесплатный Groq обрабатывает большинство запросов, Claude только при необходимости',
|
||
],
|
||
es: [
|
||
'Multi-proveedor AI: Groq (gratis, rápido) → Mistral (respaldo) → Claude (premium) — conmutación automática',
|
||
'Groq Llama 3.3 70B como AI principal con llamadas de herramientas optimizadas (15 prioritarias)',
|
||
'Ahorro de tokens: Groq gratis maneja la mayoría de consultas, Claude solo cuando es necesario',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.25.0', date: '2026-02-28',
|
||
changes: {
|
||
en: [
|
||
'Data sources streamlined: removed Equasis, using only MarineTraffic + own DB (31K+ vessels with full owner data)',
|
||
'Fixed vessel search: "OSLO BULK 7" no longer confused with port Oslo — correct tool selection',
|
||
'AISStream fix: only 1 worker connects (no more "concurrent connections exceeded")',
|
||
'SSE timeout increased from 110s to 240s — fewer premature timeouts',
|
||
'Server stability: 2 workers, 300s timeout, no --preload, SSL disabled for localhost PG',
|
||
],
|
||
ru: [
|
||
'Оптимизация источников: убран Equasis, только MarineTraffic + наша БД (31K+ судов с данными владельцев)',
|
||
'Исправлен поиск судов: "OSLO BULK 7" больше не путается с портом Осло — правильный выбор инструмента',
|
||
'AISStream фикс: только 1 воркер подключается (нет "concurrent connections exceeded")',
|
||
'SSE timeout увеличен с 110с до 240с — меньше преждевременных таймаутов',
|
||
'Стабильность сервера: 2 воркера, 300с timeout, без --preload, SSL отключён для localhost PG',
|
||
],
|
||
es: [
|
||
'Fuentes optimizadas: eliminado Equasis, solo MarineTraffic + nuestra BD (31K+ buques con datos de propietarios)',
|
||
'Búsqueda corregida: "OSLO BULK 7" ya no se confunde con puerto Oslo — selección correcta de herramienta',
|
||
'AISStream fix: solo 1 worker se conecta (sin "concurrent connections exceeded")',
|
||
'SSE timeout aumentado de 110s a 240s — menos timeouts prematuros',
|
||
'Estabilidad del servidor: 2 workers, 300s timeout, sin --preload, SSL desactivado para localhost PG',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.22.0', date: '2026-02-28',
|
||
changes: {
|
||
en: [
|
||
'Per-session budget control: soft limit (conciseness hint) + hard limit (loop stop) to prevent runaway API costs',
|
||
'Admin API Costs dashboard: daily spending, cache hit rate, top users, cost trends',
|
||
'Claude pricing constants centralized in config.py',
|
||
],
|
||
ru: [
|
||
'Бюджетный контроль сессий: soft limit (подсказка краткости) + hard limit (остановка цикла) для контроля расходов API',
|
||
'Админ-дашборд расходов API: дневные траты, процент кэша, топ пользователей, тренды',
|
||
'Константы цен Claude централизованы в config.py',
|
||
],
|
||
es: [
|
||
'Control presupuestario por sesión: límite suave (sugerencia de concisión) + límite duro (parada del ciclo) para controlar costos API',
|
||
'Panel admin de costos API: gastos diarios, tasa de caché, usuarios principales, tendencias',
|
||
'Constantes de precios Claude centralizadas en config.py',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.19.1', date: '2026-02-24',
|
||
changes: {
|
||
en: [
|
||
'Fleet intelligence: 9,954 vessels (3,108 bulk carriers) integrated into loading port search',
|
||
'MMSI enrichment — automated identity lookup for all vessels in database',
|
||
'Loading port search now covers 90 NM radius for bulk carrier contacts',
|
||
'Spatial queries for fleet intelligence data',
|
||
],
|
||
ru: [
|
||
'Флотовая разведка: 9954 судна (3108 сухогрузов) интегрированы в поиск у порта',
|
||
'MMSI обогащение — автоматический lookup идентификации для всех судов в базе',
|
||
'Поиск у порта погрузки теперь покрывает радиус 90 NM для контактов сухогрузов',
|
||
'Пространственные запросы по данным флотовой разведки',
|
||
],
|
||
es: [
|
||
'Inteligencia de flota: 9.954 buques (3.108 graneleros) integrados en búsqueda portuaria',
|
||
'Enriquecimiento MMSI — búsqueda automática de identidad para todos los buques',
|
||
'Búsqueda puerto de carga ahora cubre radio 90 NM para contactos graneleros',
|
||
'Consultas espaciales para datos de inteligencia de flota',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.19.0', date: '2026-02-24',
|
||
changes: {
|
||
en: [
|
||
'Bulk carrier ownership database: vessel owner, operator, website from maritime intelligence network',
|
||
'Loading port search: find dry bulk carriers within 90 NM with shipowner contacts and websites',
|
||
'Systematic global bulk fleet data collection',
|
||
'Vessel database enriched with: beneficial owner, registered owner, commercial manager, website fields',
|
||
],
|
||
ru: [
|
||
'База судовладельцев: собственник, оператор, сайт из морской разведывательной сети',
|
||
'Поиск у порта погрузки: сухогрузы в радиусе 90 NM с контактами и сайтами судовладельцев',
|
||
'Систематический сбор данных глобального флота',
|
||
'База судов: новые поля — beneficial owner, registered owner, commercial manager, website',
|
||
],
|
||
es: [
|
||
'Base de propietarios: armador, operador, sitio web de la red de inteligencia marítima',
|
||
'Búsqueda en puerto de carga: graneleros en radio de 90 NM con contactos y webs de armadores',
|
||
'Recopilación sistemática de datos de la flota global',
|
||
'Base de buques ampliada: beneficial owner, registered owner, commercial manager, website',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.18.0', date: '2026-02-24',
|
||
changes: {
|
||
en: [
|
||
'Caspian Sea: full intelligence database — 8 shipping companies with contacts (ASCO, KMTF, CIMS, Volgaflot, Khazar, Turkmen Fleet, Silver Shipping, Volgotanker)',
|
||
'Caspian fleet: 17 Khazar Shipping vessels with IMO numbers seeded into database',
|
||
'New ports: Kuryk (Kazakhstan) and Atyrau (Kazakhstan) added to port database (33 Caspian ports total)',
|
||
'INSTC routing: Caspian-Persian Gulf and Caspian-India freight corridors with rate benchmarks',
|
||
'AI agent: Caspian knowledge base — operators, trade corridors, port capacities, vessel classes',
|
||
'Caspian port aliases: Krasnovodsk→Turkmenbashi, Guryev→Atyrau, Noshahr→Nowshahr, Alat/Baku New Port',
|
||
],
|
||
ru: [
|
||
'Каспийское море: полная база данных — 8 судоходных компаний с контактами (ASCO, КМТФ, CIMS, Волгафлот, Хазар, Туркменский флот, Silver Shipping, Волготанкер)',
|
||
'Каспийский флот: 17 судов Khazar Shipping с IMO-номерами загружены в базу',
|
||
'Новые порты: Курык (Казахстан) и Атырау (Казахстан) добавлены (33 каспийских порта всего)',
|
||
'Маршруты INSTC: коридоры Каспий—Персидский залив и Каспий—Индия с фрахтовыми ставками',
|
||
'AI агент: база знаний по Каспию — операторы, торговые коридоры, мощности портов, классы судов',
|
||
'Алиасы портов: Красноводск→Туркменбаши, Гурьев→Атырау, Ношахр→Ноушехр, Алят/Новый порт Баку',
|
||
],
|
||
es: [
|
||
'Mar Caspio: base de datos completa — 8 navieras con contactos (ASCO, KMTF, CIMS, Volgaflot, Khazar, Flota Turkmena, Silver Shipping, Volgotanker)',
|
||
'Flota Caspio: 17 buques de Khazar Shipping con números IMO cargados en la base de datos',
|
||
'Nuevos puertos: Kuryk (Kazajstán) y Atyrau (Kazajstán) añadidos (33 puertos caspios en total)',
|
||
'Rutas INSTC: corredores Caspio-Golfo Pérsico y Caspio-India con tarifas de flete',
|
||
'Agente IA: base de conocimiento del Caspio — operadores, corredores comerciales, capacidades portuarias',
|
||
'Alias de puertos: Krasnovodsk→Turkmenbashi, Guryev→Atyrau, Noshahr→Nowshahr, Alat/Nuevo Puerto Bakú',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.17.5', date: '2026-02-24',
|
||
changes: {
|
||
en: [
|
||
'Mini App chat: loads and displays conversation history',
|
||
|
||
],
|
||
ru: [
|
||
'Чат Mini App: загрузка и отображение истории переписки',
|
||
],
|
||
es: [
|
||
'Chat Mini App: carga y muestra historial de conversación',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.17.4', date: '2026-02-24',
|
||
changes: {
|
||
en: [],
|
||
ru: [],
|
||
es: []
|
||
}
|
||
},
|
||
{
|
||
version: '3.17.3', date: '2026-02-24',
|
||
changes: {
|
||
en: [
|
||
'Fixed: user profile save now works correctly (wizard completes properly)',
|
||
'Fixed: onboarding wizard no longer repeats after completion'
|
||
],
|
||
ru: [
|
||
'Исправлено: сохранение профиля теперь работает корректно (визард завершается правильно)',
|
||
'Исправлено: визард больше не повторяется после прохождения'
|
||
],
|
||
es: [
|
||
'Corregido: guardado de perfil ahora funciona correctamente',
|
||
'Corregido: asistente ya no se repite después de completarlo'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.17.1', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Fixed: onboarding wizard no longer repeats on every login',
|
||
],
|
||
ru: [
|
||
'Исправлено: визард не повторяется при каждом входе',
|
||
],
|
||
es: [
|
||
'Corregido: asistente no se repite en cada inicio de sesión',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.17.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Full-screen Leaflet map with live vessel tracking',
|
||
'Menu button in bot chat opens the Mini App'
|
||
],
|
||
ru: [
|
||
'Полноэкранная карта Leaflet с живым отслеживанием судов',
|
||
'Кнопка меню в боте открывает Mini App'
|
||
],
|
||
es: [
|
||
'Mapa Leaflet a pantalla completa con seguimiento en vivo',
|
||
'Botón de menú en el bot abre la Mini App'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.16.1', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Fixed: wizard selections now correctly appear in profile (chip value sync)',
|
||
'Added Caspian, Timber, Cement options to profile',
|
||
],
|
||
ru: [
|
||
'Исправлено: выбор в визарде теперь корректно отображается в профиле',
|
||
'Добавлены Каспий, Древесина, Цемент в профиль',
|
||
],
|
||
es: [
|
||
'Corregido: selecciones del asistente ahora se muestran en el perfil',
|
||
'Agregados Caspio, Madera, Cemento al perfil',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.16.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [],
|
||
ru: [
|
||
'Модель «сначала сайт»: регистрация на сайте, затем привязка бота',
|
||
],
|
||
es: [
|
||
'Modelo «primero el sitio»: registro en el sitio, luego vincular el bot',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.15.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Step-by-step onboarding wizard after registration',
|
||
'Role selection: shipowner, cargo owner, or broker',
|
||
'Personalized questions based on role (vessel type, cargo, region)',
|
||
],
|
||
ru: [
|
||
'Пошаговая регистрация после создания аккаунта',
|
||
'Выбор роли: судовладелец, грузовладелец или брокер',
|
||
'Персонализированные вопросы по роли (тип судна, груз, регион)',
|
||
],
|
||
es: [
|
||
'Registro paso a paso después de crear cuenta',
|
||
'Selección de rol: armador, cargador o bróker',
|
||
'Preguntas personalizadas por rol (tipo de buque, carga, región)',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.14.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Role-aware AI: agent adapts to shipowners, cargo owners, and brokers',
|
||
'Auto-detection of user role from conversation context',
|
||
'Role-specific language, tool priority, and proactive suggestions'
|
||
],
|
||
ru: [
|
||
'Адаптивный AI: агент подстраивается под судовладельцев, грузовладельцев и брокеров',
|
||
'Авто-определение роли пользователя из контекста разговора',
|
||
'Профессиональный язык, приоритет инструментов и проактивные предложения по роли'
|
||
],
|
||
es: [
|
||
'IA adaptativa: agente se adapta a armadores, cargadores y brokers',
|
||
'Detección automática del rol del usuario desde el contexto',
|
||
'Lenguaje profesional, prioridad de herramientas y sugerencias proactivas por rol'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.13.1', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Freight-only filter: search results and map show only cargo & tanker vessels',
|
||
'Passenger, fishing, and other non-freight vessels hidden from results'
|
||
],
|
||
ru: [
|
||
'Фильтр грузовых: в поиске и на карте только cargo и tanker суда',
|
||
'Пассажирские, рыболовные и прочие суда скрыты из результатов'
|
||
],
|
||
es: [
|
||
'Filtro de carga: búsqueda y mapa muestran solo buques cargo y tanker',
|
||
'Buques de pasajeros, pesca y otros ocultos de los resultados'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.13.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Background vessel collector: auto-enriches Mediterranean, Baltic, Caspian ship data',
|
||
'Equasis integration: owner, operator, manager data collected automatically',
|
||
'Smart enrichment queue: cargo/tanker vessels prioritized, 7-day refresh cycle',
|
||
'Contact database grows automatically from discovered vessels'
|
||
],
|
||
ru: [
|
||
'Фоновый сбор данных: автообогащение судов Средиземноморья, Балтики, Каспия',
|
||
'Интеграция Equasis: владелец, оператор, менеджер собираются автоматически',
|
||
'Умная очередь: приоритет cargo/tanker, обновление каждые 7 дней',
|
||
'База контактов судовладельцев растёт автоматически'
|
||
],
|
||
es: [
|
||
'Recolector de buques: enriquecimiento automático de datos del Mediterráneo, Báltico, Caspio',
|
||
'Integración Equasis: propietario, operador, gestor recopilados automáticamente',
|
||
'Cola inteligente: prioridad carga/tanquero, ciclo de actualización de 7 días',
|
||
'Base de contactos de armadores crece automáticamente'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.12.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Caspian Sea: dedicated routing region (10 ports: Baku, Aktau, Turkmenbashi, Astrakhan...)',
|
||
'Caspian routes via Volga-Don Canal to Black Sea, Mediterranean, Europe',
|
||
'River-Sea vessel class (1-10k DWT) for Caspian/inland operations',
|
||
'Caspian freight rates: bulk and tanker corridor benchmarks',
|
||
'AIS: Caspian Sea added to real-time monitoring areas'
|
||
],
|
||
ru: [
|
||
'Каспийское море: выделенный регион маршрутизации (10 портов: Баку, Актау, Туркменбаши, Астрахань...)',
|
||
'Каспийские маршруты через Волго-Донской канал в Чёрное море, Средиземноморье, Европу',
|
||
'Класс судов River-Sea (1-10k DWT) для каспийских/внутренних перевозок',
|
||
'Фрахтовые ставки Каспия: балк и танкер бенчмарки',
|
||
'AIS: Каспийское море добавлено в зоны мониторинга'
|
||
],
|
||
es: [
|
||
'Mar Caspio: región de enrutamiento dedicada (10 puertos: Bakú, Aktau, Turkmenbashy, Astracán...)',
|
||
'Rutas caspias vía Canal Volga-Don al Mar Negro, Mediterráneo, Europa',
|
||
'Clase River-Sea (1-10k DWT) para operaciones caspio/interiores',
|
||
'Tarifas de flete del Caspio: benchmarks de graneles y tanqueros',
|
||
'AIS: Mar Caspio añadido a zonas de monitoreo en tiempo real'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.11.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Vessel Watch alerts: /watch VESSEL to PORT — push notifications on arrival',
|
||
'Status monitoring: /watch VESSEL status — alerts on nav status changes',
|
||
'Background checker thread (10 min interval) with auto-expire (7 days)'
|
||
],
|
||
ru: [
|
||
'Оповещения: /watch СУДНО to ПОРТ — push-уведомление при приходе в порт',
|
||
'Мониторинг статуса: /watch СУДНО status — оповещение при смене статуса',
|
||
'Фоновая проверка (каждые 10 мин) с автоистечением (7 дней)'
|
||
],
|
||
es: [
|
||
'Alertas: /watch BUQUE to PUERTO — notificación push al llegar',
|
||
'Monitoreo de estado: /watch BUQUE status — alerta de cambio de estado',
|
||
'Verificación en segundo plano (cada 10 min) con auto-expiración (7 días)'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.10.0', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'All tools available: vessel tracking, route calculation, cargo matching, contacts',
|
||
'Multi-language support: EN/RU/ES with auto-detection',
|
||
],
|
||
ru: [
|
||
'Все 26 инструментов: отслеживание судов, маршруты, фрахт, санкции',
|
||
'Мультиязычность: EN/RU/ES с автоопределением',
|
||
],
|
||
es: [
|
||
'Las 26 herramientas disponibles: rastreo, rutas, fletes, sanciones',
|
||
'Soporte multilingüe: EN/RU/ES con detección automática',
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.9.4', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Security hardening (IronClaw principles): prompt injection protection',
|
||
'Separated token signing key from API key',
|
||
'Message length limit (2000 chars) to prevent abuse',
|
||
'Admin-only access to diagnostic endpoints',
|
||
'Security audit logging for login/admin events'
|
||
],
|
||
ru: [
|
||
'Усиление безопасности (принципы IronClaw): защита от prompt injection',
|
||
'Разделение ключа подписи токенов и API-ключа',
|
||
'Лимит длины сообщений (2000 символов) для защиты от злоупотреблений',
|
||
'Диагностические эндпоинты только для админов',
|
||
'Аудит-логирование событий безопасности (логин, права)'
|
||
],
|
||
es: [
|
||
'Refuerzo de seguridad (principios IronClaw): protección contra inyección de prompts',
|
||
'Separación de clave de firma de tokens y clave API',
|
||
'Límite de longitud de mensajes (2000 caracteres) contra abuso',
|
||
'Endpoints de diagnóstico solo para administradores',
|
||
'Registro de auditoría de seguridad (login, permisos)'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.9.3', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'Agent memory system: 3-tier (short-term, working, long-term)',
|
||
'Agent remembers user preferences, searched vessels, and ports across sessions',
|
||
'Auto-summarize conversations every 20 messages',
|
||
'"Remember this" command: permanent facts saved for future chats'
|
||
],
|
||
ru: [
|
||
'Система памяти агента: 3 уровня (краткосрочная, рабочая, долгосрочная)',
|
||
'Агент помнит предпочтения, искомые суда и порты между сессиями',
|
||
'Авто-саммари разговоров каждые 20 сообщений',
|
||
'Команда "запомни": постоянные факты сохраняются для будущих чатов'
|
||
],
|
||
es: [
|
||
'Sistema de memoria del agente: 3 niveles (corto, medio, largo plazo)',
|
||
'El agente recuerda preferencias, buques y puertos buscados entre sesiones',
|
||
'Auto-resumen de conversaciones cada 20 mensajes',
|
||
'Comando "recuerda": hechos permanentes guardados para futuros chats'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.9.2', date: '2026-02-23',
|
||
changes: {
|
||
en: [
|
||
'HTTPS + custom domain (seafare-montana.duckdns.org)',
|
||
'Google OAuth fix for new hosting',
|
||
'Map buttons now display inside table cells next to each vessel'
|
||
],
|
||
ru: [
|
||
'HTTPS + свой домен (seafare-montana.duckdns.org)',
|
||
'Исправлена авторизация Google OAuth для нового хостинга',
|
||
'Кнопки карты теперь в таблице напротив каждого судна'
|
||
],
|
||
es: [
|
||
'HTTPS + dominio propio (seafare-montana.duckdns.org)',
|
||
'Corrección de Google OAuth para nuevo hosting',
|
||
'Botones de mapa ahora se muestran en la tabla junto a cada buque'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.9.1', date: '2026-02-23',
|
||
changes: {
|
||
en: ['Fixed voice input stuttering on Android: disabled continuous mode, auto-restart between phrases'],
|
||
ru: ['Исправлено заикание голосового ввода на Android: отключён continuous режим, авто-рестарт между фразами'],
|
||
es: ['Corregido tartamudeo de entrada de voz en Android: modo continuo desactivado, reinicio automático entre frases']
|
||
}
|
||
},
|
||
{
|
||
version: '3.9.0', date: '2026-02-20',
|
||
changes: {
|
||
en: [
|
||
'Cargo search form: select cargo type (Dry / Containers / Liquid) with clickable cards',
|
||
'Port input fields: loading port (required) + destination port (optional) + tonnage',
|
||
'Trilingual form: all labels and messages in EN/RU/ES'
|
||
],
|
||
ru: [
|
||
'Форма поиска груза: выбор типа (Сухой / Контейнеры / Наливной) кликабельными карточками',
|
||
'Поля портов: порт погрузки (обязательно) + порт назначения + тоннаж',
|
||
'Трёхъязычная форма: все надписи на EN/RU/ES'
|
||
],
|
||
es: [
|
||
'Formulario de búsqueda de carga: selección de tipo (Seca / Contenedores / Líquida) con tarjetas',
|
||
'Campos de puerto: puerto de carga (obligatorio) + puerto de destino + tonelaje',
|
||
'Formulario trilingüe: todas las etiquetas en EN/RU/ES'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.8.2', date: '2026-02-20',
|
||
changes: {
|
||
en: ['Multilingual changelog: version history now displayed in selected language (EN/RU/ES)'],
|
||
ru: ['Мультиязычный changelog: история версий теперь на выбранном языке (EN/RU/ES)'],
|
||
es: ['Changelog multilingüe: el historial de versiones ahora se muestra en el idioma seleccionado (EN/RU/ES)']
|
||
}
|
||
},
|
||
{
|
||
version: '3.8.1', date: '2026-02-20',
|
||
changes: {
|
||
en: ['Clickable version number in sidebar — shows changelog popup with full version history'],
|
||
ru: ['Кликабельный номер версии в боковой панели — всплывающее окно с историей обновлений'],
|
||
es: ['Número de versión clicable en la barra lateral — ventana emergente con historial de cambios']
|
||
}
|
||
},
|
||
{
|
||
version: '3.8.0', date: '2026-02-20',
|
||
changes: {
|
||
en: [
|
||
'Destination search: find vessels HEADING TO a port by AIS destination field',
|
||
'Nearby port matching: search destination across ports within 50 NM radius',
|
||
'Dual-mode results: "heading to port" vessels shown first, then "near port"',
|
||
'DB index on destination column for fast queries',
|
||
'Ready for expanded maritime data integration'
|
||
],
|
||
ru: [
|
||
'Поиск по назначению: суда, направляющиеся в порт по полю AIS destination',
|
||
'Поиск по соседним портам: проверка назначения в радиусе 50 морских миль',
|
||
'Двойной режим: сначала суда «идут в порт», потом «рядом с портом»',
|
||
'Индекс БД по полю destination для быстрых запросов',
|
||
'Подготовка к расширенной интеграции морских данных'
|
||
],
|
||
es: [
|
||
'Búsqueda por destino: buques que se DIRIGEN al puerto por campo AIS destination',
|
||
'Búsqueda en puertos cercanos: verificación de destino en radio de 50 millas náuticas',
|
||
'Resultados duales: primero buques "rumbo al puerto", luego "cerca del puerto"',
|
||
'Índice de BD en columna destination para consultas rápidas',
|
||
'Preparado para integración ampliada de datos marítimos'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.7.1', date: '2026-02-20',
|
||
changes: {
|
||
en: [
|
||
'Cargo matching: expanding radius search (50→200→500 NM)',
|
||
'Distance calculation from loading port for each vessel',
|
||
'Map buttons in cargo search results',
|
||
'Added flour/meal/feed to cargo classification'
|
||
],
|
||
ru: [
|
||
'Подбор судов по грузу: расширяющийся радиус поиска (50→200→500 миль)',
|
||
'Расчёт расстояния от порта погрузки для каждого судна',
|
||
'Кнопки карты в результатах поиска грузов',
|
||
'Добавлены мука/комбикорм в классификацию грузов'
|
||
],
|
||
es: [
|
||
'Búsqueda de carga: radio de búsqueda expandible (50→200→500 MN)',
|
||
'Cálculo de distancia desde puerto de carga para cada buque',
|
||
'Botones de mapa en resultados de búsqueda de carga',
|
||
'Agregados harina/pienso a la clasificación de carga'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.7.0', date: '2026-02-19',
|
||
changes: {
|
||
en: [
|
||
'Expanding vessel search radius: always returns 3+ closest vessels',
|
||
'Clickable "Show on Map" buttons in chat',
|
||
'Distance from port shown for each vessel in search results'
|
||
],
|
||
ru: [
|
||
'Расширяющийся радиус поиска судов: всегда возвращает 3+ ближайших',
|
||
'Кликабельные кнопки «Показать на карте» в чате',
|
||
'Расстояние от порта для каждого судна в результатах'
|
||
],
|
||
es: [
|
||
'Radio de búsqueda expandible: siempre devuelve 3+ buques más cercanos',
|
||
'Botones "Mostrar en mapa" clicables en el chat',
|
||
'Distancia desde el puerto para cada buque en resultados'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.6.0', date: '2026-02-18',
|
||
changes: {
|
||
en: [
|
||
'AISStream sync WebSocket fallback for Render',
|
||
'Sync queries run inside Flask request threads — reliable on free tier',
|
||
'ais-test endpoint with fallback mode'
|
||
],
|
||
ru: [
|
||
'AISStream: синхронный WebSocket fallback для Render',
|
||
'Синхронные запросы в потоках Flask — стабильная работа на бесплатном тарифе',
|
||
'Эндпоинт ais-test с режимом fallback'
|
||
],
|
||
es: [
|
||
'AISStream: WebSocket sincrónico de respaldo para Render',
|
||
'Consultas sincrónicas en hilos Flask — funcionamiento estable en plan gratuito',
|
||
'Endpoint ais-test con modo fallback'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.5.4', date: '2026-02-17',
|
||
changes: {
|
||
en: [
|
||
'Migrated AISStream from async to sync websocket-client',
|
||
'Thread stability improvements: BaseException catching + watchdog'
|
||
],
|
||
ru: [
|
||
'Миграция AISStream с async на sync websocket-client',
|
||
'Улучшение стабильности потоков: перехват BaseException + watchdog'
|
||
],
|
||
es: [
|
||
'Migración de AISStream de async a sync websocket-client',
|
||
'Mejoras de estabilidad de hilos: captura BaseException + watchdog'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.5.0', date: '2026-02-15',
|
||
changes: {
|
||
en: [
|
||
'PostgreSQL support: dual-mode DB (auto-detect DATABASE_URL)',
|
||
'Neon.tech PostgreSQL in production, SQLite locally',
|
||
'DB auto-healing for SQLite corruption'
|
||
],
|
||
ru: [
|
||
'Поддержка PostgreSQL: двойной режим БД (автоопределение DATABASE_URL)',
|
||
'Neon.tech PostgreSQL в продакшене, SQLite локально',
|
||
'Автовосстановление БД при повреждении SQLite'
|
||
],
|
||
es: [
|
||
'Soporte PostgreSQL: BD de modo dual (autodetección de DATABASE_URL)',
|
||
'Neon.tech PostgreSQL en producción, SQLite localmente',
|
||
'Autocuración de BD por corrupción de SQLite'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '3.0.0', date: '2026-02-10',
|
||
changes: {
|
||
en: [
|
||
'AIS provider: unified AISStream + AISHub + Digitraffic + MT fallback',
|
||
'Real-time vessel tracking on Leaflet map',
|
||
'26 AI agent tools with tool-calling loop',
|
||
],
|
||
ru: [
|
||
'AIS провайдер: единый AISStream + AISHub + Digitraffic + MT fallback',
|
||
'Отслеживание судов в реальном времени на карте Leaflet',
|
||
'26 инструментов AI-агента с циклом tool-calling',
|
||
'Комплаенс: проверка санкций + обнаружение тёмного флота'
|
||
],
|
||
es: [
|
||
'Proveedor AIS: unificado AISStream + AISHub + Digitraffic + MT fallback',
|
||
'Seguimiento de buques en tiempo real en mapa Leaflet',
|
||
'26 herramientas de agente AI con ciclo tool-calling',
|
||
'Cumplimiento marítimo: control de sanciones + detección de flota oscura'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '2.0.0', date: '2026-01-25',
|
||
changes: {
|
||
en: [
|
||
'Google OAuth authentication + USDT wallet system',
|
||
'SeaFare Montana',
|
||
'Contact purchase flow with atomic balance operations',
|
||
'Multi-language UI: English, Russian, Spanish'
|
||
],
|
||
ru: [
|
||
'Авторизация Google OAuth + система кошелька USDT',
|
||
'SeaFare Montana',
|
||
'Покупка контактов с атомарными операциями баланса',
|
||
'Мультиязычный интерфейс: English, Русский, Español'
|
||
],
|
||
es: [
|
||
'Autenticación Google OAuth + sistema de billetera USDT',
|
||
'SeaFare Montana',
|
||
'Flujo de compra de contactos con operaciones atómicas de saldo',
|
||
'Interfaz multilingüe: English, Русский, Español'
|
||
]
|
||
}
|
||
},
|
||
{
|
||
version: '1.0.0', date: '2026-01-10',
|
||
changes: {
|
||
en: [
|
||
'Initial release: SeaFare Montana maritime AI agent',
|
||
'Claude API integration with tool-calling',
|
||
'Vessel search, port database (16,553 ports), sea routing',
|
||
'Single-page web app deployed on Render.com'
|
||
],
|
||
ru: [
|
||
'Первый релиз: SeaFare Montana — AI-агент морской логистики',
|
||
'Интеграция Claude API с вызовом инструментов',
|
||
'Поиск судов, база портов (16 553 порта), морская маршрутизация',
|
||
'Одностраничное веб-приложение на Render.com'
|
||
],
|
||
es: [
|
||
'Lanzamiento inicial: SeaFare Montana — agente AI de logística marítima',
|
||
'Integración Claude API con llamada de herramientas',
|
||
'Búsqueda de buques, base de puertos (16.553 puertos), enrutamiento marítimo',
|
||
'Aplicación web de una página desplegada en Render.com'
|
||
]
|
||
}
|
||
}
|
||
];
|
||
|
||
function showChangelog() {
|
||
const existing = document.querySelector('.changelog-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
let html = '<div class="changelog-overlay" onclick="if(event.target===this)this.remove()">';
|
||
html += '<div class="changelog-popup" style="position:relative;">';
|
||
html += '<button class="cl-close" onclick="this.closest(\'.changelog-overlay\').remove()">×</button>';
|
||
html += `<h2>${t('changelog_title')}</h2>`;
|
||
|
||
for (const entry of CHANGELOG) {
|
||
const items = entry.changes[currentLang] || entry.changes.en;
|
||
html += '<div class="cl-ver">';
|
||
html += `<h3>v${entry.version}</h3>`;
|
||
html += `<div class="cl-date">${entry.date}</div>`;
|
||
html += '<ul>';
|
||
for (const c of items) {
|
||
html += `<li>${c}</li>`;
|
||
}
|
||
html += '</ul></div>';
|
||
}
|
||
|
||
html += '</div></div>';
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Init
|
||
// =============================================================================
|
||
|
||
// Immediately populate auth form with translations (no async needed)
|
||
// so buttons/placeholders show instantly before network requests complete
|
||
updateAuthForm();
|
||
|
||
(async function init() {
|
||
// Load auth config (Google Client ID)
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/config');
|
||
if (resp.ok) {
|
||
const cfg = await resp.json();
|
||
googleClientId = cfg.google_client_id || null;
|
||
// If GSI already loaded, init now; otherwise wait for it
|
||
if (googleClientId) {
|
||
if (window.google) {
|
||
initGoogleButton();
|
||
} else {
|
||
// GSI not yet loaded — poll until ready (max 10s)
|
||
var _gsiPoll = setInterval(function() {
|
||
if (window.google) {
|
||
clearInterval(_gsiPoll);
|
||
initGoogleButton();
|
||
}
|
||
}, 200);
|
||
setTimeout(function() { clearInterval(_gsiPoll); }, 10000);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* offline */ }
|
||
|
||
setLang(currentLang);
|
||
await checkSession();
|
||
sessionChecked = true;
|
||
|
||
// Full-page login: show app if authenticated, show login if not
|
||
if (authToken) {
|
||
document.getElementById('authOverlay').classList.add('hidden');
|
||
document.body.classList.remove('auth-mode');
|
||
} else {
|
||
// overlay already visible, just init the form state
|
||
showAuthModal();
|
||
}
|
||
|
||
// Show welcome only if no chat history was loaded
|
||
if (!userHasSentMessage) {
|
||
showWelcome();
|
||
}
|
||
|
||
if (authToken) input.focus();
|
||
|
||
// Load app version
|
||
try {
|
||
const hResp = await fetch(API_BASE + '/health');
|
||
if (hResp.ok) {
|
||
const h = await hResp.json();
|
||
if (h.version) document.getElementById('appVersion').textContent = 'v' + h.version;
|
||
}
|
||
} catch (e) { /* offline */ }
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|