diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 40af6fc..f534472 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -1,13 +1,17 @@ """ЗОВ Backend — FastAPI app. Полный порт Apps Script Code.gs.""" from __future__ import annotations +import base64 import json import logging +import os +import re import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Any from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse from .config import get_config from .auth import verify_init_data @@ -20,6 +24,16 @@ log = logging.getLogger("zov.backend") app = FastAPI(title="ZOV Backend", version="2.0") +# Каталог под фото замеров (монтируется как volume в docker-compose) +PHOTOS_DIR = Path(os.environ.get("PHOTOS_DIR", "/app/photos")) +try: + PHOTOS_DIR.mkdir(parents=True, exist_ok=True) +except Exception as _e: + logging.getLogger("zov.backend").warning("Не удалось создать PHOTOS_DIR=%s: %s", PHOTOS_DIR, _e) + +_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_\-]{1,64}$") +_SAFE_FILE_RE = re.compile(r"^[A-Za-z0-9_\-.]{1,80}$") + # CORS — MiniApp хостится на github.io, бэкенд на api.wasrusgen1.pro. # Простые запросы (text/plain или без Content-Type) не триггерят preflight. app.add_middleware( @@ -87,6 +101,7 @@ async def _dispatch_post(request: Request): "me": _handle_me, "measurement": _handle_measurement, "measurements": _handle_measurements_list, + "measurement_detail": _handle_measurement_detail, "podbor": _handle_podbor, "clients": _handle_clients, "lead": _handle_lead, @@ -156,6 +171,33 @@ async def api_measurements(request: Request): return _handle_measurements_list(body) +@app.post("/api/measurement_detail") +async def api_measurement_detail(request: Request): + body = await _safe_json(request) + return _handle_measurement_detail(body) + + +@app.get("/api/photo/{measurement_id}/{filename}") +async def api_photo(measurement_id: str, filename: str): + """Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена.""" + if not _SAFE_ID_RE.match(measurement_id) or not _SAFE_FILE_RE.match(filename): + return JSONResponse({"error": "bad_path"}, status_code=400) + if filename.startswith(".") or ".." in filename: + return JSONResponse({"error": "bad_path"}, status_code=400) + p = PHOTOS_DIR / measurement_id / filename + try: + p_resolved = p.resolve() + if PHOTOS_DIR.resolve() not in p_resolved.parents: + return JSONResponse({"error": "bad_path"}, status_code=400) + except Exception: + return JSONResponse({"error": "bad_path"}, status_code=400) + if not p.exists() or not p.is_file(): + return JSONResponse({"error": "not_found"}, status_code=404) + ext = filename.rsplit(".", 1)[-1].lower() + media = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(ext, "application/octet-stream") + return FileResponse(str(p), media_type=media) + + @app.get("/api/test_ai") async def api_test_ai(): return _handle_test_ai() @@ -396,6 +438,37 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: } +_DATA_URL_RE = re.compile(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", re.DOTALL) + + +def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str | None: + """Сохраняет фото (data-URL) в `PHOTOS_DIR//.`. + Возвращает имя файла или None при ошибке.""" + if not isinstance(data_url, str): + return None + m = _DATA_URL_RE.match(data_url.strip()) + if not m: + return None + ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1) + try: + raw = base64.b64decode(m.group(2), validate=False) + except Exception: + return None + if len(raw) > 10 * 1024 * 1024: # 10 MB hard cap + return None + if not _SAFE_ID_RE.match(measurement_id): + return None + target_dir = PHOTOS_DIR / measurement_id + try: + target_dir.mkdir(parents=True, exist_ok=True) + name = f"{idx}.{ext}" + (target_dir / name).write_bytes(raw) + return name + except Exception: + log.warning("Не удалось сохранить фото %d для замера %s", idx, measurement_id) + return None + + def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: cfg = get_config() auth = verify_init_data(body.get("initData") or "", cfg.bot_token) @@ -425,6 +498,19 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: if extras: notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "") + # Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена + raw_photos = m.get("photos") or [] + saved_photos: list[str] = [] + if isinstance(raw_photos, list): + for i, p in enumerate(raw_photos[:20]): # хард-кап 20 фото на замер + if isinstance(p, str) and p.startswith("data:"): + fn = _save_measurement_photo(measurement_id, i, p) + if fn: + saved_photos.append(fn) + elif isinstance(p, str) and p and not p.startswith("data:"): + # уже готовое имя/URL — пропускаем как есть + saved_photos.append(p) + sheets.append_row("Measurements", [ measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "", filled_by, @@ -433,7 +519,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: json.dumps(m.get("openings") or {}, ensure_ascii=False), json.dumps(m.get("infra") or {}, ensure_ascii=False), json.dumps(m.get("niches") or {}, ensure_ascii=False), - ",".join(m.get("photos") or []), + ",".join(saved_photos), notes_full, "submitted", ]) @@ -450,7 +536,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: ) sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by}) - return {"ok": True, "id": measurement_id} + return {"ok": True, "id": measurement_id, "photos": saved_photos} def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: @@ -678,6 +764,61 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]: } +def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает один замер целиком — для детальной страницы и печати.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + + measurement_id = body.get("measurement_id") or body.get("id") + if not measurement_id: + return {"error": "missing_measurement_id"} + + row = sheets.find_row("Measurements", "id", measurement_id) + if not row: + return {"error": "measurement_not_found"} + + # Только владелец-менеджер или клиент-владелец видит замер + if user.get("role") == "manager": + if str(row.get("manager_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + else: + if str(row.get("client_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + def _safe_json(s: str) -> Any: + try: + return json.loads(s) if s else {} + except (ValueError, TypeError): + return {} + + photo_files = [p for p in (row.get("photos") or "").split(",") if p] + + return { + "ok": True, + "id": row.get("id"), + "created_at": row.get("ts") or row.get("created_at"), + "client_tg_id": row.get("client_tg_id", ""), + "manager_tg_id": row.get("manager_tg_id", ""), + "filled_by": row.get("filled_by", ""), + "layout": row.get("layout", ""), + "area_m2": row.get("area_m2", ""), + "ceiling_mm": row.get("ceiling_mm", ""), + "walls": _safe_json(row.get("walls", "")), + "openings": _safe_json(row.get("openings", "")), + "infra": _safe_json(row.get("infra", "")), + "niches": _safe_json(row.get("niches", "")), + "photos": photo_files, + "notes": row.get("notes", ""), + "status": row.get("status", ""), + } + + def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: """Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name.""" cfg = get_config() @@ -714,6 +855,7 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: # Из notes / других JSON-полей вытащим client_name если был передан в measurement # (он не сохраняется в отдельной колонке — только в JSON-обвязке) # Для MVP — фильтр по имени делаем после парсинга JSON-полей + photo_files = [p for p in (row.get("photos") or "").split(",") if p] out.append({ "id": row.get("id", ""), "created_at": row.get("ts") or row.get("created_at", ""), @@ -725,6 +867,8 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: "ceiling_mm": row.get("ceiling_mm", ""), "notes": row.get("notes", ""), "status": row.get("status", ""), + "photos": photo_files, + "photo_count": len(photo_files), }) # Сортируем по дате desc diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 22d8cb4..6f44f29 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -10,6 +10,7 @@ services: - .env volumes: - ./credentials.json:/app/credentials.json:ro + - ./photos:/app/photos networks: - web # внешняя сеть от deploy-стека (Caddy там) - internal diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index 762baf6..a207660 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -18,6 +18,9 @@ const Clients = (function () { if (sub.startsWith("lead/")) { const leadId = sub.slice(5); renderLead(leadId); + } else if (sub.startsWith("measurement/")) { + const measurementId = sub.slice(12); + renderMeasurement(measurementId); } else if (sub.startsWith("client/")) { const clientKey = decodeURIComponent(sub.slice(7)); renderClientHistory(clientKey); @@ -167,14 +170,20 @@ const Clients = (function () { root.appendChild(el(`
Замеры · ${myMeasurements.length}
`)); const mList = el(`
`); for (const m of myMeasurements) { + const photoCount = m.photo_count || (m.photos || []).length; + const photoBadge = photoCount ? ` · 📷 ${photoCount}` : ""; const item = el(` -
+
+
${m.area_m2 ? m.area_m2 + " м²" : "—"}${photoBadge}
+
${ICONS.chevron || "›"}
+ `); + item.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/clients/measurement/${m.id}`; + }); mList.appendChild(item); } root.appendChild(mList); @@ -243,6 +252,94 @@ const Clients = (function () { } } + /* ===================== Деталь замера ===================== */ + + async function renderMeasurement(measurementId) { + root.innerHTML = ""; + root.appendChild(headerEl("Замер", "back")); + const loading = el(`
`); + root.appendChild(loading); + + let m; + try { + m = await fetchMeasurementDetail(measurementId); + } catch (e) { + loading.remove(); + root.appendChild(el(`
${e.message}
`)); + return; + } + loading.remove(); + + if (m.error) { + root.appendChild(el(`
${m.error}
`)); + return; + } + + const walls = m.walls || {}; + const wallsText = Object.entries(walls) + .filter(([_, v]) => v) + .map(([k, v]) => `${k.replace("wall", "стена ")}: ${v} мм`) + .join(" · "); + + const openings = m.openings || {}; + + // Шапка + кнопка печати/PDF + root.appendChild(el(` +
+
Замер #${(m.id || "").slice(0, 8)}
+

${escHtml(layoutLabel(m.layout))}

+
+ 📅 ${formatDate(m.created_at)} + ${m.area_m2 ? `📐 ${escHtml(m.area_m2)} м²` : ""} + ${m.ceiling_mm ? `📏 потолок ${escHtml(m.ceiling_mm)} мм` : ""} +
+
+ `)); + + const printBtn = el(``); + printBtn.addEventListener("click", () => window.print()); + root.appendChild(printBtn); + + // Основной блок + const detail = el(` +
+
+ ${wallsText ? `
Стены
${escHtml(wallsText)}
` : ""} + ${openings.window ? `
Окно
${escHtml(openings.window)}
` : ""} + ${openings.door ? `
Дверь
${escHtml(openings.door)}
` : ""} + ${m.notes ? `
Заметки
${escHtml(m.notes).replace(/\n/g, "
")}
` : ""} +
+
+ `); + root.appendChild(detail); + + // Фото + const photos = (m.photos || []).filter(Boolean); + if (photos.length) { + root.appendChild(el(`
Фото · ${photos.length}
`)); + const list = el(`
`); + for (const fn of photos) { + const url = `${BACKEND_URL}/api/photo/${m.id}/${fn}`; + const tile = el(` + + + + `); + list.appendChild(tile); + } + root.appendChild(list); + } + } + + async function fetchMeasurementDetail(measurementId) { + if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); + const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { + method: "POST", + body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }), + }); + return await res.json(); + } + /* ===================== Helpers ===================== */ function headerEl(title, backHref) { diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index 02d8720..0449ec3 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -4,8 +4,11 @@ const Measurements = (function () { const STORAGE_KEY = "zov-measurement-draft"; - const STEPS = ["client", "layout", "size", "openings", "summary"]; - const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"]; + const STEPS = ["client", "layout", "size", "openings", "photos", "summary"]; + const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Фото", "Готово"]; + + // Фото держим только в памяти (data-URL'ы тяжёлые, localStorage не годится) + let photos = []; // Array<{ name: string, dataUrl: string, size: number }> const LAYOUTS = [ { key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" }, @@ -56,6 +59,7 @@ const Measurements = (function () { function reset() { state = defaultState(); saveState(); + photos = []; } /* ===================== Mount + Render ===================== */ @@ -66,6 +70,7 @@ const Measurements = (function () { const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); currentStep = "client"; + photos = []; // на старте нового замера — чистый список render(); } @@ -90,6 +95,7 @@ const Measurements = (function () { case "layout": screen.appendChild(renderLayout()); break; case "size": screen.appendChild(renderSize()); break; case "openings": screen.appendChild(renderOpenings()); break; + case "photos": screen.appendChild(renderPhotos()); break; case "summary": screen.appendChild(renderSummary()); break; } } @@ -322,11 +328,118 @@ const Measurements = (function () { }); }); node.querySelector("#back").addEventListener("click", () => go("size")); + node.querySelector("#next").addEventListener("click", () => go("photos")); + return node; + } + + /* ===================== Шаг 5: Фото замера ===================== */ + + function renderPhotos() { + const node = el(` +
+

Фото
кухни

+

Сними помещение со всех углов. Минимум: общий вид, окно/дверь, ниши и коммуникации.

+ +
+ + +
+ +
+ +
+ + +
+
+ `); + + const list = node.querySelector("#photoList"); + const input = node.querySelector("#photoInput"); + + function refreshList() { + list.innerHTML = ""; + photos.forEach((ph, idx) => { + const tile = el(` +
+ фото ${idx + 1} + +
+ `); + tile.querySelector(".photo-rm").addEventListener("click", e => { + const i = +e.currentTarget.dataset.idx; + photos.splice(i, 1); + haptic && haptic("impact"); + refreshList(); + }); + list.appendChild(tile); + }); + } + + input.addEventListener("change", async (e) => { + const files = Array.from(e.target.files || []); + input.value = ""; // позволяем выбрать тот же файл снова + if (!files.length) return; + + for (const f of files) { + if (photos.length >= 12) break; + if (!f.type || !f.type.startsWith("image/")) continue; + try { + const dataUrl = await compressImage(f, 1600, 0.78); + photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl, size: dataUrl.length }); + } catch (err) { + console.warn("Не удалось сжать фото", err); + } + } + refreshList(); + haptic && haptic("success"); + }); + + refreshList(); + node.querySelector("#back").addEventListener("click", () => go("openings")); node.querySelector("#next").addEventListener("click", () => go("summary")); return node; } - /* ===================== Шаг 5: Готово + Submit ===================== */ + /* Жмём картинку через canvas, возвращаем data-URL jpeg ~75% */ + function compressImage(file, maxSide = 1600, quality = 0.78) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = reject; + reader.onload = e => { + const img = new Image(); + img.onerror = reject; + img.onload = () => { + let { width, height } = img; + if (width > maxSide || height > maxSide) { + if (width >= height) { + height = Math.round(height * maxSide / width); + width = maxSide; + } else { + width = Math.round(width * maxSide / height); + height = maxSide; + } + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + try { + resolve(canvas.toDataURL("image/jpeg", quality)); + } catch (err) { reject(err); } + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); + }); + } + + /* ===================== Шаг 6: Готово + Submit ===================== */ function renderSummary() { const layout = LAYOUTS.find(l => l.key === state.layout); @@ -348,8 +461,15 @@ const Measurements = (function () { ${(state.openings || {}).window ? `
Окно${escHtml(state.openings.window)}
` : ""} ${(state.openings || {}).door ? `
Дверь${escHtml(state.openings.door)}
` : ""} ${state.notes ? `
Заметки${escHtml(state.notes)}
` : ""} + ${photos.length ? `
Фото${photos.length} шт
` : ""} + ${photos.length ? ` +
+ ${photos.map(p => `
`).join("")} +
+ ` : ""} +
@@ -358,7 +478,7 @@ const Measurements = (function () {
`); - node.querySelector("#back").addEventListener("click", () => go("openings")); + node.querySelector("#back").addEventListener("click", () => go("photos")); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); return node; } @@ -385,7 +505,8 @@ const Measurements = (function () { openings: state.openings, infra: {}, niches: {}, - photos: [], + // Бэкенд раскодирует data-URL → файл и сохранит имена в Sheets + photos: photos.map(p => p.dataUrl), notes: state.notes, // Контакт клиента — заносим в заметки если он не зарегистрирован в системе client_name: state.client_name, diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index b3f1c30..e24adee 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -1988,3 +1988,169 @@ border-radius: 8px; overflow-x: auto; } + +/* ===== Фото замера ===== */ +.photo-uploader { margin: 12px 0 14px; } + +.photo-add-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--warm); + border: 1px dashed var(--walnut); + border-radius: 12px; + cursor: pointer; + transition: background 0.15s; + user-select: none; +} +.photo-add-btn:active { background: rgba(107, 74, 43, 0.08); } +.photo-add-ico { + font-size: 24px; + font-weight: 300; + color: var(--walnut); + line-height: 1; +} +.photo-add-label { + flex: 1; + font-weight: 500; + color: var(--ink); +} +.photo-add-hint { + font-size: 11px; + color: var(--muted); + font-family: var(--font-mono); +} + +.photo-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 8px; + margin: 12px 0 16px; +} +.photo-tile { + position: relative; + aspect-ratio: 1 / 1; + border-radius: 10px; + overflow: hidden; + background: var(--warm); + border: 1px solid rgba(107, 74, 43, 0.15); +} +.photo-tile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.photo-rm { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.55); + color: white; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} +.photo-rm:active { background: rgba(0, 0, 0, 0.75); } +.photo-tile.static { cursor: default; } + +/* ===== Карточка замера (детальная страница) ===== */ +.measurement-detail-head { + margin-bottom: 18px; +} +.measurement-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--muted); + margin-top: 6px; +} +.measurement-kv-grid { + display: grid; + gap: 10px 16px; + grid-template-columns: minmax(120px, 1fr) 2fr; + margin: 14px 0 18px; + font-size: 14px; +} +.measurement-kv-grid .k { + color: var(--muted); + font-family: var(--font-mono); + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.measurement-kv-grid .v { color: var(--ink); } + +/* ===== Кнопка "Скачать PDF" / "Печать" ===== */ +.report-print-btn { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 12px 0; + padding: 10px 16px; + background: var(--warm); + border: 1px solid var(--walnut); + border-radius: 10px; + color: var(--walnut); + font-weight: 500; + cursor: pointer; + font-size: 13.5px; +} +.report-print-btn:active { background: rgba(107, 74, 43, 0.12); } + +/* ===== Печать / PDF ===== */ +@media print { + body { background: white !important; color: black !important; } + body.has-bottom-nav, #bottom-nav, + .podbor-header, + .podbor-progress, + .podbor-cta-row, + .podbor-back, + .report-actions, + .report-print-btn, + .util-link, + .photo-add-btn, + .photo-uploader, + .photo-rm, + .btn-primary, + .btn-secondary { + display: none !important; + } + .podbor-screen, + .podbor-step, + .block, + .summary-block, + .client-detail-head, + .lead-detail-head, + .measurement-detail-head { + box-shadow: none !important; + border: none !important; + background: white !important; + page-break-inside: avoid; + break-inside: avoid; + } + .display-title { color: black !important; } + .display-title .accent { color: #6B4A2B !important; } + .photo-list { + grid-template-columns: repeat(3, 1fr); + gap: 6px; + page-break-inside: avoid; + break-inside: avoid; + } + .photo-tile { border: 1px solid #ccc; } + .report-matrix table { font-size: 10pt; } + a { color: black !important; text-decoration: none !important; } + /* Скрываем стрелки и иконки навигации */ + .lead-arrow, .client-arrow { display: none !important; } +} diff --git a/miniapp/assets/podbor.js b/miniapp/assets/podbor.js index 34c3dc9..a645ea3 100644 --- a/miniapp/assets/podbor.js +++ b/miniapp/assets/podbor.js @@ -1425,16 +1425,19 @@ ${reportEl.outerHTML} haptic && haptic("success"); } - /* Экспорт: открываем системный print диалог — пользователь сохраняет как PDF */ + /* Экспорт: системный print-диалог. На Telegram WebApp popups часто блокируются, + поэтому печатаем inline — `@media print` в podbor.css скрывает всё лишнее. */ function _exportReportPrint(reportNode, leadId) { - // Открываем новое окно с чистой версткой отчёта только - const reportEl = document.querySelector(".report"); - if (!reportEl) return; - const stylesheets = []; - document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML)); - const w = window.open("", "_blank"); - if (!w) { alert("Браузер заблокировал окно. Разрешите всплывающие окна."); return; } - w.document.write(` + const isTelegram = !!(window.Telegram && window.Telegram.WebApp); + // Сначала пробуем открыть отдельное окно (на десктопе/Safari чище) + if (!isTelegram) { + const reportEl = document.querySelector(".report"); + if (reportEl) { + const stylesheets = []; + document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML)); + const w = window.open("", "_blank"); + if (w) { + w.document.write(` @@ -1456,7 +1459,18 @@ ${reportEl.outerHTML} - - + +
@@ -21,12 +21,12 @@
- - - - - - - + + + + + + +