mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:24:49 +00:00
client note: примечание менеджера + голосовой ввод
Backend: - Лист ClientNotes (auto-create через ensure_sheet) — колонки manager_tg_id, client_key, note, updated_at. - Ключ клиента: «p:7XXXXXXXXXX» если есть телефон ≥10 цифр, иначе «n:<имя в lower>». Привязан к менеджеру. - POST /api/client_note — без поля note читает текущую, с note — upsert (хард-кап 4000 символов). Frontend в карточке клиента (#/clients/client/<key>): - Новый блок «📝 Примечание» сверху над списком подборов - Textarea + дата обновления в meta - Кнопка «🎤 Диктовать» — Web Speech API (ru-RU) · interimResults показывает прямо во время речи · final-результаты добавляются к baseText · красная пульсация во время записи · graceful degrade если SR недоступен (Telegram WebApp на iOS) - Кнопка «Сохранить» → PUT в /api/client_note + статус «✓ сохранено» CSS: .client-note-block, .btn-mic, .btn-mic.rec (pulse animation), .note-status.ok / .err. Cache bust v=20260513y.
This commit is contained in:
parent
7a5df7d011
commit
9e23239f57
@ -114,6 +114,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"measurement_next_no": _handle_measurement_next_no,
|
"measurement_next_no": _handle_measurement_next_no,
|
||||||
"measurement_logistics": _handle_measurement_logistics,
|
"measurement_logistics": _handle_measurement_logistics,
|
||||||
"geocode": _handle_geocode,
|
"geocode": _handle_geocode,
|
||||||
|
"client_note": _handle_client_note,
|
||||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||||
"seed_admin": lambda b: _handle_seed_admin(),
|
"seed_admin": lambda b: _handle_seed_admin(),
|
||||||
"test_ai": lambda b: _handle_test_ai(),
|
"test_ai": lambda b: _handle_test_ai(),
|
||||||
@ -222,6 +223,12 @@ async def api_geocode(request: Request):
|
|||||||
return _handle_geocode(body)
|
return _handle_geocode(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/client_note")
|
||||||
|
async def api_client_note(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_client_note(body)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/grant_role")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
async def api_grant_role(request: Request):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -1373,6 +1380,96 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": True, "id": measurement_id, "logistics": updates}
|
return {"ok": True, "id": measurement_id, "logistics": updates}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_client_key(name: str, phone: str) -> str:
|
||||||
|
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
|
||||||
|
digits = "".join(c for c in (phone or "") if c.isdigit())
|
||||||
|
if len(digits) >= 10:
|
||||||
|
# Нормализуем +7/8 → 7XXXXXXXXXX (последние 10 цифр)
|
||||||
|
return "p:" + digits[-10:]
|
||||||
|
return "n:" + (name or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
_CLIENT_NOTES_HEADERS = ["manager_tg_id", "client_key", "note", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_client_notes_sheet():
|
||||||
|
try:
|
||||||
|
sheets.ensure_sheet("ClientNotes", _CLIENT_NOTES_HEADERS)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Не удалось убедиться что ClientNotes есть: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Чтение/запись примечания менеджера по клиенту.
|
||||||
|
body: {initData, client_name, client_phone, note?, read?}
|
||||||
|
|
||||||
|
Если note передано — пишем (upsert). Иначе просто читаем.
|
||||||
|
Возвращает {ok, note, updated_at}."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
auth = {"user": unsafe["user"]}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user or not sheets.has_role(user, "manager"):
|
||||||
|
return {"error": "only_manager"}
|
||||||
|
|
||||||
|
client_name = (body.get("client_name") or "").strip()
|
||||||
|
client_phone = (body.get("client_phone") or "").strip()
|
||||||
|
if not client_name and not client_phone:
|
||||||
|
return {"error": "missing_client_id"}
|
||||||
|
key = _normalize_client_key(client_name, client_phone)
|
||||||
|
|
||||||
|
_ensure_client_notes_sheet()
|
||||||
|
|
||||||
|
# Ищем существующую заметку этого менеджера по этому клиенту
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("ClientNotes")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("ClientNotes read failed: %s", e)
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
headers = rows[0] if rows else _CLIENT_NOTES_HEADERS
|
||||||
|
found_row_index = None # 1-based в Sheets
|
||||||
|
current_note = ""
|
||||||
|
current_updated = ""
|
||||||
|
if rows and len(rows) >= 2:
|
||||||
|
try:
|
||||||
|
idx_mgr = headers.index("manager_tg_id")
|
||||||
|
idx_key = headers.index("client_key")
|
||||||
|
idx_note = headers.index("note")
|
||||||
|
idx_upd = headers.index("updated_at")
|
||||||
|
except ValueError:
|
||||||
|
idx_mgr = idx_key = idx_note = idx_upd = -1
|
||||||
|
if idx_mgr >= 0 and idx_key >= 0:
|
||||||
|
for i, r in enumerate(rows[1:], start=2):
|
||||||
|
row_mgr = r[idx_mgr] if idx_mgr < len(r) else ""
|
||||||
|
row_key = r[idx_key] if idx_key < len(r) else ""
|
||||||
|
if str(row_mgr) == str(tg_id) and row_key == key:
|
||||||
|
found_row_index = i
|
||||||
|
current_note = r[idx_note] if idx_note < len(r) else ""
|
||||||
|
current_updated = r[idx_upd] if idx_upd < len(r) else ""
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если note передано — пишем (upsert)
|
||||||
|
if "note" in body and body.get("note") is not None:
|
||||||
|
new_note = str(body.get("note") or "").strip()[:4000]
|
||||||
|
now_iso = _now_iso()
|
||||||
|
if found_row_index:
|
||||||
|
ws.update_cell(found_row_index, headers.index("note") + 1, new_note)
|
||||||
|
ws.update_cell(found_row_index, headers.index("updated_at") + 1, now_iso)
|
||||||
|
else:
|
||||||
|
sheets.append_row("ClientNotes", [str(tg_id), key, new_note, now_iso])
|
||||||
|
sheets.log_event("client_note_updated", tg_id, {"key": key, "len": len(new_note)})
|
||||||
|
return {"ok": True, "note": new_note, "updated_at": now_iso, "client_key": key}
|
||||||
|
|
||||||
|
return {"ok": True, "note": current_note, "updated_at": current_updated, "client_key": key}
|
||||||
|
|
||||||
|
|
||||||
def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Прямое геокодирование: текст адреса → lat/lon.
|
"""Прямое геокодирование: текст адреса → lat/lon.
|
||||||
Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM.
|
Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM.
|
||||||
|
|||||||
@ -137,6 +137,9 @@ const Clients = (function () {
|
|||||||
</div>
|
</div>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
|
// Примечание менеджера — текст или голосовой ввод
|
||||||
|
root.appendChild(renderClientNoteBlock(client));
|
||||||
|
|
||||||
root.appendChild(el(`<div class="section-head"><span class="label">Подборы · ${client.leads_count}</span></div>`));
|
root.appendChild(el(`<div class="section-head"><span class="label">Подборы · ${client.leads_count}</span></div>`));
|
||||||
|
|
||||||
const leadsList = el(`<div class="leads-list"></div>`);
|
const leadsList = el(`<div class="leads-list"></div>`);
|
||||||
@ -340,6 +343,171 @@ const Clients = (function () {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== Примечание по клиенту ===================== */
|
||||||
|
|
||||||
|
function renderClientNoteBlock(client) {
|
||||||
|
const section = el(`
|
||||||
|
<section class="block client-note-block">
|
||||||
|
<div class="block-head">
|
||||||
|
<span>📝 Примечание</span>
|
||||||
|
<span class="note-meta" id="noteMeta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="note-editor">
|
||||||
|
<textarea id="noteText" rows="3" placeholder="Заметки по клиенту — характер, предпочтения, договорённости, статус..."></textarea>
|
||||||
|
<div class="note-actions">
|
||||||
|
<button class="btn-mic" id="noteMic" type="button" title="Голосовой ввод">🎤 Диктовать</button>
|
||||||
|
<button class="btn-secondary" id="noteSave" type="button">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div class="note-status" id="noteStatus"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const textarea = section.querySelector("#noteText");
|
||||||
|
const meta = section.querySelector("#noteMeta");
|
||||||
|
const status = section.querySelector("#noteStatus");
|
||||||
|
|
||||||
|
// Загружаем сохранённую заметку
|
||||||
|
fetchClientNote(client).then(data => {
|
||||||
|
if (data?.note) textarea.value = data.note;
|
||||||
|
if (data?.updated_at) {
|
||||||
|
meta.textContent = "обновлено " + formatDate(data.updated_at);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Сохранение
|
||||||
|
section.querySelector("#noteSave").addEventListener("click", async () => {
|
||||||
|
const btn = section.querySelector("#noteSave");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Сохраняем...";
|
||||||
|
try {
|
||||||
|
const data = await saveClientNote(client, textarea.value);
|
||||||
|
if (data?.ok) {
|
||||||
|
status.textContent = "✓ сохранено";
|
||||||
|
status.className = "note-status ok";
|
||||||
|
if (data.updated_at) meta.textContent = "обновлено " + formatDate(data.updated_at);
|
||||||
|
setTimeout(() => { status.textContent = ""; }, 2500);
|
||||||
|
} else {
|
||||||
|
status.textContent = "Ошибка: " + (data?.error || "не сохранилось");
|
||||||
|
status.className = "note-status err";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = "Сеть: " + e.message;
|
||||||
|
status.className = "note-status err";
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Сохранить";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Голосовой ввод через Web Speech API
|
||||||
|
const micBtn = section.querySelector("#noteMic");
|
||||||
|
setupVoiceInput(micBtn, textarea, status);
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClientNote(client) {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/client_note`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
client_name: client.client_name || "",
|
||||||
|
client_phone: client.client_phone || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveClientNote(client, note) {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/client_note`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
client_name: client.client_name || "",
|
||||||
|
client_phone: client.client_phone || "",
|
||||||
|
note: note || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupVoiceInput(micBtn, textarea, status) {
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SR) {
|
||||||
|
micBtn.disabled = true;
|
||||||
|
micBtn.title = "Браузер не поддерживает голосовой ввод";
|
||||||
|
micBtn.style.opacity = "0.5";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rec = null;
|
||||||
|
let recording = false;
|
||||||
|
let baseText = ""; // текст до начала записи — чтобы не перетирать
|
||||||
|
|
||||||
|
micBtn.addEventListener("click", () => {
|
||||||
|
if (recording) {
|
||||||
|
rec?.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rec = new SR();
|
||||||
|
rec.lang = "ru-RU";
|
||||||
|
rec.continuous = true;
|
||||||
|
rec.interimResults = true;
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = "Микрофон недоступен: " + e.message;
|
||||||
|
status.className = "note-status err";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
baseText = (textarea.value || "").trim();
|
||||||
|
const sep = baseText ? "\n" : "";
|
||||||
|
|
||||||
|
rec.onstart = () => {
|
||||||
|
recording = true;
|
||||||
|
micBtn.classList.add("rec");
|
||||||
|
micBtn.textContent = "⏹ Стоп";
|
||||||
|
status.textContent = "Слушаю...";
|
||||||
|
status.className = "note-status";
|
||||||
|
haptic && haptic("impact");
|
||||||
|
};
|
||||||
|
rec.onresult = (ev) => {
|
||||||
|
let interim = "";
|
||||||
|
let final = "";
|
||||||
|
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
||||||
|
const t = ev.results[i][0].transcript;
|
||||||
|
if (ev.results[i].isFinal) final += t;
|
||||||
|
else interim += t;
|
||||||
|
}
|
||||||
|
if (final) {
|
||||||
|
baseText = (baseText + sep + final).trim();
|
||||||
|
textarea.value = baseText;
|
||||||
|
} else if (interim) {
|
||||||
|
textarea.value = baseText + sep + interim;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rec.onerror = (ev) => {
|
||||||
|
status.textContent = "Ошибка распознавания: " + (ev.error || "неизвестно");
|
||||||
|
status.className = "note-status err";
|
||||||
|
recording = false;
|
||||||
|
micBtn.classList.remove("rec");
|
||||||
|
micBtn.textContent = "🎤 Диктовать";
|
||||||
|
};
|
||||||
|
rec.onend = () => {
|
||||||
|
recording = false;
|
||||||
|
micBtn.classList.remove("rec");
|
||||||
|
micBtn.textContent = "🎤 Диктовать";
|
||||||
|
if (status.textContent === "Слушаю...") status.textContent = "";
|
||||||
|
haptic && haptic("impact");
|
||||||
|
};
|
||||||
|
try { rec.start(); }
|
||||||
|
catch (e) {
|
||||||
|
status.textContent = "Не запустить: " + e.message;
|
||||||
|
status.className = "note-status err";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Helpers ===================== */
|
/* ===================== Helpers ===================== */
|
||||||
|
|
||||||
function headerEl(title, backHref) {
|
function headerEl(title, backHref) {
|
||||||
|
|||||||
@ -2066,6 +2066,80 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Примечание по клиенту ===== */
|
||||||
|
.client-note-block .block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.client-note-block .note-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.client-note-block textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--paper, #FBF7F0);
|
||||||
|
border: 1px solid rgba(107, 74, 43, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 70px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink, #1F1A14);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.client-note-block textarea:focus { outline: none; border-color: var(--walnut, #6B4A2B); }
|
||||||
|
.client-note-block .note-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.client-note-block .btn-mic {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--walnut, #6B4A2B);
|
||||||
|
color: var(--walnut, #6B4A2B);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.client-note-block .btn-mic:disabled { cursor: not-allowed; }
|
||||||
|
.client-note-block .btn-mic.rec {
|
||||||
|
background: #C0392B;
|
||||||
|
border-color: #C0392B;
|
||||||
|
color: white;
|
||||||
|
animation: micPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(192, 57, 43, 0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(192, 57, 43, 0); }
|
||||||
|
}
|
||||||
|
.client-note-block .btn-secondary {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.client-note-block .note-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
.client-note-block .note-status.ok { color: #27AE60; }
|
||||||
|
.client-note-block .note-status.err { color: #C0392B; }
|
||||||
|
|
||||||
/* ===== Заявка на замер: выбор «когда удобно» ===== */
|
/* ===== Заявка на замер: выбор «когда удобно» ===== */
|
||||||
.preferred-options {
|
.preferred-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="stylesheet" href="assets/styles.css?v=20260513x">
|
<link rel="stylesheet" href="assets/styles.css?v=20260513y">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513x">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513y">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||||
@ -31,14 +31,14 @@
|
|||||||
<div class="loader-tagline">Сделано с душой!</div>
|
<div class="loader-tagline">Сделано с душой!</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513x"></script>
|
<script src="assets/icons.js?v=20260513y"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513x"></script>
|
<script src="assets/podbor.config.js?v=20260513y"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513x"></script>
|
<script src="assets/podbor.picts.js?v=20260513y"></script>
|
||||||
<script src="assets/podbor.js?v=20260513x"></script>
|
<script src="assets/podbor.js?v=20260513y"></script>
|
||||||
<script src="assets/clients.js?v=20260513x"></script>
|
<script src="assets/clients.js?v=20260513y"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260513x"></script>
|
<script src="assets/zamer-picts.js?v=20260513y"></script>
|
||||||
<script src="assets/measurements.js?v=20260513x"></script>
|
<script src="assets/measurements.js?v=20260513y"></script>
|
||||||
<script src="assets/request.js?v=20260513x"></script>
|
<script src="assets/request.js?v=20260513y"></script>
|
||||||
<script src="assets/app.js?v=20260513x"></script>
|
<script src="assets/app.js?v=20260513y"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user